Files
nntrivi2001 8ecb96e9fc fix: a new account's first Claude session no longer opens on the bypass warning
--allow-dangerously-skip-permissions (bypass in the pane's shift+tab cycle)
made Claude Code show its Bypass Permissions warning on any account that had
not accepted it yet — every new member's first session, which the Chat UI
cannot answer, though nobody asked for bypass. Now passed only when the run
starts in bypassPermissions (asking is consent to see it) or the account's
user settings already hold skipDangerousModePermissionPrompt (pty-run
bypassAccepted; a member's read owner-checked from their home). The host,
which accepted long ago, keeps the flag. Test: pty-run.
2026-10-12 15:56:16 +07:00

1006 lines
38 KiB
JavaScript

/**
* @file pty-run.test.js
* @description Unit tests for the tmux-backed run lifecycle. Injects a fake
* tmux exec implementation (via tmux.js's test seam) so no real tmux binary
* is invoked.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const { execFileSync } = require("node:child_process");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
describe("pty-run", () => {
after(() => {
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort */
}
});
beforeEach(() => {
tmux.__reset();
});
it("spawnRun creates a new tmux session named agenthub-lane-<id> when none exists", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "has-session") {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
const handle = pty.spawnRun({ laneId: 42, cwd: "/tmp/repo", model: "opus" });
assert.equal(handle.id, "agenthub-lane-42");
const newSessionCall = calls.find((c) => c[0] === "new-session");
assert.ok(newSessionCall, "expected a new-session call");
assert.deepEqual(newSessionCall.slice(0, 6), [
"new-session",
"-d",
"-s",
"agenthub-lane-42",
"-c",
"/tmp/repo",
]);
assert.ok(newSessionCall.includes("claude"));
assert.ok(newSessionCall.includes("--model"));
assert.ok(newSessionCall.includes("opus"));
});
it("spawnShell starts the login shell only once the pane has its real size (issue #55)", async () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
pty.spawnShell({ laneId: 43, cwd: "/tmp/repo" });
const call = calls.find((c) => c[0] === "new-session");
const argv = call.slice(call.indexOf("--") + 1);
assert.equal(argv[0], "sh");
assert.equal(argv[1], "-c");
// Run the wrapper with a stand-in shell and a fake `tmux` that reports no
// client until a flag file appears: the shell must wait for the attach,
// then start at once rather than at the 2 s cap.
const { spawn } = require("node:child_process");
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-"));
const flag = path.join(bin, "attached");
fs.writeFileSync(path.join(bin, "tmux"), `#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`, {
mode: 0o755,
});
try {
const t0 = Date.now();
const child = spawn("sh", ["-c", argv[2], "sh", os.tmpdir()], {
env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` },
});
let out = "";
let startedAt = 0;
child.stdout.on("data", (d) => {
out += d;
startedAt ||= Date.now() - t0;
});
setTimeout(() => fs.writeFileSync(flag, ""), 400);
await new Promise((r) => child.on("exit", r));
assert.equal(out.trim(), "-l");
assert.ok(startedAt >= 400 && startedAt < 1500, `shell started after ${startedAt} ms`);
} finally {
fs.rmSync(bin, { recursive: true, force: true });
}
});
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 9 (ten per workspace)", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
const handle = pty.spawnRun({ laneId: 42, slot: 2, cwd: "/tmp/repo" });
assert.equal(handle.id, "agenthub-lane-42-2");
assert.equal(handle.laneId, 42);
assert.equal(handle.slot, 2);
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-2"), 42);
assert.equal(pty.spawnRun({ laneId: 42, slot: 9, cwd: "/tmp/repo" }).id, "agenthub-lane-42-9");
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-9"), 42);
assert.throws(
() => pty.spawnRun({ laneId: 42, slot: 10, cwd: "/tmp/repo" }),
(err) => err.code === "EBADSLOT"
);
});
it("spawnRun refuses with ETMUXMISSING, naming the fix, when tmux is not installed", () => {
// Before this guard the ENOENT escaped from three calls down as
// `spawnSync tmux ENOENT` and the route answered 400 — blaming the request
// for a missing host package, with no hint of what to install.
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
const e = new Error("spawnSync tmux ENOENT");
e.code = "ENOENT";
throw e;
});
assert.throws(
() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }),
(err) => err.code === "ETMUXMISSING" && /install/i.test(err.message),
"a missing tmux must be reported as such, with the install command"
);
assert.ok(
!calls.some((c) => c[0] === "new-session"),
"must not attempt a session it already knows cannot be created"
);
});
it("spawnRun is a no-op (adopts) when the existing session's pane runs claude", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "claude\n";
return ""; // has-session succeeds → already running
});
const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" });
assert.equal(handle.id, "agenthub-lane-7");
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
assert.ok(!calls.some((c) => c[0] === "send-keys"), "must not type over a live agent");
});
it("spawnRun runs the argv in an existing session idling at a shell prompt", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "bash\n";
return ""; // has-session succeeds → session exists, pane is a shell
});
pty.spawnRun({ laneId: 8, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
const respawn = calls.find((c) => c[0] === "respawn-pane");
assert.ok(respawn, "expected the idle shell to be replaced by the argv");
const argv = respawn.slice(respawn.indexOf("--") + 1);
assert.deepEqual(argv.slice(3, 6), ["sh", "/tmp/repo", "claude"]);
assert.deepEqual(argv.slice(-2), ["--resume", "abc12345"]);
});
it("spawnRun passes an initial prompt as one argv element, never a shell line", () => {
// No shell is involved any more, so a prompt full of shell metacharacters
// is just a string — but it must still arrive whole and unmangled.
let respawn = null;
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "zsh\n";
if (args[0] === "respawn-pane") respawn = args;
return "";
});
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", initialPrompt: "don't; rm -rf /" });
assert.equal(respawn.at(-1), "don't; rm -rf /");
});
it("spawnRun puts AGENTHUB_LANE_ID in the new session's environment", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 33, cwd: "/tmp/repo", tool: "codex" });
const at = newSessionArgv.indexOf("-e");
assert.ok(at > -1, "expected a tmux -e environment flag");
assert.equal(newSessionArgv[at + 1], "AGENTHUB_LANE_ID=33");
// Must stay ahead of the `--` that starts the pane's own argv.
assert.ok(at < newSessionArgv.indexOf("--"));
});
it("spawnRun passes AGENTHUB_LANE_ID through tmux -e when reusing a shell pane", () => {
let respawn = null;
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "bash\n";
if (args[0] === "respawn-pane") respawn = args;
return "";
});
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
// Through argv, never as a shell assignment typed into the pane: this is
// also the path an AI Proxy's API key takes, and a typed line would be
// rendered to every attached browser and land in the shell history.
const at = respawn.indexOf("-e");
assert.ok(at > -1, "expected a tmux -e environment flag");
assert.equal(respawn[at + 1], "AGENTHUB_LANE_ID=12");
assert.ok(at < respawn.indexOf("--"));
assert.equal(respawn.at(-1), "codex");
});
it("spawnRun with resumeSessionId passes --resume in argv", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 1, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
assert.ok(newSessionArgv.includes("--resume"));
assert.ok(newSessionArgv.includes("abc12345"));
});
it("spawnRun forks a resumed Claude session into a new id, and refuses a fork elsewhere", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 31, cwd: "/tmp/repo", resumeSessionId: "abc12345", forkSession: true });
const at = newSessionArgv.indexOf("--resume");
assert.deepEqual(newSessionArgv.slice(at, at + 3), ["--resume", "abc12345", "--fork-session"]);
// The pane holds the FORK, whose id only its first hook reports.
const dashboardRuns = require("../lib/dashboard-runs");
assert.equal(dashboardRuns.getRun("agenthub-lane-31").session_id, null);
assert.throws(
() => pty.spawnRun({ laneId: 32, cwd: "/tmp/repo", forkSession: true }),
(e) => e.code === "EBADSESSION"
);
assert.throws(
() =>
pty.spawnRun({
laneId: 33,
cwd: "/tmp/repo",
tool: "codex",
resumeSessionId: "abc12345",
forkSession: true,
}),
(e) => e.code === "EUNSUPPORTED"
);
});
it("spawnRun starts a Claude session in its own worktree, Claude's way", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
const repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pty-wt-")));
// Inside a git hook GIT_DIR names the repo being committed; drop it.
const env = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
);
require("node:child_process").execFileSync("git", ["init", "-q", repo], { env });
try {
pty.spawnRun({ laneId: 41, cwd: repo, worktree: true, initialPrompt: "fix it" });
// `=` form: `-w [name]` takes an optional value and would eat the prompt.
const flag = newSessionArgv.find((a) => a.startsWith("--worktree="));
assert.match(flag, /^--worktree=ah-41-[a-z0-9]+$/);
assert.equal(newSessionArgv.at(-1), "fix it");
// Claude does not exclude its worktrees; the main repo would list them.
const exclude = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
assert.ok(exclude.split("\n").includes("/.claude/worktrees/"), exclude);
const dashboardRuns = require("../lib/dashboard-runs");
assert.equal(dashboardRuns.getRun("agenthub-lane-41").worktree, 1);
assert.throws(
() => pty.spawnRun({ laneId: 42, cwd: repo, tool: "codex", worktree: true }),
(e) => e.code === "EUNSUPPORTED"
);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it("spawnRun starts a session on ITS proxy: Claude by --settings, Codex by -p", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
// The proxy store and the files a spawn writes live under these homes.
const saved = ["CLAUDE_HOME", "CODEX_HOME", "DASHBOARD_DATA_DIR"].map((k) => [
k,
process.env[k],
]);
process.env.CLAUDE_HOME = path.join(TMP, "claude");
process.env.CODEX_HOME = path.join(TMP, "codex");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
try {
const { name } = require("../lib/ai-proxies").createProxy({
label: "Proxy",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "M",
});
pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", proxy: name, initialPrompt: "hi" });
const flag = newSessionArgv.find((a) => a.startsWith("--settings="));
assert.equal(flag, `--settings=${path.join(TMP, "data", "proxy-settings", `${name}.json`)}`);
assert.ok(!newSessionArgv.join(" ").includes("sk-secret-value"));
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").proxy, name);
pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", proxy: name });
const at = newSessionArgv.indexOf("-p");
assert.deepEqual(newSessionArgv.slice(at, at + 2), ["-p", name]);
// The key rides tmux's -e, never the command line.
assert.ok(newSessionArgv.includes("AGENTHUB_PROXY_KEY=sk-secret-value"));
pty.spawnRun({ laneId: 53, cwd: "/tmp/repo" });
assert.ok(!newSessionArgv.some((a) => a.startsWith("--settings=")));
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-53").proxy, null);
assert.throws(
() => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", proxy: "nope" }),
(e) => e.code === "EBADPROXY"
);
} finally {
for (const [k, v] of saved) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
});
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
const prevHome = process.env.CLAUDE_HOME;
const home = fs.mkdtempSync(path.join(os.tmpdir(), "bypass-home-"));
process.env.CLAUDE_HOME = home;
try {
// An account that never accepted the bypass warning: no flag, so its
// first session does not open on that warning (the Chat UI can't answer).
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
assert.ok(!newSessionArgv.includes("--allow-dangerously-skip-permissions"));
// ...unless the run asks for bypass itself: then the warning is wanted.
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo", permissionMode: "bypassPermissions" });
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
fs.writeFileSync(
path.join(home, "settings.json"),
JSON.stringify({ skipDangerousModePermissionPrompt: true })
);
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
} finally {
if (prevHome === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prevHome;
fs.rmSync(home, { recursive: true, force: true });
}
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
// ...as an OPTION only: the starting mode stays the requested one, and
// `--dangerously-skip-permissions` (which would enable it) is never passed.
assert.ok(!newSessionArgv.includes("--dangerously-skip-permissions"));
const modeAt = newSessionArgv.indexOf("--permission-mode");
// `auto` is the default when the caller named no mode: the CLI's own
// classifier asks for what it reads as risky and stays out of the way
// otherwise.
assert.equal(newSessionArgv[modeAt + 1], "auto");
});
it("spawnRun appends a positional initial prompt after argv flags", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 3, cwd: "/tmp/repo", initialPrompt: "fix the bug" });
assert.equal(newSessionArgv[newSessionArgv.length - 1], "fix the bug");
});
it("killRun calls tmux kill-session with the run id", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
assert.equal(pty.killRun("agenthub-lane-5"), true);
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "=agenthub-lane-5"));
});
it("never touches a tmux session that is not a lane pane", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
assert.equal(pty.killRun("main"), false);
assert.equal(pty.getRun("main"), null);
assert.deepEqual(calls, []);
});
it("listRuns reflects live tmux-session state, not cached memory", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "list-sessions") return "agenthub-lane-1\nagenthub-lane-2\n";
return "";
});
const first = pty.listRuns();
assert.deepEqual(first.map((r) => r.id).sort(), ["agenthub-lane-1", "agenthub-lane-2"]);
// Session killed out-of-band (not through killRun) — next list() call
// must self-correct, proving state is computed, not stored.
tmux.__setExecImpl((args) => {
if (args[0] === "list-sessions") return "agenthub-lane-1\n";
return "";
});
const second = pty.listRuns();
assert.deepEqual(
second.map((r) => r.id),
["agenthub-lane-1"]
);
});
it("listRuns asks tmux once, not once more per listed session", () => {
// Every tmux call is synchronous; the Workspace polls this every 5 s.
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args[0]);
if (args[0] === "list-sessions") return "agenthub-lane-1\nagenthub-lane-2\n";
return "";
});
const items = pty.listRuns();
assert.deepEqual(
items.map((r) => r.status),
["running", "running"]
);
assert.deepEqual(calls, ["list-sessions"]);
});
it("laneIdFromRunId parses the numeric lane id back out", () => {
assert.equal(pty.laneIdFromRunId("agenthub-lane-42"), 42);
assert.equal(pty.laneIdFromRunId("not-a-run-id"), null);
});
it("getRun returns the recorded prompt for a live run", () => {
let sessionExists = false;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
if (sessionExists) {
return ""; // session exists
}
// Session doesn't exist yet
const e = new Error("no such session");
e.status = 1;
throw e;
}
if (args[0] === "new-session") {
sessionExists = true; // Mark session as created
}
return "";
});
const handle = pty.spawnRun({
laneId: 99,
cwd: "/tmp/test",
initialPrompt: "the live prompt",
});
const retrieved = pty.getRun(handle.id);
assert.equal(retrieved.id, "agenthub-lane-99");
assert.equal(retrieved.promptPreview, "the live prompt");
assert.equal(retrieved.status, "running");
});
function captureNewSession(fn) {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "has-session") {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
fn();
return calls.find((c) => c[0] === "new-session");
}
/** The CLI's own command line: what tmux passes after the `--`, less the
* `sh -c '<cd; wait for a client>; exec "$@"' sh <cwd>` wrapper spawnRun
* puts first. */
function paneArgv(newSessionCall) {
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
assert.match(argv[2], /^cd -- "\$1".*session_attached.*exec "\$@"$/);
return argv.slice(5);
}
it("runs a Windows pane's command itself — no sh there — and a Terminal as tmux's default shell", () => {
const argv = ["C:\\Users\\u\\.local\\bin\\claude.exe", "--model", "opus"];
assert.deepEqual(pty.paneArgv(argv, "C:\\w", "win32"), argv);
assert.deepEqual(pty.paneArgv(null, "C:\\w", "win32"), []);
assert.equal(pty.paneArgv(argv, "/w", "linux")[0], "sh");
assert.deepEqual(pty.paneArgv(argv, "/w", "linux").slice(-3), argv);
assert.match(pty.paneArgv(null, "/w", "linux")[2], /exec "\$SHELL" -l$/);
});
it("keeps a Windows claude's teammates in-process — psmux cannot split a pane for them", () => {
const win = pty.buildClaudeArgv({ platform: "win32" });
assert.deepEqual(
win.slice(win.indexOf("--teammate-mode"), win.indexOf("--teammate-mode") + 2),
["--teammate-mode", "in-process"]
);
assert.ok(!pty.buildClaudeArgv({ platform: "linux" }).includes("--teammate-mode"));
});
it("starts a pane in its folder even where tmux skips -c (issue #69)", () => {
// A tmux server whose own cwd was deleted (a pruned plugin cache dir)
// ignores `-c` and hands every pane that deleted cwd.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pane-cwd-"));
const argv = pty.paneArgv(["pwd"], dir, "linux");
const out = execFileSync(argv[0], argv.slice(1), { cwd: "/", encoding: "utf8" });
assert.equal(fs.realpathSync(out.trim()), fs.realpathSync(dir));
fs.rmSync(dir, { recursive: true });
});
it("hands a long prompt to the pane through a file, never tmux's argv (issue #68)", (t) => {
process.env.DASHBOARD_DATA_DIR = TMP;
t.after(() => delete process.env.DASHBOARD_DATA_DIR);
// tmux refuses a message past 16 KB (`command too long`).
const prompt = `-${"x".repeat(20_000)}\nend`;
const call = captureNewSession(() =>
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", initialPrompt: prompt })
);
assert.ok(Buffer.byteLength(call.join("")) < 8 * 1024);
const argv = paneArgv(call);
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
const file = argv[4];
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
assert.equal(argv[5], "claude");
// Run the reader with a stand-in CLI: the prompt arrives as ONE last
// argument, leading-dash guard included, and the file is gone after.
const { execFileSync } = require("node:child_process");
const out = execFileSync(argv[0], [
...argv.slice(1, 5),
process.execPath,
"-e",
"process.stdout.write(process.argv.at(-1))",
]).toString();
assert.equal(out, ` ${prompt}`);
assert.ok(!fs.existsSync(file));
});
it("refuses a prompt no CLI can take as one argument, before starting anything", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
assert.throws(
() => pty.spawnRun({ laneId: 16, cwd: "/tmp/repo", initialPrompt: "é".repeat(70_000) }),
{ code: "EBADPROMPT" }
);
assert.equal(calls.length, 0);
});
it("spawnRun starts the CLI only once the pane has its real size, argv untouched", () => {
// A claude drawn at tmux's birth size duplicated its banner on the first
// resize; the argv rides as positional parameters, never script text.
const call = captureNewSession(() =>
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", initialPrompt: "a'; rm -rf / #" })
);
const argv = paneArgv(call);
assert.ok(argv.includes("a'; rm -rf / #"));
const { execFileSync } = require("node:child_process");
const out = execFileSync(
"sh",
[
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
"sh",
os.tmpdir(),
"printf",
"%s|",
"x y",
"$HOME",
],
{
env: { ...process.env, PATH: "/nonexistent-tmux-dir:" + process.env.PATH, TMUX_PANE: "" },
encoding: "utf8",
timeout: 5000,
}
);
assert.equal(out, "x y|$HOME|");
});
it("spawnRun builds a codex argv for a codex lane", () => {
const call = captureNewSession(() =>
pty.spawnRun({
laneId: 7,
cwd: "/tmp/repo",
tool: "codex",
model: "gpt-5.1-codex",
sandbox: "workspace-write",
approval: "on-request",
effort: "high",
initialPrompt: "hello",
})
);
const argv = paneArgv(call);
assert.deepEqual(argv, [
"codex",
"-m",
"gpt-5.1-codex",
"-s",
"workspace-write",
"-a",
"on-request",
"-c",
"model_reasoning_effort=high",
"hello",
]);
});
it("spawnRun puts codex resume first, as a subcommand", () => {
const withId = captureNewSession(() =>
pty.spawnRun({
laneId: 8,
cwd: "/tmp/repo",
tool: "codex",
resumeSessionId: "0199aaaa-bbbb-cccc-dddd-eeeeffff0000",
})
);
assert.deepEqual(paneArgv(withId).slice(0, 3), [
"codex",
"resume",
"0199aaaa-bbbb-cccc-dddd-eeeeffff0000",
]);
tmux.__reset();
const last = captureNewSession(() =>
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", tool: "codex", resumeLast: true })
);
assert.deepEqual(paneArgv(last).slice(0, 3), ["codex", "resume", "--last"]);
});
it("spawnRun rejects a codex sandbox/approval outside the allowlist", () => {
tmux.__setExecImpl(() => "");
assert.throws(
() => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", sandbox: "rm -rf" }),
(err) => err.code === "EBADSANDBOX"
);
assert.throws(
() => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", approval: "always" }),
(err) => err.code === "EBADAPPROVAL"
);
});
it("spawnRun never emits codex bypass flags", () => {
const call = captureNewSession(() =>
pty.spawnRun({ laneId: 11, cwd: "/tmp/repo", tool: "codex", sandbox: "danger-full-access" })
);
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
});
it("hands a codex pane the dashboard's own CODEX_HOME, and a claude pane none", (t) => {
const saved = process.env.CODEX_HOME;
process.env.CODEX_HOME = "/tmp/custom-codex-home";
t.after(() => {
if (saved === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = saved;
});
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
const codex = captureNewSession(() =>
pty.spawnRun({ laneId: 40, cwd: "/tmp/repo", tool: "codex" })
);
assert.ok(envFlags(codex).includes("CODEX_HOME=/tmp/custom-codex-home"));
tmux.__reset();
const claude = captureNewSession(() => pty.spawnRun({ laneId: 41, cwd: "/tmp/repo" }));
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
});
it("leaves a claude pane's screen mode to the user's own tui setting", () => {
// Forcing it inline duplicated the transcript on every pane narrowing.
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
const claude = captureNewSession(() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }));
assert.ok(!envFlags(claude).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
});
describe("catalog tools", () => {
const aiTools = require("../lib/ai-tools");
const BIN = path.join(TMP, "bin");
let savedPath;
before(() => {
fs.mkdirSync(BIN, { recursive: true });
for (const name of ["agy", "grok", "goose", "command-code"]) {
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, name), 0o755);
}
savedPath = process.env.PATH;
process.env.PATH = `${BIN}:/usr/bin:/bin`;
// The built-in catalog plus three test-only tools for the prompt modes
// and bypassEnv no built-in tool uses any more (custom files may).
aiTools.__setCatalogDirs([
path.join(__dirname, "..", "data", "ai-tools"),
path.join(__dirname, "fixtures", "ai-tools"),
]);
});
after(() => {
process.env.PATH = savedPath;
aiTools.__setCatalogDirs(null);
});
const at = (name) => path.join(BIN, name);
const envOf = (call, key) => {
const i = call.findIndex((a, n) => call[n - 1] === "-e" && a.startsWith(`${key}=`));
return i < 0 ? undefined : call[i].slice(key.length + 1);
};
it("starts antigravity by absolute path, with its model flag and none it has no use for", () => {
const call = captureNewSession(() =>
pty.spawnRun({
laneId: 13,
cwd: "/tmp/repo",
tool: "antigravity",
model: "opus",
effort: "high",
permissionMode: "bypassPermissions",
initialPrompt: "hello",
})
);
assert.deepEqual(paneArgv(call), [at("agy"), "--model", "opus", "-i", "hello"]);
assert.equal(envOf(call, "PATH"), process.env.PATH);
tmux.__reset();
const last = captureNewSession(() =>
pty.spawnRun({
laneId: 14,
cwd: "/tmp/repo",
tool: "antigravity",
resumeLast: true,
bypass: true,
})
);
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
});
// A catalog tool has no "resume THIS session" argv; starting it fresh under
// a resume request would read as a resume that silently lost the history.
it("refuses resumeSessionId for a catalog tool instead of starting fresh", () => {
assert.throws(
() =>
pty.spawnRun({
laneId: 15,
cwd: "/tmp/repo",
tool: "antigravity",
resumeSessionId: "abc12345",
}),
{ code: "EUNSUPPORTED" }
);
});
it("refuses a flag-shaped model id for any tool, before tmux is asked", () => {
for (const tool of ["claude", "codex", "antigravity"]) {
assert.throws(
() => pty.spawnRun({ laneId: 16, cwd: "/tmp/repo", tool, model: "--yolo" }),
{ code: "EBADMODEL" },
tool
);
}
});
it("builds each prompt mode, with fixed args first, a dash-led prompt never a flag", () => {
const argvFor = (tool, extra = {}) =>
paneArgv(
captureNewSession(() =>
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
)
);
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
// Behind a `--` separator the prompt is already a positional, kept as typed.
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -x y"]);
});
it("pastes an after-start prompt later and adds bypassEnv only on bypass", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const calls = [];
let live = false;
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "has-session" && !live) {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
pty.spawnRun({
laneId: 16,
cwd: "/tmp/repo",
tool: "goose",
initialPrompt: "fix it",
bypass: true,
});
const ns = calls.find((c) => c[0] === "new-session");
assert.deepEqual(paneArgv(ns), [at("goose")]);
assert.equal(envOf(ns, "GOOSE_MODE"), "auto");
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
live = true;
// delayMs (3000) counts from when the CLI starts: after up to 2 s of
// waiting for a client to attach.
t.mock.timers.tick(4999);
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
t.mock.timers.tick(1);
assert.deepEqual(
calls.find((c) => c[0] === "set-buffer"),
["set-buffer", "-b", "agenthub-paste-agenthub-lane-16", "--", "fix it"]
);
assert.ok(calls.some((c) => c[0] === "paste-buffer"));
calls.length = 0;
live = false;
pty.spawnRun({ laneId: 17, cwd: "/tmp/repo", tool: "goose" });
assert.equal(
envOf(
calls.find((c) => c[0] === "new-session"),
"GOOSE_MODE"
),
undefined
);
});
it("skips the paste when the pane is gone", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "has-session") {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
pty.spawnRun({ laneId: 18, cwd: "/tmp/repo", tool: "goose", initialPrompt: "hi" });
t.mock.timers.tick(3000);
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
});
it("codex bypass replaces sandbox/approval; claude ignores bypass", () => {
const codex = captureNewSession(() =>
pty.spawnRun({
laneId: 19,
cwd: "/tmp/repo",
tool: "codex",
sandbox: "read-only",
approval: "never",
bypass: true,
})
);
assert.ok(paneArgv(codex).includes("--dangerously-bypass-approvals-and-sandbox"));
assert.ok(!paneArgv(codex).includes("-s"));
tmux.__reset();
const claude = captureNewSession(() =>
pty.spawnRun({ laneId: 20, cwd: "/tmp/repo", bypass: true })
);
assert.ok(!paneArgv(claude).includes("--dangerously-skip-permissions"));
});
it("refuses a tool the catalog does not know", () => {
tmux.__setExecImpl(() => "");
assert.throws(() => pty.spawnRun({ laneId: 21, cwd: "/tmp/repo", tool: "nope" }), {
code: "ENOTOOL",
});
});
});
// `dashboard_runs.mode` is NOT NULL and recordRun swallows insert errors, so a
// null there persisted nothing at all while every call still looked fine: the
// runs modal rendered a row with no cwd, model, prompt or start time, and a
// resumed session kept its separate `external` row because no run row ever
// carried its session id. These assert the row actually lands.
it("refuses to swallow a resume or a prompt when the pane already runs an agent", () => {
// Adopting silently here answered 200 while spawning no `--resume` at all:
// the dashboard reported a resumed session that never happened.
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "claude\n";
return ""; // has-session succeeds → pane is busy with a live agent
});
assert.throws(
() => pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", resumeSessionId: "abc12345" }),
(err) => err.code === "ERUNLIVE"
);
assert.throws(
() => pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", initialPrompt: "do the thing" }),
(err) => err.code === "ERUNLIVE"
);
});
it("still adopts a busy pane when the request asked for nothing but a pane", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "claude\n";
return "";
});
const handle = pty.spawnRun({ laneId: 52, cwd: "/tmp/repo" });
assert.equal(handle.id, "agenthub-lane-52");
assert.ok(!calls.some((c) => c[0] === "new-session"));
assert.ok(!calls.some((c) => c[0] === "send-keys"));
});
describe("run persistence", () => {
const dashboardRuns = require("../lib/dashboard-runs");
it("spawnRun persists the run so the runs list has cwd, model and prompt", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
return "";
});
pty.spawnRun({
laneId: 71,
cwd: "/tmp/persisted",
model: "claude-opus-5",
initialPrompt: "ship it",
});
const row = dashboardRuns.getRun("agenthub-lane-71");
assert.ok(row, "expected a dashboard_runs row for the spawned run");
assert.equal(row.cwd, "/tmp/persisted");
assert.equal(row.model, "claude-opus-5");
assert.equal(row.prompt_preview, "ship it");
assert.equal(row.lane_id, 71);
assert.ok(row.started_at, "started_at must be set, or the UI dates the row to the epoch");
assert.ok(row.mode, "mode is NOT NULL — a null here silently drops the whole insert");
});
it("a resumed run carries the session id, which is what dedupes the external row", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
return "";
});
const sessionId = "c2055094-09cf-4735-9712-09e5cdf7023f";
const handle = pty.spawnRun({
laneId: 72,
cwd: "/tmp/resumed",
resumeSessionId: sessionId,
});
// The UI reads sessionId off the run list; an external session whose id is
// already on a run row is not repeated as its own `external` row.
assert.equal(handle.sessionId, sessionId);
assert.equal(dashboardRuns.getRun("agenthub-lane-72").resume_session_id, sessionId);
});
});
});