Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions Native/OptChat/optchat-chief/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,20 @@ fn main() -> std::process::ExitCode {
flags.words.get(3).cloned().unwrap_or_default(),
);
let home = home(&flags);
// The composition root of the launchers: this Chief home's store, the process's stdio.
let store_of =
|p: &optchat_chief::toolpacks::Pack| optchat_chief::toolpacks::store(&home, p);
let out = if args[1] == "mcp" {
optchat_chief::toolpacks::serve_mcp(&home, &pack, &server)
optchat_chief::toolpacks::serve_mcp(
&home,
&pack,
&server,
&store_of,
Box::new(std::io::BufReader::new(std::io::stdin())),
Box::new(std::io::stdout()),
)
} else {
optchat_chief::toolpacks::mcp_headers(&home, &pack, &server).map(|h| {
optchat_chief::toolpacks::mcp_headers(&home, &pack, &server, &store_of).map(|h| {
println!("{h}");
0
})
Expand Down
45 changes: 27 additions & 18 deletions Native/OptChat/optchat-chief/src/toolpacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,12 +591,17 @@ pub fn codex_servers(packs: &[Pack], bin: &Path) -> toml::Table {
out
}

fn secret_of(mux_home: &Path, packs: &[Pack], name: &str) -> Result<String, String> {
/// Where the launchers read a pack's secret. The composition root (`main`) passes
/// [`store`] of the Chief home (the macOS login keychain, else a 0600 file); a
/// test passes the store it wrote the secret into.
pub type StoreOf<'a> = &'a dyn Fn(&Pack) -> Box<dyn Store>;

fn secret_of(store_of: StoreOf<'_>, packs: &[Pack], name: &str) -> Result<String, String> {
let p = packs
.iter()
.find(|p| p.name == name)
.ok_or_else(|| format!("secret:{name}: no such pack is on"))?;
store(mux_home, p).get().ok_or_else(|| {
store_of(p).get().ok_or_else(|| {
format!(
"secret:{name}: this Chief has no {} token (chief pack status {name})",
p.title
Expand All @@ -613,29 +618,43 @@ fn find_server<'a>(packs: &'a [Pack], pack: &str, server: &str) -> Result<&'a Mc
}

/// `pack mcp-headers PACK SERVER`: Claude Code's headersHelper answer.
pub fn mcp_headers(mux_home: &Path, pack: &str, server: &str) -> Result<String, String> {
pub fn mcp_headers(
mux_home: &Path,
pack: &str,
server: &str,
store_of: StoreOf<'_>,
) -> Result<String, String> {
let packs = load(mux_home);
let m = find_server(&packs, pack, server)?;
let r = m
.bearer
.as_deref()
.and_then(|b| b.strip_prefix("secret:"))
.ok_or("no bearer")?;
Ok(json!({"Authorization": format!("Bearer {}", secret_of(mux_home, &packs, r)?)}).to_string())
Ok(json!({"Authorization": format!("Bearer {}", secret_of(store_of, &packs, r)?)}).to_string())
}

/// `pack mcp PACK SERVER`: runs a stdio server with its secrets in its env
/// (never in a file or argv), and keeps only the pack's allowed tools: a
/// `tools/list` answer is filtered, a `tools/call` of another tool refused.
pub fn serve_mcp(mux_home: &Path, pack: &str, server: &str) -> Result<i32, String> {
/// It speaks over `input` and `output` (`main` passes the process's stdin and
/// stdout) and reads secrets from `store_of`.
pub fn serve_mcp(
mux_home: &Path,
pack: &str,
server: &str,
store_of: StoreOf<'_>,
input: Box<dyn std::io::BufRead + Send>,
output: Box<dyn Write + Send>,
) -> Result<i32, String> {
use std::io::BufRead;
let packs = load(mux_home);
let m = find_server(&packs, pack, server)?.clone();
let mut cmd = Command::new(m.command.as_deref().ok_or("not a stdio server")?);
cmd.args(&m.args);
for (k, v) in &m.env {
match v.strip_prefix("secret:") {
Some(r) => cmd.env(k, secret_of(mux_home, &packs, r)?),
Some(r) => cmd.env(k, secret_of(store_of, &packs, r)?),
None => cmd.env(k, v),
};
}
Expand All @@ -648,14 +667,14 @@ pub fn serve_mcp(mux_home: &Path, pack: &str, server: &str) -> Result<i32, Strin
.stdout(Stdio::piped())
.spawn()
.map_err(|e| format!("{}: {e}", m.name))?;
let out = std::sync::Arc::new(std::sync::Mutex::new(std::io::stdout()));
let out = std::sync::Arc::new(std::sync::Mutex::new(output));
let lists = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let mut to_child = child.stdin.take().ok_or("no stdin")?;
let from_child = child.stdout.take().ok_or("no stdout")?;
let allowed = m.tools.clone();
let (out2, lists2) = (out.clone(), lists.clone());
let reader = std::thread::spawn(move || {
for line in std::io::stdin().lock().lines() {
for line in input.lines() {
let Ok(line) = line else { break };
let msg: Value = serde_json::from_str(&line).unwrap_or(Value::Null);
match msg["method"].as_str() {
Expand Down Expand Up @@ -858,16 +877,6 @@ impl Store for FileStore {

/// The store of a pack in this Chief home on this machine.
pub fn store(home: &Path, pack: &Pack) -> Box<dyn Store> {
// Debug builds only: with `OPTCHAT_CHIEF_TEST_FILE_SECRETS` set, the file store, so a test
// on macOS reads the secret it wrote and never the host's login keychain (cx-71g32y,
// tests/toolpack_mcp.rs). A release build has no seam: on macOS it reads the keychain.
#[cfg(debug_assertions)]
if std::env::var_os("OPTCHAT_CHIEF_TEST_FILE_SECRETS").is_some() {
return Box::new(FileStore {
path: secret_file(home, &pack.name),
pack: pack.clone(),
});
}
if cfg!(target_os = "macos") && Path::new("/usr/bin/security").exists() {
Box::new(Keychain {
account: crate::paths::home_id(home),
Expand Down
62 changes: 42 additions & 20 deletions Native/OptChat/optchat-chief/tests/toolpack_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,32 @@
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};

use optchat_chief::prompt::Tools;
use optchat_chief::session_dir::{SessionSetup, write};
use optchat_chief::toolpacks::{self, FileStore, Store};

/// What the launcher wrote, shared with the test.
#[derive(Clone, Default)]
struct Captured(Arc<Mutex<Vec<u8>>>);

impl Write for Captured {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}

impl Captured {
fn text(&self) -> String {
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
}
}

const SECRET: &str = "acme_0123456789abcdefABCDEF0123";

fn acme(enabled: bool, server: &Path) -> String {
Expand Down Expand Up @@ -197,25 +217,27 @@ fn enabling_a_pack_adds_its_skills_and_servers_and_disabling_removes_them() {
);
}

// The launcher hands the secret to the server and keeps only the allowed tools.
// The test wrote the secret to the file store. On macOS the launcher reads the login
// keychain, which a test must never write: this debug-build seam makes it read the file
// store (`toolpacks::store`), as it does off macOS.
let mut child = Command::new(env!("CARGO_BIN_EXE_optchat-chief"))
.args(["pack", "mcp", "acme", "acme", "--mux-home"])
.arg(home)
.env("OPTCHAT_CHIEF_TEST_FILE_SECRETS", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"acme_delete\"}}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"acme_read\"}}\n")
.unwrap();
let out = String::from_utf8(child.wait_with_output().unwrap().stdout).unwrap();
// The launcher hands the secret to the server and keeps only the allowed tools. It reads
// the secret from the store it is given: this test's file store (the binary's `main` passes
// the Chief home's own, the login keychain on macOS).
let output = Captured::default();
let input = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"acme_delete\"}}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"acme_read\"}}\n";
let file_store = |p: &toolpacks::Pack| -> Box<dyn Store> {
Box::new(FileStore {
path: toolpacks::secret_file(home, &p.name),
pack: p.clone(),
})
};
toolpacks::serve_mcp(
home,
"acme",
"acme",
&file_store,
Box::new(std::io::Cursor::new(input.to_vec())),
Box::new(output.clone()),
)
.unwrap();
let out = output.text();
let lines: Vec<serde_json::Value> = out
.lines()
.map(|l| serde_json::from_str(l).unwrap())
Expand Down
Loading