Files
AgentHub/server/__tests__/pty-run.test.js
T
nntrivi2001 0a86dfb8c5 feat(settings): add AI Router profiles and attach one per lane
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.

The two CLIs are configured differently, and the difference is theirs:

  - a claude lane gets eight `env` keys merged into its own
    `.claude/settings.local.json` (`.local` because the value holds a key);
  - a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
    `codex -p <name>` loads — because Codex has NO project-level config.
    Its base `config.toml` is never touched.

Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).

Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.

Three things this had to get right, each found by review and verified
against the real tools rather than assumed:

  - `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
    supported"), so every codex lane on a router would have failed to start.
    The layer writes "responses", and writes `env_key` only when the profile
    actually has a key — naming an unset variable is itself a startup error.
  - The key must never be TYPED into a pane. `tmux.sendCommand` used
    `send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
    every attached browser and bash appends to history; it now takes over the
    idle pane with `respawn-pane -k -e`, passing env through argv the way
    `newSession` always has. Verified on tmux 3.4: multi-element argv arrives
    intact and the pane renders no trace of the value.
  - Attaching COPIES a profile, so editing or deleting one in Settings has to
    reach the lanes already using it. An edit re-applies it (a rotated key
    would otherwise never reach the lane still holding the revoked one); a
    delete resets those lanes to native and removes the Codex layer file.
    Both report per-lane failures, which the tab surfaces.

`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.

DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
2026-09-14 16:04:57 +07:00

454 lines
16 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 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("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.equal(argv[0], "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 Router'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 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 "";
});
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
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");
assert.equal(newSessionArgv[modeAt + 1], "acceptEdits");
});
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("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("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 pane's own command line — everything tmux passes after the `--`. */
function paneArgv(newSessionCall) {
return newSessionCall.slice(newSessionCall.indexOf("--") + 1);
}
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")));
});
// `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);
});
});
});