diff --git a/README.md b/README.md index eef9b8e7..9e6e373a 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ Your agent knows when this matters. When it runs over SSH or on a machine with n Do this for the default data directory and for every `TOKENSTASH_HOME` you have used. Each one has its own keychain entries. 1. Run `tokenstash list`. For a custom home, set `TOKENSTASH_HOME` and keep it set for the next commands. The list shows every key in that home's index, with its identity. Generated secrets like `AUTH_SECRET` have one identity per folder. Remove each key with `tokenstash forget NAME --identity IDENTITY`. Then run `tokenstash doctor` and note the stash backend it shows. -2. Stop any program you started with `tokenstash run` and close your agent sessions. Then stop any tokenstash still running, for example with `pkill -x tokenstash`, which stops it for every home. A background inbox exits on its own only after 30 idle minutes with nothing waiting, and never if it was started with `--keep`. +2. Stop any program you started with `tokenstash run` and close your agent sessions. Then stop any tokenstash still running, for example with `pkill -x tokenstash`, which stops it for every home. A background inbox exits on its own after 30 idle minutes with nothing waiting, unless it was started with `--keep`. Any inbox also exits about a second after its data directory is deleted. 3. Clear what the index didn't know about. A key whose index was lost earlier is still in the OS store. Search for `tokenstash` in Keychain Access on macOS, or in Passwords and Keys (Seahorse) on Linux, and delete tokenstash's entries. Their service is `tokenstash` for the default home, or `tokenstash-` followed by eight hex characters for a custom one. On Linux you can also run `secret-tool clear service tokenstash` for the default home. It skips entries in a locked keyring, so unlock that in Passwords and Keys first. If `doctor` showed `keyutils`, the keys are in the Linux kernel keyring and are gone after a reboot. 4. Delete the data directory. It is `~/.config/tokenstash` on Linux (`$XDG_CONFIG_HOME/tokenstash` if you set that) and `~/Library/Application Support/tokenstash` on macOS. Also delete any `TOKENSTASH_HOME` directory you used. diff --git a/crates/cli/src/cmd/inbox.rs b/crates/cli/src/cmd/inbox.rs index bd11fae1..6d03f535 100644 --- a/crates/cli/src/cmd/inbox.rs +++ b/crates/cli/src/cmd/inbox.rs @@ -1,5 +1,6 @@ //! Localhost inbox: the human surface. One click from the notification to the vendor's page, -//! paste, submit. Binds 127.0.0.1 only. Exits after 30 idle minutes with no open tasks. +//! paste, submit. Binds 127.0.0.1 only. Exits after 30 idle minutes with no open tasks, and +//! once its home or the database in it is deleted or made again. //! //! Loopback is not authentication. See `crate::inbox_auth` for the threat model. Every route //! except `/verify` requires a credential: the browser session (full scope) or one card's @@ -62,12 +63,15 @@ use tokenstash_core::tasks::{self, Actor, AnswerResult}; pub struct InboxArgs { #[arg(long)] pub port: Option, - /// Never auto-exit. + /// Stay up when idle. The inbox still exits once its home is deleted. #[arg(long)] pub keep: bool, } const IDLE_EXIT: Duration = Duration::from_secs(30 * 60); +/// How often the inbox checks that its home is still there and, in a debug build, that the +/// process named by `TOKENSTASH_EXIT_WITH` is still running. +const CHECK_EVERY: Duration = Duration::from_secs(1); /// Cap on a form post. Answers are pasted API keys, not uploads. Judged on the declared /// `Content-Length` before any of the body is read. @@ -119,10 +123,19 @@ pub fn serve(a: InboxArgs) -> Result { } }; eprintln!("tokenstash inbox → http://127.0.0.1:{port}/"); + // Absolute, so a relative TOKENSTASH_HOME still names the same place if the working + // directory goes away. + let (home, db) = (tokenstash_core::config::config_dir(), tokenstash_core::config::db_path()); + let (home, db) = (std::path::absolute(&home).unwrap_or(home), std::path::absolute(&db).unwrap_or(db)); + // The database this inbox opened stands for its home. This process holds it open, so a + // database made in its place after a delete cannot get the same inode. + let opened = file_id(&db); + let exit_with = exit_with(); let (req_tx, requests) = mpsc::sync_channel::(READERS); spawn_readers(listener, false, req_tx.clone(), None); let mut tailnet_bound = None; let mut last_activity = Instant::now(); + let mut last_check = Instant::now(); loop { listen_tailnet(port, &mut tailnet_bound, &req_tx); match requests.recv_timeout(Duration::from_secs(1)) { @@ -143,7 +156,71 @@ pub fn serve(a: InboxArgs) -> Result { } Err(RecvTimeoutError::Disconnected) => { eprintln!("inbox: the listener stopped"); return Ok(1); } } + // Once a second whether requests arrive or not, so a client asking more often than + // that cannot keep alive an inbox that should have gone. + if last_check.elapsed() >= CHECK_EVERY { + last_check = Instant::now(); + // With the home deleted, or deleted and made again, this inbox has nothing left + // to serve, and the port is better free: it answers /verify with the proof key it + // read at start, so a `need` in a new home would find it foreign and hand out no + // links. This holds with --keep too. + let current = file_id(&db); + if !home.is_dir() || current.is_none() || current != opened { + eprintln!("inbox: {} was deleted or replaced, exiting", home.display()); + return Ok(0); + } + if let Some(pid) = exit_with.filter(|&pid| !process_alive(pid)) { + eprintln!("inbox: process {pid} has ended, exiting"); + return Ok(0); + } + } + } +} + +/// The file at `path`, as its device and inode. `None` when there is none. +#[cfg(unix)] +fn file_id(path: &std::path::Path) -> Option<(u64, u64)> { + use std::os::unix::fs::MetadataExt; + std::fs::metadata(path).ok().map(|m| (m.dev(), m.ino())) +} + +/// Without inodes, only whether there is a file at `path`. +#[cfg(not(unix))] +fn file_id(path: &std::path::Path) -> Option<(u64, u64)> { + std::fs::metadata(path).ok().map(|_| (0, 0)) +} + +/// The process this inbox ends with, from `TOKENSTASH_EXIT_WITH=`. Debug builds only; +/// release builds never read it. The test suites and scripts set it to their own pid, and +/// `ensure_inbox` passes its environment to the inbox it starts, so an inbox that a test's +/// `need` started ends with the test. Without it that inbox waits for its open cards, and no +/// test answers them all. +fn exit_with() -> Option { + #[cfg(debug_assertions)] + if let Some(pid) = std::env::var("TOKENSTASH_EXIT_WITH").ok().and_then(|p| p.trim().parse().ok()).filter(|&p| p != 0) { + return Some(pid); + } + None +} + +/// Whether process `pid` is still running. When this cannot tell, it says yes. +fn process_alive(pid: u32) -> bool { + if cfg!(target_os = "linux") && std::path::Path::new("/proc/self/stat").exists() { + // A zombie keeps its /proc entry until it is reaped, but it has ended. The state + // letter follows the command name, which is in parentheses and may contain any byte. + return match std::fs::read_to_string(format!("/proc/{pid}/stat")) { + Ok(stat) => !stat.rsplit_once(')').is_some_and(|(_, rest)| rest.trim_start().starts_with('Z')), + Err(e) => e.kind() != ErrorKind::NotFound, + }; } + // Elsewhere `kill -0`. It sends no signal, and fails when there is no such process or the + // process belongs to another user, which a test's own process never does. + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(true) } fn handle(app: &App, req: Request, tokens: &inbox_auth::Tokens) -> Result<()> { @@ -1174,6 +1251,27 @@ mod tests { assert!(tailnet.starts_with("http://box.tail1234.ts.net:"), "the listener passes the proof: {tailnet}"); } + /// A process counts as ended once it has exited. On Linux that includes a zombie its + /// parent has not reaped yet; `kill -0` elsewhere still finds a zombie. + #[test] + fn process_alive_follows_a_child_process() { + assert!(process_alive(std::process::id())); + let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap(); + let pid = child.id(); + assert!(process_alive(pid)); + child.kill().unwrap(); + #[cfg(target_os = "linux")] + { + let until = Instant::now() + Duration::from_secs(5); + while process_alive(pid) && Instant::now() < until { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(!process_alive(pid), "a zombie counts as ended"); + } + child.wait().unwrap(); + assert!(!process_alive(pid)); + } + #[test] fn csrf_field_carries_the_token_and_escapes_it() { assert_eq!(csrf_field("abc123"), ""); diff --git a/crates/cli/tests/common/mod.rs b/crates/cli/tests/common/mod.rs new file mode 100644 index 00000000..5a1406bd --- /dev/null +++ b/crates/cli/tests/common/mod.rs @@ -0,0 +1,14 @@ +//! What the integration tests share. + +use std::process::Command; + +/// The binary under test, tied to this test process. A `need` that files a card starts an +/// inbox in the background, and an inbox with an open card does not exit when idle, so the +/// inboxes behind cards no test answers ran until someone killed them. The inbox inherits +/// `TOKENSTASH_EXIT_WITH` from the command that started it, and a debug build exits once the +/// process it names has ended. +pub fn tokenstash() -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_tokenstash")); + c.env("TOKENSTASH_EXIT_WITH", std::process::id().to_string()); + c +} diff --git a/crates/cli/tests/human_only.rs b/crates/cli/tests/human_only.rs index ece15be8..a6633cf8 100644 --- a/crates/cli/tests/human_only.rs +++ b/crates/cli/tests/human_only.rs @@ -1,9 +1,11 @@ //! The commands that widen an agent's reach refuse when they cannot see a person: stdout is //! a pipe here, which is what an agent's shell looks like. +mod common; + use std::io::Write; use std::net::TcpListener; use std::path::PathBuf; -use std::process::{Command, Stdio}; +use std::process::Stdio; fn tmp(name: &str) -> PathBuf { let p = std::env::temp_dir().join(format!("tokenstash-human-{}-{}", name, std::process::id())); @@ -34,7 +36,7 @@ fn home(name: &str) -> PathBuf { fn run(home: &PathBuf, cwd: &PathBuf, args: &[&str]) -> std::process::Output { let user_home = home.join("user-home"); std::fs::create_dir_all(&user_home).unwrap(); - Command::new(env!("CARGO_BIN_EXE_tokenstash")).args(args).current_dir(cwd) + common::tokenstash().args(args).current_dir(cwd) .env("HOME", &user_home).env("XDG_CONFIG_HOME", user_home.join(".config")) .env("TOKENSTASH_HOME", home).env("TOKENSTASH_STASH", "insecure-file").env_remove("CLAUDECODE") .stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap() @@ -58,7 +60,7 @@ fn widening_commands_refuse_a_pipe() { /// Paste a value into this directory's own card from a pipe, as an agent may. fn paste(home: &PathBuf, cwd: &PathBuf, id: &str, value: &str) { - let mut child = Command::new(env!("CARGO_BIN_EXE_tokenstash")).args(["answer", id, "--stdin", "--skip-check"]).current_dir(cwd) + let mut child = common::tokenstash().args(["answer", id, "--stdin", "--skip-check"]).current_dir(cwd) .env("HOME", home.join("user-home")).env("XDG_CONFIG_HOME", home.join("user-home/.config")) .env("TOKENSTASH_HOME", home).env("TOKENSTASH_STASH", "insecure-file").env_remove("CLAUDECODE") .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); @@ -244,7 +246,7 @@ fn an_agent_cannot_answer_another_directorys_card() { for (cwd, extra) in [(&mine, vec!["--stdin", "--skip-check"]), (&mine, vec!["--deny"])] { let mut args = vec!["answer", id.as_str()]; args.extend(extra); - let mut child = Command::new(env!("CARGO_BIN_EXE_tokenstash")).args(&args).current_dir(cwd) + let mut child = common::tokenstash().args(&args).current_dir(cwd) .env("TOKENSTASH_HOME", &home).env("TOKENSTASH_STASH", "insecure-file").env_remove("CLAUDECODE") .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); child.stdin.take().unwrap().write_all(b"sk-proj-not-mine-0123456789abcdef0123456789\n").unwrap(); diff --git a/crates/cli/tests/inbox_deadline.rs b/crates/cli/tests/inbox_deadline.rs index 9a31b388..390bd4d0 100644 --- a/crates/cli/tests/inbox_deadline.rs +++ b/crates/cli/tests/inbox_deadline.rs @@ -9,12 +9,14 @@ //! mints there are throwaways. The child is killed when the test's guard drops, including //! on a panic. +mod common; + use hmac::{Hmac, Mac}; use sha2::Sha256; use std::io::{ErrorKind, Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::time::{Duration, Instant}; /// The whole-request deadline plus the per-wait timeout the inbox documents. @@ -50,7 +52,7 @@ impl Inbox { let home = tmp(&format!("home-{name}")); let port = free_port(); std::fs::write(home.join("config.toml"), format!("notifications = false\ninbox_port = {port}\nstash_backend = \"insecure-file\"\nverify_every = \"never\"\n")).unwrap(); - let child = Command::new(env!("CARGO_BIN_EXE_tokenstash")) + let child = common::tokenstash() .args(["inbox", "--port", &port.to_string()]) .env("TOKENSTASH_HOME", &home) .env("TOKENSTASH_STASH", "insecure-file") @@ -127,7 +129,7 @@ impl Inbox { } fn cli(&self, cwd: &Path, args: &[&str]) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_tokenstash")).args(args).current_dir(cwd) + common::tokenstash().args(args).current_dir(cwd) .env("TOKENSTASH_HOME", &self.home).env("TOKENSTASH_STASH", "insecure-file").env_remove("CLAUDECODE") .stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap() } diff --git a/crates/cli/tests/inbox_exit.rs b/crates/cli/tests/inbox_exit.rs new file mode 100644 index 00000000..7c05b24d --- /dev/null +++ b/crates/cli/tests/inbox_exit.rs @@ -0,0 +1,185 @@ +//! A `need` that files a card starts an inbox in the background, and an inbox with an open +//! card does not exit when idle. It still exits once the process `TOKENSTASH_EXIT_WITH` names +//! has ended (debug builds), and once its home or the database in it is deleted or replaced. +//! Each test files a card in a scratch home, checks the inbox is up and stays up, removes or +//! replaces one of those, and waits for the inbox's port to close while sending it requests. + +mod common; + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +/// Longer than the inbox's one-second check interval. +const TICK: Duration = Duration::from_millis(1500); +/// How long the inbox gets to notice and exit. +const EXIT_WITHIN: Duration = Duration::from_secs(5); + +fn tmp(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("tokenstash-inbox-exit-{}-{}", name, std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + p +} + +/// A scratch home on a free port and a project to file a card from. Both are deleted on +/// drop, which also ends an inbox that a failed assertion left running. +struct World { + home: PathBuf, + proj: PathBuf, + port: u16, +} + +impl World { + fn new(name: &str) -> World { + let home = tmp(&format!("home-{name}")); + let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let w = World { home, proj: tmp(&format!("proj-{name}")), port }; + w.write_config(&w.home); + w + } + + fn write_config(&self, home: &Path) { + std::fs::write(home.join("config.toml"), format!("notifications = false\ninbox_port = {}\nstash_backend = \"insecure-file\"\nverify_every = \"never\"\n", self.port)).unwrap(); + } + + fn addr(&self) -> SocketAddr { + ([127, 0, 0, 1], self.port).into() + } + + /// `need` for one key in `home`, which files a card and starts the inbox unless one is + /// on the port already. Its `TOKENSTASH_EXIT_WITH` is `exit_with` when given, else this + /// test process. Returns stdout, where a card link is printed only for an inbox that + /// proved it serves `home`. + fn need(&self, home: &Path, exit_with: Option) -> String { + let mut c = common::tokenstash(); + if let Some(pid) = exit_with { + c.env("TOKENSTASH_EXIT_WITH", pid.to_string()); + } + let out = c.args(["need", "GROQ_API_KEY", "--agent", "t"]).current_dir(&self.proj) + .env("TOKENSTASH_HOME", home).env("TOKENSTASH_STASH", "insecure-file") + .env("HOME", home.join("user-home")).env("XDG_CONFIG_HOME", home.join("user-home/.config")) + .env_remove("CLAUDECODE").env_remove("TOKENSTASH_AGENT") + .stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap(); + assert_eq!(out.status.code(), Some(10), "need should file a card: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).into_owned() + } + + fn card_link(&self, out: &str) -> bool { + out.split_whitespace().any(|w| w.starts_with(&format!("http://127.0.0.1:{}/p/", self.port)) && w.contains("?t=")) + } + + /// File a card in this world's home and check the inbox it started stays up. + fn file_card(&self, exit_with: Option) { + let out = self.need(&self.home, exit_with); + assert!(self.card_link(&out), "the need started no inbox of its own: {out}"); + std::thread::sleep(TICK); + assert!(self.answers(), "the inbox exited before anything it depends on was gone"); + } + + /// One complete `/verify` request. True when an HTTP response came back. + fn answers(&self) -> bool { + let Ok(mut s) = TcpStream::connect_timeout(&self.addr(), Duration::from_millis(200)) else { return false }; + let _ = s.set_read_timeout(Some(Duration::from_secs(2))); + if write!(s, "GET /verify?c=probe HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", self.port).is_err() { + return false; + } + let mut reply = Vec::new(); + let _ = s.read_to_end(&mut reply); + reply.starts_with(b"HTTP/1.1 ") + } + + /// True once the inbox's port refuses connections, within [`EXIT_WITHIN`]. A reply that + /// fails or stalls does not count, since only a refused connection shows the port was let + /// go. It sends a request every 100 ms, so the inbox is never a second without one, and an + /// exit check that waited for an idle second would never run. + fn inbox_exits(&self) -> bool { + let started = Instant::now(); + while started.elapsed() < EXIT_WITHIN { + match TcpStream::connect_timeout(&self.addr(), Duration::from_millis(200)) { + Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => return true, + Err(_) => {} + Ok(probe) => { + drop(probe); + let _ = self.answers(); + } + } + std::thread::sleep(Duration::from_millis(100)); + } + false + } +} + +impl Drop for World { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.home); + let _ = std::fs::remove_dir_all(&self.proj); + } +} + +/// A short-lived process standing in for a test run. Killed and reaped on drop. +#[cfg(debug_assertions)] +struct Parent(std::process::Child); +#[cfg(debug_assertions)] +impl Drop for Parent { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// Debug builds only: a release binary ignores `TOKENSTASH_EXIT_WITH`, and this test is +/// built with the same profile as the binary. +#[cfg(debug_assertions)] +#[test] +fn the_inbox_exits_once_the_process_it_was_started_for_has() { + let w = World::new("parent"); + let parent = Parent(std::process::Command::new("sleep").arg("30").spawn().unwrap()); + w.file_card(Some(parent.0.id())); + drop(parent); + assert!(w.inbox_exits(), "the inbox outlived the process named by TOKENSTASH_EXIT_WITH"); +} + +#[test] +fn the_inbox_exits_once_its_home_is_deleted() { + let w = World::new("home"); + w.file_card(None); + std::fs::remove_dir_all(&w.home).unwrap(); + assert!(w.inbox_exits(), "the inbox outlived its home"); +} + +#[test] +fn the_inbox_exits_once_its_database_is_deleted() { + let w = World::new("db"); + w.file_card(None); + for f in ["tokenstash.db", "tokenstash.db-wal", "tokenstash.db-shm"] { + let _ = std::fs::remove_file(w.home.join(f)); + } + assert!(w.inbox_exits(), "the inbox outlived its database"); +} + +/// The home is deleted and made again by another `need` before the inbox looks, so both +/// paths exist when it does. The old inbox still proves the old home's key, the new home +/// gets no links while it runs, and so it has to go; the next `need` then starts an inbox +/// that answers for the new home. The new home is made beside the old one and moved into +/// place, so it is whole by the time the inbox can look. Unix only: elsewhere the inbox +/// tells homes apart by existence alone. +#[cfg(unix)] +#[test] +fn the_inbox_exits_once_its_home_is_made_again() { + let w = World::new("remade"); + w.file_card(None); + let fresh = tmp("home-remade-fresh"); + w.write_config(&fresh); + let out = w.need(&fresh, None); + assert!(!w.card_link(&out), "the old inbox answered for a home it does not serve: {out}"); + let old = w.home.with_extension("old"); + std::fs::rename(&w.home, &old).unwrap(); + std::fs::rename(&fresh, &w.home).unwrap(); + std::fs::remove_dir_all(&old).unwrap(); + assert!(w.inbox_exits(), "the inbox outlived its home being made again"); + let out = w.need(&w.home, None); + assert!(w.card_link(&out), "no inbox answers for the new home: {out}"); +} diff --git a/crates/cli/tests/inbox_scope.rs b/crates/cli/tests/inbox_scope.rs index a15fe9b9..d9770d0e 100644 --- a/crates/cli/tests/inbox_scope.rs +++ b/crates/cli/tests/inbox_scope.rs @@ -5,6 +5,8 @@ //! Every process started here is a child of the test and is killed when the test ends; the //! inbox is started by the test first so that `need` finds it and spawns nothing detached. +mod common; + use std::io::{Read, Write}; use std::net::TcpStream; use std::net::TcpListener; @@ -32,7 +34,7 @@ fn home(name: &str, port: u16) -> PathBuf { } fn bin() -> Command { - let mut c = Command::new(env!("CARGO_BIN_EXE_tokenstash")); + let mut c = common::tokenstash(); c.env("TOKENSTASH_STASH", "insecure-file").env_remove("CLAUDECODE").env_remove("TOKENSTASH_AGENT"); c } diff --git a/crates/cli/tests/init_mode.rs b/crates/cli/tests/init_mode.rs index 74637d03..9be773c9 100644 --- a/crates/cli/tests/init_mode.rs +++ b/crates/cli/tests/init_mode.rs @@ -3,9 +3,11 @@ //! MCP server and undoing refuse (the human gate), while a plain `init` still installs the //! skill. On Linux, a pty run switches a scratch home between the modes, turns the server on //! and off, and backs out with `--undo`, the way a person at a terminal would. +mod common; + use std::net::TcpListener; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Stdio; const SKILL_MD: &str = include_str!("../skill/SKILL.md"); @@ -33,7 +35,7 @@ fn home(name: &str) -> PathBuf { fn run(home: &Path, cwd: &Path, args: &[&str]) -> std::process::Output { let user_home = home.join("user-home"); std::fs::create_dir_all(&user_home).unwrap(); - Command::new(env!("CARGO_BIN_EXE_tokenstash")).args(args).current_dir(cwd) + common::tokenstash().args(args).current_dir(cwd) .env("HOME", &user_home).env("XDG_CONFIG_HOME", user_home.join(".config")) .env("TOKENSTASH_HOME", home).env("TOKENSTASH_STASH", "insecure-file").env_remove("CLAUDECODE") .stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap() @@ -149,7 +151,7 @@ fn a_plain_init_refuses_a_home_that_could_add_skill_instructions() { #[cfg(target_os = "linux")] #[test] fn a_person_switches_modes_and_undoes_on_a_scratch_home() { - if !Command::new("script").arg("--version").stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) { + if !std::process::Command::new("script").arg("--version").stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) { eprintln!("skipped: util-linux `script` not available"); return; } @@ -172,10 +174,11 @@ fn a_person_switches_modes_and_undoes_on_a_scratch_home() { } let proj = tmp("pty-proj"); let person = |args: &str| -> String { - let o = Command::new("script").args(["-qec", &format!("tokenstash {args}"), "/dev/null"]) + let o = std::process::Command::new("script").args(["-qec", &format!("tokenstash {args}"), "/dev/null"]) .current_dir(&proj).env_clear() .env("HOME", &user_home).env("PATH", &path).env("XDG_CONFIG_HOME", user_home.join(".config")) .env("TOKENSTASH_HOME", &ts_home).env("TOKENSTASH_STASH", "insecure-file") + .env("TOKENSTASH_EXIT_WITH", std::process::id().to_string()) .stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap(); assert!(o.status.success(), "tokenstash {args}: {}{}", out(&o), err(&o)); out(&o) diff --git a/crates/cli/tests/mcp_bind.rs b/crates/cli/tests/mcp_bind.rs index 43b3cdaf..2dd93bb7 100644 --- a/crates/cli/tests/mcp_bind.rs +++ b/crates/cli/tests/mcp_bind.rs @@ -2,10 +2,12 @@ //! and answered, else its cwd, never a tool argument, and refuses to serve from //! directories that are not projects. +mod common; + use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; +use std::process::{Child, ChildStdin, Stdio}; use std::sync::mpsc::{channel, Receiver}; use std::time::Duration; @@ -41,7 +43,7 @@ struct Client { impl Client { fn start(home: &Path, cwd: &Path, roots_capability: bool) -> Client { let log = home.join(format!("mcp-{}.log", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())); - let mut child = Command::new(env!("CARGO_BIN_EXE_tokenstash")) + let mut child = common::tokenstash() .arg("mcp") .current_dir(cwd) .env("TOKENSTASH_HOME", home) @@ -289,14 +291,14 @@ fn secrets_list_shows_only_what_this_directory_holds() { let home = home("home-list"); let proj = tmp("proj-list"); // a key in the stash that this directory never received - let seed = Command::new(env!("CARGO_BIN_EXE_tokenstash")).args(["need", "GROQ_API_KEY", "--agent", "seed"]).current_dir(&proj) + let seed = common::tokenstash().args(["need", "GROQ_API_KEY", "--agent", "seed"]).current_dir(&proj) .env("TOKENSTASH_HOME", &home).env("TOKENSTASH_STASH", "insecure-file").env_remove("CLAUDECODE").output().unwrap(); let _ = seed; // Seeded from the project directory: `tasks --all` and answering another directory's // card are a person's, and the seed is an agent-shaped process. - let tasks = Command::new(env!("CARGO_BIN_EXE_tokenstash")).args(["tasks", "--json"]).current_dir(&proj).env("TOKENSTASH_HOME", &home).env("TOKENSTASH_STASH", "insecure-file").output().unwrap(); + let tasks = common::tokenstash().args(["tasks", "--json"]).current_dir(&proj).env("TOKENSTASH_HOME", &home).env("TOKENSTASH_STASH", "insecure-file").output().unwrap(); let tid = serde_json::from_slice::(&tasks.stdout).unwrap().as_array().unwrap().iter().find(|t| t["name"] == "GROQ_API_KEY").unwrap()["id"].as_str().unwrap().to_string(); - let mut ans = Command::new(env!("CARGO_BIN_EXE_tokenstash")).args(["answer", &tid, "--stdin", "--skip-check"]).current_dir(&proj).env("TOKENSTASH_HOME", &home).env("TOKENSTASH_STASH", "insecure-file").stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + let mut ans = common::tokenstash().args(["answer", &tid, "--stdin", "--skip-check"]).current_dir(&proj).env("TOKENSTASH_HOME", &home).env("TOKENSTASH_STASH", "insecure-file").stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); ans.stdin.take().unwrap().write_all(b"gsk_aaaaaaaaaaaaaaaaaaaa\n").unwrap(); assert!(ans.wait().unwrap().success()); // the directory that pasted it lists it; another directory lists nothing diff --git a/crates/cli/tests/mcp_surface.rs b/crates/cli/tests/mcp_surface.rs index c4566835..a64bdd39 100644 --- a/crates/cli/tests/mcp_surface.rs +++ b/crates/cli/tests/mcp_surface.rs @@ -2,10 +2,12 @@ //! context: a value must never appear there, and one project's cards must never be visible //! from another. Malformed framing is answered, never fatal. +mod common; + use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; +use std::process::{Child, ChildStdin, Stdio}; use std::sync::mpsc::{channel, Receiver}; use std::time::Duration; @@ -33,7 +35,7 @@ struct Client { child: Child, stdin: ChildStdin, lines: Receiver } impl Client { fn start(home: &Path, cwd: &Path) -> Client { - let mut child = Command::new(env!("CARGO_BIN_EXE_tokenstash")) + let mut child = common::tokenstash() .arg("mcp").current_dir(cwd) .env("TOKENSTASH_HOME", home).env("TOKENSTASH_STASH", "insecure-file") .env_remove("CLAUDECODE") @@ -76,15 +78,15 @@ impl Drop for Client { /// Seed a stashed value the way a human would: file a card, answer it from the CLI. fn seed(home: &Path, proj: &Path, name: &str, value: &str) { - let out = Command::new(env!("CARGO_BIN_EXE_tokenstash")).arg("need").arg(name) + let out = common::tokenstash().arg("need").arg(name) .current_dir(proj).env("TOKENSTASH_HOME", home).env("TOKENSTASH_STASH", "insecure-file") .output().unwrap(); assert!(out.status.code() == Some(10), "a first request is pending: {out:?}"); - let tasks = Command::new(env!("CARGO_BIN_EXE_tokenstash")).arg("tasks").arg("--json") + let tasks = common::tokenstash().arg("tasks").arg("--json") .current_dir(proj).env("TOKENSTASH_HOME", home).env("TOKENSTASH_STASH", "insecure-file").output().unwrap(); let v: serde_json::Value = serde_json::from_slice(&tasks.stdout).unwrap(); let id = v.as_array().unwrap().iter().find(|t| t["name"] == serde_json::json!(name)).expect("a card for the key")["id"].as_str().unwrap().to_string(); - let mut child = Command::new(env!("CARGO_BIN_EXE_tokenstash")).arg("answer").arg(&id).arg("--stdin").arg("--skip-check") + let mut child = common::tokenstash().arg("answer").arg(&id).arg("--stdin").arg("--skip-check") .current_dir(proj).env("TOKENSTASH_HOME", home).env("TOKENSTASH_STASH", "insecure-file") .stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); child.stdin.as_mut().unwrap().write_all(format!("{value}\n").as_bytes()).unwrap(); @@ -128,10 +130,10 @@ fn task_check_never_answers_for_another_project() { let theirs = tmp("scope-theirs"); seed(&home, &theirs, "RESEND_API_KEY", "re_theirs_00000000000000"); - let out = Command::new(env!("CARGO_BIN_EXE_tokenstash")).arg("need").arg("OPENAI_API_KEY") + let out = common::tokenstash().arg("need").arg("OPENAI_API_KEY") .current_dir(&theirs).env("TOKENSTASH_HOME", &home).env("TOKENSTASH_STASH", "insecure-file").output().unwrap(); assert_eq!(out.status.code(), Some(10)); - let tasks = Command::new(env!("CARGO_BIN_EXE_tokenstash")).arg("tasks").arg("--json") + let tasks = common::tokenstash().arg("tasks").arg("--json") .current_dir(&theirs).env("TOKENSTASH_HOME", &home).env("TOKENSTASH_STASH", "insecure-file").output().unwrap(); let v: serde_json::Value = serde_json::from_slice(&tasks.stdout).unwrap(); let their_id = v.as_array().unwrap()[0]["id"].as_str().unwrap().to_string(); diff --git a/crates/cli/tests/remote_tailscale.rs b/crates/cli/tests/remote_tailscale.rs index 37842104..2fe2bc6c 100644 --- a/crates/cli/tests/remote_tailscale.rs +++ b/crates/cli/tests/remote_tailscale.rs @@ -6,6 +6,8 @@ //! the address a request comes from. #![cfg(target_os = "linux")] +mod common; + use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -52,7 +54,7 @@ struct World { home: PathBuf, proj: PathBuf, port: u16, tailscale: PathBuf } impl World { fn cmd(&self) -> Command { - let mut c = Command::new(env!("CARGO_BIN_EXE_tokenstash")); + let mut c = common::tokenstash(); c.env("TOKENSTASH_HOME", &self.home).env("TOKENSTASH_STASH", "insecure-file").env("TOKENSTASH_TAILSCALE", &self.tailscale) .env("HOME", self.home.join("user-home")).env("XDG_CONFIG_HOME", self.home.join("user-home/.config")) .env_remove("CLAUDECODE").env_remove("TOKENSTASH_AGENT"); diff --git a/crates/cli/tests/run_redact.rs b/crates/cli/tests/run_redact.rs index b1ed3b48..1e7356bc 100644 --- a/crates/cli/tests/run_redact.rs +++ b/crates/cli/tests/run_redact.rs @@ -1,9 +1,11 @@ //! `run` forwards the child's output to the agent through a redactor seeded with the values it //! injected. The child's output reaches that redactor one line at a time, so a multi-line value //! (a PEM key, a service-account JSON) has to be masked line by line too. +mod common; + use std::net::TcpListener; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Stdio; fn tmp(name: &str) -> PathBuf { let p = std::env::temp_dir().join(format!("tokenstash-runredact-{}-{}", name, std::process::id())); @@ -25,7 +27,7 @@ fn home(name: &str) -> PathBuf { /// `tokenstash run -- sh -c SCRIPT` in `proj`, with `extra_env` set on tokenstash itself. fn run_sh(home: &Path, proj: &Path, script: &str, extra_env: &[(&str, &str)]) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_tokenstash")).args(["run", "--", "sh", "-c", script]).current_dir(proj) + common::tokenstash().args(["run", "--", "sh", "-c", script]).current_dir(proj) .env("TOKENSTASH_HOME", home).env("TOKENSTASH_STASH", "insecure-file").env_remove("CLAUDECODE") .envs(extra_env.iter().copied()) .stdout(Stdio::piped()).stderr(Stdio::piped()).output().unwrap() diff --git a/scripts/agent-conformance.sh b/scripts/agent-conformance.sh index a785452d..7e147a01 100755 --- a/scripts/agent-conformance.sh +++ b/scripts/agent-conformance.sh @@ -65,6 +65,9 @@ $SHA /dev/null >/dev/null 2>&1 || { echo "needs sha256sum, gsha256sum or shasum" # The scratch stash is a file from the very first tokenstash call: `init` must not probe the # developer's keyring on the way to choosing a backend. export TOKENSTASH_STASH=insecure-file +# In a debug build, every inbox this run starts exits once this script has, including one a +# `need` starts in the background. Release builds ignore the variable. +export TOKENSTASH_EXIT_WITH=$$ if grep -qs tokenstash "$HOME/.cursor/mcp.json"; then echo "note: ~/.cursor/mcp.json registers tokenstash globally; the project-local .cursor/mcp.json this suite writes has answered in every run so far, and the scenario grades (audit rows in the scratch home) would show if it did not" >&2 fi diff --git a/scripts/leak-test.sh b/scripts/leak-test.sh index 427ddcba..4b21b848 100755 --- a/scripts/leak-test.sh +++ b/scripts/leak-test.sh @@ -20,6 +20,9 @@ HUMAN_ENV=(env -u CLAUDECODE -u CLAUDE_CODE_ENTRYPOINT -u CODEX_SANDBOX -u CODEX human() { "${HUMAN_ENV[@]}" script -qec "$(printf '%q ' "$@")" /dev/null | tr -d '\r' | sed '/^tokenstash: WARNING: using insecure-file/d'; } export TOKENSTASH_HOME="$(mktemp -d)" export TOKENSTASH_STASH=insecure-file +# In a debug build, every inbox this run starts exits once this script has, including one a +# `need` starts in the background. Release builds ignore the variable. +export TOKENSTASH_EXIT_WITH=$$ PROJ="$(mktemp -d)"; cd "$PROJ"; git init -q . OUT="$(mktemp -d)" # Browser-facing responses legitimately contain the inbox session (the hidden CSRF field),