// Pi Tmux Test Harness — v1 // // Exposes tmux session/keystroke/capture as pi tools. All sessions go to an // isolated tmux socket (-L pi-harness) so we never interfere with the user's // own tmux server (no accidental "tmux ls" pollution, bounded kill-all). // // Killer feature: `tmux_expect` — polls `capture-pane` until a regex matches // or timeout fires. Replaces the fragile `sleep N && grep` pattern used for // pi-on-pi adversarial testing. // // SAFETY HARDENING (v1.1, post-code-review 2026-05-04): // - Session names validated against strict charset on EVERY tool, not just tmux_new // (prevents tmux flag-injection: a name like `-L` would otherwise be parsed as // a tmux argument) // - tmux_expect bounds capture size before regex.match (prevents catastrophic- // backtrack DoS on large pane snapshots) and rejects obviously dangerous // regex patterns up-front // - tmux_expect honors AbortSignal at every poll boundary import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); // Dedicated tmux socket — isolates our sessions from the user's tmux server. const SOCKET = "pi-harness"; const TMUX_BIN = "tmux"; // Strict allowlist for session names. Used everywhere the value reaches `tmux -t ` // to prevent flag injection (a name like `-L foo` or `-c bash` would be parsed as tmux // flags by the underlying tmux invocation, even though execFile prevents shell injection). // Also reserves "all" — it's the literal sentinel that tmux_kill uses to mean kill-all, // so we must not let a session be named that (round-4 nit). const SESSION_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; const RESERVED_NAMES = new Set(["all"]); // Reject argv elements that tmux's command-list parser treats specially — these // would escape the send-keys command context. Round-5 review caught that even // with the `--` end-of-options sentinel, tmux still treats `;` as a command // separator. Round-6 review tightened: any `;` or `{` or `}` anywhere in the // key (not just exact-match) is rejected, plus all raw control bytes including // newlines (which tmux also treats as a command separator). function validateTmuxKeys(keys: string[]): void { for (const k of keys) { if (typeof k !== "string" || k.length === 0) { throw new Error(`Invalid key '${k}': must be a non-empty string`); } // Round-6 fix: GPT review noted that exact-match Set rejection was bypassable // by whitespace-prefixed forms like " ;" or "foo ;". Be paranoid and reject // `;`, `{`, `}` ANYWHERE in the key (these are tmux command-list separators // and braces — no legitimate tmux key name contains them). if (/[;{}]/.test(k)) { throw new Error( `Key '${k}' contains a tmux command-separator/brace character (; { }). Tmux key names cannot contain these; use the literal name (e.g. 'Semicolon') instead.`, ); } // Round-6 fix: Gemini caught that the previous `&& !/^(C-|M-|S-)/.test(k)` // exemption let `"C-\nrun-shell ..."` slip through. Modifier-prefixed names // like `C-c` are plain 3-char ASCII; they NEVER contain raw control bytes. // Drop the exemption and reject control bytes unconditionally. if (/[\x00-\x1f]/.test(k)) { throw new Error( `Key ${JSON.stringify(k)} contains control characters; use named modifier form like 'C-c'.`, ); } } } function validateSessionName(name: string): void { if (!SESSION_NAME_RE.test(name)) { throw new Error( `Invalid session name '${name}': must match /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/ (alphanumeric + _ - , no leading dash, max 64 chars)`, ); } if (RESERVED_NAMES.has(name)) { throw new Error( `Session name '${name}' is reserved (collides with tmux_kill name='all' sentinel). Pick a different name.`, ); } } // Cap capture size before regex match to prevent catastrophic-backtrack DoS. // Pi's terminal width is typically 220 columns; a 200-line scrollback caps // at ~44 KB. (See MAX_REGEX_INPUT_BYTES below for the actual cap.) // Reject regex patterns with obvious catastrophic-backtracking shapes BEFORE compilation. // Not exhaustive (genuinely safe regex is undecidable), but catches the textbook bad // patterns. Round-3 review added bounded-OUTER-quantifier shapes for symmetry with // the inner-bounded patterns added in round 2. // // Acknowledged limitation: a sufficiently clever pattern can still cause polynomial // blow-up that this AST-level check misses (e.g. `(a+){10}` against many a's). The // 32 KB input cap bounds worst-case time to seconds, not minutes. A proper fix would // require RE2 / safe-regex / worker-thread-with-timeout — deferred to a future hardening // pass since this is a personal harness, not a public service. const SUSPICIOUS_REGEX_RES = [ /\([^()]*[+*][^()]*\)[+*]/, // (X+)+, (X*)*, (X+)*, etc. /\([^()]*\|[^()]*\)[+*]/, // (a|aa)+, (a|b|c)* /\{[\d,]*\}\s*[+*]/, // a{1,2}+, a{3}*, a{1,}* /\([^()]*\{[\d,]*\}[^()]*\)[+*]/, // (a{1,2})+, (a{3,5})* /\([^()]*[+*][^()]*\)\{[\d,]*\}/, // (a+){10}, (a*){2,5} — outer bounded around inner unbounded /\([^()]*\{[\d,]*\}[^()]*\)\{[\d,]*\}/, // (a{1,2}){3,5} — outer bounded around inner bounded ]; // Lower the input cap to 32 KB after round-2 review noted that even with the // SUSPICIOUS guard, a small unblocked polynomial DoS pattern could still consume // seconds of CPU on hundreds of KB of input. 32 KB bounds worst-case regex time // to <100ms on commodity hardware for everything that gets past the syntactic guard. const MAX_REGEX_INPUT_BYTES = 32 * 1024; function validateRegexPattern(pattern: string): void { if (pattern.length > 1024) { throw new Error(`Regex pattern too long (${pattern.length} > 1024 chars)`); } for (const bad of SUSPICIOUS_REGEX_RES) { if (bad.test(pattern)) { throw new Error( `Pattern /${pattern}/ contains nested quantifiers that risk catastrophic backtracking. Rewrite to avoid (X+)+ / (X|XX)+ shapes, or pre-extract a smaller substring before matching.`, ); } } } // In-memory registry of sessions WE created. Restart wipes this; orphans stay // on the dedicated socket and can be reaped via `tmux -L pi-harness kill-server`. interface ManagedSession { name: string; cwd: string; command: string; startedAt: number; } const managed = new Map(); // Hard cap so adversarial use can't grow `managed` forever. // Round-3 fix: pruneDeadSessions now ALWAYS prunes (cheap iteration over // at-most-CAP entries). The previous `if (size <= CAP) return` guard combined // with `ensureCapacity`'s `>= CAP` reject created an off-by-one lockout when // exactly CAP dead entries existed. const MANAGED_HARD_CAP = 64; async function pruneDeadSessions(): Promise { for (const name of Array.from(managed.keys())) { const alive = await sessionExists(name); if (!alive) managed.delete(name); } } async function ensureCapacityForNewSession(): Promise { await pruneDeadSessions(); if (managed.size >= MANAGED_HARD_CAP) { throw new Error( `Managed-session hard cap reached (${MANAGED_HARD_CAP} live). Kill a session via tmux_kill before creating a new one, or pass tmux_kill name='all' to clear them.`, ); } } async function tmux(args: string[], opts: { input?: string } = {}): Promise<{ stdout: string; stderr: string }> { try { const { stdout, stderr } = await execFileAsync(TMUX_BIN, ["-L", SOCKET, ...args], { input: opts.input, maxBuffer: 8 * 1024 * 1024, // 8MB — capture-pane scrollback can be large }); return { stdout, stderr }; } catch (e: any) { // execFile rejects with stdout/stderr attached on non-zero exit. const stdout = e?.stdout ?? ""; const stderr = e?.stderr ?? e?.message ?? String(e); throw new Error(`tmux ${args.join(" ")} failed: ${stderr.trim() || stdout.trim()}`); } } // Helpers: format a session name as an EXACT tmux target. Without exact-match // markers, tmux uses prefix matching, so `tmux -t pi` would match a session // actually named `pi-abc-xyz`. Round 9 caught the prefix issue; round 10 // caught that we need TWO different forms: // - session-target commands (has-session, kill-session) accept `=name` // - pane-target commands (send-keys, capture-pane) need `=name:` (the trailing // colon disambiguates session-vs-pane in tmux's target parser) // Verified empirically against tmux 3.6a. function exactSessionTarget(name: string): string { return `=${name}`; } function exactPaneTarget(name: string): string { return `=${name}:`; } async function sessionExists(name: string): Promise { try { await tmux(["has-session", "-t", exactSessionTarget(name)]); return true; } catch { return false; } } function uniqueName(prefix: string): string { const ts = Date.now().toString(36); const rnd = Math.random().toString(36).slice(2, 6); return `${prefix}-${ts}-${rnd}`; } function trimTrailingBlankLines(s: string): string { // Round-8 fix: replaced regex with a tight reverse scan after GPT review noted // the regex form `/\s*\n\s*$/` could be quadratic on adversarial inputs (large // whitespace-prefixed captures from TUIs). Manual scan is strictly O(n) and // preserves the original semantic: strip trailing whitespace ONLY if it includes // at least one newline (so a non-newline-terminated trailing-spaces case is // preserved — tmux's `tail -n` results often have meaningful trailing spaces). let i = s.length; let sawNewline = false; while (i > 0) { const c = s.charCodeAt(i - 1); // \t \n \v \f \r space — the chars matched by /\s/. if (c === 9 || c === 10 || c === 11 || c === 12 || c === 13 || c === 32) { if (c === 10) sawNewline = true; i--; } else break; } return sawNewline ? s.slice(0, i) : s; } export default function tmuxHarnessExtension(pi: ExtensionAPI) { // ─── tmux_new ─────────────────────────────────────────────────────────── pi.registerTool({ name: "tmux_new", label: "Tmux: new session", description: "Create a new detached tmux session on the isolated `pi-harness` socket. Returns the session name. Use to spawn pi/claude-code/any TUI for automated testing without polluting the user's own tmux.", promptSnippet: "Spawn a tmux session for automated TUI testing", promptGuidelines: [ "Use tmux_new to spawn TUI programs (especially another pi instance) for automated testing instead of running them directly with bash.", ], parameters: Type.Object({ name: Type.Optional( Type.String({ description: "Optional session name. If omitted, an auto-generated unique name is used. Must not contain `:` or `.`.", }), ), cwd: Type.Optional( Type.String({ description: "Working directory for the session. Defaults to pi's cwd.", }), ), command: Type.Optional( Type.String({ description: "Command to run in the session. Defaults to the user's shell. Examples: 'pi', 'claude', 'lazygit'.", }), ), width: Type.Optional(Type.Integer({ description: "Pane width in columns. Default 220." })), height: Type.Optional(Type.Integer({ description: "Pane height in rows. Default 50." })), }), async execute(_id, params, _signal, _onUpdate, _ctx) { const name = params.name?.trim() || uniqueName("pi"); validateSessionName(name); if (await sessionExists(name)) { throw new Error(`Session '${name}' already exists. Pick a different name or kill it first.`); } await ensureCapacityForNewSession(); const cwd = params.cwd?.trim() || process.cwd(); const command = params.command?.trim() || process.env.SHELL || "/bin/sh"; const width = params.width ?? 220; const height = params.height ?? 50; const args = [ "new-session", "-d", "-s", name, "-c", cwd, "-x", String(width), "-y", String(height), command, ]; await tmux(args); managed.set(name, { name, cwd, command, startedAt: Date.now() }); return { content: [ { type: "text", text: `Started tmux session '${name}' (${width}×${height}) in ${cwd}\nCommand: ${command}\nUse tmux_expect or tmux_capture to read output.`, }, ], details: { name, cwd, command, width, height }, }; }, }); // ─── tmux_send ────────────────────────────────────────────────────────── pi.registerTool({ name: "tmux_send", label: "Tmux: send text", description: "Send literal text to a tmux session. By default, follows with Enter (so this is `type-and-submit`). Set `enter: false` for raw text without Enter.", parameters: Type.Object({ name: Type.String({ description: "Session name (returned by tmux_new)." }), text: Type.String({ description: "Literal text to send." }), enter: Type.Optional( Type.Boolean({ description: "Append Enter after the text. Default true." }), ), }), async execute(_id, params) { validateSessionName(params.name); const enter = params.enter ?? true; // Use -l (literal) so special chars like / and - aren't interpreted as tmux key names. // Round-7 fix: also append `--` end-of-options sentinel so text starting with `-` // (e.g. `-h`, `-X`) isn't re-parsed as send-keys flags despite -l. (`-l` only // makes subsequent argv elements be treated as literal *keys*, not as flag // suppressors for any preceding arg.) await tmux(["send-keys", "-t", exactPaneTarget(params.name), "-l", "--", params.text]); if (enter) await tmux(["send-keys", "-t", exactPaneTarget(params.name), "Enter"]); return { content: [{ type: "text", text: `Sent ${params.text.length} chars${enter ? " + Enter" : ""}` }], details: { name: params.name, length: params.text.length, enter }, }; }, }); // ─── tmux_send_key ────────────────────────────────────────────────────── pi.registerTool({ name: "tmux_send_key", label: "Tmux: send special key", description: "Send a named special key (or sequence of keys) to a tmux session. Uses tmux's native key names: Enter, Escape, Tab, Up, Down, Left, Right, BSpace, Home, End, PageUp, PageDown, F1..F12, and modified keys like C-c, C-d, C-l, M-x, S-Tab.", parameters: Type.Object({ name: Type.String({ description: "Session name." }), keys: Type.Array(Type.String(), { description: "Ordered list of tmux key names. Examples: ['Escape'], ['C-c'], ['Down', 'Down', 'Enter'], ['BSpace', 'BSpace', 'BSpace'].", minItems: 1, }), }), async execute(_id, params) { validateSessionName(params.name); validateTmuxKeys(params.keys); // Round-4 hardening: prepend `--` end-of-options sentinel so a key like // "-X" or "-l" can't be re-interpreted by tmux as a flag. // Round-5 hardening: validateTmuxKeys above blocks `;` / `{` / `}` which // would escape send-keys context (tmux command separators). await tmux(["send-keys", "-t", exactPaneTarget(params.name), "--", ...params.keys]); return { content: [{ type: "text", text: `Sent keys: ${params.keys.join(" ")}` }], details: { name: params.name, keys: params.keys }, }; }, }); // ─── tmux_capture ─────────────────────────────────────────────────────── pi.registerTool({ name: "tmux_capture", label: "Tmux: capture pane", description: "Capture the current pane content of a tmux session. By default returns just the visible screen. Use `scrollback: N` to also include the last N lines of scrollback history.", parameters: Type.Object({ name: Type.String({ description: "Session name." }), scrollback: Type.Optional( Type.Integer({ description: "How many lines of scrollback to include before the visible screen. 0 = visible only (default). Negative not allowed.", minimum: 0, }), ), trim: Type.Optional( Type.Boolean({ description: "Strip trailing blank lines from the snapshot. Default true.", }), ), }), async execute(_id, params) { validateSessionName(params.name); const scrollback = params.scrollback ?? 0; const trim = params.trim ?? true; const args = ["capture-pane", "-t", exactPaneTarget(params.name), "-p"]; if (scrollback > 0) args.push("-S", `-${scrollback}`); const { stdout } = await tmux(args); const text = trim ? trimTrailingBlankLines(stdout) : stdout; return { content: [{ type: "text", text }], details: { name: params.name, bytes: text.length, lines: text.split("\n").length }, }; }, }); // ─── tmux_expect ──────────────────────────────────────────────────────── pi.registerTool({ name: "tmux_expect", label: "Tmux: expect regex", description: "Poll a tmux session's capture-pane until the given regex matches or the timeout fires. THIS IS THE FRAGILE-SLEEP KILLER — prefer this over manual sleep+capture loops. Returns `matched: true` with the matched string on success, or `matched: false` after timeout.", promptGuidelines: [ "Use tmux_expect instead of sleeping then capturing — it polls until the expected output appears or times out.", ], parameters: Type.Object({ name: Type.String({ description: "Session name." }), pattern: Type.String({ description: "JavaScript regex source (no surrounding slashes). Multi-line is implicit (we search the captured text as a whole). Use anchors carefully — `^` and `$` are line-anchors only with the `m` flag.", }), flags: Type.Optional( Type.String({ description: "Regex flags. Default 'm' (multiline)." }), ), timeoutMs: Type.Optional( Type.Integer({ description: "Max wait in ms. Default 30000.", minimum: 0 }), ), pollIntervalMs: Type.Optional( Type.Integer({ description: "Poll interval in ms. Default 250.", minimum: 50 }), ), scrollback: Type.Optional( Type.Integer({ description: "Lines of scrollback to include in each capture. Default 200 (catches recently-scrolled-off content).", minimum: 0, }), ), }), async execute(_id, params, signal, onUpdate) { validateSessionName(params.name); validateRegexPattern(params.pattern); const timeout = params.timeoutMs ?? 30_000; const interval = params.pollIntervalMs ?? 250; const scrollback = params.scrollback ?? 200; const flags = params.flags ?? "m"; let regex: RegExp; try { regex = new RegExp(params.pattern, flags); } catch (e: any) { throw new Error(`Invalid regex /${params.pattern}/${flags}: ${e?.message ?? e}`); } const start = Date.now(); let lastSnapshot = ""; let pollCount = 0; while (true) { if (signal?.aborted) { return { content: [{ type: "text", text: `tmux_expect aborted after ${Date.now() - start}ms` }], details: { matched: false, aborted: true, elapsedMs: Date.now() - start }, }; } const captureArgs = ["capture-pane", "-t", exactPaneTarget(params.name), "-p"]; if (scrollback > 0) captureArgs.push("-S", `-${scrollback}`); try { const { stdout } = await tmux(captureArgs); lastSnapshot = stdout; } catch (e: any) { // Session disappeared mid-poll — surface clearly. return { content: [{ type: "text", text: `tmux_expect: capture failed (${e?.message ?? e})` }], details: { matched: false, error: String(e?.message ?? e), elapsedMs: Date.now() - start }, }; } pollCount++; // Cap input size before regex match — catastrophic backtracking time grows // non-linearly with input length, so we trim the snapshot to a sane bound // and search the tail (most recent output is what we usually want). const searchInput = lastSnapshot.length > MAX_REGEX_INPUT_BYTES ? lastSnapshot.slice(-MAX_REGEX_INPUT_BYTES) : lastSnapshot; const m = searchInput.match(regex); if (m) { const elapsedMs = Date.now() - start; return { content: [ { type: "text", text: `Matched after ${elapsedMs}ms (${pollCount} polls): ${m[0].slice(0, 200)}`, }, ], details: { matched: true, match: m[0], groups: m.slice(1), elapsedMs, polls: pollCount, lastSnapshot, }, }; } const elapsed = Date.now() - start; if (elapsed >= timeout) { return { content: [ { type: "text", text: `tmux_expect TIMEOUT after ${elapsed}ms (${pollCount} polls). Pattern /${params.pattern}/${flags} did not match.\n\nLast snapshot (tail 20 lines):\n${lastSnapshot.split("\n").slice(-20).join("\n")}`, }, ], details: { matched: false, elapsedMs: elapsed, polls: pollCount, lastSnapshot }, }; } // Periodic progress update so the user sees we're polling. if (onUpdate && pollCount % 8 === 0) { onUpdate({ content: [ { type: "text", text: `tmux_expect: ${elapsed}ms elapsed, ${pollCount} polls, no match yet` }, ], }); } await new Promise((r) => setTimeout(r, interval)); } }, }); // ─── tmux_kill ────────────────────────────────────────────────────────── pi.registerTool({ name: "tmux_kill", label: "Tmux: kill session", description: "Kill a tmux session by name. Pass `name: 'all'` to kill every session managed by this extension.", parameters: Type.Object({ name: Type.String({ description: "Session name to kill, or 'all' to kill every managed session.", }), }), async execute(_id, params) { if (params.name === "all") { const names = Array.from(managed.keys()); const results: string[] = []; for (const n of names) { try { await tmux(["kill-session", "-t", exactSessionTarget(n)]); results.push(`✓ ${n}`); } catch (e: any) { results.push(`✗ ${n}: ${e?.message ?? e}`); } managed.delete(n); } return { content: [ { type: "text", text: `Killed ${names.length} session(s):\n${results.join("\n") || "(none)"}` }, ], details: { killed: names.length, names }, }; } try { validateSessionName(params.name); } catch (e: any) { throw new Error(`Failed to kill '${params.name}': ${e?.message ?? e}`); } try { await tmux(["kill-session", "-t", exactSessionTarget(params.name)]); } catch (e: any) { // Round-3 fix: if tmux says "can't find session", treat as already-gone // and clean up our managed entry anyway. Previously this threw and left // the dead entry in the managed map forever, contributing to the cap bug. const msg = String(e?.message ?? e); if (/no such session|can't find session|session not found/i.test(msg)) { managed.delete(params.name); return { content: [{ type: "text", text: `Session '${params.name}' was already gone; removed from managed list.` }], details: { name: params.name, alreadyGone: true }, }; } throw new Error(`Failed to kill '${params.name}': ${msg}`); } managed.delete(params.name); return { content: [{ type: "text", text: `Killed session '${params.name}'` }], details: { name: params.name }, }; }, }); // ─── tmux_list ────────────────────────────────────────────────────────── pi.registerTool({ name: "tmux_list", label: "Tmux: list managed sessions", description: "List sessions managed by this extension on the isolated pi-harness socket. Includes elapsed time + cwd + command.", parameters: Type.Object({}), async execute() { if (managed.size === 0) { return { content: [{ type: "text", text: "No managed tmux sessions." }], details: { count: 0, sessions: [] }, }; } const now = Date.now(); const rows: Array<{ name: string; cwd: string; command: string; elapsedMs: number; alive: boolean }> = []; for (const s of managed.values()) { const alive = await sessionExists(s.name); rows.push({ name: s.name, cwd: s.cwd, command: s.command, elapsedMs: now - s.startedAt, alive }); } const text = rows .map((r) => { const sec = Math.floor(r.elapsedMs / 1000); const elapsed = sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m ${sec % 60}s`; return `• ${r.alive ? "🟢" : "🔴"} ${r.name} (${elapsed}) — ${r.command} @ ${r.cwd}`; }) .join("\n"); return { content: [{ type: "text", text }], details: { count: rows.length, sessions: rows }, }; }, }); }