diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 91176c1..ad55927 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -45,7 +45,8 @@ nix build .#check nix build .#test # or: cargo test --bin yeschef # E2E tests — require git + tmux on PATH (no containers/macOS). Drive real tmux -# sessions on yeschef's private `-L yeschef` server, so run single-threaded. +# sessions on a throwaway per-run `-L` socket (never the live `yeschef` server), +# so run single-threaded. cargo test --test e2e -- --ignored --test-threads=1 # or via the flake (PATH-checks git + tmux first): nix run .#e2e @@ -55,9 +56,12 @@ nix run .#e2e -- cargo test ``` -The e2e suite is light now (no image builds). It uses unique per-test project names but -shares the global `yeschef` tmux server (a private `-L` socket) — `--test-threads=1` -avoids cross-test races, and each test kills its own tmux session on drop. +The e2e suite is light now (no image builds). It uses unique per-test project names and +runs on a **throwaway per-run tmux socket** — it picks a unique `-L` name (PID + nanos), +exports it as `YESCHEF_TMUX_SOCKET` so the spawned `yeschef` binary and the tests' own +`tmux` helpers drive that same private server, and never touches the operator's live +`yeschef` server. `--test-threads=1` avoids cross-test races, and each test kills its own +tmux session on drop. ## CI — run `nix flake check` before you push @@ -72,8 +76,9 @@ nix run .#e2e # the e2e suite (run separately — see below) `checks` in `flake.nix` covers **fmt** (`cargo fmt --check`), **nixfmt** (nixfmt-rfc-style on `flake.nix`), **lint** (strict clippy), and **test** (unit tests). The **e2e** suite is deliberately *not* a flake check: it drives real -tmux sessions and real git worktrees (impure, shares the global `yeschef` tmux -server), so it runs un-sandboxed via `nix run .#e2e`. Run both before pushing. +tmux sessions and real git worktrees (impure, though on a throwaway per-run `-L` +socket rather than the live `yeschef` server), so it runs un-sandboxed via +`nix run .#e2e`. Run both before pushing. The GitHub Action (`.github/workflows/ci.yml`) runs exactly these two commands on every push and PR, on an `ubuntu-latest` runner. tmux is a cross-platform nixpkgs @@ -150,11 +155,15 @@ derived name stays a clean tmux session id. and detaches on its own without disturbing the others. `session_exists`/`list_windows` derive the brigade's state from the set of `yeschef-…` sessions; there is no parent session to pre-create, so `ensure_session` is a no-op. -- **Private server.** Every `tmux` invocation runs against a dedicated `-L` socket - (`TMUX_SOCKET = "yeschef"`) loaded with yeschef's own config (`tmux -f /tmux.conf`), - so yeschef's sessions never touch the user's default tmux server or `~/.tmux.conf`. The - config is baked into the binary (`include_str!("../tmux.conf")`) and rewritten to - `/tmux.conf` on every `Config::load` (see `config::ensure_tmux_conf`). +- **Private server.** Every `tmux` invocation runs against a dedicated `-L` socket loaded + with yeschef's own config (`tmux -f /tmux.conf`), so yeschef's sessions never touch + the user's default tmux server or `~/.tmux.conf`. The socket name is resolved once per + backend by `backend::real::resolve_tmux_socket` — `YESCHEF_TMUX_SOCKET` if set, else the + `DEFAULT_TMUX_SOCKET` (`"yeschef"`). Production leaves it unset; the e2e tests point it at + a throwaway per-run socket so their `kill-session`/`kill-server` calls can never reach the + operator's live `yeschef` server. The config is baked into the binary + (`include_str!("../tmux.conf")`) and rewritten to `/tmux.conf` on every + `Config::load` (see `config::ensure_tmux_conf`). - **Shift+Enter.** `tmux.conf` sets `extended-keys on` + `terminal-features 'xterm*:extkeys'` so tmux forwards CSI-u / modifyOtherKeys sequences to the running agent — that is what lets Claude Code see Shift+Enter as a distinct key (insert newline) @@ -180,7 +189,9 @@ derived name stays a clean tmux session id. ## Home directory Defaults to `~/yeschef`; overridden with `YESCHEF_HOME` (used by e2e tests for isolation). -Layout: +A second env var, `YESCHEF_TMUX_SOCKET`, overrides the tmux `-L` socket name (default +`yeschef`) — the e2e tests set it to a throwaway per-run name so they never touch the +operator's live tmux server. Layout: ``` ~/yeschef/ diff --git a/docs/tmux-backend-demo-setup.sh b/docs/tmux-backend-demo-setup.sh index afccfb4..bfdb7ab 100755 --- a/docs/tmux-backend-demo-setup.sh +++ b/docs/tmux-backend-demo-setup.sh @@ -5,6 +5,9 @@ set -euo pipefail export YESCHEF_HOME=/tmp/yeschef-tmux-demo +# Drive a throwaway tmux `-L` socket, NOT the operator's live `yeschef` server: +# the reset `kill-server` below would otherwise nuke every running line cook. +export YESCHEF_TMUX_SOCKET=yeschef-demo REPO=/tmp/yeschef-tmux-demo-repo COOK=/tmp/yeschef-demo-cook.sh BIN=./target/debug/yeschef @@ -23,7 +26,7 @@ done COOKEOF rm -rf "$YESCHEF_HOME" "$REPO" -tmux -L yeschef kill-server 2>/dev/null || true +tmux -L "$YESCHEF_TMUX_SOCKET" kill-server 2>/dev/null || true mkdir -p "$REPO" git -C "$REPO" init -q -b main diff --git a/flake.nix b/flake.nix index 47e1ed6..baec3e4 100644 --- a/flake.nix +++ b/flake.nix @@ -132,10 +132,10 @@ # NOT covered here: the e2e suite. It is intentionally kept out of # `nix flake check` and run as a separate `nix run .#e2e` CI step. Two # reasons: - # 1. e2e drives REAL tmux sessions and REAL git worktrees, sharing the - # global `yeschef` tmux server (a private `-L` socket) and spawning - # detached sessions. That is an impure integration test, not a - # hermetic build. + # 1. e2e drives REAL tmux sessions and REAL git worktrees on a + # throwaway per-run `-L` socket (via `YESCHEF_TMUX_SOCKET`, never the + # live `yeschef` server) and spawns detached sessions. That is an + # impure integration test, not a hermetic build. # 2. naersk's test mode can't cleanly run the `#[ignore]`d e2e tests: # its deps-only build phase replays the same `cargo test` options # against a dummy src that has no `e2e` target and fails. Forcing it diff --git a/src/backend/real.rs b/src/backend/real.rs index 6c4603e..fe0730e 100644 --- a/src/backend/real.rs +++ b/src/backend/real.rs @@ -256,21 +256,49 @@ impl GitBackend for RealGitBackend { // user's default tmux server and stops it from reading or clobbering their // `~/.tmux.conf`. The config ships the `extended-keys`/`terminal-features` // settings that let Claude Code see Shift+Enter (see `config::ensure_tmux_conf`). - -/// The `-L` socket name for yeschef's private tmux server. Sessions on this -/// socket are isolated from the user's default (`~/.tmux`) server. -pub const TMUX_SOCKET: &str = "yeschef"; +// +// The socket name is configurable via `YESCHEF_TMUX_SOCKET` (default `yeschef`), +// resolved once per backend in `new`. Production leaves it unset and runs on +// `yeschef`; the e2e tests point it at a throwaway per-run socket so they can +// create and kill sessions — even a whole `kill-server` — without ever touching +// the operator's live `yeschef` server. + +/// Default `-L` socket name for yeschef's private tmux server, used when +/// `YESCHEF_TMUX_SOCKET` is unset. Sessions on this socket are isolated from +/// the user's default (`~/.tmux`) server. +pub const DEFAULT_TMUX_SOCKET: &str = "yeschef"; + +/// The env var that overrides the tmux `-L` socket name. +pub const TMUX_SOCKET_ENV: &str = "YESCHEF_TMUX_SOCKET"; + +/// Resolve the tmux `-L` socket name from [`TMUX_SOCKET_ENV`], falling back to +/// [`DEFAULT_TMUX_SOCKET`] when it is unset or empty. This is the single source +/// of truth for which tmux server every yeschef invocation drives — making it +/// configurable is what lets the e2e suite run on a throwaway per-run socket +/// instead of the operator's live `yeschef` server. +pub fn resolve_tmux_socket() -> String { + match std::env::var(TMUX_SOCKET_ENV) { + Ok(s) if !s.trim().is_empty() => s, + _ => DEFAULT_TMUX_SOCKET.to_string(), + } +} pub struct RealTmuxBackend { /// Path to yeschef's own tmux config, passed via `-f` so the private server /// starts with our `extended-keys`/scrollback settings and never sources /// the user's `~/.tmux.conf`. config_path: PathBuf, + /// The `-L` socket name this backend drives, resolved once from + /// [`resolve_tmux_socket`] at construction. + socket: String, } impl RealTmuxBackend { pub fn new(config_path: PathBuf) -> Self { - Self { config_path } + Self { + config_path, + socket: resolve_tmux_socket(), + } } /// A `tmux` command pre-wired to yeschef's private server: the dedicated @@ -279,7 +307,7 @@ impl RealTmuxBackend { fn cmd(&self) -> Command { let mut c = Command::new("tmux"); c.arg("-L") - .arg(TMUX_SOCKET) + .arg(&self.socket) .arg("-f") .arg(&self.config_path); c diff --git a/tests/e2e.rs b/tests/e2e.rs index 7fcb958..42a7d88 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -4,13 +4,23 @@ //! cargo test --test e2e -- --ignored --test-threads=1 //! //! Requirements: `git` and `tmux` in PATH. No containers, no Nix, no macOS -//! requirement — the head chef drives real git worktrees and real tmux -//! sessions on yeschef's private `-L yeschef` server. `--test-threads=1` keeps -//! the shared `yeschef` sessions sane across tests (each test uses a unique -//! project name, so windows don't clash). +//! requirement — the tests drive real git worktrees and real tmux sessions. +//! +//! ## Socket isolation +//! +//! The suite NEVER touches the operator's live `yeschef` tmux server. It picks a +//! unique, throwaway `-L` socket for the run (see [`test_socket`]), exports it as +//! `YESCHEF_TMUX_SOCKET` so every spawned `yeschef` binary drives that same +//! private server, and points every direct `tmux` helper here at it too. So a +//! `kill-session` (or even a `kill-server`) in a test can only ever affect the +//! test's own server — running the full suite from inside a live yeschef session +//! leaves the operator's sessions untouched. `--test-threads=1` keeps the shared +//! per-run sessions sane across tests (each test also uses a unique project name, +//! so windows don't clash). use std::path::Path; use std::process::Command; +use std::sync::OnceLock; use predicates::prelude::*; use tempfile::TempDir; @@ -39,6 +49,9 @@ impl TestEnv { fn cmd(&self) -> assert_cmd::Command { let mut cmd = assert_cmd::Command::cargo_bin("yeschef").unwrap(); cmd.env("YESCHEF_HOME", &self.home); + // Pin the spawned binary to the run's throwaway tmux socket so it never + // drives the operator's live `yeschef` server (see `test_socket`). + cmd.env("YESCHEF_TMUX_SOCKET", test_socket()); cmd } @@ -97,17 +110,31 @@ fn unique_name() -> String { format!("t{pid:08x}{:04x}", nanos & 0xffff) } -/// yeschef's private tmux socket (`tmux -L`), mirroring -/// `backend::real::TMUX_SOCKET`. All helpers here talk to that server so they -/// observe exactly the sessions the yeschef binary created. -const TMUX_SOCKET: &str = "yeschef"; +/// A unique, throwaway tmux `-L` socket for THIS test-binary run. Computed once +/// (PID + startup nanos) and shared by every helper here and — via the +/// `YESCHEF_TMUX_SOCKET` env var set in [`TestEnv::cmd`] — by every `yeschef` +/// child process the tests spawn. This is the isolation guarantee: the suite +/// stands up and tears down its own private server and can never touch the +/// operator's live `yeschef` socket, even when run from inside a live session. +fn test_socket() -> &'static str { + static SOCKET: OnceLock = OnceLock::new(); + SOCKET.get_or_init(|| { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .subsec_nanos(); + let pid = std::process::id(); + format!("yeschef-test-{pid:08x}-{nanos:08x}") + }) +} -/// A `tmux` command wired to yeschef's private server. Read/kill helpers don't -/// need `-f` (the server is already running with its config from the binary's -/// spawn), so only the socket is set. +/// A `tmux` command wired to the run's throwaway server ([`test_socket`]). +/// Read/kill helpers don't need `-f` (the server is already running with its +/// config from the binary's spawn), so only the socket is set. fn tmux(args: &[&str]) -> std::process::Output { Command::new("tmux") - .args(["-L", TMUX_SOCKET]) + .args(["-L", test_socket()]) .args(args) .output() .expect("failed to run tmux") @@ -126,7 +153,7 @@ impl Drop for WindowCleanup { fn drop(&mut self) { for window in &self.0 { let _ = Command::new("tmux") - .args(["-L", TMUX_SOCKET, "kill-session", "-t", &sid(window)]) + .args(["-L", test_socket(), "kill-session", "-t", &sid(window)]) .output(); } }