From 10a6d18e62a9c9f226bea1c77a8d40c4cb7b4419 Mon Sep 17 00:00:00 2001 From: Krish Garg <181051779+kgarg2468@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:59:15 +0000 Subject: [PATCH 1/3] inbox: exit once its home is deleted, and with the test that started it A `need` that files a card starts an inbox in the background, and an inbox with an open card never reaches its idle exit. Tests file cards and leave them, so one `cargo test` run left 11 inboxes running for good. The inbox now checks on each idle tick that its home and database still exist, and exits when either is gone, with or without --keep. Debug builds also read TOKENSTASH_EXIT_WITH= and exit once that process has ended; release builds ignore it. The integration tests start the binary through one helper that sets it to the test's own pid, and leak-test.sh and agent-conformance.sh set it to the script's. inbox_exit.rs files a card in a scratch home and checks the inbox exits once a stand-in parent process ends, once the home is deleted, and once the database is deleted. Co-Authored-By: Claude --- README.md | 2 +- crates/cli/src/cmd/inbox.rs | 76 ++++++++++++++++- crates/cli/tests/common/mod.rs | 14 ++++ crates/cli/tests/human_only.rs | 10 ++- crates/cli/tests/inbox_deadline.rs | 8 +- crates/cli/tests/inbox_exit.rs | 121 +++++++++++++++++++++++++++ crates/cli/tests/inbox_scope.rs | 4 +- crates/cli/tests/init_mode.rs | 5 +- crates/cli/tests/mcp_bind.rs | 12 +-- crates/cli/tests/mcp_surface.rs | 16 ++-- crates/cli/tests/remote_tailscale.rs | 4 +- crates/cli/tests/run_redact.rs | 6 +- scripts/agent-conformance.sh | 3 + scripts/leak-test.sh | 3 + 14 files changed, 257 insertions(+), 27 deletions(-) create mode 100644 crates/cli/tests/common/mod.rs create mode 100644 crates/cli/tests/inbox_exit.rs 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..b4122748 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. //! //! 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,7 +63,7 @@ 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, } @@ -119,6 +120,11 @@ 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)); + 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; @@ -133,6 +139,18 @@ pub fn serve(a: InboxArgs) -> Result { } } Err(RecvTimeoutError::Timeout) => { + // With the home or its database deleted there is nothing left to serve, and + // the port is better free: this inbox answers /verify with the proof key it + // read at start, so the next `need` in a recreated home would find it foreign + // and hand out no links. This holds with --keep too. + if !home.is_dir() || !db.is_file() { + eprintln!("inbox: {} or its database is gone, 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); + } if !a.keep && last_activity.elapsed() > IDLE_EXIT { let _ = app.db.expire_overdue(); if app.db.list_tasks(None, true).map(|v| v.is_empty()).unwrap_or(true) { @@ -146,6 +164,39 @@ pub fn serve(a: InboxArgs) -> Result { } } +/// 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<()> { use inbox_auth::Scope; let url = req.url.clone(); @@ -1174,6 +1225,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..c5c2690e --- /dev/null +++ b/crates/cli/tests/inbox_exit.rs @@ -0,0 +1,121 @@ +//! 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. Each test +//! files a card in a scratch home, checks the inbox is up and stays up, removes one of those, +//! and waits for the inbox's port to close. + +mod common; + +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Longer than one idle tick of the inbox's main loop, which is one second. +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, + addr: SocketAddr, +} + +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(); + std::fs::write(home.join("config.toml"), format!("notifications = false\ninbox_port = {port}\nstash_backend = \"insecure-file\"\nverify_every = \"never\"\n")).unwrap(); + World { home, proj: tmp(&format!("proj-{name}")), addr: ([127, 0, 0, 1], port).into() } + } + + /// File a card with `need`, which starts the inbox. Its `TOKENSTASH_EXIT_WITH` is + /// `exit_with` when given, else this test process. + fn file_card(&self, exit_with: Option) { + 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", &self.home).env("TOKENSTASH_STASH", "insecure-file") + .env("HOME", self.home.join("user-home")).env("XDG_CONFIG_HOME", self.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)); + assert!(self.listening(), "the need started no inbox"); + std::thread::sleep(TICK); + assert!(self.listening(), "the inbox exited before anything it depends on was gone"); + } + + fn listening(&self) -> bool { + TcpStream::connect_timeout(&self.addr, Duration::from_millis(200)).is_ok() + } + + /// True once nothing listens on the inbox's port, within [`EXIT_WITHIN`]. + fn inbox_exits(&self) -> bool { + let started = Instant::now(); + while started.elapsed() < EXIT_WITHIN { + if !self.listening() { + return true; + } + 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. +struct Parent(Child); +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(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"); +} 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..36648e6f 100644 --- a/crates/cli/tests/init_mode.rs +++ b/crates/cli/tests/init_mode.rs @@ -3,6 +3,8 @@ //! 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}; @@ -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() @@ -176,6 +178,7 @@ fn a_person_switches_modes_and_undoes_on_a_scratch_home() { .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), From c89774f0dfa790bd699148b3665bbc1b0be028d3 Mon Sep 17 00:00:00 2001 From: Krish Garg <181051779+kgarg2468@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:21:19 +0000 Subject: [PATCH 2/3] inbox: check the home every second whatever the traffic, and notice a remade one The exit checks ran only after a second with no request, so a client asking more often than that kept a deleted home's inbox up. They now run once a second whether requests arrive or not. The inbox records its database's device and inode at start and exits when they change, so a home deleted and made again by another `need` before the next check no longer leaves the old inbox holding the port with the old proof key. The exit tests in inbox_exit.rs now send a /verify every 100 ms while they wait, and the_inbox_exits_once_its_home_is_made_again checks that the old inbox goes and the next `need` gets a link from a new one. The debug-only helpers there are compiled only in debug builds, and init_mode.rs no longer imports `Command`, which only its Linux test used. Co-Authored-By: Claude --- crates/cli/src/cmd/inbox.rs | 52 ++++++++++++----- crates/cli/tests/inbox_exit.rs | 102 ++++++++++++++++++++++++++------- crates/cli/tests/init_mode.rs | 6 +- 3 files changed, 122 insertions(+), 38 deletions(-) diff --git a/crates/cli/src/cmd/inbox.rs b/crates/cli/src/cmd/inbox.rs index b4122748..6d03f535 100644 --- a/crates/cli/src/cmd/inbox.rs +++ b/crates/cli/src/cmd/inbox.rs @@ -1,6 +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, and -//! once its home or the database in it is deleted. +//! 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 @@ -69,6 +69,9 @@ pub struct InboxArgs { } 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. @@ -124,11 +127,15 @@ pub fn serve(a: InboxArgs) -> Result { // 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)) { @@ -139,18 +146,6 @@ pub fn serve(a: InboxArgs) -> Result { } } Err(RecvTimeoutError::Timeout) => { - // With the home or its database deleted there is nothing left to serve, and - // the port is better free: this inbox answers /verify with the proof key it - // read at start, so the next `need` in a recreated home would find it foreign - // and hand out no links. This holds with --keep too. - if !home.is_dir() || !db.is_file() { - eprintln!("inbox: {} or its database is gone, 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); - } if !a.keep && last_activity.elapsed() > IDLE_EXIT { let _ = app.db.expire_overdue(); if app.db.list_tasks(None, true).map(|v| v.is_empty()).unwrap_or(true) { @@ -161,9 +156,40 @@ 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 diff --git a/crates/cli/tests/inbox_exit.rs b/crates/cli/tests/inbox_exit.rs index c5c2690e..da900395 100644 --- a/crates/cli/tests/inbox_exit.rs +++ b/crates/cli/tests/inbox_exit.rs @@ -1,17 +1,18 @@ //! 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. Each test -//! files a card in a scratch home, checks the inbox is up and stays up, removes one of those, -//! and waits for the inbox's port to close. +//! 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::PathBuf; -use std::process::{Child, Command, Stdio}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; use std::time::{Duration, Instant}; -/// Longer than one idle tick of the inbox's main loop, which is one second. +/// 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); @@ -28,44 +29,75 @@ fn tmp(name: &str) -> PathBuf { struct World { home: PathBuf, proj: PathBuf, - addr: SocketAddr, + 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(); - std::fs::write(home.join("config.toml"), format!("notifications = false\ninbox_port = {port}\nstash_backend = \"insecure-file\"\nverify_every = \"never\"\n")).unwrap(); - World { home, proj: tmp(&format!("proj-{name}")), addr: ([127, 0, 0, 1], port).into() } + let w = World { home, proj: tmp(&format!("proj-{name}")), port }; + w.write_config(&w.home); + w } - /// File a card with `need`, which starts the inbox. Its `TOKENSTASH_EXIT_WITH` is - /// `exit_with` when given, else this test process. - fn file_card(&self, exit_with: Option) { + 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", &self.home).env("TOKENSTASH_STASH", "insecure-file") - .env("HOME", self.home.join("user-home")).env("XDG_CONFIG_HOME", self.home.join("user-home/.config")) + .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)); - assert!(self.listening(), "the need started no inbox"); + 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.listening(), "the inbox exited before anything it depends on was gone"); + assert!(self.answers(), "the inbox exited before anything it depends on was gone"); } - fn listening(&self) -> bool { - TcpStream::connect_timeout(&self.addr, Duration::from_millis(200)).is_ok() + /// 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 nothing listens on the inbox's port, within [`EXIT_WITHIN`]. + /// True once nothing answers on the inbox's port, within [`EXIT_WITHIN`]. Asks every + /// 100 ms, so the inbox is never a second without a request: an exit check that waits + /// for an idle second would never run. fn inbox_exits(&self) -> bool { let started = Instant::now(); while started.elapsed() < EXIT_WITHIN { - if !self.listening() { + if !self.answers() { return true; } std::thread::sleep(Duration::from_millis(100)); @@ -82,7 +114,9 @@ impl Drop for World { } /// A short-lived process standing in for a test run. Killed and reaped on drop. -struct Parent(Child); +#[cfg(debug_assertions)] +struct Parent(std::process::Child); +#[cfg(debug_assertions)] impl Drop for Parent { fn drop(&mut self) { let _ = self.0.kill(); @@ -96,7 +130,7 @@ impl Drop for Parent { #[test] fn the_inbox_exits_once_the_process_it_was_started_for_has() { let w = World::new("parent"); - let parent = Parent(Command::new("sleep").arg("30").spawn().unwrap()); + 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"); @@ -119,3 +153,27 @@ fn the_inbox_exits_once_its_database_is_deleted() { } 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/init_mode.rs b/crates/cli/tests/init_mode.rs index 36648e6f..9be773c9 100644 --- a/crates/cli/tests/init_mode.rs +++ b/crates/cli/tests/init_mode.rs @@ -7,7 +7,7 @@ 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"); @@ -151,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; } @@ -174,7 +174,7 @@ 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") From adb334269215e609238e38bb742bc65353a0dcec Mon Sep 17 00:00:00 2001 From: Krish Garg <181051779+kgarg2468@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:42:01 +0000 Subject: [PATCH 3/3] tests: an inbox counts as gone only once its port refuses connections A reply that failed or stalled used to count as an exit, so a stuck inbox holding the port could pass. The test now keeps asking until the connection is refused. Co-Authored-By: Claude --- crates/cli/tests/inbox_exit.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/cli/tests/inbox_exit.rs b/crates/cli/tests/inbox_exit.rs index da900395..7c05b24d 100644 --- a/crates/cli/tests/inbox_exit.rs +++ b/crates/cli/tests/inbox_exit.rs @@ -91,14 +91,20 @@ impl World { reply.starts_with(b"HTTP/1.1 ") } - /// True once nothing answers on the inbox's port, within [`EXIT_WITHIN`]. Asks every - /// 100 ms, so the inbox is never a second without a request: an exit check that waits - /// for an idle second would never run. + /// 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 { - if !self.answers() { - return true; + 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)); }