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
35 changes: 23 additions & 12 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -55,9 +56,12 @@ nix run .#e2e -- <test_name>
cargo test <test_name>
```

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

Expand All @@ -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
Expand Down Expand Up @@ -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 <home>/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
`<home>/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 <home>/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 `<home>/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)
Expand All @@ -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/
Expand Down
5 changes: 4 additions & 1 deletion docs/tmux-backend-demo-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 34 additions & 6 deletions src/backend/real.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
53 changes: 40 additions & 13 deletions tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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<String> = 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")
Expand All @@ -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();
}
}
Expand Down