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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
102 changes: 100 additions & 2 deletions crates/cli/src/cmd/inbox.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -62,12 +63,15 @@ use tokenstash_core::tasks::{self, Actor, AnswerResult};
pub struct InboxArgs {
#[arg(long)]
pub port: Option<u16>,
/// 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.
Expand Down Expand Up @@ -119,10 +123,19 @@ pub fn serve(a: InboxArgs) -> Result<i32> {
}
};
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::<Request>(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)) {
Expand All @@ -143,7 +156,71 @@ pub fn serve(a: InboxArgs) -> Result<i32> {
}
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=<pid>`. 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<u32> {
#[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<()> {
Expand Down Expand Up @@ -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"), "<input type=hidden name=t value=\"abc123\">");
Expand Down
14 changes: 14 additions & 0 deletions crates/cli/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 6 additions & 4 deletions crates/cli/tests/human_only.rs
Original file line number Diff line number Diff line change
@@ -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()));
Expand Down Expand Up @@ -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()
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 5 additions & 3 deletions crates/cli/tests/inbox_deadline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
}
Expand Down
Loading
Loading