diff --git a/server/__tests__/pty-run.test.js b/server/__tests__/pty-run.test.js index c6d9d188..63b0975d 100644 --- a/server/__tests__/pty-run.test.js +++ b/server/__tests__/pty-run.test.js @@ -214,4 +214,88 @@ describe("pty-run", () => { 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"); + } + + 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 = call.slice(7); + 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(withId.slice(7, 10), [ + "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(last.slice(7, 10), ["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"))); + }); }); diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index 9c904d33..a173ec15 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -19,6 +19,8 @@ * the session (one-click Start/Resume) and is just one of possibly several * attached clients tmux already keeps in sync. * + * Spawns `claude` or `codex` in the tmux pane based on the tool parameter. + * * @author Nguyễn Ngọc Trí Vĩ */ @@ -36,6 +38,12 @@ const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]); // Pane commands that mean "idle shell prompt, safe to type a command into". const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]); +const VALID_TOOLS = new Set(["claude", "codex"]); +const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]); +const CODEX_APPROVALS = new Set(["on-request", "never"]); +// Codex has no `--effort` flag; reasoning effort is a config override. +const CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]); +const CODEX_SESSION_RE = /^[0-9a-fA-F-]{8,}$/; function runIdForLane(laneId) { return `ccam-lane-${laneId}`; @@ -53,13 +61,14 @@ function makeErr(code, message) { } /** - * Build the pane's command argv. Unlike the old stream-json spawner there is - * no headless/conversation split — every run is a live interactive pane, so - * an initial prompt (when given) is a trailing POSITIONAL argument: `claude` - * treats a bare positional as the first turn's message and stays interactive - * afterward (unlike `-p`, which forces print-and-exit and closes stdin). + * Build the pane's command argv for Claude. Unlike the old stream-json spawner + * there is no headless/conversation split — every run is a live interactive + * pane, so an initial prompt (when given) is a trailing POSITIONAL argument: + * `claude` treats a bare positional as the first turn's message and stays + * interactive afterward (unlike `-p`, which forces print-and-exit and closes + * stdin). */ -function buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) { +function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) { const argv = ["claude"]; argv.push("--permission-mode", permissionMode || "acceptEdits"); // Puts "bypass permissions" INTO the pane's shift+tab cycle without turning @@ -76,24 +85,87 @@ function buildArgv({ model, permissionMode, effort, resumeSessionId, initialProm return argv; } +/** + * Codex's CLI shape differs from Claude's in two ways that matter here: + * `resume` is a SUBCOMMAND (so it precedes every flag) and reasoning effort has + * no flag of its own — it rides on a `-c` config override. The prompt stays a + * trailing positional, same as Claude, so the pane stays interactive. + */ +function buildCodexArgv({ + model, + effort, + sandbox, + approval, + resumeSessionId, + resumeLast, + initialPrompt, +}) { + const argv = ["codex"]; + if (resumeSessionId) argv.push("resume", resumeSessionId); + else if (resumeLast) argv.push("resume", "--last"); + if (model) argv.push("-m", model); + if (sandbox) argv.push("-s", sandbox); + if (approval) argv.push("-a", approval); + if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`); + if (initialPrompt) argv.push(initialPrompt); + return argv; +} + +function buildArgv(args) { + return args.tool === "codex" ? buildCodexArgv(args) : buildClaudeArgv(args); +} + /** * @param {object} args * @param {number} args.laneId * @param {string} args.cwd + * @param {string} [args.tool="claude"] * @param {string} [args.model] * @param {string} [args.permissionMode] * @param {string} [args.effort] + * @param {string} [args.sandbox] + * @param {string} [args.approval] * @param {string} [args.resumeSessionId] + * @param {boolean} [args.resumeLast] * @param {string} [args.initialPrompt] */ function spawnRun(args) { - const { laneId, cwd, model, permissionMode, effort, resumeSessionId, initialPrompt } = args || {}; + const { + laneId, + cwd, + tool = "claude", + model, + permissionMode, + effort, + sandbox, + approval, + resumeSessionId, + resumeLast, + initialPrompt, + } = args || {}; if (typeof laneId !== "number" || !Number.isInteger(laneId)) { throw makeErr("EBADLANE", "laneId must be an integer"); } if (typeof cwd !== "string" || !cwd) { throw makeErr("EBADCWD", "cwd is required"); } + if (!VALID_TOOLS.has(tool)) { + throw makeErr("EBADTOOL", `tool must be one of: ${Array.from(VALID_TOOLS).join(", ")}`); + } + if (tool === "codex") { + if (sandbox != null && sandbox !== "" && !CODEX_SANDBOXES.has(sandbox)) { + throw makeErr( + "EBADSANDBOX", + `sandbox must be one of: ${Array.from(CODEX_SANDBOXES).join(", ")}` + ); + } + if (approval != null && approval !== "" && !CODEX_APPROVALS.has(approval)) { + throw makeErr( + "EBADAPPROVAL", + `approval must be one of: ${Array.from(CODEX_APPROVALS).join(", ")}` + ); + } + } if (permissionMode != null && !ALLOWED_PERMISSION_MODES.has(permissionMode)) { throw makeErr( "EBADMODE", @@ -112,7 +184,17 @@ function spawnRun(args) { const id = runIdForLane(laneId); const startedAt = Date.now(); - const argv = buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }); + const argv = buildArgv({ + tool, + model, + permissionMode, + effort, + sandbox, + approval, + resumeSessionId, + resumeLast, + initialPrompt, + }); const record = () => { if (!dashboardRuns) return; @@ -121,6 +203,7 @@ function spawnRun(args) { sessionId: resumeSessionId || null, mode: null, cwd, + tool: tool === "codex" ? "codex" : "claude", model: model || null, permissionMode: permissionMode || "acceptEdits", effort: effort || null, @@ -171,6 +254,7 @@ function publicRun(id) { id, laneId, status: live ? "running" : "gone", + tool: row?.tool || "claude", cwd: row?.cwd || null, model: row?.model || null, permissionMode: row?.permission_mode || null,