From e8a1e8c97ce4b53a3b4182d3f7bf6e03116d1b31 Mon Sep 17 00:00:00 2001 From: Praveen Mittal Date: Mon, 27 Jul 2026 16:08:48 +0200 Subject: [PATCH 01/53] fix: honor stop_hook_active in the stop-review-gate hook (#548) When the review gate is enabled, main() ran the stop-time Codex review and emitted {"decision":"block"} on any non-ok outcome (no output, timeout, failure, invalid JSON) without checking whether this invocation was already a forced retry. Claude Code re-invokes Stop hooks with stop_hook_active: true after a block decision, so a review that times out or fails would re-run and re-block on every retry, until the harness's forced-retry cap force-ends the turn -- worst in exactly the cases where the gate is least useful. Fix: skip the review and return cleanly when stop_hook_active is set, matching the fix the issue reporter suggested. The issue also flagged codex-companion.mjs's `config.stopReviewGate` branches as worth auditing for the same problem; traced them and confirmed they're only config get/set/report code, not the review-blocking logic itself (that lives solely in stop-review-gate-hook.mjs), so no second file needs the fix. Added a regression test that snapshots the fake Codex binary's appServerStarts counter before and after a stop_hook_active retry, proving the review subprocess is not spawned a second time and no "decision":"block" is emitted. Confirmed the test fails against the pre-fix code (git stash isolation) with the exact stale block decision the issue describes. Full suite: 92/92 passed, no regressions. --- .../codex/scripts/stop-review-gate-hook.mjs | 11 +++++ tests/runtime.test.mjs | 44 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index 2346bdcf4..52a8f144b 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -156,6 +156,17 @@ function main() { return; } + if (input.stop_hook_active) { + // Claude Code re-invokes the Stop hook with stop_hook_active: true when a + // prior invocation returned a "block" decision. Running the review again + // here would just block again -- every non-ok outcome (no output, timeout, + // failure, invalid JSON) re-blocks unconditionally -- until the harness's + // forced-retry cap kicks in and ends the turn anyway. Skip the re-run and + // let this retry succeed instead of repeating it up to the cap. + logNote(runningTaskNote); + return; + } + const setupNote = buildSetupNote(cwd); if (setupNote) { logNote(setupNote); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..17e412e18 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1979,6 +1979,50 @@ test("stop hook runs a stop-time review task and blocks on findings when the rev assert.match(status.stdout, /Codex Stop Gate Review/); }); +test("stop hook does not re-run the review and does not block on a forced retry (stop_hook_active)", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const setup = run("node", [SCRIPT, "setup", "--enable-review-gate", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(setup.status, 0, setup.stderr); + + // `appServerStarts` only increments when the fake Codex binary's + // `app-server` subcommand actually runs (i.e. a real review turn was + // spawned). Snapshot it now so we can prove below that the forced retry + // does not spawn a second review. + const appServerStartsBeforeRetry = fs.existsSync(fakeStatePath) + ? JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts || 0 + : 0; + + const retried = run("node", [STOP_HOOK], { + cwd: repo, + env: buildEnv(binDir), + input: JSON.stringify({ + cwd: repo, + session_id: "sess-stop-review-retry", + stop_hook_active: true, + last_assistant_message: "I completed the refactor and updated the retry logic." + }) + }); + + assert.equal(retried.status, 0, retried.stderr); + assert.equal(retried.stdout.trim(), ""); + + const appServerStartsAfterRetry = fs.existsSync(fakeStatePath) + ? JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts || 0 + : 0; + assert.equal(appServerStartsAfterRetry, appServerStartsBeforeRetry); +}); + test("stop hook logs running tasks to stderr without blocking when the review gate is disabled", () => { const repo = makeTempDir(); initGitRepo(repo); From bdfcd8627e5cd6c9517a8f640bcfcc91b70cb971 Mon Sep 17 00:00:00 2001 From: Praveen Mittal Date: Sat, 15 Aug 2026 20:43:10 +0200 Subject: [PATCH 02/53] fix: surface the stop_hook_active skip via systemMessage logNote() only writes to stderr, which isn't surfaced for a successful hook run -- a skipped review looked identical to a passed one. Emit a systemMessage instead (no "decision" key, so it still doesn't block) so the skip is visible right when it matters most, since the whole reason there's a retry turn is that the previous review blocked on something. Per review feedback from @Wintersta7e on the PR. --- plugins/codex/scripts/stop-review-gate-hook.mjs | 10 ++++++++++ tests/runtime.test.mjs | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index 52a8f144b..8d480cac0 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -163,6 +163,16 @@ function main() { // failure, invalid JSON) re-blocks unconditionally -- until the harness's // forced-retry cap kicks in and ends the turn anyway. Skip the re-run and // let this retry succeed instead of repeating it up to the cap. + // + // logNote() alone is invisible to the user (it only writes to stderr, which + // isn't surfaced for a successful hook run) -- a skipped review would look + // identical to a passed one. Emit a systemMessage instead: it reaches the + // user without blocking (no "decision" key), so the skip is visible right + // when it matters -- the previous review blocked on something. + emitDecision({ + systemMessage: + "Codex stop-gate review skipped on this continuation turn. Run /codex:review --wait to verify the fixes." + }); logNote(runningTaskNote); return; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 17e412e18..ab8faef07 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -2015,7 +2015,9 @@ test("stop hook does not re-run the review and does not block on a forced retry }); assert.equal(retried.status, 0, retried.stderr); - assert.equal(retried.stdout.trim(), ""); + const payload = JSON.parse(retried.stdout.trim()); + assert.equal(payload.decision, undefined); + assert.match(payload.systemMessage, /skipped/i); const appServerStartsAfterRetry = fs.existsSync(fakeStatePath) ? JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts || 0 From 208e07b224e0df15b0c289346f83becccea6cbd7 Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Fri, 4 Sep 2026 09:46:11 +0900 Subject: [PATCH 03/53] feat: enforce scoped Codex task reads --- .claude-plugin/marketplace.json | 4 +- README.md | 4 + package-lock.json | 4 +- package.json | 2 +- plugins/codex/.claude-plugin/plugin.json | 2 +- plugins/codex/CHANGELOG.md | 5 + plugins/codex/agents/codex-rescue.md | 1 + plugins/codex/commands/rescue.md | 3 +- plugins/codex/scripts/codex-companion.mjs | 16 +++- plugins/codex/scripts/lib/args.mjs | 17 +++- plugins/codex/scripts/lib/codex.mjs | 91 ++++++++++++++---- .../codex/skills/codex-cli-runtime/SKILL.md | 1 + tests/commands.test.mjs | 5 + tests/fake-codex-fixture.mjs | 8 ++ tests/runtime.test.mjs | 93 +++++++++++++++++++ 15 files changed, 224 insertions(+), 32 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 870246242..750163912 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Codex plugins to use in Claude Code for delegation and code review.", - "version": "1.0.6" + "version": "1.0.7" }, "plugins": [ { "name": "codex", "description": "Use Codex from Claude Code to review code or delegate tasks.", - "version": "1.0.6", + "version": "1.0.7", "author": { "name": "OpenAI" }, diff --git a/README.md b/README.md index 937a3037b..9fbc5d7e2 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ Examples: /codex:rescue --model gpt-5.4-mini --effort medium investigate the flaky integration test /codex:rescue --model spark fix the issue quickly /codex:rescue --background investigate the regression +/codex:rescue --read-root ./src --read-root ./tests inspect only the approved paths ``` You can also just ask for a task to be delegated to Codex: @@ -161,6 +162,9 @@ Ask Codex to redesign the database connection to be more resilient. - if you do not pass `--model` or `--effort`, Codex chooses its own defaults. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo +- each `--read-root ` opts into an OS-enforced permission profile that denies local command reads outside the listed paths and Codex's minimal runtime paths +- scoped reads require Codex 0.138.0 or later and fail closed when the runtime cannot enforce permission profiles +- filesystem profiles apply to local sandboxed commands, not web search, MCP servers, connectors, browser tools, or computer use ### `/codex:transfer` diff --git a/package-lock.json b/package-lock.json index 0c919c3db..86aa59a10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openai/codex-plugin-cc", - "version": "1.0.6", + "version": "1.0.7", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index b1d984d1a..26ebc91dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.6", + "version": "1.0.7", "private": true, "type": "module", "description": "Use Codex from Claude Code to review code or delegate tasks.", diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index e91e5238c..9b0202e32 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "1.0.6", + "version": "1.0.7", "description": "Use Codex from Claude Code to review code or delegate tasks.", "author": { "name": "OpenAI" diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index d647561bb..9cc3bd52f 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.0.7 + +- Add opt-in `--read-root` enforcement for Codex rescue tasks using request-scoped permission profiles. +- Preserve scoped roots across foreground, background, and resumed tasks, with fail-closed runtime compatibility errors. + ## 1.0.0 - Initial version of the Codex plugin for Claude Code diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 7009ec86a..935776171 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -31,6 +31,7 @@ Forwarding rules: - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. +- Preserve every `--read-root ` pair as a runtime control and do not include either token in the task text you pass through. - Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 56de9555d..aa817705d 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--resume|--fresh] [--read-root ...] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" allowed-tools: Bash(node:*), AskUserQuestion, Agent --- @@ -18,6 +18,7 @@ Execution mode: - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. +- Preserve every `--read-root ` pair for the forwarded `task` call and remove both tokens from the natural-language task text. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..776d48175 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -79,7 +79,7 @@ function printUsage() { " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--read-root ...] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", @@ -156,6 +156,11 @@ function resolveCommandWorkspace(options = {}) { return resolveWorkspaceRoot(resolveCommandCwd(options)); } +function resolveReadRoot(cwd, readRoot) { + const resolved = path.resolve(cwd, readRoot); + return fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved; +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -489,6 +494,8 @@ async function executeTaskRun(request) { model: request.model, effort: request.effort, sandbox: request.write ? "workspace-write" : "read-only", + readRoots: request.readRoots, + write: request.write, onProgress: request.onProgress, persistThread: true, threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) @@ -601,13 +608,14 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, write, readRoots, resumeLast, jobId }) { return { cwd, model, effort, prompt, write, + readRoots, resumeLast, jobId }; @@ -762,6 +770,7 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["model", "effort", "cwd", "prompt-file"], + multiValueOptions: ["read-root"], booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], aliasMap: { m: "model" @@ -780,6 +789,7 @@ async function handleTask(argv) { throw new Error("Choose either --resume/--resume-last or --fresh."); } const write = Boolean(options.write); + const readRoots = (options["read-root"] ?? []).map((readRoot) => resolveReadRoot(cwd, readRoot)); const taskMetadata = buildTaskRunMetadata({ prompt, resumeLast @@ -796,6 +806,7 @@ async function handleTask(argv) { effort, prompt, write, + readRoots, resumeLast, jobId: job.id }); @@ -814,6 +825,7 @@ async function handleTask(argv) { effort, prompt, write, + readRoots, resumeLast, jobId: job.id, onProgress: progress diff --git a/plugins/codex/scripts/lib/args.mjs b/plugins/codex/scripts/lib/args.mjs index 6b1518502..0e2fd4c2d 100644 --- a/plugins/codex/scripts/lib/args.mjs +++ b/plugins/codex/scripts/lib/args.mjs @@ -1,5 +1,6 @@ export function parseArgs(argv, config = {}) { const valueOptions = new Set(config.valueOptions ?? []); + const multiValueOptions = new Set(config.multiValueOptions ?? []); const booleanOptions = new Set(config.booleanOptions ?? []); const aliasMap = config.aliasMap ?? {}; const options = {}; @@ -33,12 +34,16 @@ export function parseArgs(argv, config = {}) { continue; } - if (valueOptions.has(key)) { + if (valueOptions.has(key) || multiValueOptions.has(key)) { const nextValue = inlineValue ?? argv[index + 1]; if (nextValue === undefined) { throw new Error(`Missing value for --${rawKey}`); } - options[key] = nextValue; + if (multiValueOptions.has(key)) { + options[key] = [...(options[key] ?? []), nextValue]; + } else { + options[key] = nextValue; + } if (inlineValue === undefined) { index += 1; } @@ -57,12 +62,16 @@ export function parseArgs(argv, config = {}) { continue; } - if (valueOptions.has(key)) { + if (valueOptions.has(key) || multiValueOptions.has(key)) { const nextValue = argv[index + 1]; if (nextValue === undefined) { throw new Error(`Missing value for -${shortKey}`); } - options[key] = nextValue; + if (multiValueOptions.has(key)) { + options[key] = [...(options[key] ?? []), nextValue]; + } else { + options[key] = nextValue; + } index += 1; continue; } diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..8c0febeeb 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -50,6 +50,51 @@ const DEFAULT_CONTINUE_PROMPT = "Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved."; const EXTERNAL_AGENT_IMPORT_COMPLETED = "externalAgentConfig/import/completed"; const EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000; +const SCOPED_PERMISSION_PROFILE = "claude_companion_scoped"; + +function buildThreadAccessParams(cwd, options = {}) { + const readRoots = Array.isArray(options.readRoots) ? options.readRoots : []; + if (readRoots.length === 0) { + return { sandbox: options.sandbox ?? "read-only" }; + } + + const filesystem = { + ":root": "deny", + ":minimal": "read" + }; + for (const readRoot of readRoots) { + filesystem[path.resolve(cwd, readRoot)] = "read"; + } + if (options.write) { + filesystem[path.resolve(cwd)] = "write"; + } + + return { + config: { + default_permissions: SCOPED_PERMISSION_PROFILE, + permissions: { + [SCOPED_PERMISSION_PROFILE]: { + description: "Claude companion request-scoped filesystem access", + filesystem + } + } + } + }; +} + +function scopedAccessError(error, options = {}) { + const message = String(error?.message ?? error ?? ""); + if ( + (options.readRoots?.length ?? 0) > 0 && + /default_permissions|permission profiles?|unknown field.*config|invalid.*permissions/i.test(message) + ) { + return new Error( + `Codex cannot enforce the requested read scope. Upgrade to a runtime with permission profiles (0.138.0 or later). Original error: ${message}`, + { cause: error } + ); + } + return error; +} function cleanCodexStderr(stderr) { return stderr @@ -65,7 +110,7 @@ function buildThreadParams(cwd, options = {}) { cwd, model: options.model ?? null, approvalPolicy: options.approvalPolicy ?? "never", - sandbox: options.sandbox ?? "read-only", + ...buildThreadAccessParams(cwd, options), serviceName: SERVICE_NAME, ephemeral: options.ephemeral ?? true }; @@ -78,7 +123,7 @@ function buildResumeParams(threadId, cwd, options = {}) { cwd, model: options.model ?? null, approvalPolicy: options.approvalPolicy ?? "never", - sandbox: options.sandbox ?? "read-only" + ...buildThreadAccessParams(cwd, options) }; } @@ -1101,23 +1146,31 @@ export async function runAppServerTurn(cwd, options = {}) { return withAppServer(cwd, async (client) => { let threadId; - if (options.resumeThreadId) { - emitProgress(options.onProgress, `Resuming thread ${options.resumeThreadId}.`, "starting"); - const response = await resumeThread(client, options.resumeThreadId, cwd, { - model: options.model, - sandbox: options.sandbox, - ephemeral: false - }); - threadId = response.thread.id; - } else { - emitProgress(options.onProgress, "Starting Codex task thread.", "starting"); - const response = await startThread(client, cwd, { - model: options.model, - sandbox: options.sandbox, - ephemeral: options.persistThread ? false : true, - threadName: options.persistThread ? options.threadName : options.threadName ?? null - }); - threadId = response.thread.id; + try { + if (options.resumeThreadId) { + emitProgress(options.onProgress, `Resuming thread ${options.resumeThreadId}.`, "starting"); + const response = await resumeThread(client, options.resumeThreadId, cwd, { + model: options.model, + sandbox: options.sandbox, + readRoots: options.readRoots, + write: options.write, + ephemeral: false + }); + threadId = response.thread.id; + } else { + emitProgress(options.onProgress, "Starting Codex task thread.", "starting"); + const response = await startThread(client, cwd, { + model: options.model, + sandbox: options.sandbox, + readRoots: options.readRoots, + write: options.write, + ephemeral: options.persistThread ? false : true, + threadName: options.persistThread ? options.threadName : options.threadName ?? null + }); + threadId = response.thread.id; + } + } catch (error) { + throw scopedAccessError(error, options); } emitProgress(options.onProgress, `Thread ready (${threadId}).`, "starting", { diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 0e91bfb50..53c4366cd 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -28,6 +28,7 @@ Command selection: - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. +- Preserve every `--read-root ` pair, pass it through to `task`, and exclude both tokens from the natural-language task text. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b06059..40571ea23 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -105,6 +105,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /--resume\|--fresh/); assert.match(rescue, /--model /); assert.match(rescue, /--effort /); + assert.match(rescue, /--read-root /); + assert.match(rescue, /Preserve every `--read-root `/i); assert.match(rescue, /task-resume-candidate --json/); assert.match(rescue, /AskUserQuestion/); assert.match(rescue, /Continue current Codex thread/); @@ -126,6 +128,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /Leave `--resume` and `--fresh` in the forwarded request/i); assert.match(agent, /--resume/); assert.match(agent, /--fresh/); + assert.match(agent, /Preserve every `--read-root `/i); assert.match(agent, /thin forwarding wrapper/i); assert.match(agent, /prefer foreground for a small, clearly bounded rescue request/i); assert.match(agent, /If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution/i); @@ -151,11 +154,13 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); assert.match(runtimeSkill, /Strip it before calling `task`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); + assert.match(runtimeSkill, /Preserve every `--read-root `/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); assert.match(readme, /`codex:codex-rescue` subagent/i); assert.match(readme, /if you do not pass `--model` or `--effort`, Codex chooses its own defaults/i); assert.match(readme, /--model gpt-5\.4-mini --effort medium/i); + assert.match(readme, /--read-root/); assert.match(readme, /`spark`, the plugin maps that to `gpt-5\.3-codex-spark`/i); assert.match(readme, /continue a previous Codex task/i); assert.match(readme, /### `\/codex:setup`/); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..18108c2e8 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -309,9 +309,13 @@ rl.on("line", (line) => { if (BEHAVIOR === "auth-run-fails") { throw new Error("authentication expired; run codex login"); } + if (BEHAVIOR === "permission-profiles-unsupported" && message.params.config) { + throw new Error("unknown field config.default_permissions"); + } if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) { throw new Error("thread/start.persistFullHistory requires experimentalApi capability"); } + state.lastThreadStart = message.params; const thread = nextThread(state, message.params.cwd, message.params.ephemeral); send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); send({ method: "thread/started", params: { thread: { id: thread.id } } }); @@ -341,9 +345,13 @@ rl.on("line", (line) => { } case "thread/resume": { + if (BEHAVIOR === "permission-profiles-unsupported" && message.params.config) { + throw new Error("unknown field config.default_permissions"); + } if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) { throw new Error("thread/resume.persistFullHistory requires experimentalApi capability"); } + state.lastThreadResume = message.params; const thread = ensureThread(state, message.params.threadId); thread.updatedAt = now(); saveState(state); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..2b07c7793 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -716,6 +716,75 @@ test("write task output focuses on the Codex result without generic follow-up hi assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); }); +test("task --read-root sends a scoped permission profile without legacy sandbox", () => { + const repo = makeTempDir(); + const extraReadRoot = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + + const result = run( + "node", + [SCRIPT, "task", "--write", "--read-root", "src", "--read-root", extraReadRoot, "fix the test"], + { cwd: repo, env: buildEnv(binDir) } + ); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + const params = fakeState.lastThreadStart; + const profile = params.config.permissions.claude_companion_scoped; + assert.equal(params.sandbox, undefined); + assert.equal(params.config.default_permissions, "claude_companion_scoped"); + assert.equal(profile.filesystem[":root"], "deny"); + assert.equal(profile.filesystem[":minimal"], "read"); + assert.equal(profile.filesystem[fs.realpathSync(path.join(repo, "src"))], "read"); + assert.equal(profile.filesystem[fs.realpathSync(extraReadRoot)], "read"); + assert.equal(profile.filesystem[fs.realpathSync(repo)], "write"); +}); + +test("task --resume-last reapplies the approved read roots", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + + const first = run("node", [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(first.status, 0, first.stderr); + + const result = run("node", [SCRIPT, "task", "--resume-last", "--read-root", repo, "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadResume.sandbox, undefined); + assert.equal(fakeState.lastThreadResume.config.default_permissions, "claude_companion_scoped"); + assert.equal( + fakeState.lastThreadResume.config.permissions.claude_companion_scoped.filesystem[fs.realpathSync(repo)], + "read" + ); +}); + +test("task --read-root fails closed when permission profiles are unsupported", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "permission-profiles-unsupported"); + initGitRepo(repo); + + const result = run("node", [SCRIPT, "task", "--read-root", repo, "inspect the file"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /cannot enforce the requested read scope/i); + assert.match(result.stderr, /0\.138\.0 or later/); +}); + test("task --resume acts like --resume-last without leaking the flag into the prompt", () => { const repo = makeTempDir(); const binDir = makeTempDir(); @@ -969,6 +1038,30 @@ test("task --background enqueues a detached worker and exposes per-job status", assert.match(resultPayload.storedJob.rendered, /Handled the requested task/); }); +test("task --background preserves read roots for the detached worker", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + + const launched = run("node", [SCRIPT, "task", "--background", "--json", "--read-root", repo, "inspect"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(launched.status, 0, launched.stderr); + const jobId = JSON.parse(launched.stdout).jobId; + const waited = run("node", [SCRIPT, "status", jobId, "--wait", "--timeout-ms", "15000", "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(waited.status, 0, waited.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, undefined); + assert.equal(fakeState.lastThreadStart.config.default_permissions, "claude_companion_scoped"); +}); + test("review rejects focus text because it is native-review only", () => { const repo = makeTempDir(); const binDir = makeTempDir(); From 401ea76acb6c4591c611e45b2700b84391b414fc Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Fri, 4 Sep 2026 09:56:38 +0900 Subject: [PATCH 04/53] fix: require scoped read directories --- .claude-plugin/marketplace.json | 4 ++-- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- plugins/codex/.claude-plugin/plugin.json | 2 +- plugins/codex/CHANGELOG.md | 4 ++++ plugins/codex/agents/codex-rescue.md | 2 +- plugins/codex/commands/rescue.md | 4 ++-- plugins/codex/scripts/codex-companion.mjs | 7 +++++-- plugins/codex/skills/codex-cli-runtime/SKILL.md | 2 +- tests/commands.test.mjs | 9 +++++---- tests/runtime.test.mjs | 17 +++++++++++++++++ 12 files changed, 42 insertions(+), 17 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 750163912..cecb681cd 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Codex plugins to use in Claude Code for delegation and code review.", - "version": "1.0.7" + "version": "1.0.8" }, "plugins": [ { "name": "codex", "description": "Use Codex from Claude Code to review code or delegate tasks.", - "version": "1.0.7", + "version": "1.0.8", "author": { "name": "OpenAI" }, diff --git a/README.md b/README.md index 9fbc5d7e2..4f1c2199b 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ Ask Codex to redesign the database connection to be more resilient. - if you do not pass `--model` or `--effort`, Codex chooses its own defaults. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo -- each `--read-root ` opts into an OS-enforced permission profile that denies local command reads outside the listed paths and Codex's minimal runtime paths +- each `--read-root ` must name an existing directory and opts into an OS-enforced permission profile that denies local command reads outside the listed directories and Codex's minimal runtime paths - scoped reads require Codex 0.138.0 or later and fail closed when the runtime cannot enforce permission profiles - filesystem profiles apply to local sandboxed commands, not web search, MCP servers, connectors, browser tools, or computer use diff --git a/package-lock.json b/package-lock.json index 86aa59a10..feabd258a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.7", + "version": "1.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openai/codex-plugin-cc", - "version": "1.0.7", + "version": "1.0.8", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index 26ebc91dc..d199e0ec1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.7", + "version": "1.0.8", "private": true, "type": "module", "description": "Use Codex from Claude Code to review code or delegate tasks.", diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index 9b0202e32..0c0ff22df 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "1.0.7", + "version": "1.0.8", "description": "Use Codex from Claude Code to review code or delegate tasks.", "author": { "name": "OpenAI" diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index 9cc3bd52f..aaa64ad94 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.0.8 + +- Require every `--read-root` to be an existing directory so scoped tasks do not claim unsupported file-level isolation on macOS. + ## 1.0.7 - Add opt-in `--read-root` enforcement for Codex rescue tasks using request-scoped permission profiles. diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 935776171..be02fd24a 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -31,7 +31,7 @@ Forwarding rules: - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. -- Preserve every `--read-root ` pair as a runtime control and do not include either token in the task text you pass through. +- Preserve every `--read-root ` pair as a runtime control and do not include either token in the task text you pass through. - Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index aa817705d..10b7e015d 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background|--wait] [--resume|--fresh] [--read-root ...] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--resume|--fresh] [--read-root ...] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" allowed-tools: Bash(node:*), AskUserQuestion, Agent --- @@ -18,7 +18,7 @@ Execution mode: - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. -- Preserve every `--read-root ` pair for the forwarded `task` call and remove both tokens from the natural-language task text. +- Preserve every `--read-root ` pair for the forwarded `task` call and remove both tokens from the natural-language task text. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 776d48175..75ef15381 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -79,7 +79,7 @@ function printUsage() { " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--read-root ...] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--read-root ...] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", @@ -158,7 +158,10 @@ function resolveCommandWorkspace(options = {}) { function resolveReadRoot(cwd, readRoot) { const resolved = path.resolve(cwd, readRoot); - return fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved; + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) { + throw new Error(`--read-root must name an existing directory: ${readRoot}`); + } + return fs.realpathSync(resolved); } function sleep(ms) { diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 53c4366cd..f6777c383 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -28,7 +28,7 @@ Command selection: - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. -- Preserve every `--read-root ` pair, pass it through to `task`, and exclude both tokens from the natural-language task text. +- Preserve every `--read-root ` pair, pass it through to `task`, and exclude both tokens from the natural-language task text. Each value must name an existing directory; files and missing paths fail before Codex starts. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 40571ea23..a4e9cdc2c 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -105,8 +105,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /--resume\|--fresh/); assert.match(rescue, /--model /); assert.match(rescue, /--effort /); - assert.match(rescue, /--read-root /); - assert.match(rescue, /Preserve every `--read-root `/i); + assert.match(rescue, /--read-root /); + assert.match(rescue, /Preserve every `--read-root `/i); assert.match(rescue, /task-resume-candidate --json/); assert.match(rescue, /AskUserQuestion/); assert.match(rescue, /Continue current Codex thread/); @@ -128,7 +128,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /Leave `--resume` and `--fresh` in the forwarded request/i); assert.match(agent, /--resume/); assert.match(agent, /--fresh/); - assert.match(agent, /Preserve every `--read-root `/i); + assert.match(agent, /Preserve every `--read-root `/i); assert.match(agent, /thin forwarding wrapper/i); assert.match(agent, /prefer foreground for a small, clearly bounded rescue request/i); assert.match(agent, /If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution/i); @@ -154,7 +154,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); assert.match(runtimeSkill, /Strip it before calling `task`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); - assert.match(runtimeSkill, /Preserve every `--read-root `/i); + assert.match(runtimeSkill, /Preserve every `--read-root `/i); + assert.match(runtimeSkill, /existing directory/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); assert.match(readme, /`codex:codex-rescue` subagent/i); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 2b07c7793..1f9450a45 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -785,6 +785,23 @@ test("task --read-root fails closed when permission profiles are unsupported", ( assert.match(result.stderr, /0\.138\.0 or later/); }); +test("task --read-root rejects files and missing directories before starting Codex", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "allowed.txt"), "fixture\n"); + + for (const readRoot of ["allowed.txt", "missing-directory"]) { + const result = run("node", [SCRIPT, "task", "--read-root", readRoot, "inspect"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /--read-root must name an existing directory/); + } +}); + test("task --resume acts like --resume-last without leaking the flag into the prompt", () => { const repo = makeTempDir(); const binDir = makeTempDir(); From 2522bf9e05bc512a51797c0e4821cadce28c5051 Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Fri, 4 Sep 2026 10:03:24 +0900 Subject: [PATCH 05/53] fix: preserve scoped workspace safeguards --- .claude-plugin/marketplace.json | 4 ++-- README.md | 1 + package-lock.json | 4 ++-- package.json | 2 +- plugins/codex/.claude-plugin/plugin.json | 2 +- plugins/codex/CHANGELOG.md | 4 ++++ plugins/codex/agents/codex-rescue.md | 1 + plugins/codex/commands/rescue.md | 1 + plugins/codex/scripts/codex-companion.mjs | 8 +++++++ plugins/codex/scripts/lib/codex.mjs | 4 +--- .../codex/skills/codex-cli-runtime/SKILL.md | 1 + tests/commands.test.mjs | 2 ++ tests/runtime.test.mjs | 23 +++++++++++++++---- 13 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cecb681cd..4c9879e58 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Codex plugins to use in Claude Code for delegation and code review.", - "version": "1.0.8" + "version": "1.0.9" }, "plugins": [ { "name": "codex", "description": "Use Codex from Claude Code to review code or delegate tasks.", - "version": "1.0.8", + "version": "1.0.9", "author": { "name": "OpenAI" }, diff --git a/README.md b/README.md index 4f1c2199b..4807381a8 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ Ask Codex to redesign the database connection to be more resilient. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo - each `--read-root ` must name an existing directory and opts into an OS-enforced permission profile that denies local command reads outside the listed directories and Codex's minimal runtime paths +- scoped `--write` requires the approved read roots to cover the workspace directory and retains Codex's built-in `:workspace` safeguards for protected repository metadata - scoped reads require Codex 0.138.0 or later and fail closed when the runtime cannot enforce permission profiles - filesystem profiles apply to local sandboxed commands, not web search, MCP servers, connectors, browser tools, or computer use diff --git a/package-lock.json b/package-lock.json index feabd258a..0684648a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.8", + "version": "1.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openai/codex-plugin-cc", - "version": "1.0.8", + "version": "1.0.9", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index d199e0ec1..797d45cea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.8", + "version": "1.0.9", "private": true, "type": "module", "description": "Use Codex from Claude Code to review code or delegate tasks.", diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index 0c0ff22df..040a087bd 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "1.0.8", + "version": "1.0.9", "description": "Use Codex from Claude Code to review code or delegate tasks.", "author": { "name": "OpenAI" diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index aaa64ad94..a64614427 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.0.9 + +- Preserve the built-in `:workspace` safeguards for scoped write tasks and require the approved read roots to cover the workspace. + ## 1.0.8 - Require every `--read-root` to be an existing directory so scoped tasks do not claim unsupported file-level isolation on macOS. diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index be02fd24a..b87fa2363 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -32,6 +32,7 @@ Forwarding rules: - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. - Preserve every `--read-root ` pair as a runtime control and do not include either token in the task text you pass through. +- When forwarding both scoped roots and `--write`, include an approved read root that covers the workspace directory. - Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 10b7e015d..d7206e9d3 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -19,6 +19,7 @@ Execution mode: - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. - Preserve every `--read-root ` pair for the forwarded `task` call and remove both tokens from the natural-language task text. +- When scoped `--write` is requested, the approved read roots must cover the workspace directory. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 75ef15381..0510db32a 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -164,6 +164,11 @@ function resolveReadRoot(cwd, readRoot) { return fs.realpathSync(resolved); } +function pathCovers(parent, child) { + const relative = path.relative(parent, child); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".."); +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -793,6 +798,9 @@ async function handleTask(argv) { } const write = Boolean(options.write); const readRoots = (options["read-root"] ?? []).map((readRoot) => resolveReadRoot(cwd, readRoot)); + if (write && readRoots.length > 0 && !readRoots.some((readRoot) => pathCovers(readRoot, workspaceRoot))) { + throw new Error("--write requires an approved --read-root that covers the workspace directory."); + } const taskMetadata = buildTaskRunMetadata({ prompt, resumeLast diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 8c0febeeb..23daa8d07 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -65,9 +65,6 @@ function buildThreadAccessParams(cwd, options = {}) { for (const readRoot of readRoots) { filesystem[path.resolve(cwd, readRoot)] = "read"; } - if (options.write) { - filesystem[path.resolve(cwd)] = "write"; - } return { config: { @@ -75,6 +72,7 @@ function buildThreadAccessParams(cwd, options = {}) { permissions: { [SCOPED_PERMISSION_PROFILE]: { description: "Claude companion request-scoped filesystem access", + ...(options.write ? { extends: ":workspace" } : {}), filesystem } } diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index f6777c383..a9e897c53 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -29,6 +29,7 @@ Command selection: - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. - Preserve every `--read-root ` pair, pass it through to `task`, and exclude both tokens from the natural-language task text. Each value must name an existing directory; files and missing paths fail before Codex starts. +- With scoped access, `--write` requires an approved read root to cover the workspace directory; the runtime keeps the built-in `:workspace` safeguards. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index a4e9cdc2c..ba5af9cac 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -156,12 +156,14 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); assert.match(runtimeSkill, /Preserve every `--read-root `/i); assert.match(runtimeSkill, /existing directory/i); + assert.match(runtimeSkill, /`--write`.*cover the workspace directory/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); assert.match(readme, /`codex:codex-rescue` subagent/i); assert.match(readme, /if you do not pass `--model` or `--effort`, Codex chooses its own defaults/i); assert.match(readme, /--model gpt-5\.4-mini --effort medium/i); assert.match(readme, /--read-root/); + assert.match(readme, /`--write`.*cover the workspace directory/i); assert.match(readme, /`spark`, the plugin maps that to `gpt-5\.3-codex-spark`/i); assert.match(readme, /continue a previous Codex task/i); assert.match(readme, /### `\/codex:setup`/); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 1f9450a45..6a223ea2c 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -723,11 +723,10 @@ test("task --read-root sends a scoped permission profile without legacy sandbox" const statePath = path.join(binDir, "fake-codex-state.json"); installFakeCodex(binDir); initGitRepo(repo); - fs.mkdirSync(path.join(repo, "src")); const result = run( "node", - [SCRIPT, "task", "--write", "--read-root", "src", "--read-root", extraReadRoot, "fix the test"], + [SCRIPT, "task", "--write", "--read-root", repo, "--read-root", extraReadRoot, "fix the test"], { cwd: repo, env: buildEnv(binDir) } ); @@ -739,9 +738,25 @@ test("task --read-root sends a scoped permission profile without legacy sandbox" assert.equal(params.config.default_permissions, "claude_companion_scoped"); assert.equal(profile.filesystem[":root"], "deny"); assert.equal(profile.filesystem[":minimal"], "read"); - assert.equal(profile.filesystem[fs.realpathSync(path.join(repo, "src"))], "read"); + assert.equal(profile.filesystem[fs.realpathSync(repo)], "read"); assert.equal(profile.filesystem[fs.realpathSync(extraReadRoot)], "read"); - assert.equal(profile.filesystem[fs.realpathSync(repo)], "write"); + assert.equal(profile.extends, ":workspace"); +}); + +test("task --write requires the approved read scope to cover the workspace", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.mkdirSync(path.join(repo, "src")); + + const result = run("node", [SCRIPT, "task", "--write", "--read-root", "src", "fix"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /--write requires an approved --read-root that covers the workspace/); }); test("task --resume-last reapplies the approved read roots", () => { From 9375ff1310cf087883e8b87f5c0f87b1029d4ebf Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Fri, 4 Sep 2026 10:05:25 +0900 Subject: [PATCH 06/53] fix: deny unapproved temp roots --- .claude-plugin/marketplace.json | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- plugins/codex/.claude-plugin/plugin.json | 2 +- plugins/codex/CHANGELOG.md | 4 ++++ plugins/codex/scripts/lib/codex.mjs | 4 +++- tests/runtime.test.mjs | 2 ++ 7 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4c9879e58..36a405993 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Codex plugins to use in Claude Code for delegation and code review.", - "version": "1.0.9" + "version": "1.0.10" }, "plugins": [ { "name": "codex", "description": "Use Codex from Claude Code to review code or delegate tasks.", - "version": "1.0.9", + "version": "1.0.10", "author": { "name": "OpenAI" }, diff --git a/package-lock.json b/package-lock.json index 0684648a5..d01eff478 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.9", + "version": "1.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openai/codex-plugin-cc", - "version": "1.0.9", + "version": "1.0.10", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index 797d45cea..fd731eead 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.9", + "version": "1.0.10", "private": true, "type": "module", "description": "Use Codex from Claude Code to review code or delegate tasks.", diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index 040a087bd..a969f9b47 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "1.0.9", + "version": "1.0.10", "description": "Use Codex from Claude Code to review code or delegate tasks.", "author": { "name": "OpenAI" diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index a64614427..bdc39b2e6 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.0.10 + +- Deny inherited system temp roots in scoped profiles so only approved directories and minimal runtime paths remain readable. + ## 1.0.9 - Preserve the built-in `:workspace` safeguards for scoped write tasks and require the approved read roots to cover the workspace. diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 23daa8d07..93e21afd9 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -60,7 +60,9 @@ function buildThreadAccessParams(cwd, options = {}) { const filesystem = { ":root": "deny", - ":minimal": "read" + ":minimal": "read", + ":tmpdir": "deny", + ":slash_tmp": "deny" }; for (const readRoot of readRoots) { filesystem[path.resolve(cwd, readRoot)] = "read"; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 6a223ea2c..7fe85c61e 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -738,6 +738,8 @@ test("task --read-root sends a scoped permission profile without legacy sandbox" assert.equal(params.config.default_permissions, "claude_companion_scoped"); assert.equal(profile.filesystem[":root"], "deny"); assert.equal(profile.filesystem[":minimal"], "read"); + assert.equal(profile.filesystem[":tmpdir"], "deny"); + assert.equal(profile.filesystem[":slash_tmp"], "deny"); assert.equal(profile.filesystem[fs.realpathSync(repo)], "read"); assert.equal(profile.filesystem[fs.realpathSync(extraReadRoot)], "read"); assert.equal(profile.extends, ":workspace"); From 22dac7a6e11b3a353e1e7724e7f49fc03e1a3140 Mon Sep 17 00:00:00 2001 From: kilhyeonjun Date: Fri, 4 Sep 2026 10:09:12 +0900 Subject: [PATCH 07/53] fix: reject empty scoped roots --- .claude-plugin/marketplace.json | 4 ++-- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- plugins/codex/.claude-plugin/plugin.json | 2 +- plugins/codex/CHANGELOG.md | 6 +++++- plugins/codex/scripts/codex-companion.mjs | 3 +++ plugins/codex/skills/codex-cli-runtime/SKILL.md | 2 +- tests/runtime.test.mjs | 2 +- 9 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 36a405993..58c70a428 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Codex plugins to use in Claude Code for delegation and code review.", - "version": "1.0.10" + "version": "1.0.11" }, "plugins": [ { "name": "codex", "description": "Use Codex from Claude Code to review code or delegate tasks.", - "version": "1.0.10", + "version": "1.0.11", "author": { "name": "OpenAI" }, diff --git a/README.md b/README.md index 4807381a8..6ab0b00af 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ Ask Codex to redesign the database connection to be more resilient. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo - each `--read-root ` must name an existing directory and opts into an OS-enforced permission profile that denies local command reads outside the listed directories and Codex's minimal runtime paths -- scoped `--write` requires the approved read roots to cover the workspace directory and retains Codex's built-in `:workspace` safeguards for protected repository metadata +- scoped `--write` requires the approved read roots to cover the workspace directory and uses Codex's built-in `:workspace` write policy - scoped reads require Codex 0.138.0 or later and fail closed when the runtime cannot enforce permission profiles - filesystem profiles apply to local sandboxed commands, not web search, MCP servers, connectors, browser tools, or computer use diff --git a/package-lock.json b/package-lock.json index d01eff478..7a7696f92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.10", + "version": "1.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openai/codex-plugin-cc", - "version": "1.0.10", + "version": "1.0.11", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index fd731eead..97a18ccd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openai/codex-plugin-cc", - "version": "1.0.10", + "version": "1.0.11", "private": true, "type": "module", "description": "Use Codex from Claude Code to review code or delegate tasks.", diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index a969f9b47..b1f35c9e7 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "1.0.10", + "version": "1.0.11", "description": "Use Codex from Claude Code to review code or delegate tasks.", "author": { "name": "OpenAI" diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index bdc39b2e6..a8206c1d7 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -1,12 +1,16 @@ # Changelog +## 1.0.11 + +- Reject empty `--read-root` values instead of resolving them to the current workspace. + ## 1.0.10 - Deny inherited system temp roots in scoped profiles so only approved directories and minimal runtime paths remain readable. ## 1.0.9 -- Preserve the built-in `:workspace` safeguards for scoped write tasks and require the approved read roots to cover the workspace. +- Use the built-in `:workspace` profile for scoped write tasks and require the approved read roots to cover the workspace. ## 1.0.8 diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 0510db32a..ddf5956aa 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -157,6 +157,9 @@ function resolveCommandWorkspace(options = {}) { } function resolveReadRoot(cwd, readRoot) { + if (typeof readRoot !== "string" || !readRoot.trim()) { + throw new Error("--read-root must name an existing directory: value is empty"); + } const resolved = path.resolve(cwd, readRoot); if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) { throw new Error(`--read-root must name an existing directory: ${readRoot}`); diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index a9e897c53..adc288c93 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -29,7 +29,7 @@ Command selection: - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. - Preserve every `--read-root ` pair, pass it through to `task`, and exclude both tokens from the natural-language task text. Each value must name an existing directory; files and missing paths fail before Codex starts. -- With scoped access, `--write` requires an approved read root to cover the workspace directory; the runtime keeps the built-in `:workspace` safeguards. +- With scoped access, `--write` requires an approved read root to cover the workspace directory and uses Codex's built-in `:workspace` write policy. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 7fe85c61e..b5ed94632 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -809,7 +809,7 @@ test("task --read-root rejects files and missing directories before starting Cod initGitRepo(repo); fs.writeFileSync(path.join(repo, "allowed.txt"), "fixture\n"); - for (const readRoot of ["allowed.txt", "missing-directory"]) { + for (const readRoot of ["", "allowed.txt", "missing-directory"]) { const result = run("node", [SCRIPT, "task", "--read-root", readRoot, "inspect"], { cwd: repo, env: buildEnv(binDir) From cacf2270c10607798e3a6a1baf7122adf1841d4c Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 13:52:15 -0300 Subject: [PATCH 08/53] feat: add task --resume-thread --- plugins/codex/scripts/codex-companion.mjs | 36 ++++--- .../codex/skills/codex-cli-runtime/SKILL.md | 1 + tests/commands.test.mjs | 1 + tests/runtime.test.mjs | 96 +++++++++++++++++++ 4 files changed, 121 insertions(+), 13 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..05b050c5b 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -79,7 +79,7 @@ function printUsage() { " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--resume-thread |--fresh] [--model ] [--effort ] [prompt]", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", @@ -464,11 +464,13 @@ async function executeTaskRun(request) { const taskMetadata = buildTaskRunMetadata({ prompt: request.prompt, - resumeLast: request.resumeLast + resumeLast: request.resumeLast || Boolean(request.resumeThread) }); let resumeThreadId = null; - if (request.resumeLast) { + if (request.resumeThread) { + resumeThreadId = request.resumeThread; + } else if (request.resumeLast) { const latestThread = await resolveLatestTrackedTaskThread(workspaceRoot, { excludeJobId: request.jobId }); @@ -479,7 +481,7 @@ async function executeTaskRun(request) { } if (!request.prompt && !resumeThreadId) { - throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); + throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last / --resume-thread ."); } const result = await runAppServerTurn(workspaceRoot, { @@ -601,7 +603,7 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, resumeThread = null, jobId }) { return { cwd, model, @@ -609,6 +611,7 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId prompt, write, resumeLast, + resumeThread, jobId }; } @@ -649,9 +652,9 @@ function readTaskPrompt(cwd, options, positionals) { return positionalPrompt || readStdinIfPiped(); } -function requireTaskRequest(prompt, resumeLast) { - if (!prompt && !resumeLast) { - throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); +function requireTaskRequest(prompt, resumeLast, resumeThread = null) { + if (!prompt && !resumeLast && !resumeThread) { + throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last / --resume-thread ."); } } @@ -761,7 +764,7 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["model", "effort", "cwd", "prompt-file"], + valueOptions: ["model", "effort", "cwd", "prompt-file", "resume-thread"], booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], aliasMap: { m: "model" @@ -775,19 +778,24 @@ async function handleTask(argv) { const prompt = readTaskPrompt(cwd, options, positionals); const resumeLast = Boolean(options["resume-last"] || options.resume); + const resumeThreadOption = options["resume-thread"]; + const resumeThread = resumeThreadOption === undefined ? null : String(resumeThreadOption).trim(); const fresh = Boolean(options.fresh); - if (resumeLast && fresh) { - throw new Error("Choose either --resume/--resume-last or --fresh."); + if (resumeThreadOption !== undefined && !resumeThread) { + throw new Error("--resume-thread requires a non-empty thread id."); + } + if (Number(resumeLast) + Number(Boolean(resumeThread)) + Number(fresh) > 1) { + throw new Error("Choose only one of --resume/--resume-last, --resume-thread , or --fresh."); } const write = Boolean(options.write); const taskMetadata = buildTaskRunMetadata({ prompt, - resumeLast + resumeLast: resumeLast || Boolean(resumeThread) }); if (options.background) { ensureCodexAvailable(cwd); - requireTaskRequest(prompt, resumeLast); + requireTaskRequest(prompt, resumeLast, resumeThread); const job = buildTaskJob(workspaceRoot, taskMetadata, write); const request = buildTaskRequest({ @@ -797,6 +805,7 @@ async function handleTask(argv) { prompt, write, resumeLast, + resumeThread, jobId: job.id }); const { payload } = enqueueBackgroundTask(cwd, job, request); @@ -815,6 +824,7 @@ async function handleTask(argv) { prompt, write, resumeLast, + resumeThread, jobId: job.id, onProgress: progress }), diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 0e91bfb50..1c8c9da7f 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -30,6 +30,7 @@ Command selection: - If the forwarded request includes `--effort`, pass it through to `task`. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. +- If the forwarded request includes `--resume-thread `, strip both tokens from the task text and pass `--resume-thread ` to `task`; this resumes that exact Codex thread instead of the latest one. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. - `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up. - `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b06059..308a0f8a2 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -195,6 +195,7 @@ test("internal docs use task terminology for rescue runs", () => { assert.match(runtimeSkill, /codex-companion\.mjs" task ""/); assert.match(runtimeSkill, /Use `task` for every rescue request/i); assert.match(runtimeSkill, /task --resume-last/i); + assert.match(runtimeSkill, /--resume-thread /i); assert.match(promptingSkill, /Use `task` when the task is diagnosis/i); assert.match(promptRecipes, /Codex task prompts/i); assert.match(promptRecipes, /Use these as starting templates for Codex task prompts/i); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..5d5d6c16a 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -503,6 +503,68 @@ test("task --resume-last resumes the latest persisted task thread", () => { assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); }); +test("task --resume-thread resumes the requested thread instead of the latest one", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + assert.equal(run("node", [SCRIPT, "task", "first task"], { cwd: repo, env: buildEnv(binDir) }).status, 0); + assert.equal(run("node", [SCRIPT, "task", "second task"], { cwd: repo, env: buildEnv(binDir) }).status, 0); + + const result = run("node", [SCRIPT, "task", "--resume-thread", "thr_1", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); + assert.equal(fakeState.lastTurnStart.prompt, "follow up"); +}); + +test("task --resume-thread can continue without an explicit prompt", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + assert.equal(run("node", [SCRIPT, "task", "initial task"], { cwd: repo, env: buildEnv(binDir) }).status, 0); + + const result = run("node", [SCRIPT, "task", "--resume-thread", "thr_1"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); + assert.equal(fakeState.lastTurnStart.prompt, "Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved."); +}); +test("task --resume-thread rejects conflicting routing controls", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + const env = buildEnv(binDir); + + for (const args of [ + ["task", "--resume-thread", "thr_1", "--resume-last", "follow up"], + ["task", "--resume-thread", "thr_1", "--fresh", "follow up"] + ]) { + const result = run("node", [SCRIPT, ...args], { cwd: repo, env }); + assert.equal(result.status, 1); + assert.match(result.stderr, /Choose only one of --resume\/--resume-last, --resume-thread , or --fresh/); + } +}); + test("task-resume-candidate returns the latest rescue thread from the current session", () => { const workspace = makeTempDir(); const stateDir = resolveStateDir(workspace); @@ -969,6 +1031,40 @@ test("task --background enqueues a detached worker and exposes per-job status", assert.match(resultPayload.storedJob.rendered, /Handled the requested task/); }); +test("task --background preserves an explicit resume thread", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + const env = buildEnv(binDir); + + assert.equal(run("node", [SCRIPT, "task", "first task"], { cwd: repo, env }).status, 0); + assert.equal(run("node", [SCRIPT, "task", "second task"], { cwd: repo, env }).status, 0); + + const launched = run( + "node", + [SCRIPT, "task", "--background", "--json", "--resume-thread", "thr_1", "follow up"], + { cwd: repo, env } + ); + assert.equal(launched.status, 0, launched.stderr); + const launchPayload = JSON.parse(launched.stdout); + const waited = run( + "node", + [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"], + { cwd: repo, env } + ); + assert.equal(waited.status, 0, waited.stderr); + assert.equal(JSON.parse(waited.stdout).job.status, "completed"); + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); + assert.equal(fakeState.lastTurnStart.prompt, "follow up"); +}); + test("review rejects focus text because it is native-review only", () => { const repo = makeTempDir(); const binDir = makeTempDir(); From 4be1ab5588d733428060e4094ceac7d752be3318 Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 14:01:32 -0300 Subject: [PATCH 09/53] fix: support relocated Claude config transfers --- plugins/codex/scripts/codex-companion.mjs | 10 ++- .../scripts/lib/claude-session-transfer.mjs | 87 +++++++++++++++++-- tests/runtime.test.mjs | 49 ++++++++++- 3 files changed, 137 insertions(+), 9 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..445c10ff7 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -21,7 +21,7 @@ import { runAppServerReview, runAppServerTurn } from "./lib/codex.mjs"; -import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; +import { prepareClaudeSessionImport, resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; @@ -626,7 +626,13 @@ async function executeTransfer(cwd, options = {}) { const sourcePath = resolveClaudeSessionPath(cwd, { source: options.source }); - const result = await importExternalAgentSession(cwd, { sourcePath }); + const prepared = prepareClaudeSessionImport(cwd, sourcePath); + let result; + try { + result = await importExternalAgentSession(cwd, { sourcePath: prepared.importPath }); + } finally { + prepared.cleanup(); + } const payload = { threadId: result.threadId, resumeCommand: `codex resume ${result.threadId}`, diff --git a/plugins/codex/scripts/lib/claude-session-transfer.mjs b/plugins/codex/scripts/lib/claude-session-transfer.mjs index eea0aeba2..7bd2eeba9 100644 --- a/plugins/codex/scripts/lib/claude-session-transfer.mjs +++ b/plugins/codex/scripts/lib/claude-session-transfer.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import { ensureAbsolutePath } from "./fs.mjs"; export const TRANSCRIPT_PATH_ENV = "CODEX_COMPANION_TRANSCRIPT_PATH"; -const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); +const CLAUDE_CONFIG_DIR_ENV = "CLAUDE_CONFIG_DIR"; function resolveUserPath(cwd, value) { if (value === "~") { @@ -17,6 +17,42 @@ function resolveUserPath(cwd, value) { return ensureAbsolutePath(cwd, value); } +function defaultClaudeProjectsDir() { + return path.join(os.homedir(), ".claude", "projects"); +} + +function configuredClaudeProjectsDir(cwd) { + const configured = process.env[CLAUDE_CONFIG_DIR_ENV]; + return configured ? path.join(resolveUserPath(cwd, configured), "projects") : null; +} + +function allowedClaudeProjectsDirs(cwd) { + return [...new Set([configuredClaudeProjectsDir(cwd), defaultClaudeProjectsDir()].filter(Boolean))]; +} + +function realpathIfExists(value) { + try { + return fs.realpathSync(value); + } catch { + return null; + } +} + +function isWithin(root, candidate) { + const relative = path.relative(root, candidate); + return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); +} + +function findContainingProjectsRoot(cwd, source) { + for (const configuredPath of allowedClaudeProjectsDirs(cwd)) { + const realRoot = realpathIfExists(configuredPath); + if (realRoot && isWithin(realRoot, source)) { + return { configuredPath, realRoot }; + } + } + return null; +} + export function resolveClaudeSessionPath(cwd, options = {}) { const requestedPath = options.source || process.env[TRANSCRIPT_PATH_ENV]; if (!requestedPath) { @@ -29,16 +65,55 @@ export function resolveClaudeSessionPath(cwd, options = {}) { } let source; - let projects; try { source = fs.realpathSync(sourcePath); - projects = fs.realpathSync(CLAUDE_PROJECTS_DIR); } catch { throw new Error(`Claude session file not found: ${sourcePath}`); } - const relative = path.relative(projects, source); - if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { - throw new Error(`Codex can import Claude sessions only from ${CLAUDE_PROJECTS_DIR}: ${source}`); + + if (!findContainingProjectsRoot(cwd, source)) { + throw new Error( + `Codex can import Claude sessions only from ${allowedClaudeProjectsDirs(cwd).join(" or ")}: ${source}` + ); } return source; } + +export function prepareClaudeSessionImport(cwd, sourcePath) { + const source = fs.realpathSync(sourcePath); + const defaultProjects = defaultClaudeProjectsDir(); + const defaultRoot = realpathIfExists(defaultProjects); + if (defaultRoot && isWithin(defaultRoot, source)) { + return { sourcePath: source, importPath: source, staged: false, cleanup() {} }; + } + + const sourceRoot = findContainingProjectsRoot(cwd, source); + if (!sourceRoot) { + throw new Error(`Claude session is outside the configured projects roots: ${source}`); + } + + const relative = path.relative(sourceRoot.realRoot, source); + const importPath = path.join(defaultProjects, relative); + fs.mkdirSync(path.dirname(importPath), { recursive: true }); + + if (fs.existsSync(importPath)) { + throw new Error(`Cannot stage Claude session for Codex because the destination already exists: ${importPath}`); + } + + fs.copyFileSync(source, importPath, fs.constants.COPYFILE_EXCL); + const canonicalImportPath = fs.realpathSync(importPath); + return { + sourcePath: source, + importPath: canonicalImportPath, + staged: true, + cleanup() { + try { + fs.unlinkSync(canonicalImportPath); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + } + }; +} diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..f35b5d54f 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -220,6 +220,7 @@ test("transfer delegates the current Claude session directly to native import", env: { ...buildEnv(binDir), HOME: home, + USERPROFILE: home, CODEX_HOME: path.join(home, ".codex"), CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath } @@ -244,6 +245,50 @@ test("transfer delegates the current Claude session directly to native import", ); }); +test("transfer supports CLAUDE_CONFIG_DIR and stages a temporary default-root copy", () => { + const home = makeTempDir(); + const repo = path.join(home, "repo"); + const binDir = makeTempDir(); + const claudeConfigDir = path.join(home, ".claude-work"); + const projectDir = path.join(claudeConfigDir, "projects", "-repo"); + const sourcePath = path.join(projectDir, "session-alt-root.jsonl"); + fs.mkdirSync(repo, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync( + sourcePath, + `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Transfer from relocated config." } })}\n`, + "utf8" + ); + + const result = run("node", [SCRIPT, "transfer", "--json"], { + cwd: repo, + env: { + ...buildEnv(binDir), + HOME: home, + USERPROFILE: home, + CODEX_HOME: path.join(home, ".codex"), + CLAUDE_CONFIG_DIR: claudeConfigDir, + CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath + } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + const original = fs.realpathSync(sourcePath); + assert.equal(payload.sourcePath, original); + + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + const importedPath = fakeState.lastExternalAgentImport.sourcePath; + const defaultProjects = path.join(home, ".claude", "projects"); + const relativeImport = path.relative(defaultProjects, importedPath); + assert.equal(relativeImport.startsWith("..") || path.isAbsolute(relativeImport), false); + assert.notEqual(importedPath, original); + assert.equal(fs.existsSync(importedPath), false); + assert.equal(fs.existsSync(original), true); +}); + test("transfer reports an actionable upgrade error when native import is unsupported", () => { const home = makeTempDir(); const repo = path.join(home, "repo"); @@ -265,6 +310,7 @@ test("transfer reports an actionable upgrade error when native import is unsuppo env: { ...buildEnv(binDir), HOME: home, + USERPROFILE: home, CODEX_HOME: path.join(home, ".codex") } }); @@ -295,6 +341,7 @@ test("transfer fails visibly when native import completes without a ledger recor env: { ...buildEnv(binDir), HOME: home, + USERPROFILE: home, CODEX_HOME: path.join(home, ".codex") } }); @@ -320,7 +367,7 @@ test("transfer rejects sources outside the Claude projects directory", () => { const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], { cwd: repo, - env: { ...buildEnv(binDir), HOME: home } + env: { ...buildEnv(binDir), HOME: home, USERPROFILE: home } }); assert.notEqual(result.status, 0); From 08cd20861a2712523698c30f1982c15fdcb46e61 Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 14:55:10 -0300 Subject: [PATCH 10/53] fix: route explicit resume threads through rescue --- plugins/codex/agents/codex-rescue.md | 5 +++-- plugins/codex/commands/rescue.md | 5 +++-- tests/commands.test.mjs | 8 ++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 7009ec86a..7cca4479b 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -32,10 +32,11 @@ Forwarding rules: - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. - Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. -- Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. +- Treat `--resume`, `--resume-thread `, and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. +- `--resume-thread ` means pass that exact routing pair through and do not add `--resume-last`. - `--fresh` means do not add `--resume-last`. -- If the user is clearly asking to continue prior Codex work in this repository, such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", add `--resume-last` unless `--fresh` is present. +- If the user is clearly asking to continue prior Codex work in this repository, such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", add `--resume-last` unless `--fresh` or `--resume-thread ` is present. - Otherwise forward the task as a fresh `task` run. - Preserve the user's task text as-is apart from stripping routing flags. - Return the stdout of the `codex-companion` command exactly as-is. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 56de9555d..b9b2e9ca0 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--resume|--resume-thread |--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" allowed-tools: Bash(node:*), AskUserQuestion, Agent --- @@ -20,6 +20,7 @@ Execution mode: - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. +- If the request includes `--resume-thread `, do not ask whether to continue. The user already chose the exact Codex thread. - Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: ```bash @@ -44,6 +45,6 @@ Operating rules: - Do not ask the subagent to inspect files, monitor progress, poll `/codex:status`, fetch `/codex:result`, call `/codex:cancel`, summarize output, or do follow-up work of its own. - Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort. - Leave the model unset unless the user explicitly asks for one. If they ask for `spark`, map it to `gpt-5.3-codex-spark`. -- Leave `--resume` and `--fresh` in the forwarded request. The subagent handles that routing when it builds the `task` command. +- Leave `--resume`, `--resume-thread `, and `--fresh` in the forwarded request. The subagent handles that routing when it builds the `task` command. - If the helper reports that Codex is missing or unauthenticated, stop and tell the user to run `/codex:setup`. - If the user did not supply a request, ask what Codex should investigate or fix. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 308a0f8a2..c465b72e4 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -102,7 +102,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /do not call `Skill\(codex:codex-rescue\)`/i); assert.doesNotMatch(rescue, /^context:\s*fork\b/m); assert.match(rescue, /--background\|--wait/); - assert.match(rescue, /--resume\|--fresh/); + assert.match(rescue, /--resume\|--resume-thread \|--fresh/); assert.match(rescue, /--model /); assert.match(rescue, /--effort /); assert.match(rescue, /task-resume-candidate --json/); @@ -117,16 +117,20 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /If they ask for `spark`, map it to `gpt-5\.3-codex-spark`/i); assert.match(rescue, /If the request includes `--resume`, do not ask whether to continue/i); assert.match(rescue, /If the request includes `--fresh`, do not ask whether to continue/i); + assert.match(rescue, /If the request includes `--resume-thread `, do not ask whether to continue/i); assert.match(rescue, /If the user chooses continue, add `--resume`/i); assert.match(rescue, /If the user chooses a new thread, add `--fresh`/i); assert.match(rescue, /thin forwarder only/i); assert.match(rescue, /Return the Codex companion stdout verbatim to the user/i); assert.match(rescue, /Do not paraphrase, summarize, rewrite, or add commentary before or after it/i); assert.match(rescue, /return that command's stdout as-is/i); - assert.match(rescue, /Leave `--resume` and `--fresh` in the forwarded request/i); + assert.match(rescue, /Leave `--resume`, `--resume-thread `, and `--fresh` in the forwarded request/i); assert.match(agent, /--resume/); assert.match(agent, /--fresh/); + assert.match(agent, /--resume-thread /); assert.match(agent, /thin forwarding wrapper/i); + assert.match(agent, /Treat `--resume`, `--resume-thread `, and `--fresh` as routing controls/i); + assert.match(agent, /`--resume-thread ` means pass that exact routing pair through and do not add `--resume-last`/i); assert.match(agent, /prefer foreground for a small, clearly bounded rescue request/i); assert.match(agent, /If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution/i); assert.match(agent, /Use exactly one `Bash` call/i); From e123511d2ec4cab0b056ba343def6114d6d28f2f Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 15:05:18 -0300 Subject: [PATCH 11/53] fix: persist review gate config outside transient state --- plugins/codex/scripts/lib/state.mjs | 49 +++++++++++++++++++++++------ tests/runtime.test.mjs | 28 +++++++++++++++++ tests/state.test.mjs | 24 +++++++++++++- 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 2da23498f..4a222172f 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -7,6 +7,8 @@ import { resolveWorkspaceRoot } from "./workspace.mjs"; const STATE_VERSION = 1; const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; +const CODEX_HOME_ENV = "CODEX_HOME"; +const CONFIG_DIR_NAME = path.join("plugin-cc", "config"); const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "codex-companion"); const STATE_FILE_NAME = "state.json"; const JOBS_DIR_NAME = "jobs"; @@ -26,7 +28,7 @@ function defaultState() { }; } -export function resolveStateDir(cwd) { +function resolveWorkspaceKey(cwd) { const workspaceRoot = resolveWorkspaceRoot(cwd); let canonicalWorkspaceRoot = workspaceRoot; try { @@ -34,13 +36,21 @@ export function resolveStateDir(cwd) { } catch { canonicalWorkspaceRoot = workspaceRoot; } - const slugSource = path.basename(workspaceRoot) || "workspace"; const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace"; const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16); + return `${slug}-${hash}`; +} + +export function resolveStateDir(cwd) { const pluginDataDir = process.env[PLUGIN_DATA_ENV]; const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR; - return path.join(stateRoot, `${slug}-${hash}`); + return path.join(stateRoot, resolveWorkspaceKey(cwd)); +} + +export function resolveConfigFile(cwd) { + const codexHome = path.resolve(process.env[CODEX_HOME_ENV] || path.join(os.homedir(), ".codex")); + return path.join(codexHome, CONFIG_DIR_NAME, `${resolveWorkspaceKey(cwd)}.json`); } export function resolveStateFile(cwd) { @@ -150,17 +160,38 @@ export function listJobs(cwd) { return loadState(cwd).jobs; } +function readDurableConfig(cwd) { + const configFile = resolveConfigFile(cwd); + if (!fs.existsSync(configFile)) { + return null; + } + try { + const parsed = JSON.parse(fs.readFileSync(configFile, "utf8")); + return { ...defaultState().config, ...(parsed ?? {}) }; + } catch { + return null; + } +} + +function writeDurableConfig(cwd, config) { + const configFile = resolveConfigFile(cwd); + fs.mkdirSync(path.dirname(configFile), { recursive: true }); + const nextConfig = { ...defaultState().config, ...(config ?? {}) }; + fs.writeFileSync(configFile, `${JSON.stringify(nextConfig, null, 2)} +`, "utf8"); + return nextConfig; +} + export function setConfig(cwd, key, value) { - return updateState(cwd, (state) => { - state.config = { - ...state.config, - [key]: value - }; + const nextConfig = writeDurableConfig(cwd, { ...getConfig(cwd), [key]: value }); + updateState(cwd, (state) => { + state.config = { ...state.config, ...nextConfig }; }); + return nextConfig; } export function getConfig(cwd) { - return loadState(cwd).config; + return readDurableConfig(cwd) ?? loadState(cwd).config; } export function writeJobFile(cwd, jobId, payload) { diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..6f7c621f0 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -2036,6 +2036,34 @@ test("stop hook logs running tasks to stderr without blocking when the review ga assert.match(blocked.stderr, /\/codex:cancel task-live/i); }); +test("review gate enabled under one plugin-data root is enforced by Stop under another", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const codexHome = makeTempDir(); + const pluginDataSetup = makeTempDir(); + const pluginDataStop = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + const setup = run("node", [SCRIPT, "setup", "--enable-review-gate", "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_HOME: codexHome, CLAUDE_PLUGIN_DATA: pluginDataSetup } + }); + assert.equal(setup.status, 0, setup.stderr); + assert.equal(JSON.parse(setup.stdout).reviewGateEnabled, true); + const stopped = run("node", [STOP_HOOK], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_HOME: codexHome, CLAUDE_PLUGIN_DATA: pluginDataStop, CODEX_COMPANION_SESSION_ID: "sess-cross-root" }, + input: JSON.stringify({ cwd: repo, session_id: "sess-cross-root", last_assistant_message: "I completed the change." }) + }); + assert.equal(stopped.status, 0, stopped.stderr); + const payload = JSON.parse(stopped.stdout); + assert.equal(payload.decision, "block"); + assert.match(payload.reason, /Codex stop-time review found issues/i); +}); + test("stop hook allows the stop when the review gate is enabled and the stop-time review task is clean", () => { const repo = makeTempDir(); const binDir = makeTempDir(); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 0f8f57cea..1312493df 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -5,7 +5,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { makeTempDir } from "./helpers.mjs"; -import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs"; +import { getConfig, resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState, setConfig } from "../plugins/codex/scripts/lib/state.mjs"; test("resolveStateDir uses a temp-backed per-workspace directory", () => { const workspace = makeTempDir(); @@ -40,6 +40,28 @@ test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => { } }); +test("review-gate config remains authoritative when CLAUDE_PLUGIN_DATA changes", () => { + const workspace = makeTempDir(); + const codexHome = makeTempDir(); + const pluginDataA = makeTempDir(); + const pluginDataB = makeTempDir(); + const previousCodexHome = process.env.CODEX_HOME; + const previousPluginData = process.env.CLAUDE_PLUGIN_DATA; + try { + process.env.CODEX_HOME = codexHome; + process.env.CLAUDE_PLUGIN_DATA = pluginDataA; + setConfig(workspace, "stopReviewGate", true); + process.env.CLAUDE_PLUGIN_DATA = pluginDataB; + assert.equal(getConfig(workspace).stopReviewGate, true); + setConfig(workspace, "stopReviewGate", false); + process.env.CLAUDE_PLUGIN_DATA = pluginDataA; + assert.equal(getConfig(workspace).stopReviewGate, false); + } finally { + if (previousCodexHome == null) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; + if (previousPluginData == null) delete process.env.CLAUDE_PLUGIN_DATA; else process.env.CLAUDE_PLUGIN_DATA = previousPluginData; + } +}); + test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", () => { const workspace = makeTempDir(); const stateFile = resolveStateFile(workspace); From a81f52f9c2c366d7d5e095179d2473a9f7e12798 Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 15:41:49 -0300 Subject: [PATCH 12/53] test: isolate persistent review gate config --- tests/fake-codex-fixture.mjs | 1 + tests/runtime.test.mjs | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..8e8107ba6 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -653,6 +653,7 @@ export function buildEnv(binDir) { const sep = process.platform === "win32" ? ";" : ":"; return { ...process.env, + CODEX_HOME: path.join(binDir, "codex-home"), PATH: `${binDir}${sep}${process.env.PATH}` }; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 6f7c621f0..83352e47f 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -2096,8 +2096,10 @@ test("stop hook does not block when Codex is unavailable even if the review gate run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); + const codexHome = makeTempDir(); const setup = run(process.execPath, [SCRIPT, "setup", "--enable-review-gate", "--json"], { - cwd: repo + cwd: repo, + env: { ...process.env, CODEX_HOME: codexHome } }); assert.equal(setup.status, 0, setup.stderr); @@ -2105,6 +2107,7 @@ test("stop hook does not block when Codex is unavailable even if the review gate cwd: repo, env: { ...process.env, + CODEX_HOME: codexHome, PATH: "" }, input: JSON.stringify({ cwd: repo }) From a9581326579e688224a30c76f5a6d12d910a21f3 Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 21:31:35 -0300 Subject: [PATCH 13/53] fix: contain staged Claude session imports --- .../scripts/lib/claude-session-transfer.mjs | 10 +++++++ tests/runtime.test.mjs | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/plugins/codex/scripts/lib/claude-session-transfer.mjs b/plugins/codex/scripts/lib/claude-session-transfer.mjs index 7bd2eeba9..478464910 100644 --- a/plugins/codex/scripts/lib/claude-session-transfer.mjs +++ b/plugins/codex/scripts/lib/claude-session-transfer.mjs @@ -96,12 +96,22 @@ export function prepareClaudeSessionImport(cwd, sourcePath) { const importPath = path.join(defaultProjects, relative); fs.mkdirSync(path.dirname(importPath), { recursive: true }); + const canonicalDefaultRoot = fs.realpathSync(defaultProjects); + const canonicalImportParent = fs.realpathSync(path.dirname(importPath)); + if (canonicalImportParent !== canonicalDefaultRoot && !isWithin(canonicalDefaultRoot, canonicalImportParent)) { + throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalImportParent}`); + } + if (fs.existsSync(importPath)) { throw new Error(`Cannot stage Claude session for Codex because the destination already exists: ${importPath}`); } fs.copyFileSync(source, importPath, fs.constants.COPYFILE_EXCL); const canonicalImportPath = fs.realpathSync(importPath); + if (!isWithin(canonicalDefaultRoot, canonicalImportPath)) { + fs.unlinkSync(canonicalImportPath); + throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalImportPath}`); + } return { sourcePath: source, importPath: canonicalImportPath, diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index f35b5d54f..f56e4cfa8 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -289,6 +289,34 @@ test("transfer supports CLAUDE_CONFIG_DIR and stages a temporary default-root co assert.equal(fs.existsSync(original), true); }); +test("transfer rejects a staging path that escapes the default projects root through a symlink", () => { + const home = makeTempDir(); + const repo = path.join(home, "repo"); + const binDir = makeTempDir(); + const claudeConfigDir = path.join(home, ".claude-work"); + const projectDir = path.join(claudeConfigDir, "projects", "-repo"); + const sourcePath = path.join(projectDir, "session-symlink.jsonl"); + const defaultProjects = path.join(home, ".claude", "projects"); + const escapedDir = path.join(home, "escaped-staging"); + fs.mkdirSync(repo, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(defaultProjects, { recursive: true }); + fs.mkdirSync(escapedDir, { recursive: true }); + fs.symlinkSync(escapedDir, path.join(defaultProjects, "-repo"), "junction"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(sourcePath, `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Do not escape staging." } })}\n`, "utf8"); + + const result = run("node", [SCRIPT, "transfer", "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), HOME: home, USERPROFILE: home, CODEX_HOME: path.join(home, ".codex"), CLAUDE_CONFIG_DIR: claudeConfigDir, CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath } + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /outside.*default Claude projects root|staging.*outside/i); + assert.equal(fs.existsSync(path.join(escapedDir, "session-symlink.jsonl")), false); +}); + test("transfer reports an actionable upgrade error when native import is unsupported", () => { const home = makeTempDir(); const repo = path.join(home, "repo"); From d1ac612b715e9406b10ba930c36395c7b2c3153b Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 21:43:26 -0300 Subject: [PATCH 14/53] fix: reject unsafe staging ancestors --- .../scripts/lib/claude-session-transfer.mjs | 21 +++++++++++++++++-- tests/runtime.test.mjs | 6 +++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/plugins/codex/scripts/lib/claude-session-transfer.mjs b/plugins/codex/scripts/lib/claude-session-transfer.mjs index 478464910..3898ba4ee 100644 --- a/plugins/codex/scripts/lib/claude-session-transfer.mjs +++ b/plugins/codex/scripts/lib/claude-session-transfer.mjs @@ -38,6 +38,16 @@ function realpathIfExists(value) { } } +function nearestExistingAncestor(value) { + let current = value; + while (!fs.existsSync(current)) { + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } + return fs.realpathSync(current); +} + function isWithin(root, candidate) { const relative = path.relative(root, candidate); return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); @@ -94,10 +104,17 @@ export function prepareClaudeSessionImport(cwd, sourcePath) { const relative = path.relative(sourceRoot.realRoot, source); const importPath = path.join(defaultProjects, relative); - fs.mkdirSync(path.dirname(importPath), { recursive: true }); + fs.mkdirSync(defaultProjects, { recursive: true }); const canonicalDefaultRoot = fs.realpathSync(defaultProjects); - const canonicalImportParent = fs.realpathSync(path.dirname(importPath)); + const importParent = path.dirname(importPath); + const canonicalExistingAncestor = nearestExistingAncestor(importParent); + if (!canonicalExistingAncestor || (canonicalExistingAncestor !== canonicalDefaultRoot && !isWithin(canonicalDefaultRoot, canonicalExistingAncestor))) { + throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalExistingAncestor ?? importParent}`); + } + + fs.mkdirSync(importParent, { recursive: true }); + const canonicalImportParent = fs.realpathSync(importParent); if (canonicalImportParent !== canonicalDefaultRoot && !isWithin(canonicalDefaultRoot, canonicalImportParent)) { throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalImportParent}`); } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index f56e4cfa8..e57866353 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -295,11 +295,11 @@ test("transfer rejects a staging path that escapes the default projects root thr const binDir = makeTempDir(); const claudeConfigDir = path.join(home, ".claude-work"); const projectDir = path.join(claudeConfigDir, "projects", "-repo"); - const sourcePath = path.join(projectDir, "session-symlink.jsonl"); + const sourcePath = path.join(projectDir, "new", "session-symlink.jsonl"); const defaultProjects = path.join(home, ".claude", "projects"); const escapedDir = path.join(home, "escaped-staging"); fs.mkdirSync(repo, { recursive: true }); - fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); fs.mkdirSync(defaultProjects, { recursive: true }); fs.mkdirSync(escapedDir, { recursive: true }); fs.symlinkSync(escapedDir, path.join(defaultProjects, "-repo"), "junction"); @@ -314,7 +314,7 @@ test("transfer rejects a staging path that escapes the default projects root thr assert.notEqual(result.status, 0); assert.match(result.stderr, /outside.*default Claude projects root|staging.*outside/i); - assert.equal(fs.existsSync(path.join(escapedDir, "session-symlink.jsonl")), false); + assert.equal(fs.existsSync(path.join(escapedDir, "new")), false); }); test("transfer reports an actionable upgrade error when native import is unsupported", () => { From 44eea538ce8f83c533c0c5d7e2b212f307caaa07 Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 21:54:30 -0300 Subject: [PATCH 15/53] test: pin explicit resume cwd rebinding --- tests/fake-codex-fixture.mjs | 3 +++ tests/runtime.test.mjs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..901858e97 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -345,6 +345,9 @@ rl.on("line", (line) => { throw new Error("thread/resume.persistFullHistory requires experimentalApi capability"); } const thread = ensureThread(state, message.params.threadId); + if (message.params.cwd) { + thread.cwd = message.params.cwd; + } thread.updatedAt = now(); saveState(state); send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 5d5d6c16a..f17e10caf 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -527,6 +527,27 @@ test("task --resume-thread resumes the requested thread instead of the latest on assert.equal(fakeState.lastTurnStart.prompt, "follow up"); }); +test("task --resume-thread rebinds a foreign thread to the current workspace", () => { + const repoA = makeTempDir(); + const repoB = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repoA); + initGitRepo(repoB); + + assert.equal(run("node", [SCRIPT, "task", "first task"], { cwd: repoA, env: buildEnv(binDir) }).status, 0); + const resumed = run("node", [SCRIPT, "task", "--resume-thread", "thr_1", "follow up"], { + cwd: repoB, + env: buildEnv(binDir) + }); + + assert.equal(resumed.status, 0, resumed.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(path.resolve(fakeState.threads.find((thread) => thread.id === "thr_1").cwd), path.resolve(repoB)); + assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); +}); + test("task --resume-thread can continue without an explicit prompt", () => { const repo = makeTempDir(); const binDir = makeTempDir(); From 638f727146dcb2f7a8ae88b4477817791feade38 Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 21:57:41 -0300 Subject: [PATCH 16/53] fix: allocate collision-free transfer staging --- .../scripts/lib/claude-session-transfer.mjs | 25 +++++++++++++---- tests/runtime.test.mjs | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/plugins/codex/scripts/lib/claude-session-transfer.mjs b/plugins/codex/scripts/lib/claude-session-transfer.mjs index 3898ba4ee..41e5af2dd 100644 --- a/plugins/codex/scripts/lib/claude-session-transfer.mjs +++ b/plugins/codex/scripts/lib/claude-session-transfer.mjs @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; @@ -48,6 +49,22 @@ function nearestExistingAncestor(value) { return fs.realpathSync(current); } +function copyToExclusiveStagingPath(source, preferredPath) { + const parsed = path.parse(preferredPath); + for (let attempt = 0; attempt < 16; attempt += 1) { + const candidate = attempt === 0 + ? preferredPath + : path.join(parsed.dir, `${parsed.name}.codex-import-${randomUUID()}${parsed.ext}`); + try { + fs.copyFileSync(source, candidate, fs.constants.COPYFILE_EXCL); + return candidate; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + } + } + throw new Error(`Cannot allocate an exclusive staging path under ${parsed.dir}`); +} + function isWithin(root, candidate) { const relative = path.relative(root, candidate); return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); @@ -119,12 +136,8 @@ export function prepareClaudeSessionImport(cwd, sourcePath) { throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalImportParent}`); } - if (fs.existsSync(importPath)) { - throw new Error(`Cannot stage Claude session for Codex because the destination already exists: ${importPath}`); - } - - fs.copyFileSync(source, importPath, fs.constants.COPYFILE_EXCL); - const canonicalImportPath = fs.realpathSync(importPath); + const stagedPath = copyToExclusiveStagingPath(source, importPath); + const canonicalImportPath = fs.realpathSync(stagedPath); if (!isWithin(canonicalDefaultRoot, canonicalImportPath)) { fs.unlinkSync(canonicalImportPath); throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalImportPath}`); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index e57866353..5b30a2fab 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -289,6 +289,34 @@ test("transfer supports CLAUDE_CONFIG_DIR and stages a temporary default-root co assert.equal(fs.existsSync(original), true); }); +test("transfer retries with a collision-free staged filename when the mirrored destination exists", () => { + const home = makeTempDir(); + const repo = path.join(home, "repo"); + const binDir = makeTempDir(); + const claudeConfigDir = path.join(home, ".claude-work"); + const projectDir = path.join(claudeConfigDir, "projects", "-repo"); + const sourcePath = path.join(projectDir, "session-collision.jsonl"); + const mirroredPath = path.join(home, ".claude", "projects", "-repo", "session-collision.jsonl"); + fs.mkdirSync(repo, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(path.dirname(mirroredPath), { recursive: true }); + fs.writeFileSync(mirroredPath, "STALE-STAGING\n", "utf8"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(sourcePath, `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Retry safely." } })}\n`, "utf8"); + + const result = run("node", [SCRIPT, "transfer", "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), HOME: home, USERPROFILE: home, CODEX_HOME: path.join(home, ".codex"), CLAUDE_CONFIG_DIR: claudeConfigDir, CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath } + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(mirroredPath, "utf8"), "STALE-STAGING\n"); + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.notEqual(path.resolve(fakeState.lastExternalAgentImport.sourcePath), path.resolve(mirroredPath)); + assert.equal(fs.existsSync(fakeState.lastExternalAgentImport.sourcePath), false); +}); + test("transfer rejects a staging path that escapes the default projects root through a symlink", () => { const home = makeTempDir(); const repo = path.join(home, "repo"); From 8f09814f4bf4bcc0cced83988fd6836fd72767d1 Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 22:25:47 -0300 Subject: [PATCH 17/53] fix: resolve Node outside minimal hook PATH --- plugins/codex/commands/cancel.md | 4 +-- plugins/codex/commands/result.md | 4 +-- plugins/codex/commands/status.md | 4 +-- plugins/codex/hooks/hooks.json | 6 ++-- plugins/codex/scripts/run-node.sh | 46 +++++++++++++++++++++++++++++++ tests/commands.test.mjs | 13 +++++++++ tests/node-launcher.test.mjs | 33 ++++++++++++++++++++++ 7 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 plugins/codex/scripts/run-node.sh create mode 100644 tests/node-launcher.test.mjs diff --git a/plugins/codex/commands/cancel.md b/plugins/codex/commands/cancel.md index a1472b836..f596bfa2d 100644 --- a/plugins/codex/commands/cancel.md +++ b/plugins/codex/commands/cancel.md @@ -2,7 +2,7 @@ description: Cancel an active background Codex job in this repository argument-hint: '[job-id]' disable-model-invocation: true -allowed-tools: Bash(node:*) +allowed-tools: Bash(bash:*) --- -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" cancel "$ARGUMENTS"` +!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" cancel "$ARGUMENTS"` diff --git a/plugins/codex/commands/result.md b/plugins/codex/commands/result.md index 3abc2d931..799923dfc 100644 --- a/plugins/codex/commands/result.md +++ b/plugins/codex/commands/result.md @@ -2,10 +2,10 @@ description: Show the stored final output for a finished Codex job in this repository argument-hint: '[job-id]' disable-model-invocation: true -allowed-tools: Bash(node:*) +allowed-tools: Bash(bash:*) --- -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" result "$ARGUMENTS"` +!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" result "$ARGUMENTS"` Present the full command output to the user. Do not summarize or condense it. Preserve all details including: - Job ID and status diff --git a/plugins/codex/commands/status.md b/plugins/codex/commands/status.md index 8f70663d1..17536358a 100644 --- a/plugins/codex/commands/status.md +++ b/plugins/codex/commands/status.md @@ -2,10 +2,10 @@ description: Show active and recent Codex jobs for this repository, including review-gate status argument-hint: '[job-id] [--wait] [--timeout-ms ] [--all]' disable-model-invocation: true -allowed-tools: Bash(node:*) +allowed-tools: Bash(bash:*) --- -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" status "$ARGUMENTS"` +!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" status "$ARGUMENTS"` If the user did not pass a job ID: - Render the command output as a single Markdown table for the current and past runs in this session. diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json index 19e33b818..09db8a3ee 100644 --- a/plugins/codex/hooks/hooks.json +++ b/plugins/codex/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs\" SessionStart", + "command": "bash \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PLUGIN_ROOT}\"; else printf '%s' \"${CLAUDE_PLUGIN_ROOT}\"; fi)/scripts/run-node.sh\" \"session-lifecycle-hook.mjs\" SessionStart", "timeout": 5 } ] @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs\" SessionEnd", + "command": "bash \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PLUGIN_ROOT}\"; else printf '%s' \"${CLAUDE_PLUGIN_ROOT}\"; fi)/scripts/run-node.sh\" \"session-lifecycle-hook.mjs\" SessionEnd", "timeout": 5 } ] @@ -28,7 +28,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop-review-gate-hook.mjs\"", + "command": "bash \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PLUGIN_ROOT}\"; else printf '%s' \"${CLAUDE_PLUGIN_ROOT}\"; fi)/scripts/run-node.sh\" \"stop-review-gate-hook.mjs\"", "timeout": 900 } ] diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh new file mode 100644 index 000000000..5ebe9ab93 --- /dev/null +++ b/plugins/codex/scripts/run-node.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -u + +target=${1:-} +if [ -z "$target" ]; then + echo "Codex Companion Node launcher requires a script name." >&2 + exit 64 +fi +shift + +case "$0" in + */*) script_base=${0%/*} ;; + *) script_base=. ;; +esac +script_dir=$(CDPATH= cd -- "$script_base" && pwd) + +find_node() { + if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ]; then + printf '%s\n' "$CODEX_COMPANION_NODE" + return 0 + fi + if command -v node >/dev/null 2>&1; then + command -v node + return 0 + fi + home=${HOME:-} + for candidate in \ + /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ + "$home/.volta/bin/node" \ + "$home"/.nvm/versions/node/*/bin/node \ + "$home"/.local/share/fnm/node-versions/*/installation/bin/node \ + "$home"/.asdf/installs/nodejs/*/bin/node \ + "$home"/.local/share/mise/installs/node/*/bin/node; do + [ -x "$candidate" ] || continue + printf '%s\n' "$candidate" + return 0 + done + return 1 +} + +node_bin=$(find_node) || { + echo "Codex Companion requires Node.js >=18.18. Add node to PATH or set CODEX_COMPANION_NODE to its executable path." >&2 + exit 127 +} + +exec "$node_bin" "$script_dir/$target" "$@" diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b06059..7da82b686 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -202,6 +202,19 @@ test("internal docs use task terminology for rescue runs", () => { assert.match(promptRecipes, /## Narrow Fix/); }); +test("hooks and deterministic commands use the portable Node launcher", () => { + const hooks = read("hooks/hooks.json"); + for (const relative of ["commands/status.md", "commands/result.md", "commands/cancel.md"]) { + const source = read(relative); + assert.match(source, /scripts\/run-node\.sh/); + assert.match(source, /cygpath/); + assert.doesNotMatch(source, /!`node "/); + } + assert.match(hooks, /scripts\/run-node\.sh/); + assert.match(hooks, /cygpath/); + assert.doesNotMatch(hooks, /"command": "node /); +}); + test("hooks keep session-end cleanup and stop gating enabled", () => { const source = read("hooks/hooks.json"); assert.match(source, /SessionStart/); diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs new file mode 100644 index 000000000..c03526ded --- /dev/null +++ b/tests/node-launcher.test.mjs @@ -0,0 +1,33 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const LAUNCHER = path.join(ROOT, "plugins", "codex", "scripts", "run-node.sh"); +const BASH = process.env.SHELL || (process.platform === "win32" ? "C:\\Program Files\\Git\\bin\\bash.exe" : "/bin/bash"); + +test("portable launcher finds an nvm Node when PATH does not contain node", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-launcher-")); + const nodePath = path.join(home, ".nvm", "versions", "node", "v22.0.0", "bin", "node"); + fs.mkdirSync(path.dirname(nodePath), { recursive: true }); + fs.writeFileSync(nodePath, "#!/bin/sh\nprintf 'FAKE_NODE:%s\\n' \"$*\"\n", "utf8"); + fs.chmodSync(nodePath, 0o755); + + const emptyBin = path.join(home, "empty-bin"); + fs.mkdirSync(emptyBin); + + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /FAKE_NODE:.*[\\/]companion\.mjs status --json\n$/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); From 31b8b184dad8c9f5a4fdaca16e8827b9a1a0f66c Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 22:36:59 -0300 Subject: [PATCH 18/53] fix: preserve compatible Node toolchain PATH --- plugins/codex/scripts/run-node.sh | 23 +++++++++++++--- tests/node-launcher.test.mjs | 45 +++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 5ebe9ab93..3bf67e044 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -14,14 +14,21 @@ case "$0" in esac script_dir=$(CDPATH= cd -- "$script_base" && pwd) +is_supported_node() { + "$1" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 18 || (major === 18 && minor >= 18) ? 0 : 1)' >/dev/null 2>&1 +} + find_node() { - if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ]; then + if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ] && is_supported_node "$CODEX_COMPANION_NODE"; then printf '%s\n' "$CODEX_COMPANION_NODE" return 0 fi if command -v node >/dev/null 2>&1; then - command -v node - return 0 + candidate=$(command -v node) + if is_supported_node "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi fi home=${HOME:-} for candidate in \ @@ -32,6 +39,7 @@ find_node() { "$home"/.asdf/installs/nodejs/*/bin/node \ "$home"/.local/share/mise/installs/node/*/bin/node; do [ -x "$candidate" ] || continue + is_supported_node "$candidate" || continue printf '%s\n' "$candidate" return 0 done @@ -39,8 +47,15 @@ find_node() { } node_bin=$(find_node) || { - echo "Codex Companion requires Node.js >=18.18. Add node to PATH or set CODEX_COMPANION_NODE to its executable path." >&2 + echo "Codex Companion requires Node.js >=18.18. Add a supported node to PATH or set CODEX_COMPANION_NODE to its executable path." >&2 exit 127 } +case "$node_bin" in + */*) node_dir=${node_bin%/*} ;; + *) node_dir=. ;; +esac +PATH="$node_dir${PATH:+:$PATH}" +export PATH + exec "$node_bin" "$script_dir/$target" "$@" diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index c03526ded..89c1c59c5 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -10,23 +10,46 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const LAUNCHER = path.join(ROOT, "plugins", "codex", "scripts", "run-node.sh"); const BASH = process.env.SHELL || (process.platform === "win32" ? "C:\\Program Files\\Git\\bin\\bash.exe" : "/bin/bash"); -test("portable launcher finds an nvm Node when PATH does not contain node", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-launcher-")); - const nodePath = path.join(home, ".nvm", "versions", "node", "v22.0.0", "bin", "node"); - fs.mkdirSync(path.dirname(nodePath), { recursive: true }); - fs.writeFileSync(nodePath, "#!/bin/sh\nprintf 'FAKE_NODE:%s\\n' \"$*\"\n", "utf8"); +function installFakeNode(home, version, supported) { + const binDir = path.join(home, ".nvm", "versions", "node", version, "bin"); + const nodePath = path.join(binDir, "node"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(nodePath, `#!/bin/sh\nif [ "\${1:-}" = "-e" ]; then exit ${supported ? 0 : 1}; fi\nprintf 'FAKE_NODE_${version}:%s\\n' "$*"\nprintf 'CODEX:%s\\n' "$(command -v codex || true)"\n`, "utf8"); fs.chmodSync(nodePath, 0o755); + return binDir; +} +function runWithMinimalPath(home) { const emptyBin = path.join(home, "empty-bin"); - fs.mkdirSync(emptyBin); + fs.mkdirSync(emptyBin, { recursive: true }); + return spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); +} +test("portable launcher preserves the selected Node toolchain directory on PATH", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-path-")); + const binDir = installFakeNode(home, "v22.0.0", true); + const codexPath = path.join(binDir, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); try { - const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { - encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } - }); + const result = runWithMinimalPath(home); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /FAKE_NODE:.*[\\/]companion\.mjs status --json\n$/); + assert.match(result.stdout, /CODEX:.+[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("portable launcher skips unsupported Node versions", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-version-")); + installFakeNode(home, "v12.22.0", false); + installFakeNode(home, "v22.0.0", true); + try { const result = runWithMinimalPath(home); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /FAKE_NODE_v22\.0\.0:/); + assert.doesNotMatch(result.stdout, /FAKE_NODE_v12\.22\.0:/); } finally { fs.rmSync(home, { recursive: true, force: true }); } From a17c3dafe445bd55ef484ce60810a19c8402bfbc Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 22:37:20 -0300 Subject: [PATCH 19/53] test: tidy Node launcher regressions --- tests/node-launcher.test.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 89c1c59c5..a35456d53 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -23,7 +23,8 @@ function runWithMinimalPath(home) { const emptyBin = path.join(home, "empty-bin"); fs.mkdirSync(emptyBin, { recursive: true }); return spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { - encoding: "utf8", env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); } @@ -46,7 +47,8 @@ test("portable launcher skips unsupported Node versions", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-version-")); installFakeNode(home, "v12.22.0", false); installFakeNode(home, "v22.0.0", true); - try { const result = runWithMinimalPath(home); + try { + const result = runWithMinimalPath(home); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /FAKE_NODE_v22\.0\.0:/); assert.doesNotMatch(result.stdout, /FAKE_NODE_v12\.22\.0:/); From cc0ffadc0e565950196a78422ea89438ae8881e8 Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 23:10:31 -0300 Subject: [PATCH 20/53] fix: preserve alternate Codex toolchain paths --- plugins/codex/scripts/run-node.sh | 17 +++++++++++++---- tests/node-launcher.test.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 3bf67e044..ba57d98f2 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -51,11 +51,20 @@ node_bin=$(find_node) || { exit 127 } -case "$node_bin" in - */*) node_dir=${node_bin%/*} ;; - *) node_dir=. ;; -esac +add_supported_node_dir_to_path() { + candidate=$1 + [ -x "$candidate" ] || return 0 + is_supported_node "$candidate" || return 0 + case "$candidate" in */*) candidate_dir=${candidate%/*} ;; *) candidate_dir=. ;; esac + case ":$PATH:" in *":$candidate_dir:"*) ;; *) PATH="$PATH:$candidate_dir" ;; esac +} + +case "$node_bin" in */*) node_dir=${node_bin%/*} ;; *) node_dir=. ;; esac PATH="$node_dir${PATH:+:$PATH}" +home=${HOME:-} +for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$home"/.nvm/versions/node/*/bin/node "$home"/.local/share/fnm/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do + add_supported_node_dir_to_path "$candidate" +done export PATH exec "$node_bin" "$script_dir/$target" "$@" diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index a35456d53..d44ec746b 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -56,3 +56,29 @@ test("portable launcher skips unsupported Node versions", () => { fs.rmSync(home, { recursive: true, force: true }); } }); + +test("portable launcher keeps searching compatible toolchains for codex", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-mixed-path-")); + const systemBin = path.join(home, "system-bin"); + fs.mkdirSync(systemBin, { recursive: true }); + const systemNode = path.join(systemBin, "node"); + fs.writeFileSync(systemNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf \'SYSTEM_NODE:%s\\n\' "$*"\nprintf \'CODEX:%s\\n\' "$(command -v codex || true)"\n', "utf8"); + fs.chmodSync(systemNode, 0o755); + + const managedBin = installFakeNode(home, "v22.0.0", true); + const codexPath = path.join(managedBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /SYSTEM_NODE:/); + assert.match(result.stdout, /CODEX:.+[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); From 866d3aa3303ca18398ba67c2db601e0aa89592fc Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Sat, 5 Sep 2026 00:26:01 -0300 Subject: [PATCH 21/53] fix: preserve transfer identity across staging retries --- .../scripts/lib/claude-session-transfer.mjs | 36 +++++++++++++------ tests/runtime.test.mjs | 14 ++++++-- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/plugins/codex/scripts/lib/claude-session-transfer.mjs b/plugins/codex/scripts/lib/claude-session-transfer.mjs index 41e5af2dd..c77b52651 100644 --- a/plugins/codex/scripts/lib/claude-session-transfer.mjs +++ b/plugins/codex/scripts/lib/claude-session-transfer.mjs @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { randomUUID } from "node:crypto"; +import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; @@ -49,20 +49,35 @@ function nearestExistingAncestor(value) { return fs.realpathSync(current); } +function fileSha256(filePath) { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function filesHaveSameContent(source, candidate, sourceSha256) { + try { + if (fs.statSync(source).size !== fs.statSync(candidate).size) return false; + return fileSha256(candidate) === sourceSha256; + } catch { + return false; + } +} + function copyToExclusiveStagingPath(source, preferredPath) { const parsed = path.parse(preferredPath); - for (let attempt = 0; attempt < 16; attempt += 1) { - const candidate = attempt === 0 - ? preferredPath - : path.join(parsed.dir, `${parsed.name}.codex-import-${randomUUID()}${parsed.ext}`); + const sourceSha256 = fileSha256(source); + const stableFallback = path.join(parsed.dir, `${parsed.name}.codex-import-${sourceSha256}${parsed.ext}`); + for (const candidate of [preferredPath, stableFallback]) { try { fs.copyFileSync(source, candidate, fs.constants.COPYFILE_EXCL); - return candidate; + return { path: candidate, created: true }; } catch (error) { if (error?.code !== "EEXIST") throw error; + if (filesHaveSameContent(source, candidate, sourceSha256)) { + return { path: candidate, created: false }; + } } } - throw new Error(`Cannot allocate an exclusive staging path under ${parsed.dir}`); + throw new Error(`Cannot allocate a stable staging path under ${parsed.dir}`); } function isWithin(root, candidate) { @@ -136,10 +151,10 @@ export function prepareClaudeSessionImport(cwd, sourcePath) { throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalImportParent}`); } - const stagedPath = copyToExclusiveStagingPath(source, importPath); - const canonicalImportPath = fs.realpathSync(stagedPath); + const staged = copyToExclusiveStagingPath(source, importPath); + const canonicalImportPath = fs.realpathSync(staged.path); if (!isWithin(canonicalDefaultRoot, canonicalImportPath)) { - fs.unlinkSync(canonicalImportPath); + if (staged.created) fs.unlinkSync(canonicalImportPath); throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalImportPath}`); } return { @@ -147,6 +162,7 @@ export function prepareClaudeSessionImport(cwd, sourcePath) { importPath: canonicalImportPath, staged: true, cleanup() { + if (!staged.created) return; try { fs.unlinkSync(canonicalImportPath); } catch (error) { diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 5b30a2fab..4d1f60539 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -313,8 +313,18 @@ test("transfer retries with a collision-free staged filename when the mirrored d assert.equal(result.status, 0, result.stderr); assert.equal(fs.readFileSync(mirroredPath, "utf8"), "STALE-STAGING\n"); const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); - assert.notEqual(path.resolve(fakeState.lastExternalAgentImport.sourcePath), path.resolve(mirroredPath)); - assert.equal(fs.existsSync(fakeState.lastExternalAgentImport.sourcePath), false); + const firstImportedPath = fakeState.lastExternalAgentImport.sourcePath; + assert.notEqual(path.resolve(firstImportedPath), path.resolve(mirroredPath)); + assert.equal(fs.existsSync(firstImportedPath), false); + + const retry = run("node", [SCRIPT, "transfer", "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), HOME: home, USERPROFILE: home, CODEX_HOME: path.join(home, ".codex"), CLAUDE_CONFIG_DIR: claudeConfigDir, CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath } + }); + assert.equal(retry.status, 0, retry.stderr); + const retryState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(path.resolve(retryState.lastExternalAgentImport.sourcePath), path.resolve(firstImportedPath)); + assert.equal(fs.existsSync(retryState.lastExternalAgentImport.sourcePath), false); }); test("transfer rejects a staging path that escapes the default projects root through a symlink", () => { From 21b617c0ea8032c7b27df0277a9025e7de6d3241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:39:59 -0300 Subject: [PATCH 22/53] fix: coordinate shared transfer staging leases --- .../scripts/lib/claude-session-transfer.mjs | 113 ++++++++++++++++-- tests/claude-session-transfer.test.mjs | 47 ++++++++ 2 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 tests/claude-session-transfer.test.mjs diff --git a/plugins/codex/scripts/lib/claude-session-transfer.mjs b/plugins/codex/scripts/lib/claude-session-transfer.mjs index c77b52651..ae228a80c 100644 --- a/plugins/codex/scripts/lib/claude-session-transfer.mjs +++ b/plugins/codex/scripts/lib/claude-session-transfer.mjs @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; @@ -62,6 +62,32 @@ function filesHaveSameContent(source, candidate, sourceSha256) { } } +const STAGING_MARKER_CONTENT = "codex-plugin-cc-staging-v1\n"; +const STAGING_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4)); + +function processIsAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +function staleLockCanBeRemoved(lockPath) { + const ownerPath = path.join(lockPath, "owner"); + try { + const pid = Number(fs.readFileSync(ownerPath, "utf8")); + return !(Number.isSafeInteger(pid) && pid > 0 && processIsAlive(pid)); + } catch { + try { + return Date.now() - fs.statSync(lockPath).mtimeMs > 1000; + } catch { + return true; + } + } +} + function copyToExclusiveStagingPath(source, preferredPath) { const parsed = path.parse(preferredPath); const sourceSha256 = fileSha256(source); @@ -69,17 +95,88 @@ function copyToExclusiveStagingPath(source, preferredPath) { for (const candidate of [preferredPath, stableFallback]) { try { fs.copyFileSync(source, candidate, fs.constants.COPYFILE_EXCL); - return { path: candidate, created: true }; + return { path: candidate, created: true, sourceSha256 }; } catch (error) { if (error?.code !== "EEXIST") throw error; if (filesHaveSameContent(source, candidate, sourceSha256)) { - return { path: candidate, created: false }; + return { path: candidate, created: false, sourceSha256 }; } } } throw new Error(`Cannot allocate a stable staging path under ${parsed.dir}`); } +function withStagingLock(stagedPath, callback) { + const lockPath = `${stagedPath}.codex-staging-lock`; + const ownerPath = path.join(lockPath, "owner"); + const deadline = Date.now() + 5000; + while (true) { + try { + fs.mkdirSync(lockPath); + try { + fs.writeFileSync(ownerPath, `${process.pid}\n`, { flag: "wx" }); + } catch (error) { + fs.rmSync(lockPath, { recursive: true, force: true }); + throw error; + } + break; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + if (staleLockCanBeRemoved(lockPath)) { + try { fs.rmSync(lockPath, { recursive: true, force: true }); } catch {} + continue; + } + if (Date.now() >= deadline) throw new Error(`Timed out acquiring staging lock for ${stagedPath}`); + Atomics.wait(STAGING_LOCK_WAIT, 0, 0, 5); + } + } + try { return callback(); } finally { fs.rmSync(lockPath, { recursive: true, force: true }); } +} + +function managedMarkerMatches(markerPath) { + try { return fs.readFileSync(markerPath, "utf8") === STAGING_MARKER_CONTENT; } catch { return false; } +} + +function acquireStagingLease(stagedPath, staged) { + const directory = path.dirname(stagedPath); + const base = path.basename(stagedPath); + const markerPath = `${stagedPath}.codex-staging-managed`; + const leasePrefix = `${base}.codex-staging-lease-`; + const leasePath = path.join(directory, `${leasePrefix}${process.pid}-${randomUUID()}`); + let managed = false; + + withStagingLock(stagedPath, () => { + const markerMatches = managedMarkerMatches(markerPath); + if (staged.created) { + if (fs.existsSync(markerPath) && !markerMatches) { + throw new Error(`Refusing to manage unknown staging marker: ${markerPath}`); + } + if (!markerMatches) fs.writeFileSync(markerPath, STAGING_MARKER_CONTENT, { flag: "wx" }); + managed = true; + } else { + managed = markerMatches; + } + if (managed) fs.writeFileSync(leasePath, "", { flag: "wx" }); + }); + + return { + release() { + if (!managed) return; + withStagingLock(stagedPath, () => { + try { fs.unlinkSync(leasePath); } catch (error) { if (error?.code !== "ENOENT") throw error; } + const activeLeases = fs.readdirSync(directory).filter((name) => name.startsWith(leasePrefix)); + if (activeLeases.length > 0 || !managedMarkerMatches(markerPath)) return; + if (fs.existsSync(stagedPath) && fileSha256(stagedPath) !== staged.sourceSha256) { + fs.unlinkSync(markerPath); + return; + } + try { fs.unlinkSync(stagedPath); } catch (error) { if (error?.code !== "ENOENT") throw error; } + try { fs.unlinkSync(markerPath); } catch (error) { if (error?.code !== "ENOENT") throw error; } + }); + } + }; +} + function isWithin(root, candidate) { const relative = path.relative(root, candidate); return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); @@ -157,19 +254,13 @@ export function prepareClaudeSessionImport(cwd, sourcePath) { if (staged.created) fs.unlinkSync(canonicalImportPath); throw new Error(`Cannot stage Claude session outside the default Claude projects root: ${canonicalImportPath}`); } + const lease = acquireStagingLease(canonicalImportPath, staged); return { sourcePath: source, importPath: canonicalImportPath, staged: true, cleanup() { - if (!staged.created) return; - try { - fs.unlinkSync(canonicalImportPath); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } + lease.release(); } }; } diff --git a/tests/claude-session-transfer.test.mjs b/tests/claude-session-transfer.test.mjs new file mode 100644 index 000000000..0ba7b48eb --- /dev/null +++ b/tests/claude-session-transfer.test.mjs @@ -0,0 +1,47 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { prepareClaudeSessionImport } from "../plugins/codex/scripts/lib/claude-session-transfer.mjs"; + +function tempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +test("shared staged imports keep the file alive until the final cleanup", () => { + const home = tempDir("codex-transfer-home-"); + const repo = path.join(home, "repo"); + const claudeConfigDir = path.join(home, ".claude-work"); + const projectDir = path.join(claudeConfigDir, "projects", "-repo"); + const source = path.join(projectDir, "session.jsonl"); + const previousHome = process.env.USERPROFILE; + const previousConfig = process.env.CLAUDE_CONFIG_DIR; + + fs.mkdirSync(repo, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(source, '{"type":"user","message":{"content":"hello"}}\n', "utf8"); + process.env.USERPROFILE = home; + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir; + + try { + const first = prepareClaudeSessionImport(repo, source); + const second = prepareClaudeSessionImport(repo, source); + + assert.equal(path.resolve(first.importPath), path.resolve(second.importPath)); + assert.equal(fs.existsSync(first.importPath), true); + + first.cleanup(); + assert.equal(fs.existsSync(second.importPath), true); + + second.cleanup(); + assert.equal(fs.existsSync(second.importPath), false); + } finally { + if (previousHome === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousHome; + if (previousConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousConfig; + fs.rmSync(home, { recursive: true, force: true }); + } +}); From 75eda99621ca3b35454478c1a55e4fd094360e90 Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 14:36:29 +0200 Subject: [PATCH 23/53] feat: add --sandbox to task and /codex:rescue `task` always mapped `--write` to `workspace-write` and everything else to `read-only`, so a rescue run could never ask for `danger-full-access` even when the work needs it (test tooling that writes outside the repository, or network access). Add an explicit `--sandbox ` flag: - the flag wins over `--write`; `--write --sandbox read-only` is rejected - the resolved mode is stored in the job request, so the detached worker and `--resume-last` reuse exactly what the caller asked for - jobs started with a writable sandbox keep the review hints that `--write` jobs already get - older stored requests without a sandbox field keep the old mapping The fake app-server fixture now records the `thread/start` and `thread/resume` params so tests can assert what reached Codex. The rescue command, agent and runtime skill forward `--sandbox` as a runtime flag and never add `danger-full-access` on their own. Closes #145 Co-Authored-By: Claude Fable 5.1 --- README.md | 6 +- plugins/codex/agents/codex-rescue.md | 6 +- plugins/codex/commands/rescue.md | 3 +- plugins/codex/scripts/codex-companion.mjs | 39 +++- .../codex/skills/codex-cli-runtime/SKILL.md | 3 + tests/commands.test.mjs | 8 + tests/fake-codex-fixture.mjs | 14 ++ tests/runtime.test.mjs | 180 ++++++++++++++++++ 8 files changed, 250 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 937a3037b..54ce077b2 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Use it when you want Codex to: > [!NOTE] > Depending on the task and the model you choose these tasks might take a long time and it's generally recommended to force the task to be in the background or move the agent to the background. -It supports `--background`, `--wait`, `--resume`, and `--fresh`. If you omit `--resume` and `--fresh`, the plugin can offer to continue the latest rescue thread for this repo. +It supports `--background`, `--wait`, `--resume`, `--fresh`, and `--sandbox `. If you omit `--resume` and `--fresh`, the plugin can offer to continue the latest rescue thread for this repo. Examples: @@ -148,6 +148,8 @@ Examples: /codex:rescue --model gpt-5.4-mini --effort medium investigate the flaky integration test /codex:rescue --model spark fix the issue quickly /codex:rescue --background investigate the regression +/codex:rescue --sandbox read-only explain how the cache invalidation works +/codex:rescue --sandbox danger-full-access run the integration tests and fix what fails ``` You can also just ask for a task to be delegated to Codex: @@ -161,6 +163,8 @@ Ask Codex to redesign the database connection to be more resilient. - if you do not pass `--model` or `--effort`, Codex chooses its own defaults. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo +- rescue runs edit files inside the repository by default (`workspace-write`). `--sandbox read-only` blocks edits, and `--sandbox danger-full-access` disables the Codex sandbox entirely, so Codex can write outside the repository and use the network without asking. Reserve it for tasks the sandbox blocks, such as running test tooling that writes to system locations. +- the sandbox applies to the run you start. A resumed thread uses the sandbox of the new request, not the one it had before. ### `/codex:transfer` diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 7009ec86a..c526f3508 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -30,8 +30,10 @@ Forwarding rules: - Leave model unset by default. Only add `--model` when the user explicitly asks for a specific model. - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. -- Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. -- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. +- Treat `--effort `, `--model `, and `--sandbox ` as runtime controls and do not include them in the task text you pass through. +- If the user passes `--sandbox `, forward it to `task` unchanged and do not add `--write`. The sandbox flag already decides whether Codex may edit. +- Never add `--sandbox danger-full-access` on your own. Only forward it when the user explicitly asked for it. +- Otherwise default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. - `--fresh` means do not add `--resume-last`. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 56de9555d..427bb9532 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [--sandbox ] [what Codex should investigate, solve, or continue]" allowed-tools: Bash(node:*), AskUserQuestion, Agent --- @@ -18,6 +18,7 @@ Execution mode: - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. +- `--sandbox` is a runtime-selection flag. Preserve it for the forwarded `task` call, but do not treat it as part of the natural-language task text. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..3101b8363 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -69,6 +69,7 @@ const REVIEW_SCHEMA = path.join(ROOT_DIR, "schemas", "review-output.schema.json" const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000; const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); +const VALID_SANDBOX_MODES = new Set(["read-only", "workspace-write", "danger-full-access"]); const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]); const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; @@ -79,7 +80,7 @@ function printUsage() { " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--sandbox ] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", @@ -127,6 +128,26 @@ function normalizeReasoningEffort(effort) { return normalized; } +function normalizeSandboxMode(sandbox) { + if (sandbox == null) { + return null; + } + const normalized = String(sandbox).trim().toLowerCase(); + if (!normalized) { + return null; + } + if (!VALID_SANDBOX_MODES.has(normalized)) { + throw new Error( + `Unsupported sandbox mode "${sandbox}". Use one of: read-only, workspace-write, danger-full-access.` + ); + } + return normalized; +} + +function defaultTaskSandbox(write) { + return write ? "workspace-write" : "read-only"; +} + function normalizeArgv(argv) { if (argv.length === 1) { const [raw] = argv; @@ -488,7 +509,7 @@ async function executeTaskRun(request) { defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "", model: request.model, effort: request.effort, - sandbox: request.write ? "workspace-write" : "read-only", + sandbox: request.sandbox ?? defaultTaskSandbox(Boolean(request.write)), onProgress: request.onProgress, persistThread: true, threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) @@ -601,13 +622,14 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, write, sandbox, resumeLast, jobId }) { return { cwd, model, effort, prompt, write, + sandbox, resumeLast, jobId }; @@ -761,7 +783,7 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["model", "effort", "cwd", "prompt-file"], + valueOptions: ["model", "effort", "cwd", "prompt-file", "sandbox"], booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], aliasMap: { m: "model" @@ -779,7 +801,12 @@ async function handleTask(argv) { if (resumeLast && fresh) { throw new Error("Choose either --resume/--resume-last or --fresh."); } - const write = Boolean(options.write); + const requestedSandbox = normalizeSandboxMode(options.sandbox); + if (options.write && requestedSandbox === "read-only") { + throw new Error("Choose either --write or --sandbox read-only."); + } + const sandbox = requestedSandbox ?? defaultTaskSandbox(Boolean(options.write)); + const write = sandbox !== "read-only"; const taskMetadata = buildTaskRunMetadata({ prompt, resumeLast @@ -796,6 +823,7 @@ async function handleTask(argv) { effort, prompt, write, + sandbox, resumeLast, jobId: job.id }); @@ -814,6 +842,7 @@ async function handleTask(argv) { effort, prompt, write, + sandbox, resumeLast, jobId: job.id, onProgress: progress diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 0e91bfb50..d22ad391b 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -28,15 +28,18 @@ Command selection: - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. +- If the forwarded request includes `--sandbox`, pass it through to `task` and do not add `--write`. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. - `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up. - `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. +- `--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`. Without it, `task` runs `workspace-write` when `--write` is set and `read-only` otherwise. - `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run. Safety rules: - Default to write-capable Codex work in `codex:codex-rescue` unless the user explicitly asks for read-only behavior. +- Never add `--sandbox danger-full-access` unless the user explicitly asked for it. It disables the Codex sandbox entirely. - Preserve the user's task text as-is apart from stripping routing flags. - Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own. - Return the stdout of the `task` command exactly as-is. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b06059..6823058f6 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -105,6 +105,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /--resume\|--fresh/); assert.match(rescue, /--model /); assert.match(rescue, /--effort /); + assert.match(rescue, /--sandbox /); assert.match(rescue, /task-resume-candidate --json/); assert.match(rescue, /AskUserQuestion/); assert.match(rescue, /Continue current Codex thread/); @@ -113,6 +114,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /default to foreground/i); assert.match(rescue, /Do not forward them to `task`/i); assert.match(rescue, /`--model` and `--effort` are runtime-selection flags/i); + assert.match(rescue, /`--sandbox` is a runtime-selection flag/i); assert.match(rescue, /Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort/i); assert.match(rescue, /If they ask for `spark`, map it to `gpt-5\.3-codex-spark`/i); assert.match(rescue, /If the request includes `--resume`, do not ask whether to continue/i); @@ -136,6 +138,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /Leave model unset by default/i); assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i); assert.match(agent, /If the user asks for a concrete model name such as `gpt-5\.4-mini`, pass it through with `--model`/i); + assert.match(agent, /If the user passes `--sandbox `, forward it to `task` unchanged and do not add `--write`/i); + assert.match(agent, /Never add `--sandbox danger-full-access` on your own/i); assert.match(agent, /Return the stdout of the `codex-companion` command exactly as-is/i); assert.match(agent, /If the Bash call fails or Codex cannot be invoked, return nothing/i); assert.match(agent, /gpt-5-4-prompting/); @@ -151,6 +155,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); assert.match(runtimeSkill, /Strip it before calling `task`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); + assert.match(runtimeSkill, /If the forwarded request includes `--sandbox`, pass it through to `task` and do not add `--write`/i); + assert.match(runtimeSkill, /`--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); assert.match(readme, /`codex:codex-rescue` subagent/i); @@ -158,6 +164,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(readme, /--model gpt-5\.4-mini --effort medium/i); assert.match(readme, /`spark`, the plugin maps that to `gpt-5\.3-codex-spark`/i); assert.match(readme, /continue a previous Codex task/i); + assert.match(readme, /--sandbox /); + assert.match(readme, /`--sandbox danger-full-access` disables the Codex sandbox entirely/i); assert.match(readme, /### `\/codex:setup`/); assert.match(readme, /### `\/codex:review`/); assert.match(readme, /### `\/codex:adversarial-review`/); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..91ab3b9e6 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -313,6 +313,14 @@ rl.on("line", (line) => { throw new Error("thread/start.persistFullHistory requires experimentalApi capability"); } const thread = nextThread(state, message.params.cwd, message.params.ephemeral); + state.lastThreadStart = { + threadId: thread.id, + model: message.params.model ?? null, + sandbox: message.params.sandbox ?? null, + approvalPolicy: message.params.approvalPolicy ?? null, + ephemeral: Boolean(message.params.ephemeral) + }; + saveState(state); send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); send({ method: "thread/started", params: { thread: { id: thread.id } } }); break; @@ -346,6 +354,12 @@ rl.on("line", (line) => { } const thread = ensureThread(state, message.params.threadId); thread.updatedAt = now(); + state.lastThreadResume = { + threadId: thread.id, + model: message.params.model ?? null, + sandbox: message.params.sandbox ?? null, + approvalPolicy: message.params.approvalPolicy ?? null + }; saveState(state); send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); break; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..21c932464 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -784,6 +784,186 @@ test("task forwards model selection and reasoning effort to app-server turn/star assert.equal(fakeState.lastTurnStart.effort, "low"); }); +test("task defaults to a read-only sandbox and --write selects workspace-write", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const readOnly = run("node", [SCRIPT, "task", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(readOnly.status, 0, readOnly.stderr); + assert.equal(JSON.parse(fs.readFileSync(statePath, "utf8")).lastThreadStart.sandbox, "read-only"); + + const write = run("node", [SCRIPT, "task", "--write", "fix the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(write.status, 0, write.stderr); + assert.equal(JSON.parse(fs.readFileSync(statePath, "utf8")).lastThreadStart.sandbox, "workspace-write"); +}); + +test("task --sandbox forwards the requested sandbox mode to thread/start", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const fullAccess = run("node", [SCRIPT, "task", "--sandbox", "danger-full-access", "run the integration tests"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(fullAccess.status, 0, fullAccess.stderr); + let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "danger-full-access"); + assert.equal(fakeState.lastThreadStart.approvalPolicy, "never"); + assert.equal(fakeState.lastTurnStart.prompt, "run the integration tests"); + + const readOnly = run("node", [SCRIPT, "task", "--sandbox", "READ-ONLY", "review the diff"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(readOnly.status, 0, readOnly.stderr); + fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); +}); + +test("task --sandbox rejects unknown modes and a read-only sandbox combined with --write", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const unknown = run("node", [SCRIPT, "task", "--sandbox", "everything", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(unknown.status > 0, true); + assert.match(unknown.stderr, /Unsupported sandbox mode "everything"/); + assert.match(unknown.stderr, /read-only, workspace-write, danger-full-access/); + + const conflict = run("node", [SCRIPT, "task", "--write", "--sandbox", "read-only", "fix the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(conflict.status > 0, true); + assert.match(conflict.stderr, /Choose either --write or --sandbox read-only/); + + const threads = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, "utf8")).threads : []; + assert.equal(threads.length, 0); +}); + +test("task --resume-last applies the sandbox of the new request to thread/resume", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const firstRun = run("node", [SCRIPT, "task", "--sandbox", "danger-full-access", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const resumedWithSandbox = run("node", [SCRIPT, "task", "--resume", "--sandbox", "danger-full-access", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(resumedWithSandbox.status, 0, resumedWithSandbox.stderr); + let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadResume.threadId, "thr_1"); + assert.equal(fakeState.lastThreadResume.sandbox, "danger-full-access"); + assert.equal(fakeState.lastTurnStart.prompt, "follow up"); + + const resumedWithoutSandbox = run("node", [SCRIPT, "task", "--resume", "keep going"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(resumedWithoutSandbox.status, 0, resumedWithoutSandbox.stderr); + fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadResume.threadId, "thr_1"); + assert.equal(fakeState.lastThreadResume.sandbox, "read-only"); +}); + +test("task --background stores the sandbox in the job request so the detached worker reuses it", async () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const launched = run( + "node", + [SCRIPT, "task", "--background", "--json", "--sandbox", "danger-full-access", "run the integration tests"], + { + cwd: repo, + env: buildEnv(binDir) + } + ); + + assert.equal(launched.status, 0, launched.stderr); + const launchPayload = JSON.parse(launched.stdout); + assert.equal(launchPayload.status, "queued"); + + const waitedStatus = run( + "node", + [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"], + { + cwd: repo, + env: buildEnv(binDir) + } + ); + + assert.equal(waitedStatus.status, 0, waitedStatus.stderr); + assert.equal(JSON.parse(waitedStatus.stdout).job.status, "completed"); + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "danger-full-access"); + + const resultPayload = await waitFor(() => { + const result = run("node", [SCRIPT, "result", launchPayload.jobId, "--json"], { + cwd: repo, + env: buildEnv(binDir) + }); + if (result.status !== 0) { + return null; + } + return JSON.parse(result.stdout); + }); + + assert.equal(resultPayload.storedJob.request.sandbox, "danger-full-access"); + assert.equal(resultPayload.storedJob.request.write, true); +}); + test("task logs reasoning summaries and assistant messages to the job log", () => { const repo = makeTempDir(); const binDir = makeTempDir(); From e5f3a38c0f5616761d491a1ee323cc158a78b32c Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 14:54:18 +0200 Subject: [PATCH 24/53] docs: a resumed thread keeps its starting sandbox; test the worker on a stored request without a sandbox field Measured on Codex CLI 0.153.2: the app-server keeps the sandbox a thread was started with when it is resumed, whichever mode the new request names on thread/resume. Say so in the README instead of the opposite, and keep the resume test to what the plugin forwards. Add a task-worker test that replays a job record written before this change, with no sandbox field in the request, and checks it still starts the thread with the --write mapping. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- tests/commands.test.mjs | 1 + tests/runtime.test.mjs | 58 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 54ce077b2..7dd12e3ba 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ Ask Codex to redesign the database connection to be more resilient. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo - rescue runs edit files inside the repository by default (`workspace-write`). `--sandbox read-only` blocks edits, and `--sandbox danger-full-access` disables the Codex sandbox entirely, so Codex can write outside the repository and use the network without asking. Reserve it for tasks the sandbox blocks, such as running test tooling that writes to system locations. -- the sandbox applies to the run you start. A resumed thread uses the sandbox of the new request, not the one it had before. +- a resumed thread keeps the sandbox it was started with. Passing `--sandbox` or `--write` together with `--resume` does not change it on current Codex releases (checked with 0.153.2). Start a new thread with `--fresh` to change the sandbox. ### `/codex:transfer` diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 6823058f6..8a2deff28 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -166,6 +166,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(readme, /continue a previous Codex task/i); assert.match(readme, /--sandbox /); assert.match(readme, /`--sandbox danger-full-access` disables the Codex sandbox entirely/i); + assert.match(readme, /a resumed thread keeps the sandbox it was started with/i); assert.match(readme, /### `\/codex:setup`/); assert.match(readme, /### `\/codex:review`/); assert.match(readme, /### `\/codex:adversarial-review`/); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 21c932464..646c12103 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -873,7 +873,7 @@ test("task --sandbox rejects unknown modes and a read-only sandbox combined with assert.equal(threads.length, 0); }); -test("task --resume-last applies the sandbox of the new request to thread/resume", () => { +test("task --resume-last forwards the sandbox of the new request on thread/resume", () => { const repo = makeTempDir(); const binDir = makeTempDir(); const statePath = path.join(binDir, "fake-codex-state.json"); @@ -911,6 +911,62 @@ test("task --resume-last applies the sandbox of the new request to thread/resume assert.equal(fakeState.lastThreadResume.sandbox, "read-only"); }); +test("task-worker replays a stored request without a sandbox field using the --write mapping", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const jobsDir = path.join(resolveStateDir(repo), "jobs"); + fs.mkdirSync(jobsDir, { recursive: true }); + const jobId = "task-legacy"; + fs.writeFileSync( + path.join(jobsDir, `${jobId}.json`), + `${JSON.stringify( + { + id: jobId, + kind: "task", + kindLabel: "task", + status: "queued", + phase: "queued", + title: "Codex Task", + jobClass: "task", + summary: "fix the failing test", + workspaceRoot: repo, + write: true, + createdAt: "2026-03-18T15:30:00.000Z", + updatedAt: "2026-03-18T15:30:00.000Z", + request: { + cwd: repo, + model: null, + effort: null, + prompt: "fix the failing test", + write: true, + resumeLast: false, + jobId + } + }, + null, + 2 + )}\n`, + "utf8" + ); + + const result = run("node", [SCRIPT, "task-worker", "--cwd", repo, "--job-id", jobId], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "workspace-write"); + assert.equal(fakeState.lastTurnStart.prompt, "fix the failing test"); +}); + test("task --background stores the sandbox in the job request so the detached worker reuses it", async () => { const repo = makeTempDir(); const binDir = makeTempDir(); From 4151f0416d57d4cbe614b1f64a6a8a1c2b24404f Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 15:14:56 +0200 Subject: [PATCH 25/53] feat: let --sandbox take precedence over --write instead of rejecting the pair A rescue request such as `--sandbox read-only fix the failing test` reads as write intent, and the agent that forwards it may still add --write; a hard error there returns nothing to the user. The explicit flag now wins: `--write --sandbox read-only` starts a read-only thread. Say so in the README and the runtime skill, and scope `--sandbox` to /codex:rescue in the README, since the review commands stay read-only. The rescue command carries the same "never add danger-full-access yourself" rule as the agent and the skill. The fake app-server records only the thread params the tests assert. Co-Authored-By: Claude Fable 5.1 --- README.md | 1 + plugins/codex/commands/rescue.md | 2 +- plugins/codex/scripts/codex-companion.mjs | 6 +----- plugins/codex/skills/codex-cli-runtime/SKILL.md | 2 +- tests/commands.test.mjs | 3 +++ tests/fake-codex-fixture.mjs | 8 ++------ tests/runtime.test.mjs | 16 +++++++++------- 7 files changed, 18 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7dd12e3ba..e85249581 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ Ask Codex to redesign the database connection to be more resilient. - if you do not pass `--model` or `--effort`, Codex chooses its own defaults. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo +- `--sandbox` applies to `/codex:rescue` only; `/codex:review` and `/codex:adversarial-review` stay read-only. It takes precedence over the rescue agent's default `--write`. - rescue runs edit files inside the repository by default (`workspace-write`). `--sandbox read-only` blocks edits, and `--sandbox danger-full-access` disables the Codex sandbox entirely, so Codex can write outside the repository and use the network without asking. Reserve it for tasks the sandbox blocks, such as running test tooling that writes to system locations. - a resumed thread keeps the sandbox it was started with. Passing `--sandbox` or `--write` together with `--resume` does not change it on current Codex releases (checked with 0.153.2). Start a new thread with `--fresh` to change the sandbox. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 427bb9532..08d16972c 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -18,7 +18,7 @@ Execution mode: - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. -- `--sandbox` is a runtime-selection flag. Preserve it for the forwarded `task` call, but do not treat it as part of the natural-language task text. +- `--sandbox` is a runtime-selection flag. Preserve it for the forwarded `task` call, but do not treat it as part of the natural-language task text. Never add `--sandbox danger-full-access` yourself; forward it only when the user typed it. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 3101b8363..7f7f5adb8 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -801,11 +801,7 @@ async function handleTask(argv) { if (resumeLast && fresh) { throw new Error("Choose either --resume/--resume-last or --fresh."); } - const requestedSandbox = normalizeSandboxMode(options.sandbox); - if (options.write && requestedSandbox === "read-only") { - throw new Error("Choose either --write or --sandbox read-only."); - } - const sandbox = requestedSandbox ?? defaultTaskSandbox(Boolean(options.write)); + const sandbox = normalizeSandboxMode(options.sandbox) ?? defaultTaskSandbox(Boolean(options.write)); const write = sandbox !== "read-only"; const taskMetadata = buildTaskRunMetadata({ prompt, diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index d22ad391b..9f0dd008c 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -34,7 +34,7 @@ Command selection: - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. - `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up. - `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. -- `--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`. Without it, `task` runs `workspace-write` when `--write` is set and `read-only` otherwise. +- `--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`. It takes precedence over `--write`. Without it, `task` runs `workspace-write` when `--write` is set and `read-only` otherwise. - `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run. Safety rules: diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 8a2deff28..69b6ccd5c 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -115,6 +115,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /Do not forward them to `task`/i); assert.match(rescue, /`--model` and `--effort` are runtime-selection flags/i); assert.match(rescue, /`--sandbox` is a runtime-selection flag/i); + assert.match(rescue, /Never add `--sandbox danger-full-access` yourself/i); assert.match(rescue, /Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort/i); assert.match(rescue, /If they ask for `spark`, map it to `gpt-5\.3-codex-spark`/i); assert.match(rescue, /If the request includes `--resume`, do not ask whether to continue/i); @@ -157,6 +158,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); assert.match(runtimeSkill, /If the forwarded request includes `--sandbox`, pass it through to `task` and do not add `--write`/i); assert.match(runtimeSkill, /`--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`/i); + assert.match(runtimeSkill, /It takes precedence over `--write`/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); assert.match(readme, /`codex:codex-rescue` subagent/i); @@ -167,6 +169,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(readme, /--sandbox /); assert.match(readme, /`--sandbox danger-full-access` disables the Codex sandbox entirely/i); assert.match(readme, /a resumed thread keeps the sandbox it was started with/i); + assert.match(readme, /`--sandbox` applies to `\/codex:rescue` only/i); assert.match(readme, /### `\/codex:setup`/); assert.match(readme, /### `\/codex:review`/); assert.match(readme, /### `\/codex:adversarial-review`/); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index 91ab3b9e6..ec773217a 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -315,10 +315,8 @@ rl.on("line", (line) => { const thread = nextThread(state, message.params.cwd, message.params.ephemeral); state.lastThreadStart = { threadId: thread.id, - model: message.params.model ?? null, sandbox: message.params.sandbox ?? null, - approvalPolicy: message.params.approvalPolicy ?? null, - ephemeral: Boolean(message.params.ephemeral) + approvalPolicy: message.params.approvalPolicy ?? null }; saveState(state); send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); @@ -356,9 +354,7 @@ rl.on("line", (line) => { thread.updatedAt = now(); state.lastThreadResume = { threadId: thread.id, - model: message.params.model ?? null, - sandbox: message.params.sandbox ?? null, - approvalPolicy: message.params.approvalPolicy ?? null + sandbox: message.params.sandbox ?? null }; saveState(state); send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 646c12103..64a7b6dfd 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -842,7 +842,7 @@ test("task --sandbox forwards the requested sandbox mode to thread/start", () => assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); }); -test("task --sandbox rejects unknown modes and a read-only sandbox combined with --write", () => { +test("task --sandbox rejects unknown modes and takes precedence over --write", () => { const repo = makeTempDir(); const binDir = makeTempDir(); const statePath = path.join(binDir, "fake-codex-state.json"); @@ -861,16 +861,18 @@ test("task --sandbox rejects unknown modes and a read-only sandbox combined with assert.match(unknown.stderr, /Unsupported sandbox mode "everything"/); assert.match(unknown.stderr, /read-only, workspace-write, danger-full-access/); - const conflict = run("node", [SCRIPT, "task", "--write", "--sandbox", "read-only", "fix the failing test"], { + const threads = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, "utf8")).threads : []; + assert.equal(threads.length, 0); + + const explicit = run("node", [SCRIPT, "task", "--write", "--sandbox", "read-only", "fix the failing test"], { cwd: repo, env: buildEnv(binDir) }); - assert.equal(conflict.status > 0, true); - assert.match(conflict.stderr, /Choose either --write or --sandbox read-only/); - - const threads = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, "utf8")).threads : []; - assert.equal(threads.length, 0); + assert.equal(explicit.status, 0, explicit.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); + assert.equal(fakeState.lastTurnStart.prompt, "fix the failing test"); }); test("task --resume-last forwards the sandbox of the new request on thread/resume", () => { From 3d7b5247c8beba5092da4a5ed81ef62a074a110a Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 15:24:48 +0200 Subject: [PATCH 26/53] docs: say when a resumed thread keeps its sandbox Measured on Codex CLI 0.153.2: the mode named on thread/resume is applied when the app-server loads the thread again from disk, and ignored while the thread is still live in the app-server. The plugin keeps one shared app-server per session, so inside a session a resumed rescue keeps the sandbox it started with. The README says exactly that. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e85249581..d401f7552 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ Ask Codex to redesign the database connection to be more resilient. - follow-up rescue requests can continue the latest Codex task in the repo - `--sandbox` applies to `/codex:rescue` only; `/codex:review` and `/codex:adversarial-review` stay read-only. It takes precedence over the rescue agent's default `--write`. - rescue runs edit files inside the repository by default (`workspace-write`). `--sandbox read-only` blocks edits, and `--sandbox danger-full-access` disables the Codex sandbox entirely, so Codex can write outside the repository and use the network without asking. Reserve it for tasks the sandbox blocks, such as running test tooling that writes to system locations. -- a resumed thread keeps the sandbox it was started with. Passing `--sandbox` or `--write` together with `--resume` does not change it on current Codex releases (checked with 0.153.2). Start a new thread with `--fresh` to change the sandbox. +- a resumed thread keeps the sandbox it was started with while the plugin's shared app-server still holds it, which is the normal case inside one Claude Code session: Codex CLI 0.153.2 applies the sandbox named on `thread/resume` only when it loads the thread again from disk. Start a new thread with `--fresh` to change the sandbox. ### `/codex:transfer` From 91cc73bb3016090bd2c74a2ce3c89603e48d6031 Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 15:57:01 +0200 Subject: [PATCH 27/53] feat: read --sandbox only before the task text, and refuse a resume that asks for another sandbox Two findings from the Codex review of this branch. The argument parser recognises flags anywhere in the request, so a task whose text merely mentioned `--sandbox danger-full-access` ran without a sandbox and with the words stripped from the prompt. `task` now takes `--sandbox` only from the leading option block; once the task text has begun the same words stay part of the prompt. Other options keep their existing behaviour. A thread the shared app-server still holds keeps its sandbox on resume, so forwarding a different mode on `thread/resume` produced a turn with broader or narrower access than requested, silently. The job record now carries the sandbox a thread was started with, and `--resume-last` refuses a request whose sandbox differs from it, naming `--fresh` and the matching `--sandbox` as the ways out. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +- plugins/codex/agents/codex-rescue.md | 2 +- plugins/codex/scripts/codex-companion.mjs | 91 ++++++++++++++++--- .../codex/skills/codex-cli-runtime/SKILL.md | 2 +- tests/commands.test.mjs | 6 +- tests/runtime.test.mjs | 62 ++++++++++++- 6 files changed, 146 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index d401f7552..9075298b7 100644 --- a/README.md +++ b/README.md @@ -163,9 +163,9 @@ Ask Codex to redesign the database connection to be more resilient. - if you do not pass `--model` or `--effort`, Codex chooses its own defaults. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo -- `--sandbox` applies to `/codex:rescue` only; `/codex:review` and `/codex:adversarial-review` stay read-only. It takes precedence over the rescue agent's default `--write`. +- `--sandbox` applies to `/codex:rescue` only; `/codex:review` and `/codex:adversarial-review` stay read-only. It takes precedence over the rescue agent's default `--write`, and it counts only before the task text: the same words inside the text stay part of the prompt. - rescue runs edit files inside the repository by default (`workspace-write`). `--sandbox read-only` blocks edits, and `--sandbox danger-full-access` disables the Codex sandbox entirely, so Codex can write outside the repository and use the network without asking. Reserve it for tasks the sandbox blocks, such as running test tooling that writes to system locations. -- a resumed thread keeps the sandbox it was started with while the plugin's shared app-server still holds it, which is the normal case inside one Claude Code session: Codex CLI 0.153.2 applies the sandbox named on `thread/resume` only when it loads the thread again from disk. Start a new thread with `--fresh` to change the sandbox. +- a resumed thread keeps the sandbox it was started with while the plugin's shared app-server still holds it, which is the normal case inside one Claude Code session: Codex CLI 0.153.2 applies the sandbox named on `thread/resume` only when it loads the thread again from disk. So `task` refuses a resume that asks for a different sandbox than the thread has; resume with the same `--sandbox`, or start a new thread with `--fresh`. ### `/codex:transfer` diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index c526f3508..086d39ab3 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -31,7 +31,7 @@ Forwarding rules: - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort `, `--model `, and `--sandbox ` as runtime controls and do not include them in the task text you pass through. -- If the user passes `--sandbox `, forward it to `task` unchanged and do not add `--write`. The sandbox flag already decides whether Codex may edit. +- If the user passes `--sandbox `, forward it to `task` unchanged, before the task text, and do not add `--write`. The sandbox flag already decides whether Codex may edit. - Never add `--sandbox danger-full-access` on your own. Only forward it when the user explicitly asked for it. - Otherwise default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 7f7f5adb8..c41eba4d1 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -70,6 +70,9 @@ const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000; const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); const VALID_SANDBOX_MODES = new Set(["read-only", "workspace-write", "danger-full-access"]); +const TASK_VALUE_OPTIONS = ["model", "effort", "cwd", "prompt-file"]; +const TASK_BOOLEAN_OPTIONS = ["json", "write", "resume-last", "resume", "fresh", "background"]; +const TASK_SHORT_VALUE_OPTIONS = ["m", "C"]; const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]); const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; @@ -148,6 +151,62 @@ function defaultTaskSandbox(write) { return write ? "workspace-write" : "read-only"; } +function extractLeadingSandbox(argv) { + const tokens = normalizeArgv(argv); + const rest = []; + let sandbox = null; + let leading = true; + let i = 0; + while (i < tokens.length) { + const token = tokens[i]; + if (leading && token.startsWith("--") && token !== "--") { + const [key, inlineValue] = token.slice(2).split("=", 2); + if (key === "sandbox") { + sandbox = inlineValue ?? tokens[i + 1] ?? ""; + i += inlineValue === undefined ? 2 : 1; + continue; + } + if (TASK_VALUE_OPTIONS.includes(key)) { + rest.push(token); + if (inlineValue === undefined && i + 1 < tokens.length) { + rest.push(tokens[i + 1]); + } + i += inlineValue === undefined ? 2 : 1; + continue; + } + if (TASK_BOOLEAN_OPTIONS.includes(key)) { + rest.push(token); + i += 1; + continue; + } + leading = false; + } else if (leading && token.startsWith("-") && token !== "-" && TASK_SHORT_VALUE_OPTIONS.includes(token.slice(1))) { + rest.push(token); + if (i + 1 < tokens.length) { + rest.push(tokens[i + 1]); + } + i += 2; + continue; + } else { + leading = false; + } + rest.push(token); + i += 1; + } + return { argv: rest, sandbox }; +} + +function threadStartSandbox(jobs, threadId) { + const onThread = jobs + .filter((job) => job.threadId === threadId) + .sort((left, right) => String(left.createdAt ?? "").localeCompare(String(right.createdAt ?? ""))); + if (!onThread.length) { + return null; + } + const first = onThread[0]; + return VALID_SANDBOX_MODES.has(first.sandbox) ? first.sandbox : defaultTaskSandbox(Boolean(first.write)); +} + function normalizeArgv(argv) { if (argv.length === 1) { const [raw] = argv; @@ -366,7 +425,7 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) { const trackedTask = findLatestResumableTaskJob(visibleJobs); if (trackedTask) { - return { id: trackedTask.threadId }; + return { id: trackedTask.threadId, sandbox: threadStartSandbox(jobs, trackedTask.threadId) }; } if (sessionId) { @@ -497,6 +556,13 @@ async function executeTaskRun(request) { throw new Error("No previous Codex task thread was found for this repository."); } resumeThreadId = latestThread.id; + const requestedSandbox = request.sandbox ?? defaultTaskSandbox(Boolean(request.write)); + if (latestThread.sandbox && latestThread.sandbox !== requestedSandbox) { + throw new Error( + `Thread ${latestThread.id} was started with sandbox ${latestThread.sandbox}, and a thread the shared app-server still holds keeps it on resume. ` + + `This request asks for ${requestedSandbox}. Resume with --sandbox ${latestThread.sandbox}, or start a fresh thread with --fresh.` + ); + } } if (!request.prompt && !resumeThreadId) { @@ -585,7 +651,7 @@ function getJobKindLabel(kind, jobClass) { return jobClass === "review" ? "review" : "rescue"; } -function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summary, write = false }) { +function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summary, write = false, sandbox = null }) { return createJobRecord({ id: generateJobId(prefix), kind, @@ -594,7 +660,8 @@ function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summ workspaceRoot, jobClass, summary, - write + write, + ...(sandbox ? { sandbox } : {}) }); } @@ -610,7 +677,7 @@ function createTrackedProgress(job, options = {}) { }; } -function buildTaskJob(workspaceRoot, taskMetadata, write) { +function buildTaskJob(workspaceRoot, taskMetadata, write, sandbox) { return createCompanionJob({ prefix: "task", kind: "task", @@ -618,7 +685,8 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { workspaceRoot, jobClass: "task", summary: taskMetadata.summary, - write + write, + sandbox }); } @@ -782,9 +850,10 @@ async function handleReview(argv) { } async function handleTask(argv) { - const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["model", "effort", "cwd", "prompt-file", "sandbox"], - booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], + const leading = extractLeadingSandbox(argv); + const { options, positionals } = parseCommandInput(leading.argv, { + valueOptions: TASK_VALUE_OPTIONS, + booleanOptions: TASK_BOOLEAN_OPTIONS, aliasMap: { m: "model" } @@ -801,7 +870,7 @@ async function handleTask(argv) { if (resumeLast && fresh) { throw new Error("Choose either --resume/--resume-last or --fresh."); } - const sandbox = normalizeSandboxMode(options.sandbox) ?? defaultTaskSandbox(Boolean(options.write)); + const sandbox = normalizeSandboxMode(leading.sandbox) ?? defaultTaskSandbox(Boolean(options.write)); const write = sandbox !== "read-only"; const taskMetadata = buildTaskRunMetadata({ prompt, @@ -812,7 +881,7 @@ async function handleTask(argv) { ensureCodexAvailable(cwd); requireTaskRequest(prompt, resumeLast); - const job = buildTaskJob(workspaceRoot, taskMetadata, write); + const job = buildTaskJob(workspaceRoot, taskMetadata, write, sandbox); const request = buildTaskRequest({ cwd, model, @@ -828,7 +897,7 @@ async function handleTask(argv) { return; } - const job = buildTaskJob(workspaceRoot, taskMetadata, write); + const job = buildTaskJob(workspaceRoot, taskMetadata, write, sandbox); await runForegroundCommand( job, (progress) => diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 9f0dd008c..c506763c9 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -28,7 +28,7 @@ Command selection: - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. -- If the forwarded request includes `--sandbox`, pass it through to `task` and do not add `--write`. +- If the forwarded request includes `--sandbox`, pass it through to `task` before the task text and do not add `--write`. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 69b6ccd5c..1c65f4f76 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -139,7 +139,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /Leave model unset by default/i); assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i); assert.match(agent, /If the user asks for a concrete model name such as `gpt-5\.4-mini`, pass it through with `--model`/i); - assert.match(agent, /If the user passes `--sandbox `, forward it to `task` unchanged and do not add `--write`/i); + assert.match(agent, /If the user passes `--sandbox `, forward it to `task` unchanged, before the task text, and do not add `--write`/i); assert.match(agent, /Never add `--sandbox danger-full-access` on your own/i); assert.match(agent, /Return the stdout of the `codex-companion` command exactly as-is/i); assert.match(agent, /If the Bash call fails or Codex cannot be invoked, return nothing/i); @@ -156,7 +156,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); assert.match(runtimeSkill, /Strip it before calling `task`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); - assert.match(runtimeSkill, /If the forwarded request includes `--sandbox`, pass it through to `task` and do not add `--write`/i); + assert.match(runtimeSkill, /If the forwarded request includes `--sandbox`, pass it through to `task` before the task text and do not add `--write`/i); assert.match(runtimeSkill, /`--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`/i); assert.match(runtimeSkill, /It takes precedence over `--write`/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); @@ -169,6 +169,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(readme, /--sandbox /); assert.match(readme, /`--sandbox danger-full-access` disables the Codex sandbox entirely/i); assert.match(readme, /a resumed thread keeps the sandbox it was started with/i); + assert.match(readme, /it counts only before the task text/i); + assert.match(readme, /refuses a resume that asks for a different sandbox/i); assert.match(readme, /`--sandbox` applies to `\/codex:rescue` only/i); assert.match(readme, /### `\/codex:setup`/); assert.match(readme, /### `\/codex:review`/); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 64a7b6dfd..5dec48dd3 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -842,6 +842,48 @@ test("task --sandbox forwards the requested sandbox mode to thread/start", () => assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); }); +test("task reads --sandbox only before the task text", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const mentioned = run("node", [SCRIPT, "task", "explain how --sandbox danger-full-access works in this plugin"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(mentioned.status, 0, mentioned.stderr); + let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); + assert.equal(fakeState.lastTurnStart.prompt, "explain how --sandbox danger-full-access works in this plugin"); + + const splitTokens = run("node", [SCRIPT, "task", "document", "why", "--sandbox", "danger-full-access", "is", "off"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(splitTokens.status, 0, splitTokens.stderr); + fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); + assert.equal(fakeState.lastTurnStart.prompt, "document why --sandbox danger-full-access is off"); + + const leading = run("node", [SCRIPT, "task", "-m", "spark", "--sandbox=danger-full-access", "--write", "run the integration tests"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(leading.status, 0, leading.stderr); + fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "danger-full-access"); + assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark"); + assert.equal(fakeState.lastTurnStart.prompt, "run the integration tests"); +}); + test("task --sandbox rejects unknown modes and takes precedence over --write", () => { const repo = makeTempDir(); const binDir = makeTempDir(); @@ -875,7 +917,7 @@ test("task --sandbox rejects unknown modes and takes precedence over --write", ( assert.equal(fakeState.lastTurnStart.prompt, "fix the failing test"); }); -test("task --resume-last forwards the sandbox of the new request on thread/resume", () => { +test("task --resume-last keeps the sandbox of the thread and refuses another one", () => { const repo = makeTempDir(); const binDir = makeTempDir(); const statePath = path.join(binDir, "fake-codex-state.json"); @@ -907,10 +949,21 @@ test("task --resume-last forwards the sandbox of the new request on thread/resum env: buildEnv(binDir) }); - assert.equal(resumedWithoutSandbox.status, 0, resumedWithoutSandbox.stderr); + assert.equal(resumedWithoutSandbox.status > 0, true); + assert.match(resumedWithoutSandbox.stderr, /was started with sandbox danger-full-access/); + assert.match(resumedWithoutSandbox.stderr, /asks for read-only/); + assert.match(resumedWithoutSandbox.stderr, /--fresh/); fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastThreadResume.threadId, "thr_1"); - assert.equal(fakeState.lastThreadResume.sandbox, "read-only"); + assert.equal(fakeState.lastThreadResume.sandbox, "danger-full-access"); + assert.equal(fakeState.lastTurnStart.prompt, "follow up"); + + const narrowedWithWrite = run("node", [SCRIPT, "task", "--resume", "--write", "apply the fix"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(narrowedWithWrite.status > 0, true); + assert.match(narrowedWithWrite.stderr, /asks for workspace-write/); }); test("task-worker replays a stored request without a sandbox field using the --write mapping", () => { @@ -1020,6 +1073,7 @@ test("task --background stores the sandbox in the job request so the detached wo assert.equal(resultPayload.storedJob.request.sandbox, "danger-full-access"); assert.equal(resultPayload.storedJob.request.write, true); + assert.equal(resultPayload.job.sandbox, "danger-full-access"); }); test("task logs reasoning summaries and assistant messages to the job log", () => { From f3803d293519b85cecd776fa71fbdcb9c754dc60 Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 16:15:44 +0200 Subject: [PATCH 28/53] fix: check the sandbox the app-server reports on resume, keep a mid-text --sandbox in the prompt, validate the whole inline value Three findings from the second Codex review of this branch. The shared app-server answers thread/resume with the sandbox the thread actually has (measured on Codex CLI 0.153.2: a live danger-full-access thread answers dangerFullAccess to a read-only request). runAppServerTurn now compares that answer with the requested mode before turn/start and refuses a mismatch, so a resume is checked twice: against the job record before connecting, and against the server's own answer when the record is missing or predates the sandbox field. The fake app-server keeps a sandbox per thread and echoes it the same way. The rescue command, agent and skill told the wrapper to treat any --sandbox as a runtime flag, which would have hoisted one out of the task text and defeated the leading-only parser. They now say: forward the request in the user's order; a leading --sandbox is a flag, one inside the task text stays there as prompt text. extractLeadingSandbox split the inline value at the second "=", so --sandbox=danger-full-access=false passed as danger-full-access. It now splits at the first "=" and validates the whole remainder. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- plugins/codex/agents/codex-rescue.md | 4 +- plugins/codex/commands/rescue.md | 2 +- plugins/codex/scripts/codex-companion.mjs | 4 +- plugins/codex/scripts/lib/codex.mjs | 31 +++++++++++ .../codex/skills/codex-cli-runtime/SKILL.md | 2 +- tests/commands.test.mjs | 7 ++- tests/fake-codex-fixture.mjs | 19 +++++-- tests/runtime.test.mjs | 53 +++++++++++++++++++ 9 files changed, 112 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9075298b7..2ab852309 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ Ask Codex to redesign the database connection to be more resilient. - follow-up rescue requests can continue the latest Codex task in the repo - `--sandbox` applies to `/codex:rescue` only; `/codex:review` and `/codex:adversarial-review` stay read-only. It takes precedence over the rescue agent's default `--write`, and it counts only before the task text: the same words inside the text stay part of the prompt. - rescue runs edit files inside the repository by default (`workspace-write`). `--sandbox read-only` blocks edits, and `--sandbox danger-full-access` disables the Codex sandbox entirely, so Codex can write outside the repository and use the network without asking. Reserve it for tasks the sandbox blocks, such as running test tooling that writes to system locations. -- a resumed thread keeps the sandbox it was started with while the plugin's shared app-server still holds it, which is the normal case inside one Claude Code session: Codex CLI 0.153.2 applies the sandbox named on `thread/resume` only when it loads the thread again from disk. So `task` refuses a resume that asks for a different sandbox than the thread has; resume with the same `--sandbox`, or start a new thread with `--fresh`. +- a resumed thread keeps the sandbox it was started with while the plugin's shared app-server still holds it, which is the normal case inside one Claude Code session: Codex CLI 0.153.2 applies the sandbox named on `thread/resume` only when it loads the thread again from disk. So `task` refuses a resume that asks for a different sandbox than the thread has, checking both its own job record and the sandbox the app-server reports on `thread/resume`; resume with the same `--sandbox`, or start a new thread with `--fresh`. ### `/codex:transfer` diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 086d39ab3..52a623905 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -30,8 +30,8 @@ Forwarding rules: - Leave model unset by default. Only add `--model` when the user explicitly asks for a specific model. - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. -- Treat `--effort `, `--model `, and `--sandbox ` as runtime controls and do not include them in the task text you pass through. -- If the user passes `--sandbox `, forward it to `task` unchanged, before the task text, and do not add `--write`. The sandbox flag already decides whether Codex may edit. +- Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. +- If the user passes `--sandbox ` before the task text, forward it to `task` in that position and do not add `--write`. The sandbox flag already decides whether Codex may edit. A `--sandbox` inside the task text is part of the prompt: leave it there, and do not move it in front. - Never add `--sandbox danger-full-access` on your own. Only forward it when the user explicitly asked for it. - Otherwise default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 08d16972c..09eea6f93 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -18,7 +18,7 @@ Execution mode: - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. -- `--sandbox` is a runtime-selection flag. Preserve it for the forwarded `task` call, but do not treat it as part of the natural-language task text. Never add `--sandbox danger-full-access` yourself; forward it only when the user typed it. +- `--sandbox` is a runtime-selection flag only when it comes before the task text. Forward the request in the user's order: a leading `--sandbox` reaches `task` as a flag, and a `--sandbox` inside the task text stays where it is and is part of the prompt. Do not move, strip or add one. Never add `--sandbox danger-full-access` yourself; forward it only when the user typed it before the task text. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index c41eba4d1..1696d76f2 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -160,7 +160,9 @@ function extractLeadingSandbox(argv) { while (i < tokens.length) { const token = tokens[i]; if (leading && token.startsWith("--") && token !== "--") { - const [key, inlineValue] = token.slice(2).split("=", 2); + const separator = token.indexOf("="); + const key = separator === -1 ? token.slice(2) : token.slice(2, separator); + const inlineValue = separator === -1 ? undefined : token.slice(separator + 1); if (key === "sandbox") { sandbox = inlineValue ?? tokens[i + 1] ?? ""; i += inlineValue === undefined ? 2 : 1; diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..929394bd1 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -59,6 +59,36 @@ function cleanCodexStderr(stderr) { .join("\n"); } +const SANDBOX_POLICY_TYPES = new Map([ + ["read-only", "readOnly"], + ["workspace-write", "workspaceWrite"], + ["danger-full-access", "dangerFullAccess"] +]); + +function sandboxModeForPolicy(policy) { + const type = policy && typeof policy === "object" ? policy.type : null; + for (const [mode, policyType] of SANDBOX_POLICY_TYPES) { + if (policyType === type) { + return mode; + } + } + return null; +} + +function assertResumedSandbox(threadId, requestedMode, response) { + if (!requestedMode || !SANDBOX_POLICY_TYPES.has(requestedMode)) { + return; + } + const effectiveMode = sandboxModeForPolicy(response?.sandbox); + if (!effectiveMode || effectiveMode === requestedMode) { + return; + } + throw new Error( + `Thread ${threadId} still has sandbox ${effectiveMode} in the shared app-server, so this turn would not run ${requestedMode}. ` + + `Resume with --sandbox ${effectiveMode}, or start a fresh thread with --fresh.` + ); +} + /** @returns {ThreadStartParams} */ function buildThreadParams(cwd, options = {}) { return { @@ -1108,6 +1138,7 @@ export async function runAppServerTurn(cwd, options = {}) { sandbox: options.sandbox, ephemeral: false }); + assertResumedSandbox(options.resumeThreadId, options.sandbox, response); threadId = response.thread.id; } else { emitProgress(options.onProgress, "Starting Codex task thread.", "starting"); diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index c506763c9..cf7993b5d 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -28,7 +28,7 @@ Command selection: - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. -- If the forwarded request includes `--sandbox`, pass it through to `task` before the task text and do not add `--write`. +- If the forwarded request starts with `--sandbox` (before the task text), pass it through to `task` in that position and do not add `--write`. A `--sandbox` inside the task text is prompt text; leave it in place. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 1c65f4f76..b0b41d568 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -116,6 +116,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /`--model` and `--effort` are runtime-selection flags/i); assert.match(rescue, /`--sandbox` is a runtime-selection flag/i); assert.match(rescue, /Never add `--sandbox danger-full-access` yourself/i); + assert.match(rescue, /a `--sandbox` inside the task text stays where it is and is part of the prompt/i); assert.match(rescue, /Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort/i); assert.match(rescue, /If they ask for `spark`, map it to `gpt-5\.3-codex-spark`/i); assert.match(rescue, /If the request includes `--resume`, do not ask whether to continue/i); @@ -139,7 +140,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /Leave model unset by default/i); assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i); assert.match(agent, /If the user asks for a concrete model name such as `gpt-5\.4-mini`, pass it through with `--model`/i); - assert.match(agent, /If the user passes `--sandbox `, forward it to `task` unchanged, before the task text, and do not add `--write`/i); + assert.match(agent, /If the user passes `--sandbox ` before the task text, forward it to `task` in that position and do not add `--write`/i); + assert.match(agent, /A `--sandbox` inside the task text is part of the prompt: leave it there, and do not move it in front/i); assert.match(agent, /Never add `--sandbox danger-full-access` on your own/i); assert.match(agent, /Return the stdout of the `codex-companion` command exactly as-is/i); assert.match(agent, /If the Bash call fails or Codex cannot be invoked, return nothing/i); @@ -156,7 +158,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); assert.match(runtimeSkill, /Strip it before calling `task`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); - assert.match(runtimeSkill, /If the forwarded request includes `--sandbox`, pass it through to `task` before the task text and do not add `--write`/i); + assert.match(runtimeSkill, /If the forwarded request starts with `--sandbox` \(before the task text\), pass it through to `task` in that position and do not add `--write`/i); + assert.match(runtimeSkill, /A `--sandbox` inside the task text is prompt text; leave it in place/i); assert.match(runtimeSkill, /`--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`/i); assert.match(runtimeSkill, /It takes precedence over `--write`/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index ec773217a..a45a02bf8 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -116,13 +116,24 @@ function send(message) { process.stdout.write(JSON.stringify(message) + "\\n"); } -function nextThread(state, cwd, ephemeral) { +function sandboxPolicy(mode) { + if (mode === "danger-full-access") { + return { type: "dangerFullAccess" }; + } + if (mode === "workspace-write") { + return { type: "workspaceWrite", writableRoots: [], networkAccess: false, excludeTmpdirEnvVar: false, excludeSlashTmp: false }; + } + return { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }; +} + +function nextThread(state, cwd, ephemeral, sandbox) { const thread = { id: "thr_" + state.nextThreadId++, cwd: cwd || process.cwd(), name: null, preview: "", ephemeral: Boolean(ephemeral), + sandbox: sandbox || "read-only", createdAt: now(), updatedAt: now() }; @@ -312,14 +323,14 @@ rl.on("line", (line) => { if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) { throw new Error("thread/start.persistFullHistory requires experimentalApi capability"); } - const thread = nextThread(state, message.params.cwd, message.params.ephemeral); + const thread = nextThread(state, message.params.cwd, message.params.ephemeral, message.params.sandbox); state.lastThreadStart = { threadId: thread.id, sandbox: message.params.sandbox ?? null, approvalPolicy: message.params.approvalPolicy ?? null }; saveState(state); - send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); + send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: sandboxPolicy(thread.sandbox), reasoningEffort: null } }); send({ method: "thread/started", params: { thread: { id: thread.id } } }); break; } @@ -357,7 +368,7 @@ rl.on("line", (line) => { sandbox: message.params.sandbox ?? null }; saveState(state); - send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); + send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: sandboxPolicy(thread.sandbox), reasoningEffort: null } }); break; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 5dec48dd3..266667b73 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -903,6 +903,14 @@ test("task --sandbox rejects unknown modes and takes precedence over --write", ( assert.match(unknown.stderr, /Unsupported sandbox mode "everything"/); assert.match(unknown.stderr, /read-only, workspace-write, danger-full-access/); + const suffixed = run("node", [SCRIPT, "task", "--sandbox=danger-full-access=false", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(suffixed.status > 0, true); + assert.match(suffixed.stderr, /Unsupported sandbox mode "danger-full-access=false"/); + const threads = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, "utf8")).threads : []; assert.equal(threads.length, 0); @@ -966,6 +974,51 @@ test("task --resume-last keeps the sandbox of the thread and refuses another one assert.match(narrowedWithWrite.stderr, /asks for workspace-write/); }); +test("task --resume-last refuses a resume when the app-server reports a different sandbox than requested", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const firstRun = run("node", [SCRIPT, "task", "--sandbox", "danger-full-access", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const stateDir = resolveStateDir(repo); + const stateFile = path.join(stateDir, "state.json"); + const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); + for (const job of state.jobs) { + delete job.sandbox; + job.write = false; + const jobFile = path.join(stateDir, "jobs", `${job.id}.json`); + if (fs.existsSync(jobFile)) { + const stored = JSON.parse(fs.readFileSync(jobFile, "utf8")); + delete stored.sandbox; + stored.write = false; + fs.writeFileSync(jobFile, `${JSON.stringify(stored, null, 2)}\n`, "utf8"); + } + } + fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + + const resumed = run("node", [SCRIPT, "task", "--resume", "--sandbox", "read-only", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(resumed.status > 0, true); + assert.match(resumed.stderr, /still has sandbox danger-full-access in the shared app-server/); + assert.match(resumed.stderr, /--fresh/); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadResume.sandbox, "read-only"); + assert.equal(fakeState.lastTurnStart.prompt, "initial task"); +}); + test("task-worker replays a stored request without a sandbox field using the --write mapping", () => { const repo = makeTempDir(); const binDir = makeTempDir(); From 89792b541c36146df409c74bc0930c2f0dfa66cd Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 16:26:34 +0200 Subject: [PATCH 29/53] docs: --sandbox counts anywhere in the leading option block, not only as the first token The runtime skill said "starts with --sandbox", which would have made the forwarder miss `--model spark --sandbox danger-full-access run tests` or a request with --resume prepended, leaving the tokens in the prompt while it added its default --write. It now matches the command, the agent and the parser: any --sandbox before the task text is the control. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/skills/codex-cli-runtime/SKILL.md | 2 +- tests/commands.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index cf7993b5d..7076bc489 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -28,7 +28,7 @@ Command selection: - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. -- If the forwarded request starts with `--sandbox` (before the task text), pass it through to `task` in that position and do not add `--write`. A `--sandbox` inside the task text is prompt text; leave it in place. +- If the forwarded request carries `--sandbox` anywhere before the task text (alongside `--model`, `--effort`, `--resume` or `--fresh`), pass it through to `task` in that position and do not add `--write`. A `--sandbox` inside the task text is prompt text; leave it in place. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index b0b41d568..ae49ec644 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -158,7 +158,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); assert.match(runtimeSkill, /Strip it before calling `task`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); - assert.match(runtimeSkill, /If the forwarded request starts with `--sandbox` \(before the task text\), pass it through to `task` in that position and do not add `--write`/i); + assert.match(runtimeSkill, /If the forwarded request carries `--sandbox` anywhere before the task text \(alongside `--model`, `--effort`, `--resume` or `--fresh`\), pass it through to `task` in that position and do not add `--write`/i); assert.match(runtimeSkill, /A `--sandbox` inside the task text is prompt text; leave it in place/i); assert.match(runtimeSkill, /`--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`/i); assert.match(runtimeSkill, /It takes precedence over `--write`/i); From c3e03cc77c524f345505c9547378e9a67cbef79f Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 16:40:57 +0200 Subject: [PATCH 30/53] fix: an empty --sandbox value is an error, not the default `--sandbox=` and a trailing `--sandbox` produced an empty value that normalized to null and silently fell back to the --write mapping. The flag now needs one of the documented modes whenever it is present. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/codex-companion.mjs | 3 +++ tests/runtime.test.mjs | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 1696d76f2..de1ccab8a 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -165,6 +165,9 @@ function extractLeadingSandbox(argv) { const inlineValue = separator === -1 ? undefined : token.slice(separator + 1); if (key === "sandbox") { sandbox = inlineValue ?? tokens[i + 1] ?? ""; + if (!String(sandbox).trim()) { + throw new Error("Missing value for --sandbox. Use one of: read-only, workspace-write, danger-full-access."); + } i += inlineValue === undefined ? 2 : 1; continue; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 266667b73..6da137bcd 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -911,6 +911,16 @@ test("task --sandbox rejects unknown modes and takes precedence over --write", ( assert.equal(suffixed.status > 0, true); assert.match(suffixed.stderr, /Unsupported sandbox mode "danger-full-access=false"/); + for (const argv of [["--sandbox=", "diagnose the failing test"], ["--resume", "--sandbox"]]) { + const empty = run("node", [SCRIPT, "task", ...argv], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(empty.status > 0, true, argv.join(" ")); + assert.match(empty.stderr, /Missing value for --sandbox/); + } + const threads = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, "utf8")).threads : []; assert.equal(threads.length, 0); From c462cedab4c713124c0385c901d9cbd21de9ca77 Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 16:49:25 +0200 Subject: [PATCH 31/53] fix: tokenize the raw task input once extractLeadingSandbox already normalizes the argv; parseCommandInput normalized it again, and when the leading options left one positional token the second pass re-split it and reinterpreted its quotes and backslashes. handleTask now hands the already tokenized argv to parseArgs directly, so `task "--sandbox read-only '{\"key\":\"value\"}'"` sends the JSON as typed. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/codex-companion.mjs | 3 ++- tests/runtime.test.mjs | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index de1ccab8a..de8a5562f 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -856,10 +856,11 @@ async function handleReview(argv) { async function handleTask(argv) { const leading = extractLeadingSandbox(argv); - const { options, positionals } = parseCommandInput(leading.argv, { + const { options, positionals } = parseArgs(leading.argv, { valueOptions: TASK_VALUE_OPTIONS, booleanOptions: TASK_BOOLEAN_OPTIONS, aliasMap: { + C: "cwd", m: "model" } }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 6da137bcd..fad90126c 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -882,6 +882,16 @@ test("task reads --sandbox only before the task text", () => { assert.equal(fakeState.lastThreadStart.sandbox, "danger-full-access"); assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark"); assert.equal(fakeState.lastTurnStart.prompt, "run the integration tests"); + + const quoted = run("node", [SCRIPT, "task", "--sandbox read-only '{\"key\":\"value\"}'"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(quoted.status, 0, quoted.stderr); + fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); + assert.equal(fakeState.lastTurnStart.prompt, '{"key":"value"}'); }); test("task --sandbox rejects unknown modes and takes precedence over --write", () => { From 32932ecd59979b2da8297bd5d7a2659e7a7f0ba6 Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 16:58:22 +0200 Subject: [PATCH 32/53] fix: infer a thread's starting sandbox only from records that carry one A job record written before this change has no sandbox field, and its write flag is not evidence of the thread's sandbox: the shared app-server ignored sandbox changes on live resumes, so a legacy --write resume of a read-only thread was recorded as write-capable while the thread stayed read-only. When only such a record remained, the job-record check rejected the correct read-only request before the response-based check could run, and the thread could not be resumed at all. threadStartSandbox now reads only records with an explicit sandbox field, which every run since this change writes and which the checks guarantee matched the thread. Without one it returns null and the thread/resume response decides. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/codex-companion.mjs | 10 ++--- tests/runtime.test.mjs | 53 +++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index de8a5562f..023ab246d 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -202,14 +202,10 @@ function extractLeadingSandbox(argv) { } function threadStartSandbox(jobs, threadId) { - const onThread = jobs - .filter((job) => job.threadId === threadId) + const recorded = jobs + .filter((job) => job.threadId === threadId && VALID_SANDBOX_MODES.has(job.sandbox)) .sort((left, right) => String(left.createdAt ?? "").localeCompare(String(right.createdAt ?? ""))); - if (!onThread.length) { - return null; - } - const first = onThread[0]; - return VALID_SANDBOX_MODES.has(first.sandbox) ? first.sandbox : defaultTaskSandbox(Boolean(first.write)); + return recorded.length ? recorded[0].sandbox : null; } function normalizeArgv(argv) { diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index fad90126c..02bd86d79 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1039,6 +1039,59 @@ test("task --resume-last refuses a resume when the app-server reports a differen assert.equal(fakeState.lastTurnStart.prompt, "initial task"); }); +test("task --resume-last does not infer the thread's sandbox from a job record without a sandbox field", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const firstRun = run("node", [SCRIPT, "task", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const stateDir = resolveStateDir(repo); + const stateFile = path.join(stateDir, "state.json"); + const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); + for (const job of state.jobs) { + delete job.sandbox; + job.write = true; + const jobFile = path.join(stateDir, "jobs", `${job.id}.json`); + if (fs.existsSync(jobFile)) { + const stored = JSON.parse(fs.readFileSync(jobFile, "utf8")); + delete stored.sandbox; + stored.write = true; + fs.writeFileSync(jobFile, `${JSON.stringify(stored, null, 2)}\n`, "utf8"); + } + } + fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + + const readOnlyResume = run("node", [SCRIPT, "task", "--resume", "keep going"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(readOnlyResume.status, 0, readOnlyResume.stderr); + let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadResume.threadId, "thr_1"); + assert.equal(fakeState.lastTurnStart.prompt, "keep going"); + + const writeResume = run("node", [SCRIPT, "task", "--resume", "--write", "apply the fix"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(writeResume.status > 0, true); + assert.match(writeResume.stderr, /still has sandbox read-only in the shared app-server/); + fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.prompt, "keep going"); +}); + test("task-worker replays a stored request without a sandbox field using the --write mapping", () => { const repo = makeTempDir(); const binDir = makeTempDir(); From 011b1c435b2e3bc02b142de8c46c33fe9f898f43 Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 17:00:04 +0200 Subject: [PATCH 33/53] test: the write resume in the legacy-record case is refused by the job-record check After the read-only resume succeeded it wrote a record with an explicit sandbox, so the following --write resume is refused before connecting, not by the thread/resume answer. The test asserted the wrong message. Co-Authored-By: Claude Fable 5.1 --- tests/runtime.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 02bd86d79..79849da33 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1087,7 +1087,9 @@ test("task --resume-last does not infer the thread's sandbox from a job record w }); assert.equal(writeResume.status > 0, true); - assert.match(writeResume.stderr, /still has sandbox read-only in the shared app-server/); + assert.match(writeResume.stderr, /sandbox read-only/); + assert.match(writeResume.stderr, /asks for workspace-write/); + assert.match(writeResume.stderr, /--fresh/); fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); assert.equal(fakeState.lastTurnStart.prompt, "keep going"); }); From 3d6c7d9e04ec2246fc44a0659500574b309faf7a Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 17:08:16 +0200 Subject: [PATCH 34/53] refactor: one parser rule and one resume check instead of a pre-parser and a job-record pre-flight Six review rounds had each added a patch on top of the last. This commit removes what they accumulated and keeps the behaviour: - parseArgs gains `leadingOnlyOptions`: an option in that set is recognised only until the first positional, and afterwards stays in the positionals. `task` lists `--sandbox` there, so the hand-rolled extractLeadingSandbox pre-parser, its option tables and the second tokenization pass go. Inline values are now split at the first "=" for every option. - normalizeSandboxMode treats a present-but-empty value as an error. - The resume check is the app-server's own answer on thread/resume, and only that: the job-record pre-flight, threadStartSandbox and the sandbox field on job records go, together with the edge cases they had (legacy records, pruned records, refused runs). A cold-loaded thread that takes the new mode is no longer refused. - The rescue command, agent, skill and README each say the rule once. Tests cover the same behaviour with two fewer cases; 98 pass. Co-Authored-By: Claude Fable 5.1 --- README.md | 5 +- plugins/codex/agents/codex-rescue.md | 3 +- plugins/codex/commands/rescue.md | 2 +- plugins/codex/scripts/codex-companion.mjs | 98 +++------------- plugins/codex/scripts/lib/args.mjs | 11 +- .../codex/skills/codex-cli-runtime/SKILL.md | 6 +- tests/commands.test.mjs | 19 +-- tests/runtime.test.mjs | 111 +----------------- 8 files changed, 47 insertions(+), 208 deletions(-) diff --git a/README.md b/README.md index 2ab852309..272d8cb40 100644 --- a/README.md +++ b/README.md @@ -163,9 +163,8 @@ Ask Codex to redesign the database connection to be more resilient. - if you do not pass `--model` or `--effort`, Codex chooses its own defaults. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo -- `--sandbox` applies to `/codex:rescue` only; `/codex:review` and `/codex:adversarial-review` stay read-only. It takes precedence over the rescue agent's default `--write`, and it counts only before the task text: the same words inside the text stay part of the prompt. -- rescue runs edit files inside the repository by default (`workspace-write`). `--sandbox read-only` blocks edits, and `--sandbox danger-full-access` disables the Codex sandbox entirely, so Codex can write outside the repository and use the network without asking. Reserve it for tasks the sandbox blocks, such as running test tooling that writes to system locations. -- a resumed thread keeps the sandbox it was started with while the plugin's shared app-server still holds it, which is the normal case inside one Claude Code session: Codex CLI 0.153.2 applies the sandbox named on `thread/resume` only when it loads the thread again from disk. So `task` refuses a resume that asks for a different sandbox than the thread has, checking both its own job record and the sandbox the app-server reports on `thread/resume`; resume with the same `--sandbox`, or start a new thread with `--fresh`. +- `--sandbox` applies to `/codex:rescue` only; the review commands stay read-only. It takes precedence over `--write` and counts only before the task text. Rescue runs edit files inside the repository by default (`workspace-write`); `read-only` blocks edits, and `danger-full-access` disables the Codex sandbox entirely, so Codex can write outside the repository and use the network without asking. Reserve it for tasks the sandbox blocks. +- a resumed thread keeps the sandbox it was started with while the plugin's shared app-server still holds it, which is the normal case inside one Claude Code session (Codex CLI 0.153.2 applies a new mode only when it loads the thread again from disk). `task` refuses a resume whose sandbox differs from what the app-server reports; resume with the same `--sandbox`, or start a new thread with `--fresh`. ### `/codex:transfer` diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 52a623905..7df1e096c 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -31,8 +31,7 @@ Forwarding rules: - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. -- If the user passes `--sandbox ` before the task text, forward it to `task` in that position and do not add `--write`. The sandbox flag already decides whether Codex may edit. A `--sandbox` inside the task text is part of the prompt: leave it there, and do not move it in front. -- Never add `--sandbox danger-full-access` on your own. Only forward it when the user explicitly asked for it. +- If the user passes `--sandbox ` before the task text, forward it in that position and do not add `--write`; the sandbox already decides whether Codex may edit. A `--sandbox` inside the task text is part of the prompt: leave it there. Never add a `--sandbox` on your own. - Otherwise default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 09eea6f93..8bd8cf0f7 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -18,7 +18,7 @@ Execution mode: - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. - `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. -- `--sandbox` is a runtime-selection flag only when it comes before the task text. Forward the request in the user's order: a leading `--sandbox` reaches `task` as a flag, and a `--sandbox` inside the task text stays where it is and is part of the prompt. Do not move, strip or add one. Never add `--sandbox danger-full-access` yourself; forward it only when the user typed it before the task text. +- `--sandbox` is a runtime-selection flag when it comes before the task text. Forward the request in the user's order and never add a `--sandbox` yourself; a `--sandbox` inside the task text is part of the prompt and stays there. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 023ab246d..0689a7042 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -70,9 +70,6 @@ const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000; const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); const VALID_SANDBOX_MODES = new Set(["read-only", "workspace-write", "danger-full-access"]); -const TASK_VALUE_OPTIONS = ["model", "effort", "cwd", "prompt-file"]; -const TASK_BOOLEAN_OPTIONS = ["json", "write", "resume-last", "resume", "fresh", "background"]; -const TASK_SHORT_VALUE_OPTIONS = ["m", "C"]; const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]); const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; @@ -132,12 +129,12 @@ function normalizeReasoningEffort(effort) { } function normalizeSandboxMode(sandbox) { - if (sandbox == null) { + if (sandbox === undefined) { return null; } const normalized = String(sandbox).trim().toLowerCase(); if (!normalized) { - return null; + throw new Error("Missing value for --sandbox. Use one of: read-only, workspace-write, danger-full-access."); } if (!VALID_SANDBOX_MODES.has(normalized)) { throw new Error( @@ -151,63 +148,6 @@ function defaultTaskSandbox(write) { return write ? "workspace-write" : "read-only"; } -function extractLeadingSandbox(argv) { - const tokens = normalizeArgv(argv); - const rest = []; - let sandbox = null; - let leading = true; - let i = 0; - while (i < tokens.length) { - const token = tokens[i]; - if (leading && token.startsWith("--") && token !== "--") { - const separator = token.indexOf("="); - const key = separator === -1 ? token.slice(2) : token.slice(2, separator); - const inlineValue = separator === -1 ? undefined : token.slice(separator + 1); - if (key === "sandbox") { - sandbox = inlineValue ?? tokens[i + 1] ?? ""; - if (!String(sandbox).trim()) { - throw new Error("Missing value for --sandbox. Use one of: read-only, workspace-write, danger-full-access."); - } - i += inlineValue === undefined ? 2 : 1; - continue; - } - if (TASK_VALUE_OPTIONS.includes(key)) { - rest.push(token); - if (inlineValue === undefined && i + 1 < tokens.length) { - rest.push(tokens[i + 1]); - } - i += inlineValue === undefined ? 2 : 1; - continue; - } - if (TASK_BOOLEAN_OPTIONS.includes(key)) { - rest.push(token); - i += 1; - continue; - } - leading = false; - } else if (leading && token.startsWith("-") && token !== "-" && TASK_SHORT_VALUE_OPTIONS.includes(token.slice(1))) { - rest.push(token); - if (i + 1 < tokens.length) { - rest.push(tokens[i + 1]); - } - i += 2; - continue; - } else { - leading = false; - } - rest.push(token); - i += 1; - } - return { argv: rest, sandbox }; -} - -function threadStartSandbox(jobs, threadId) { - const recorded = jobs - .filter((job) => job.threadId === threadId && VALID_SANDBOX_MODES.has(job.sandbox)) - .sort((left, right) => String(left.createdAt ?? "").localeCompare(String(right.createdAt ?? ""))); - return recorded.length ? recorded[0].sandbox : null; -} - function normalizeArgv(argv) { if (argv.length === 1) { const [raw] = argv; @@ -426,7 +366,7 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) { const trackedTask = findLatestResumableTaskJob(visibleJobs); if (trackedTask) { - return { id: trackedTask.threadId, sandbox: threadStartSandbox(jobs, trackedTask.threadId) }; + return { id: trackedTask.threadId }; } if (sessionId) { @@ -557,13 +497,6 @@ async function executeTaskRun(request) { throw new Error("No previous Codex task thread was found for this repository."); } resumeThreadId = latestThread.id; - const requestedSandbox = request.sandbox ?? defaultTaskSandbox(Boolean(request.write)); - if (latestThread.sandbox && latestThread.sandbox !== requestedSandbox) { - throw new Error( - `Thread ${latestThread.id} was started with sandbox ${latestThread.sandbox}, and a thread the shared app-server still holds keeps it on resume. ` + - `This request asks for ${requestedSandbox}. Resume with --sandbox ${latestThread.sandbox}, or start a fresh thread with --fresh.` - ); - } } if (!request.prompt && !resumeThreadId) { @@ -652,7 +585,7 @@ function getJobKindLabel(kind, jobClass) { return jobClass === "review" ? "review" : "rescue"; } -function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summary, write = false, sandbox = null }) { +function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summary, write = false }) { return createJobRecord({ id: generateJobId(prefix), kind, @@ -661,8 +594,7 @@ function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summ workspaceRoot, jobClass, summary, - write, - ...(sandbox ? { sandbox } : {}) + write }); } @@ -678,7 +610,7 @@ function createTrackedProgress(job, options = {}) { }; } -function buildTaskJob(workspaceRoot, taskMetadata, write, sandbox) { +function buildTaskJob(workspaceRoot, taskMetadata, write) { return createCompanionJob({ prefix: "task", kind: "task", @@ -686,8 +618,7 @@ function buildTaskJob(workspaceRoot, taskMetadata, write, sandbox) { workspaceRoot, jobClass: "task", summary: taskMetadata.summary, - write, - sandbox + write }); } @@ -851,12 +782,11 @@ async function handleReview(argv) { } async function handleTask(argv) { - const leading = extractLeadingSandbox(argv); - const { options, positionals } = parseArgs(leading.argv, { - valueOptions: TASK_VALUE_OPTIONS, - booleanOptions: TASK_BOOLEAN_OPTIONS, + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["model", "effort", "cwd", "prompt-file", "sandbox"], + booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], + leadingOnlyOptions: ["sandbox"], aliasMap: { - C: "cwd", m: "model" } }); @@ -872,7 +802,7 @@ async function handleTask(argv) { if (resumeLast && fresh) { throw new Error("Choose either --resume/--resume-last or --fresh."); } - const sandbox = normalizeSandboxMode(leading.sandbox) ?? defaultTaskSandbox(Boolean(options.write)); + const sandbox = normalizeSandboxMode(options.sandbox) ?? defaultTaskSandbox(Boolean(options.write)); const write = sandbox !== "read-only"; const taskMetadata = buildTaskRunMetadata({ prompt, @@ -883,7 +813,7 @@ async function handleTask(argv) { ensureCodexAvailable(cwd); requireTaskRequest(prompt, resumeLast); - const job = buildTaskJob(workspaceRoot, taskMetadata, write, sandbox); + const job = buildTaskJob(workspaceRoot, taskMetadata, write); const request = buildTaskRequest({ cwd, model, @@ -899,7 +829,7 @@ async function handleTask(argv) { return; } - const job = buildTaskJob(workspaceRoot, taskMetadata, write, sandbox); + const job = buildTaskJob(workspaceRoot, taskMetadata, write); await runForegroundCommand( job, (progress) => diff --git a/plugins/codex/scripts/lib/args.mjs b/plugins/codex/scripts/lib/args.mjs index 6b1518502..325b969dc 100644 --- a/plugins/codex/scripts/lib/args.mjs +++ b/plugins/codex/scripts/lib/args.mjs @@ -2,6 +2,7 @@ export function parseArgs(argv, config = {}) { const valueOptions = new Set(config.valueOptions ?? []); const booleanOptions = new Set(config.booleanOptions ?? []); const aliasMap = config.aliasMap ?? {}; + const leadingOnlyOptions = new Set(config.leadingOnlyOptions ?? []); const options = {}; const positionals = []; let passthrough = false; @@ -25,9 +26,17 @@ export function parseArgs(argv, config = {}) { } if (token.startsWith("--")) { - const [rawKey, inlineValue] = token.slice(2).split("=", 2); + const body = token.slice(2); + const separator = body.indexOf("="); + const rawKey = separator === -1 ? body : body.slice(0, separator); + const inlineValue = separator === -1 ? undefined : body.slice(separator + 1); const key = aliasMap[rawKey] ?? rawKey; + if (leadingOnlyOptions.has(key) && positionals.length > 0) { + positionals.push(token); + continue; + } + if (booleanOptions.has(key)) { options[key] = inlineValue === undefined ? true : inlineValue !== "false"; continue; diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 7076bc489..efe6a63e2 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -28,18 +28,18 @@ Command selection: - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. -- If the forwarded request carries `--sandbox` anywhere before the task text (alongside `--model`, `--effort`, `--resume` or `--fresh`), pass it through to `task` in that position and do not add `--write`. A `--sandbox` inside the task text is prompt text; leave it in place. +- If the forwarded request carries `--sandbox` before the task text (alongside `--model`, `--effort`, `--resume` or `--fresh`), pass it through in that position and do not add `--write`. A `--sandbox` inside the task text is prompt text; leave it in place. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. - `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up. - `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. -- `--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`. It takes precedence over `--write`. Without it, `task` runs `workspace-write` when `--write` is set and `read-only` otherwise. +- `--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`. It takes precedence over `--write`; without it, `task` runs `workspace-write` when `--write` is set and `read-only` otherwise. - `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run. Safety rules: - Default to write-capable Codex work in `codex:codex-rescue` unless the user explicitly asks for read-only behavior. -- Never add `--sandbox danger-full-access` unless the user explicitly asked for it. It disables the Codex sandbox entirely. +- Never add a `--sandbox` the user did not type. `danger-full-access` disables the Codex sandbox entirely. - Preserve the user's task text as-is apart from stripping routing flags. - Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own. - Return the stdout of the `task` command exactly as-is. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index ae49ec644..f884efc5c 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -115,8 +115,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /Do not forward them to `task`/i); assert.match(rescue, /`--model` and `--effort` are runtime-selection flags/i); assert.match(rescue, /`--sandbox` is a runtime-selection flag/i); - assert.match(rescue, /Never add `--sandbox danger-full-access` yourself/i); - assert.match(rescue, /a `--sandbox` inside the task text stays where it is and is part of the prompt/i); + assert.match(rescue, /never add a `--sandbox` yourself/i); + assert.match(rescue, /a `--sandbox` inside the task text is part of the prompt and stays there/i); assert.match(rescue, /Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort/i); assert.match(rescue, /If they ask for `spark`, map it to `gpt-5\.3-codex-spark`/i); assert.match(rescue, /If the request includes `--resume`, do not ask whether to continue/i); @@ -140,9 +140,9 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /Leave model unset by default/i); assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i); assert.match(agent, /If the user asks for a concrete model name such as `gpt-5\.4-mini`, pass it through with `--model`/i); - assert.match(agent, /If the user passes `--sandbox ` before the task text, forward it to `task` in that position and do not add `--write`/i); - assert.match(agent, /A `--sandbox` inside the task text is part of the prompt: leave it there, and do not move it in front/i); - assert.match(agent, /Never add `--sandbox danger-full-access` on your own/i); + assert.match(agent, /If the user passes `--sandbox ` before the task text, forward it in that position and do not add `--write`/i); + assert.match(agent, /A `--sandbox` inside the task text is part of the prompt: leave it there/i); + assert.match(agent, /Never add a `--sandbox` on your own/i); assert.match(agent, /Return the stdout of the `codex-companion` command exactly as-is/i); assert.match(agent, /If the Bash call fails or Codex cannot be invoked, return nothing/i); assert.match(agent, /gpt-5-4-prompting/); @@ -158,10 +158,11 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); assert.match(runtimeSkill, /Strip it before calling `task`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); - assert.match(runtimeSkill, /If the forwarded request carries `--sandbox` anywhere before the task text \(alongside `--model`, `--effort`, `--resume` or `--fresh`\), pass it through to `task` in that position and do not add `--write`/i); + assert.match(runtimeSkill, /If the forwarded request carries `--sandbox` before the task text \(alongside `--model`, `--effort`, `--resume` or `--fresh`\), pass it through in that position and do not add `--write`/i); assert.match(runtimeSkill, /A `--sandbox` inside the task text is prompt text; leave it in place/i); assert.match(runtimeSkill, /`--sandbox`: accepted values are `read-only`, `workspace-write`, `danger-full-access`/i); assert.match(runtimeSkill, /It takes precedence over `--write`/i); + assert.match(runtimeSkill, /Never add a `--sandbox` the user did not type/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); assert.match(readme, /`codex:codex-rescue` subagent/i); @@ -170,10 +171,10 @@ test("rescue command absorbs continue semantics", () => { assert.match(readme, /`spark`, the plugin maps that to `gpt-5\.3-codex-spark`/i); assert.match(readme, /continue a previous Codex task/i); assert.match(readme, /--sandbox /); - assert.match(readme, /`--sandbox danger-full-access` disables the Codex sandbox entirely/i); + assert.match(readme, /`danger-full-access` disables the Codex sandbox entirely/i); assert.match(readme, /a resumed thread keeps the sandbox it was started with/i); - assert.match(readme, /it counts only before the task text/i); - assert.match(readme, /refuses a resume that asks for a different sandbox/i); + assert.match(readme, /counts only before the task text/i); + assert.match(readme, /refuses a resume whose sandbox differs from what the app-server reports/i); assert.match(readme, /`--sandbox` applies to `\/codex:rescue` only/i); assert.match(readme, /### `\/codex:setup`/); assert.match(readme, /### `\/codex:review`/); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 79849da33..ff5b21853 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -945,7 +945,7 @@ test("task --sandbox rejects unknown modes and takes precedence over --write", ( assert.equal(fakeState.lastTurnStart.prompt, "fix the failing test"); }); -test("task --resume-last keeps the sandbox of the thread and refuses another one", () => { +test("task --resume-last refuses a sandbox the app-server does not grant the resumed thread", () => { const repo = makeTempDir(); const binDir = makeTempDir(); const statePath = path.join(binDir, "fake-codex-state.json"); @@ -978,11 +978,11 @@ test("task --resume-last keeps the sandbox of the thread and refuses another one }); assert.equal(resumedWithoutSandbox.status > 0, true); - assert.match(resumedWithoutSandbox.stderr, /was started with sandbox danger-full-access/); - assert.match(resumedWithoutSandbox.stderr, /asks for read-only/); + assert.match(resumedWithoutSandbox.stderr, /still has sandbox danger-full-access in the shared app-server/); + assert.match(resumedWithoutSandbox.stderr, /would not run read-only/); assert.match(resumedWithoutSandbox.stderr, /--fresh/); fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastThreadResume.sandbox, "danger-full-access"); + assert.equal(fakeState.lastThreadResume.sandbox, "read-only"); assert.equal(fakeState.lastTurnStart.prompt, "follow up"); const narrowedWithWrite = run("node", [SCRIPT, "task", "--resume", "--write", "apply the fix"], { @@ -991,107 +991,9 @@ test("task --resume-last keeps the sandbox of the thread and refuses another one }); assert.equal(narrowedWithWrite.status > 0, true); - assert.match(narrowedWithWrite.stderr, /asks for workspace-write/); -}); - -test("task --resume-last refuses a resume when the app-server reports a different sandbox than requested", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const firstRun = run("node", [SCRIPT, "task", "--sandbox", "danger-full-access", "initial task"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(firstRun.status, 0, firstRun.stderr); - - const stateDir = resolveStateDir(repo); - const stateFile = path.join(stateDir, "state.json"); - const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); - for (const job of state.jobs) { - delete job.sandbox; - job.write = false; - const jobFile = path.join(stateDir, "jobs", `${job.id}.json`); - if (fs.existsSync(jobFile)) { - const stored = JSON.parse(fs.readFileSync(jobFile, "utf8")); - delete stored.sandbox; - stored.write = false; - fs.writeFileSync(jobFile, `${JSON.stringify(stored, null, 2)}\n`, "utf8"); - } - } - fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - - const resumed = run("node", [SCRIPT, "task", "--resume", "--sandbox", "read-only", "follow up"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(resumed.status > 0, true); - assert.match(resumed.stderr, /still has sandbox danger-full-access in the shared app-server/); - assert.match(resumed.stderr, /--fresh/); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastThreadResume.sandbox, "read-only"); - assert.equal(fakeState.lastTurnStart.prompt, "initial task"); -}); - -test("task --resume-last does not infer the thread's sandbox from a job record without a sandbox field", () => { - const repo = makeTempDir(); - const binDir = makeTempDir(); - const statePath = path.join(binDir, "fake-codex-state.json"); - installFakeCodex(binDir); - initGitRepo(repo); - fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); - run("git", ["add", "README.md"], { cwd: repo }); - run("git", ["commit", "-m", "init"], { cwd: repo }); - - const firstRun = run("node", [SCRIPT, "task", "initial task"], { - cwd: repo, - env: buildEnv(binDir) - }); - assert.equal(firstRun.status, 0, firstRun.stderr); - - const stateDir = resolveStateDir(repo); - const stateFile = path.join(stateDir, "state.json"); - const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); - for (const job of state.jobs) { - delete job.sandbox; - job.write = true; - const jobFile = path.join(stateDir, "jobs", `${job.id}.json`); - if (fs.existsSync(jobFile)) { - const stored = JSON.parse(fs.readFileSync(jobFile, "utf8")); - delete stored.sandbox; - stored.write = true; - fs.writeFileSync(jobFile, `${JSON.stringify(stored, null, 2)}\n`, "utf8"); - } - } - fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - - const readOnlyResume = run("node", [SCRIPT, "task", "--resume", "keep going"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(readOnlyResume.status, 0, readOnlyResume.stderr); - let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastThreadResume.threadId, "thr_1"); - assert.equal(fakeState.lastTurnStart.prompt, "keep going"); - - const writeResume = run("node", [SCRIPT, "task", "--resume", "--write", "apply the fix"], { - cwd: repo, - env: buildEnv(binDir) - }); - - assert.equal(writeResume.status > 0, true); - assert.match(writeResume.stderr, /sandbox read-only/); - assert.match(writeResume.stderr, /asks for workspace-write/); - assert.match(writeResume.stderr, /--fresh/); + assert.match(narrowedWithWrite.stderr, /would not run workspace-write/); fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); - assert.equal(fakeState.lastTurnStart.prompt, "keep going"); + assert.equal(fakeState.lastTurnStart.prompt, "follow up"); }); test("task-worker replays a stored request without a sandbox field using the --write mapping", () => { @@ -1201,7 +1103,6 @@ test("task --background stores the sandbox in the job request so the detached wo assert.equal(resultPayload.storedJob.request.sandbox, "danger-full-access"); assert.equal(resultPayload.storedJob.request.write, true); - assert.equal(resultPayload.job.sandbox, "danger-full-access"); }); test("task logs reasoning summaries and assistant messages to the job log", () => { From 959b0ec4e8b0f436760134e35969318071474330 Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 17:21:37 +0200 Subject: [PATCH 35/53] fix: a resumed sandbox policy the plugin cannot compare is refused, not trusted thread/resume can report a policy outside the three modes the flag names (externalSandbox today, anything added later). The check treated that as verified; it now refuses with the reported type and names --fresh, since the requested boundary cannot be shown to hold. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/codex.mjs | 13 ++++++++++--- tests/fake-codex-fixture.mjs | 3 ++- tests/runtime.test.mjs | 29 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 929394bd1..7cf099726 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -80,12 +80,19 @@ function assertResumedSandbox(threadId, requestedMode, response) { return; } const effectiveMode = sandboxModeForPolicy(response?.sandbox); - if (!effectiveMode || effectiveMode === requestedMode) { + if (effectiveMode === requestedMode) { return; } + if (effectiveMode) { + throw new Error( + `Thread ${threadId} still has sandbox ${effectiveMode} in the shared app-server, so this turn would not run ${requestedMode}. ` + + `Resume with --sandbox ${effectiveMode}, or start a fresh thread with --fresh.` + ); + } + const reported = typeof response?.sandbox?.type === "string" ? response.sandbox.type : "none"; throw new Error( - `Thread ${threadId} still has sandbox ${effectiveMode} in the shared app-server, so this turn would not run ${requestedMode}. ` + - `Resume with --sandbox ${effectiveMode}, or start a fresh thread with --fresh.` + `Thread ${threadId} reports a sandbox policy (${reported}) this plugin cannot compare with the requested ${requestedMode}. ` + + "Start a fresh thread with --fresh." ); } diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index a45a02bf8..46161dbce 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -363,12 +363,13 @@ rl.on("line", (line) => { } const thread = ensureThread(state, message.params.threadId); thread.updatedAt = now(); + const resumedPolicy = BEHAVIOR === "external-sandbox" ? { type: "externalSandbox", networkAccess: "restricted" } : sandboxPolicy(thread.sandbox); state.lastThreadResume = { threadId: thread.id, sandbox: message.params.sandbox ?? null }; saveState(state); - send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: sandboxPolicy(thread.sandbox), reasoningEffort: null } }); + send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: resumedPolicy, reasoningEffort: null } }); break; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index ff5b21853..66de74a85 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1052,6 +1052,35 @@ test("task-worker replays a stored request without a sandbox field using the --w assert.equal(fakeState.lastTurnStart.prompt, "fix the failing test"); }); +test("task --resume-last refuses a resume when the app-server reports a sandbox policy it cannot compare", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir, "external-sandbox"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const firstRun = run("node", [SCRIPT, "task", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const resumed = run("node", [SCRIPT, "task", "--resume", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(resumed.status > 0, true); + assert.match(resumed.stderr, /sandbox policy \(externalSandbox\) this plugin cannot compare with the requested read-only/); + assert.match(resumed.stderr, /--fresh/); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadResume.threadId, "thr_1"); + assert.equal(fakeState.lastTurnStart.prompt, "initial task"); +}); + test("task --background stores the sandbox in the job request so the detached worker reuses it", async () => { const repo = makeTempDir(); const binDir = makeTempDir(); From ff36d0a6609a627509e00e75769f2995599df4d5 Mon Sep 17 00:00:00 2001 From: Alireza Rezagholian Date: Sat, 5 Sep 2026 17:42:09 +0200 Subject: [PATCH 36/53] fix: an inline boolean value must be exactly true or false Splitting inline values at the first "=" left `--write=false=x` with the value `false=x`, which the boolean branch read as true. A boolean option now accepts no value, `true` or `false`, and anything else is an error, instead of either silently enabling or silently disabling the flag. Co-Authored-By: Claude Fable 5.1 --- plugins/codex/scripts/lib/args.mjs | 5 ++++- tests/runtime.test.mjs | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/lib/args.mjs b/plugins/codex/scripts/lib/args.mjs index 325b969dc..a6cb3cc26 100644 --- a/plugins/codex/scripts/lib/args.mjs +++ b/plugins/codex/scripts/lib/args.mjs @@ -38,7 +38,10 @@ export function parseArgs(argv, config = {}) { } if (booleanOptions.has(key)) { - options[key] = inlineValue === undefined ? true : inlineValue !== "false"; + if (inlineValue !== undefined && inlineValue !== "true" && inlineValue !== "false") { + throw new Error(`Invalid value for --${rawKey}: expected true or false`); + } + options[key] = inlineValue !== "false"; continue; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 66de74a85..4315b7f6e 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -931,6 +931,14 @@ test("task --sandbox rejects unknown modes and takes precedence over --write", ( assert.match(empty.stderr, /Missing value for --sandbox/); } + const malformedBoolean = run("node", [SCRIPT, "task", "--write=false=x", "fix the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(malformedBoolean.status > 0, true); + assert.match(malformedBoolean.stderr, /Invalid value for --write: expected true or false/); + const threads = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, "utf8")).threads : []; assert.equal(threads.length, 0); @@ -940,9 +948,18 @@ test("task --sandbox rejects unknown modes and takes precedence over --write", ( }); assert.equal(explicit.status, 0, explicit.stderr); - const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); assert.equal(fakeState.lastTurnStart.prompt, "fix the failing test"); + + const writeFalse = run("node", [SCRIPT, "task", "--write=false", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(writeFalse.status, 0, writeFalse.stderr); + fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); }); test("task --resume-last refuses a sandbox the app-server does not grant the resumed thread", () => { From ef58bf15973134f3da7066bef9f84a037c3361ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:59:39 -0300 Subject: [PATCH 37/53] fix: discover Windows Node installs in launcher --- plugins/codex/scripts/run-node.sh | 81 ++++++++++++++++++++++++++++++- tests/node-launcher.test.mjs | 31 +++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index ba57d98f2..28b50e934 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -18,6 +18,58 @@ is_supported_node() { "$1" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 18 || (major === 18 && minor >= 18) ? 0 : 1)' >/dev/null 2>&1 } +windows_path_to_posix() { + value=$1 + [ -n "$value" ] || return 1 + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$value" + else + printf '%s\n' "$value" + fi +} + +find_windows_node() { + root= + if [ -n "${NVM_SYMLINK:-}" ]; then + root=$(windows_path_to_posix "$NVM_SYMLINK") || root= + for candidate in "$root/node.exe" "$root/node"; do + [ -x "$candidate" ] || continue + is_supported_node "$candidate" || continue + printf '%s\n' "$candidate" + return 0 + done + fi + if [ -n "${VOLTA_HOME:-}" ]; then + root=$(windows_path_to_posix "$VOLTA_HOME") || root= + for candidate in "$root/bin/node.exe" "$root/bin/node"; do + [ -x "$candidate" ] || continue + is_supported_node "$candidate" || continue + printf '%s\n' "$candidate" + return 0 + done + fi + if [ -n "${LOCALAPPDATA:-}" ]; then + root=$(windows_path_to_posix "$LOCALAPPDATA") || root= + for candidate in "$root/Volta/bin/node.exe" "$root/Volta/bin/node"; do + [ -x "$candidate" ] || continue + is_supported_node "$candidate" || continue + printf '%s\n' "$candidate" + return 0 + done + fi + program_files=${PROGRAMFILES:-${PROGRAMW6432:-${ProgramFiles:-}}} + if [ -n "$program_files" ]; then + root=$(windows_path_to_posix "$program_files") || root= + for candidate in "$root/nodejs/node.exe" "$root/nodejs/node"; do + [ -x "$candidate" ] || continue + is_supported_node "$candidate" || continue + printf '%s\n' "$candidate" + return 0 + done + fi + return 1 +} + find_node() { if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ] && is_supported_node "$CODEX_COMPANION_NODE"; then printf '%s\n' "$CODEX_COMPANION_NODE" @@ -43,6 +95,7 @@ find_node() { printf '%s\n' "$candidate" return 0 done + find_windows_node && return 0 return 1 } @@ -56,7 +109,32 @@ add_supported_node_dir_to_path() { [ -x "$candidate" ] || return 0 is_supported_node "$candidate" || return 0 case "$candidate" in */*) candidate_dir=${candidate%/*} ;; *) candidate_dir=. ;; esac - case ":$PATH:" in *":$candidate_dir:"*) ;; *) PATH="$PATH:$candidate_dir" ;; esac + case ":${PATH:-}:" in *":$candidate_dir:"*) ;; *) PATH="${PATH:-}${PATH:+:}$candidate_dir" ;; esac +} + +add_windows_node_dirs_to_path() { + root= + if [ -n "${NVM_SYMLINK:-}" ]; then + root=$(windows_path_to_posix "$NVM_SYMLINK") || root= + [ -n "$root" ] && add_supported_node_dir_to_path "$root/node.exe" + [ -n "$root" ] && add_supported_node_dir_to_path "$root/node" + fi + if [ -n "${VOLTA_HOME:-}" ]; then + root=$(windows_path_to_posix "$VOLTA_HOME") || root= + [ -n "$root" ] && add_supported_node_dir_to_path "$root/bin/node.exe" + [ -n "$root" ] && add_supported_node_dir_to_path "$root/bin/node" + fi + if [ -n "${LOCALAPPDATA:-}" ]; then + root=$(windows_path_to_posix "$LOCALAPPDATA") || root= + [ -n "$root" ] && add_supported_node_dir_to_path "$root/Volta/bin/node.exe" + [ -n "$root" ] && add_supported_node_dir_to_path "$root/Volta/bin/node" + fi + program_files=${PROGRAMFILES:-${PROGRAMW6432:-${ProgramFiles:-}}} + if [ -n "$program_files" ]; then + root=$(windows_path_to_posix "$program_files") || root= + [ -n "$root" ] && add_supported_node_dir_to_path "$root/nodejs/node.exe" + [ -n "$root" ] && add_supported_node_dir_to_path "$root/nodejs/node" + fi } case "$node_bin" in */*) node_dir=${node_bin%/*} ;; *) node_dir=. ;; esac @@ -65,6 +143,7 @@ home=${HOME:-} for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$home"/.nvm/versions/node/*/bin/node "$home"/.local/share/fnm/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do add_supported_node_dir_to_path "$candidate" done +add_windows_node_dirs_to_path export PATH exec "$node_bin" "$script_dir/$target" "$@" diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index d44ec746b..b5cd0935e 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -82,3 +82,32 @@ test("portable launcher keeps searching compatible toolchains for codex", () => fs.rmSync(home, { recursive: true, force: true }); } }); + +// Regression for review: minimal Git Bash PATH must still discover a normal Windows Node install. +test("portable launcher discovers Windows Node install roots under Git Bash", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-windows-")); + const emptyBin = path.join(home, "empty-bin"); + const programFiles = path.join(home, "Program Files"); + const nodeDir = path.join(programFiles, "nodejs"); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(nodeDir, { recursive: true }); + const cygpath = path.join(emptyBin, "cygpath"); + fs.writeFileSync(cygpath, '#!/bin/sh\nif [ "${1:-}" = "-u" ]; then shift; fi\nprintf "%s\\n" "$1"\n', "utf8"); + fs.chmodSync(cygpath, 0o755); + const nodePath = path.join(nodeDir, "node.exe"); + fs.copyFileSync(process.execPath, nodePath); + const probeName = `windows-node-probe-${process.pid}.mjs`; + const probePath = path.join(path.dirname(LAUNCHER), probeName); + fs.writeFileSync(probePath, 'console.log("WINDOWS_NODE")\n', "utf8"); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), probeName], { + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), ProgramFiles: programFiles.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /WINDOWS_NODE/); + } finally { + fs.rmSync(probePath, { force: true }); + fs.rmSync(home, { recursive: true, force: true }); + } +}); From 1338edef04413adccf86f89eb884a4177ca08d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:23:37 -0300 Subject: [PATCH 38/53] fix: complete portable launcher coverage --- plugins/codex/commands/transfer.md | 4 +- plugins/codex/scripts/run-node.sh | 18 +++++++++ tests/commands.test.mjs | 2 +- tests/node-launcher.test.mjs | 63 ++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/plugins/codex/commands/transfer.md b/plugins/codex/commands/transfer.md index 42170e51d..027f9b78c 100644 --- a/plugins/codex/commands/transfer.md +++ b/plugins/codex/commands/transfer.md @@ -2,9 +2,9 @@ description: Transfer the current Claude Code session into a resumable Codex thread argument-hint: "[--source ]" disable-model-invocation: true -allowed-tools: Bash(node:*) +allowed-tools: Bash(bash:*) --- -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" transfer "$ARGUMENTS"` +!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" transfer "$ARGUMENTS"` Present the command output to the user exactly as returned. Preserve the Codex session ID and the `codex resume ` command. diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 28b50e934..05c7ebabc 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -112,6 +112,23 @@ add_supported_node_dir_to_path() { case ":${PATH:-}:" in *":$candidate_dir:"*) ;; *) PATH="${PATH:-}${PATH:+:}$candidate_dir" ;; esac } +add_dir_to_path() { + candidate_dir=$1 + [ -d "$candidate_dir" ] || return 0 + case ":${PATH:-}:" in *":$candidate_dir:"*) ;; *) PATH="${PATH:-}${PATH:+:}$candidate_dir" ;; esac +} + +add_npm_prefix_dirs_to_path() { + prefix=${NPM_CONFIG_PREFIX:-} + if [ -z "$prefix" ] && command -v npm >/dev/null 2>&1; then + prefix=$(npm prefix -g 2>/dev/null) || prefix= + fi + [ -n "$prefix" ] || return 0 + prefix=$(windows_path_to_posix "$prefix") || return 0 + add_dir_to_path "$prefix/bin" + add_dir_to_path "$prefix" +} + add_windows_node_dirs_to_path() { root= if [ -n "${NVM_SYMLINK:-}" ]; then @@ -144,6 +161,7 @@ for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node add_supported_node_dir_to_path "$candidate" done add_windows_node_dirs_to_path +add_npm_prefix_dirs_to_path export PATH exec "$node_bin" "$script_dir/$target" "$@" diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 7da82b686..04bc4c76d 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -204,7 +204,7 @@ test("internal docs use task terminology for rescue runs", () => { test("hooks and deterministic commands use the portable Node launcher", () => { const hooks = read("hooks/hooks.json"); - for (const relative of ["commands/status.md", "commands/result.md", "commands/cancel.md"]) { + for (const relative of ["commands/status.md", "commands/result.md", "commands/cancel.md", "commands/transfer.md"]) { const source = read(relative); assert.match(source, /scripts\/run-node\.sh/); assert.match(source, /cygpath/); diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index b5cd0935e..48e699e65 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -111,3 +111,66 @@ test("portable launcher discovers Windows Node install roots under Git Bash", () fs.rmSync(home, { recursive: true, force: true }); } }); + + +test("portable launcher restores a configured npm global prefix for codex", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-npm-prefix-")); + const nodeBin = installFakeNode(home, "v22.0.0", true); + const prefix = path.join(home, ".npm-global"); + const prefixBin = path.join(prefix, "bin"); + fs.mkdirSync(prefixBin, { recursive: true }); + const codexPath = path.join(prefixBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { + ...process.env, + HOME: home.replaceAll("\\", "/"), + PATH: nodeBin.replaceAll("\\", "/"), + NPM_CONFIG_PREFIX: prefix.replaceAll("\\", "/"), + CODEX_COMPANION_NODE: "" + } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CODEX:.+[\\/]\.npm-global[\\/]bin[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + + +test("portable launcher restores npm prefix reported by npm config", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-npm-config-")); + const nodeBin = installFakeNode(home, "v22.0.0", true); + const prefix = path.join(home, ".npm-configured"); + const prefixBin = path.join(prefix, "bin"); + fs.mkdirSync(prefixBin, { recursive: true }); + const codexPath = path.join(prefixBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + const npmPath = path.join(nodeBin, "npm"); + fs.writeFileSync( + npmPath, + `#!/bin/sh\nif [ "\${1:-}" = "prefix" ] && [ "\${2:-}" = "-g" ]; then printf '%s\\n' "${prefix.replaceAll("\\", "/")}"; exit 0; fi\nexit 1\n`, + "utf8" + ); + fs.chmodSync(npmPath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { + ...process.env, + HOME: home.replaceAll("\\", "/"), + PATH: nodeBin.replaceAll("\\", "/"), + NPM_CONFIG_PREFIX: "", + CODEX_COMPANION_NODE: "" + } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CODEX:.+[\\/]\.npm-configured[\\/]bin[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); From a6855f39924831a7119f70aba400e43712bff898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:32:56 -0300 Subject: [PATCH 39/53] fix: honor custom NVM_DIR in launcher --- plugins/codex/scripts/run-node.sh | 28 ++++++++++++++--- tests/node-launcher.test.mjs | 52 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 05c7ebabc..1a76dd072 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -23,9 +23,27 @@ windows_path_to_posix() { [ -n "$value" ] || return 1 if command -v cygpath >/dev/null 2>&1; then cygpath -u "$value" - else - printf '%s\n' "$value" + return fi + + normalized= + remaining=$value + while [ -n "$remaining" ]; do + char=${remaining%"${remaining#?}"} + remaining=${remaining#?} + case "$char" in + \\) normalized="${normalized}/" ;; + *) normalized="${normalized}${char}" ;; + esac + done + case "$normalized" in + [A-Za-z]:/*) + drive=${normalized%%:*} + rest=${normalized#?:} + printf '/%s%s\n' "$drive" "$rest" + ;; + *) printf '%s\n' "$normalized" ;; + esac } find_windows_node() { @@ -83,10 +101,11 @@ find_node() { fi fi home=${HOME:-} + nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= for candidate in \ /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ "$home/.volta/bin/node" \ - "$home"/.nvm/versions/node/*/bin/node \ + "$nvm_dir"/versions/node/*/bin/node \ "$home"/.local/share/fnm/node-versions/*/installation/bin/node \ "$home"/.asdf/installs/nodejs/*/bin/node \ "$home"/.local/share/mise/installs/node/*/bin/node; do @@ -157,7 +176,8 @@ add_windows_node_dirs_to_path() { case "$node_bin" in */*) node_dir=${node_bin%/*} ;; *) node_dir=. ;; esac PATH="$node_dir${PATH:+:$PATH}" home=${HOME:-} -for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$home"/.nvm/versions/node/*/bin/node "$home"/.local/share/fnm/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do +nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= +for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$home"/.local/share/fnm/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do add_supported_node_dir_to_path "$candidate" done add_windows_node_dirs_to_path diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 48e699e65..4e4b500c8 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -174,3 +174,55 @@ test("portable launcher restores npm prefix reported by npm config", () => { fs.rmSync(home, { recursive: true, force: true }); } }); + + +test("portable launcher discovers Node from a custom NVM_DIR", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-nvm-")); + const emptyBin = path.join(home, "empty-bin"); + const nvmDir = path.join(home, "custom-nvm"); + const binDir = path.join(nvmDir, "versions", "node", "v22.0.0", "bin"); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + const nodePath = path.join(binDir, "node"); + fs.writeFileSync(nodePath, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_NVM_NODE:%s\\n" "$*"\n', "utf8"); + fs.chmodSync(nodePath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "", ProgramFiles: "", PROGRAMFILES: "", PROGRAMW6432: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CUSTOM_NVM_NODE:/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("portable launcher enriches PATH from a custom NVM_DIR", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-nvm-path-")); + const systemBin = path.join(home, "system-bin"); + const nvmDir = path.join(home, "custom-nvm"); + const managedBin = path.join(nvmDir, "versions", "node", "v22.0.0", "bin"); + fs.mkdirSync(systemBin, { recursive: true }); + fs.mkdirSync(managedBin, { recursive: true }); + const systemNode = path.join(systemBin, "node"); + fs.writeFileSync(systemNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "SYSTEM_NODE:%s\\n" "$*"\nprintf "CODEX:%s\\n" "$(command -v codex || true)"\n', "utf8"); + fs.chmodSync(systemNode, 0o755); + const managedNode = path.join(managedBin, "node"); + fs.writeFileSync(managedNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nexit 0\n', "utf8"); + fs.chmodSync(managedNode, 0o755); + const codexPath = path.join(managedBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /SYSTEM_NODE:/); + assert.match(result.stdout, /CODEX:.+[\\/]custom-nvm[\\/]versions[\\/]node[\\/]v22\.0\.0[\\/]bin[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); From 64dcef92723eea9287f4757a98470dbb3b51b1cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:57:05 -0300 Subject: [PATCH 40/53] fix: align launcher node with codex toolchain --- plugins/codex/scripts/run-node.sh | 56 ++++++++++++++++++++++++------- tests/node-launcher.test.mjs | 27 +++++++++++++-- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 1a76dd072..7394b50d2 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -88,18 +88,14 @@ find_windows_node() { return 1 } -find_node() { - if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ] && is_supported_node "$CODEX_COMPANION_NODE"; then - printf '%s\n' "$CODEX_COMPANION_NODE" - return 0 - fi - if command -v node >/dev/null 2>&1; then - candidate=$(command -v node) - if is_supported_node "$candidate"; then - printf '%s\n' "$candidate" - return 0 - fi - fi +node_dir_has_codex() { + candidate=$1 + case "$candidate" in */*) candidate_dir=${candidate%/*} ;; *) candidate_dir=. ;; esac + [ -x "$candidate_dir/codex" ] || [ -f "$candidate_dir/codex.cmd" ] || [ -f "$candidate_dir/codex.exe" ] +} + +find_managed_node() { + require_codex=$1 home=${HOME:-} nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= for candidate in \ @@ -111,9 +107,45 @@ find_node() { "$home"/.local/share/mise/installs/node/*/bin/node; do [ -x "$candidate" ] || continue is_supported_node "$candidate" || continue + if [ "$require_codex" = "true" ]; then + node_dir_has_codex "$candidate" || continue + fi printf '%s\n' "$candidate" return 0 done + return 1 +} + +find_node() { + if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ] && is_supported_node "$CODEX_COMPANION_NODE"; then + printf '%s\n' "$CODEX_COMPANION_NODE" + return 0 + fi + + path_node= + if command -v node >/dev/null 2>&1; then + candidate=$(command -v node) + if is_supported_node "$candidate"; then + path_node=$candidate + if command -v codex >/dev/null 2>&1; then + printf '%s\n' "$path_node" + return 0 + fi + fi + fi + + if candidate=$(find_managed_node true); then + printf '%s\n' "$candidate" + return 0 + fi + if [ -n "$path_node" ]; then + printf '%s\n' "$path_node" + return 0 + fi + if candidate=$(find_managed_node false); then + printf '%s\n' "$candidate" + return 0 + fi find_windows_node && return 0 return 1 } diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 4e4b500c8..6219d8377 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -76,7 +76,8 @@ test("portable launcher keeps searching compatible toolchains for codex", () => env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /SYSTEM_NODE:/); + assert.match(result.stdout, /FAKE_NODE_v22\.0\.0:/); + assert.doesNotMatch(result.stdout, /SYSTEM_NODE:/); assert.match(result.stdout, /CODEX:.+[\\/]codex\n$/m); } finally { fs.rmSync(home, { recursive: true, force: true }); @@ -209,7 +210,7 @@ test("portable launcher enriches PATH from a custom NVM_DIR", () => { fs.writeFileSync(systemNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "SYSTEM_NODE:%s\\n" "$*"\nprintf "CODEX:%s\\n" "$(command -v codex || true)"\n', "utf8"); fs.chmodSync(systemNode, 0o755); const managedNode = path.join(managedBin, "node"); - fs.writeFileSync(managedNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nexit 0\n', "utf8"); + fs.writeFileSync(managedNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_NVM_MANAGED:%s\\n" "$*"\nprintf "CODEX:%s\\n" "$(command -v codex || true)"\n', "utf8"); fs.chmodSync(managedNode, 0o755); const codexPath = path.join(managedBin, "codex"); fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); @@ -220,9 +221,29 @@ test("portable launcher enriches PATH from a custom NVM_DIR", () => { env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /SYSTEM_NODE:/); + assert.match(result.stdout, /CUSTOM_NVM_MANAGED:/); + assert.doesNotMatch(result.stdout, /SYSTEM_NODE:/); assert.match(result.stdout, /CODEX:.+[\\/]custom-nvm[\\/]versions[\\/]node[\\/]v22\.0\.0[\\/]bin[\\/]codex\n$/m); } finally { fs.rmSync(home, { recursive: true, force: true }); } }); + + +test("portable launcher aligns Node with the supported toolchain that contains codex", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-multi-supported-")); + installFakeNode(home, "v20.19.0", true); + const newerBin = installFakeNode(home, "v24.2.0", true); + const codexPath = path.join(newerBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + try { + const result = runWithMinimalPath(home); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /FAKE_NODE_v24\.2\.0:/); + assert.doesNotMatch(result.stdout, /FAKE_NODE_v20\.19\.0:/); + assert.match(result.stdout, /CODEX:.+[\\/]v24\.2\.0[\\/]bin[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); From a420fc0dfbfa84626afd0f94c58759657090e55f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:14:13 -0300 Subject: [PATCH 41/53] test: isolate portable launcher environment --- tests/node-launcher.test.mjs | 68 ++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 6219d8377..00b15c9b8 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -9,6 +9,21 @@ import { fileURLToPath } from "node:url"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const LAUNCHER = path.join(ROOT, "plugins", "codex", "scripts", "run-node.sh"); const BASH = process.env.SHELL || (process.platform === "win32" ? "C:\\Program Files\\Git\\bin\\bash.exe" : "/bin/bash"); +const VERSION_MANAGER_ENV_KEYS = new Set([ + "nvm_dir", + "nvm_symlink", + "volta_home", + "localappdata", + "programfiles", + "programw6432" +]); + +function cleanVersionManagerEnv() { + return Object.fromEntries( + Object.entries(process.env).filter(([key]) => !VERSION_MANAGER_ENV_KEYS.has(key.toLowerCase())) + ); +} + function installFakeNode(home, version, supported) { const binDir = path.join(home, ".nvm", "versions", "node", version, "bin"); @@ -24,7 +39,7 @@ function runWithMinimalPath(home) { fs.mkdirSync(emptyBin, { recursive: true }); return spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); } @@ -73,7 +88,7 @@ test("portable launcher keeps searching compatible toolchains for codex", () => try { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /FAKE_NODE_v22\.0\.0:/); @@ -89,6 +104,9 @@ test("portable launcher discovers Windows Node install roots under Git Bash", () const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-windows-")); const emptyBin = path.join(home, "empty-bin"); const programFiles = path.join(home, "Program Files"); + const programFilesPosix = programFiles + .replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`) + .replaceAll("\\", "/"); const nodeDir = path.join(programFiles, "nodejs"); fs.mkdirSync(emptyBin, { recursive: true }); fs.mkdirSync(nodeDir, { recursive: true }); @@ -103,7 +121,7 @@ test("portable launcher discovers Windows Node install roots under Git Bash", () try { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), probeName], { encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), ProgramFiles: programFiles.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "" } + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), PROGRAMFILES: programFilesPosix, CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "" } }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /WINDOWS_NODE/); @@ -127,7 +145,7 @@ test("portable launcher restores a configured npm global prefix for codex", () = const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", env: { - ...process.env, + ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: nodeBin.replaceAll("\\", "/"), NPM_CONFIG_PREFIX: prefix.replaceAll("\\", "/"), @@ -162,7 +180,7 @@ test("portable launcher restores npm prefix reported by npm config", () => { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", env: { - ...process.env, + ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: nodeBin.replaceAll("\\", "/"), NPM_CONFIG_PREFIX: "", @@ -190,7 +208,7 @@ test("portable launcher discovers Node from a custom NVM_DIR", () => { try { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "", ProgramFiles: "", PROGRAMFILES: "", PROGRAMW6432: "" } + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "", ProgramFiles: "", PROGRAMFILES: "", PROGRAMW6432: "" } }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /CUSTOM_NVM_NODE:/); @@ -218,7 +236,7 @@ test("portable launcher enriches PATH from a custom NVM_DIR", () => { try { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /CUSTOM_NVM_MANAGED:/); @@ -230,6 +248,42 @@ test("portable launcher enriches PATH from a custom NVM_DIR", () => { }); +test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-configured-windows-")); + const emptyBin = path.join(home, "empty-bin"); + const portableDir = path.join(home, "Portable Node"); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(portableDir, { recursive: true }); + const nodePath = path.join(portableDir, "node.exe"); + fs.copyFileSync(process.execPath, nodePath); + const probeName = `configured-node-probe-${process.pid}.mjs`; + const probePath = path.join(path.dirname(LAUNCHER), probeName); + fs.writeFileSync(probePath, 'console.log("CONFIGURED_WINDOWS_NODE")\n', "utf8"); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), probeName], { + encoding: "utf8", + env: { + ...cleanVersionManagerEnv(), + HOME: home.replaceAll("\\", "/"), + PATH: emptyBin.replaceAll("\\", "/"), + CODEX_COMPANION_NODE: nodePath, + NVM_DIR: "", + NVM_SYMLINK: "", + VOLTA_HOME: "", + LOCALAPPDATA: "", + ProgramFiles: "", + PROGRAMFILES: "", + PROGRAMW6432: "" + } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CONFIGURED_WINDOWS_NODE/); + } finally { + fs.rmSync(probePath, { force: true }); + fs.rmSync(home, { recursive: true, force: true }); + } +}); + test("portable launcher aligns Node with the supported toolchain that contains codex", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-multi-supported-")); installFakeNode(home, "v20.19.0", true); From 0ef6b735bc6183873e0443e2e0e519b7742ca970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:42:37 -0300 Subject: [PATCH 42/53] fix: honor custom fnm directory --- plugins/codex/scripts/run-node.sh | 6 ++-- tests/node-launcher.test.mjs | 53 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 7394b50d2..e5260ea6d 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -98,11 +98,12 @@ find_managed_node() { require_codex=$1 home=${HOME:-} nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= + fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= for candidate in \ /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ "$home/.volta/bin/node" \ "$nvm_dir"/versions/node/*/bin/node \ - "$home"/.local/share/fnm/node-versions/*/installation/bin/node \ + "$fnm_dir"/node-versions/*/installation/bin/node \ "$home"/.asdf/installs/nodejs/*/bin/node \ "$home"/.local/share/mise/installs/node/*/bin/node; do [ -x "$candidate" ] || continue @@ -209,7 +210,8 @@ case "$node_bin" in */*) node_dir=${node_bin%/*} ;; *) node_dir=. ;; esac PATH="$node_dir${PATH:+:$PATH}" home=${HOME:-} nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= -for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$home"/.local/share/fnm/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do +fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= +for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$fnm_dir"/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do add_supported_node_dir_to_path "$candidate" done add_windows_node_dirs_to_path diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 00b15c9b8..36ca7475f 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -12,6 +12,7 @@ const BASH = process.env.SHELL || (process.platform === "win32" ? "C:\\Program F const VERSION_MANAGER_ENV_KEYS = new Set([ "nvm_dir", "nvm_symlink", + "fnm_dir", "volta_home", "localappdata", "programfiles", @@ -248,6 +249,58 @@ test("portable launcher enriches PATH from a custom NVM_DIR", () => { }); +test("portable launcher discovers Node from a custom FNM_DIR", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-fnm-")); + const emptyBin = path.join(home, "empty-bin"); + const fnmDir = path.join(home, "custom-fnm"); + const binDir = path.join(fnmDir, "node-versions", "v22.0.0", "installation", "bin"); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + const nodePath = path.join(binDir, "node"); + fs.writeFileSync(nodePath, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_FNM_NODE:%s\\n" "$*"\n', "utf8"); + fs.chmodSync(nodePath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), FNM_DIR: fnmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CUSTOM_FNM_NODE:/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("portable launcher aligns Node and codex from a custom FNM_DIR", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-fnm-path-")); + const systemBin = path.join(home, "system-bin"); + const fnmDir = path.join(home, "custom-fnm"); + const managedBin = path.join(fnmDir, "node-versions", "v24.2.0", "installation", "bin"); + fs.mkdirSync(systemBin, { recursive: true }); + fs.mkdirSync(managedBin, { recursive: true }); + const systemNode = path.join(systemBin, "node"); + fs.writeFileSync(systemNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "SYSTEM_NODE:%s\\n" "$*"\n', "utf8"); + fs.chmodSync(systemNode, 0o755); + const managedNode = path.join(managedBin, "node"); + fs.writeFileSync(managedNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_FNM_MANAGED:%s\\n" "$*"\nprintf "CODEX:%s\\n" "$(command -v codex || true)"\n', "utf8"); + fs.chmodSync(managedNode, 0o755); + const codexPath = path.join(managedBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), FNM_DIR: fnmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CUSTOM_FNM_MANAGED:/); + assert.doesNotMatch(result.stdout, /SYSTEM_NODE:/); + assert.match(result.stdout, /CODEX:.+[\\/]custom-fnm[\\/]node-versions[\\/]v24\.2\.0[\\/]installation[\\/]bin[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-configured-windows-")); const emptyBin = path.join(home, "empty-bin"); From 2c6b2556242c4ea7afccf18b3597d4feb8140557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:08:38 -0300 Subject: [PATCH 43/53] fix: honor custom asdf and mise roots --- plugins/codex/scripts/run-node.sh | 19 ++++++++----- tests/node-launcher.test.mjs | 44 +++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index e5260ea6d..9eea0f6fb 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -99,13 +99,15 @@ find_managed_node() { home=${HOME:-} nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= + asdf_dir=$(windows_path_to_posix "${ASDF_DATA_DIR:-$home/.asdf}") || asdf_dir= + mise_dir=$(windows_path_to_posix "${MISE_DATA_DIR:-$home/.local/share/mise}") || mise_dir= for candidate in \ /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ "$home/.volta/bin/node" \ "$nvm_dir"/versions/node/*/bin/node \ "$fnm_dir"/node-versions/*/installation/bin/node \ - "$home"/.asdf/installs/nodejs/*/bin/node \ - "$home"/.local/share/mise/installs/node/*/bin/node; do + "$asdf_dir"/installs/nodejs/*/bin/node \ + "$mise_dir"/installs/node/*/bin/node; do [ -x "$candidate" ] || continue is_supported_node "$candidate" || continue if [ "$require_codex" = "true" ]; then @@ -118,9 +120,12 @@ find_managed_node() { } find_node() { - if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ] && is_supported_node "$CODEX_COMPANION_NODE"; then - printf '%s\n' "$CODEX_COMPANION_NODE" - return 0 + if [ -n "${CODEX_COMPANION_NODE:-}" ]; then + configured_node=$(windows_path_to_posix "$CODEX_COMPANION_NODE") || configured_node=$CODEX_COMPANION_NODE + if [ -x "$configured_node" ] && is_supported_node "$configured_node"; then + printf '%s\n' "$configured_node" + return 0 + fi fi path_node= @@ -211,7 +216,9 @@ PATH="$node_dir${PATH:+:$PATH}" home=${HOME:-} nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= -for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$fnm_dir"/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do +asdf_dir=$(windows_path_to_posix "${ASDF_DATA_DIR:-$home/.asdf}") || asdf_dir= +mise_dir=$(windows_path_to_posix "${MISE_DATA_DIR:-$home/.local/share/mise}") || mise_dir= +for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$fnm_dir"/node-versions/*/installation/bin/node "$asdf_dir"/installs/nodejs/*/bin/node "$mise_dir"/installs/node/*/bin/node; do add_supported_node_dir_to_path "$candidate" done add_windows_node_dirs_to_path diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 36ca7475f..798594072 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -13,6 +13,8 @@ const VERSION_MANAGER_ENV_KEYS = new Set([ "nvm_dir", "nvm_symlink", "fnm_dir", + "asdf_data_dir", + "mise_data_dir", "volta_home", "localappdata", "programfiles", @@ -301,6 +303,43 @@ test("portable launcher aligns Node and codex from a custom FNM_DIR", () => { } }); + +test("portable launcher honors custom ASDF_DATA_DIR and MISE_DATA_DIR", () => { + const cases = [ + { envKey: "ASDF_DATA_DIR", label: "ASDF", relative: ["installs", "nodejs", "v22.0.0", "bin"] }, + { envKey: "MISE_DATA_DIR", label: "MISE", relative: ["installs", "node", "v22.0.0", "bin"] } + ]; + for (const { envKey, label, relative } of cases) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), `codex-node-custom-${label.toLowerCase()}-`)); + const emptyBin = path.join(home, "empty-bin"); + const dataDir = path.join(home, `custom-${label.toLowerCase()}`); + const binDir = path.join(dataDir, ...relative); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + const nodePath = path.join(binDir, "node"); + fs.writeFileSync(nodePath, `#!/bin/sh\nif [ "\${1:-}" = "-e" ]; then exit 0; fi\nprintf '${label}_NODE:%s\\n' "$*"\n`, "utf8"); + fs.chmodSync(nodePath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { + ...cleanVersionManagerEnv(), + HOME: home.replaceAll("\\\\", "/"), + PATH: emptyBin.replaceAll("\\\\", "/"), + ASDF_DATA_DIR: "", + MISE_DATA_DIR: "", + [envKey]: dataDir.replaceAll("\\\\", "/"), + CODEX_COMPANION_NODE: "" + } + }); + assert.equal(result.status, 0, `${label}: ${result.stderr}`); + assert.match(result.stdout, new RegExp(`${label}_NODE:`)); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + } +}); + test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-configured-windows-")); const emptyBin = path.join(home, "empty-bin"); @@ -311,7 +350,7 @@ test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", fs.copyFileSync(process.execPath, nodePath); const probeName = `configured-node-probe-${process.pid}.mjs`; const probePath = path.join(path.dirname(LAUNCHER), probeName); - fs.writeFileSync(probePath, 'console.log("CONFIGURED_WINDOWS_NODE")\n', "utf8"); + fs.writeFileSync(probePath, 'console.log("CONFIGURED_WINDOWS_NODE"); console.log("PATH:" + (process.env.PATH ?? ""));\n', "utf8"); try { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), probeName], { encoding: "utf8", @@ -331,6 +370,7 @@ test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /CONFIGURED_WINDOWS_NODE/); + assert.match(result.stdout, /PATH:.*Portable Node/i); } finally { fs.rmSync(probePath, { force: true }); fs.rmSync(home, { recursive: true, force: true }); From 11007e405a86415407f208879324f5960868f2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:09:36 -0300 Subject: [PATCH 44/53] test: preserve launcher test encoding --- tests/node-launcher.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 798594072..bd5db201f 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; From e116d57b1d5fa61a3bfa02ab6aa9595335b84416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:38:11 -0300 Subject: [PATCH 45/53] fix: honor Linuxbrew prefix in launcher --- plugins/codex/scripts/run-node.sh | 10 ++++++++-- tests/node-launcher.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 9eea0f6fb..9b60d47c9 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -101,8 +101,11 @@ find_managed_node() { fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= asdf_dir=$(windows_path_to_posix "${ASDF_DATA_DIR:-$home/.asdf}") || asdf_dir= mise_dir=$(windows_path_to_posix "${MISE_DATA_DIR:-$home/.local/share/mise}") || mise_dir= + homebrew_prefix=$(windows_path_to_posix "${HOMEBREW_PREFIX:-}") || homebrew_prefix= + homebrew_node= + [ -n "$homebrew_prefix" ] && homebrew_node="$homebrew_prefix/bin/node" for candidate in \ - /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ + "$homebrew_node" /home/linuxbrew/.linuxbrew/bin/node /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ "$home/.volta/bin/node" \ "$nvm_dir"/versions/node/*/bin/node \ "$fnm_dir"/node-versions/*/installation/bin/node \ @@ -218,7 +221,10 @@ nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= asdf_dir=$(windows_path_to_posix "${ASDF_DATA_DIR:-$home/.asdf}") || asdf_dir= mise_dir=$(windows_path_to_posix "${MISE_DATA_DIR:-$home/.local/share/mise}") || mise_dir= -for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$fnm_dir"/node-versions/*/installation/bin/node "$asdf_dir"/installs/nodejs/*/bin/node "$mise_dir"/installs/node/*/bin/node; do +homebrew_prefix=$(windows_path_to_posix "${HOMEBREW_PREFIX:-}") || homebrew_prefix= +homebrew_node= +[ -n "$homebrew_prefix" ] && homebrew_node="$homebrew_prefix/bin/node" +for candidate in "$homebrew_node" /home/linuxbrew/.linuxbrew/bin/node /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$fnm_dir"/node-versions/*/installation/bin/node "$asdf_dir"/installs/nodejs/*/bin/node "$mise_dir"/installs/node/*/bin/node; do add_supported_node_dir_to_path "$candidate" done add_windows_node_dirs_to_path diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index bd5db201f..e51128241 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -15,6 +15,7 @@ const VERSION_MANAGER_ENV_KEYS = new Set([ "fnm_dir", "asdf_data_dir", "mise_data_dir", + "homebrew_prefix", "volta_home", "localappdata", "programfiles", @@ -340,6 +341,28 @@ test("portable launcher honors custom ASDF_DATA_DIR and MISE_DATA_DIR", () => { } }); +test("portable launcher honors HOMEBREW_PREFIX under a minimal PATH", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-homebrew-prefix-")); + const emptyBin = path.join(home, "empty-bin"); + const brewPrefix = path.join(home, "linuxbrew-prefix"); + const binDir = path.join(brewPrefix, "bin"); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + const nodePath = path.join(binDir, "node"); + fs.writeFileSync(nodePath, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "HOMEBREW_NODE:%s\\n" "$*"\n', "utf8"); + fs.chmodSync(nodePath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), HOMEBREW_PREFIX: brewPrefix.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /HOMEBREW_NODE:/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-configured-windows-")); const emptyBin = path.join(home, "empty-bin"); From 66dcf2d6c97e99b18becaddf3504e8b57112fd48 Mon Sep 17 00:00:00 2001 From: taur-us <93385619+taur-us@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:55:56 +1000 Subject: [PATCH 46/53] Support --effort on adversarial-review, and surface unrecognised options `/codex:adversarial-review --model gpt-6-astra --effort xhigh` silently does something other than what it says. `handleReviewCommand` parses valueOptions ["base","scope","model","cwd"], with no "effort" - only `handleTask` parses it. `parseArgs` then demotes any unrecognised long option to a positional, and `handleReviewCommand` builds the review's focus text with `positionals.join(" ")`. So the two tokens `--effort` and `xhigh` are concatenated into the prompt handed to the reviewer, the run uses whatever `model_reasoning_effort` the config holds, and nothing warns. The run looks like it did what was asked. That is two separate problems, fixed separately. 1. The review commands now accept `--effort`, normalised through the existing `normalizeReasoningEffort` and threaded into the `runAppServerTurn` call that the adversarial path already makes. `runAppServerTurn` already accepts an `effort` option, so this is plumbing rather than new capability. The usage line is updated to match, including the `--model` flag it already supported but did not advertise. 2. `parseArgs` now returns `unknownOptions` alongside `options` and `positionals`, and `parseCommandInput` warns on stderr for each one. Behaviour is deliberately unchanged - the token still reaches positionals, because commands such as adversarial-review take free-form focus text and a hard error would break a prose word that happens to start with two dashes. The point is only that the demotion stops being silent. This generalises past `--effort`: any future flag typo on any command currently ends up pasted into a prompt with no indication. Tests: five in tests/args.test.mjs, covering the unknown-option report, the clean case, the `--` passthrough boundary, `--effort` staying out of the focus text under the real review config, and quoted focus phrases. Mutation-tested by reverting the `unknownOptions.push` and confirming the first fails, then restoring it. Full suite on Windows goes from 79 passing / 12 failing to 84 passing / 12 failing. Those 12 fail identically on clean main - they are platform tests (Unix sockets, temp-backed state dirs) and are untouched by this change. --- plugins/codex/scripts/codex-companion.mjs | 21 ++++++-- plugins/codex/scripts/lib/args.mjs | 4 +- tests/args.test.mjs | 65 +++++++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 tests/args.test.mjs diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..f72b24967 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -78,7 +78,7 @@ function printUsage() { "Usage:", " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", - " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", + " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [focus text]", " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", @@ -139,13 +139,25 @@ function normalizeArgv(argv) { } function parseCommandInput(argv, config = {}) { - return parseArgs(normalizeArgv(argv), { + const parsed = parseArgs(normalizeArgv(argv), { ...config, aliasMap: { C: "cwd", ...(config.aliasMap ?? {}) } }); + + // An unrecognised long option is still treated as a positional, because some + // commands take free-form text. Say so on stderr rather than swallowing it: + // a mistyped or unsupported flag would otherwise be silently folded into a + // prompt, and the run would look like it did what was asked. + for (const token of parsed.unknownOptions ?? []) { + console.warn( + `Warning: unrecognised option ${token}; treating it as text. It will be passed through verbatim, not interpreted as a flag.` + ); + } + + return parsed; } function resolveCommandCwd(options = {}) { @@ -411,6 +423,7 @@ async function executeReviewRun(request) { const result = await runAppServerTurn(context.repoRoot, { prompt, model: request.model, + effort: request.effort, sandbox: "read-only", outputSchema: readOutputSchema(REVIEW_SCHEMA), onProgress: request.onProgress @@ -711,7 +724,7 @@ function enqueueBackgroundTask(cwd, job, request) { async function handleReviewCommand(argv, config) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["base", "scope", "model", "cwd"], + valueOptions: ["base", "scope", "model", "effort", "cwd"], booleanOptions: ["json", "background", "wait"], aliasMap: { m: "model" @@ -720,6 +733,7 @@ async function handleReviewCommand(argv, config) { const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); + const effort = normalizeReasoningEffort(options.effort); const focusText = positionals.join(" ").trim(); const target = resolveReviewTarget(cwd, { base: options.base, @@ -744,6 +758,7 @@ async function handleReviewCommand(argv, config) { base: options.base, scope: options.scope, model: options.model, + effort, focusText, reviewName: config.reviewName, onProgress: progress diff --git a/plugins/codex/scripts/lib/args.mjs b/plugins/codex/scripts/lib/args.mjs index 6b1518502..3454e65c3 100644 --- a/plugins/codex/scripts/lib/args.mjs +++ b/plugins/codex/scripts/lib/args.mjs @@ -4,6 +4,7 @@ export function parseArgs(argv, config = {}) { const aliasMap = config.aliasMap ?? {}; const options = {}; const positionals = []; + const unknownOptions = []; let passthrough = false; for (let index = 0; index < argv.length; index += 1) { @@ -45,6 +46,7 @@ export function parseArgs(argv, config = {}) { continue; } + unknownOptions.push(token); positionals.push(token); continue; } @@ -70,7 +72,7 @@ export function parseArgs(argv, config = {}) { positionals.push(token); } - return { options, positionals }; + return { options, positionals, unknownOptions }; } export function splitRawArgumentString(raw) { diff --git a/tests/args.test.mjs b/tests/args.test.mjs new file mode 100644 index 000000000..4f41b2924 --- /dev/null +++ b/tests/args.test.mjs @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseArgs, splitRawArgumentString } from "../plugins/codex/scripts/lib/args.mjs"; + +const REVIEW_CONFIG = { + valueOptions: ["base", "scope", "model", "effort", "cwd"], + booleanOptions: ["json", "background", "wait"], + aliasMap: { m: "model" } +}; + +test("parseArgs reports an unrecognised long option instead of silently demoting it", () => { + const { options, positionals, unknownOptions } = parseArgs( + ["--model", "gpt-6-astra", "--nonsense", "value"], + { valueOptions: ["model"], booleanOptions: [] } + ); + + assert.equal(options.model, "gpt-6-astra"); + // Behaviour is unchanged: the token still reaches positionals, because some + // commands take free-form text after their flags. + assert.deepEqual(positionals, ["--nonsense", "value"]); + // But it is now reported, so a caller can warn rather than swallow it. + assert.deepEqual(unknownOptions, ["--nonsense"]); +}); + +test("parseArgs reports nothing when every option is recognised", () => { + const { unknownOptions } = parseArgs(["--model", "gpt-6-astra", "--json"], { + valueOptions: ["model"], + booleanOptions: ["json"] + }); + + assert.deepEqual(unknownOptions, []); +}); + +test("parseArgs does not treat text after -- as an unrecognised option", () => { + const { positionals, unknownOptions } = parseArgs(["--", "--not-a-flag"], { + valueOptions: [], + booleanOptions: [] + }); + + assert.deepEqual(positionals, ["--not-a-flag"]); + assert.deepEqual(unknownOptions, []); +}); + +test("review commands accept --effort rather than folding it into the focus text", () => { + const { options, positionals, unknownOptions } = parseArgs( + ["--model", "gpt-6-astra", "--effort", "xhigh", "focus", "on", "auth"], + REVIEW_CONFIG + ); + + assert.equal(options.model, "gpt-6-astra"); + assert.equal(options.effort, "xhigh"); + assert.deepEqual(unknownOptions, []); + // The regression this guards: --effort and xhigh used to land here and be + // joined into the prompt the reviewer was given. + assert.deepEqual(positionals, ["focus", "on", "auth"]); + assert.equal(positionals.join(" "), "focus on auth"); +}); + +test("splitRawArgumentString keeps a quoted focus phrase together", () => { + assert.deepEqual( + splitRawArgumentString('--model gpt-6-astra --effort xhigh "the auth path"'), + ["--model", "gpt-6-astra", "--effort", "xhigh", "the auth path"] + ); +}); From 912e98dab063b57b3ac957acc2cdcf40aaa42329 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:07:45 +0100 Subject: [PATCH 47/53] fix: make runCommand maxBuffer explicit --- plugins/codex/scripts/lib/process.mjs | 5 ++++- tests/process.test.mjs | 24 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index dd8fc3751..78bb9a20e 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -1,13 +1,16 @@ import { spawnSync } from "node:child_process"; import process from "node:process"; +// Git metadata can exceed Node's 1 MiB spawnSync default. Keep a generous explicit bound. +const DEFAULT_MAX_BUFFER = 256 * 1024 * 1024; + export function runCommand(command, args = [], options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, env: options.env, encoding: "utf8", input: options.input, - maxBuffer: options.maxBuffer, + maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER, stdio: options.stdio ?? "pipe", shell: options.shell ?? (process.platform === "win32" ? (process.env.SHELL || true) : false), windowsHide: true diff --git a/tests/process.test.mjs b/tests/process.test.mjs index 80e0715b0..003a51bd5 100644 --- a/tests/process.test.mjs +++ b/tests/process.test.mjs @@ -1,7 +1,29 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs"; +import { runCommand, terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs"; + +test("runCommand allows output larger than Node's default maxBuffer", () => { + const outputBytes = 2 * 1024 * 1024; + const result = runCommand(process.execPath, [ + "-e", + `process.stdout.write("x".repeat(${outputBytes}))` + ]); + + assert.equal(result.error, null); + assert.equal(result.status, 0); + assert.equal(Buffer.byteLength(result.stdout), outputBytes); +}); + +test("runCommand preserves an explicit maxBuffer override", () => { + const result = runCommand( + process.execPath, + ["-e", `process.stdout.write("x".repeat(${2 * 1024}))`], + { maxBuffer: 1024 } + ); + + assert.equal(result.error?.code, "ENOBUFS"); +}); test("terminateProcessTree uses taskkill on Windows", () => { let captured = null; From 4ab1fa24bd8c0487920838ba22344498e1276189 Mon Sep 17 00:00:00 2001 From: MeGaNeKo Date: Mon, 7 Sep 2026 11:14:38 +0900 Subject: [PATCH 48/53] fix: keep only the latest value per key in CLAUDE_ENV_FILE (SessionStart) --- .../codex/scripts/session-lifecycle-hook.mjs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/plugins/codex/scripts/session-lifecycle-hook.mjs b/plugins/codex/scripts/session-lifecycle-hook.mjs index 778571e6c..03d5cbcc5 100644 --- a/plugins/codex/scripts/session-lifecycle-hook.mjs +++ b/plugins/codex/scripts/session-lifecycle-hook.mjs @@ -32,11 +32,27 @@ function shellEscape(value) { return `'${String(value).replace(/'/g, `'\"'\"'`)}'`; } -function appendEnvVar(name, value) { - if (!process.env.CLAUDE_ENV_FILE || value == null || value === "") { +function setEnv(name, value) { + const envFile = process.env.CLAUDE_ENV_FILE; + if (!envFile || value == null || value === "") { return; } - fs.appendFileSync(process.env.CLAUDE_ENV_FILE, `export ${name}=${shellEscape(value)}\n`, "utf8"); + const prefix = `export ${name}=`; + const line = `${prefix}${shellEscape(value)}`; + + let content = ""; + try { + content = fs.readFileSync(envFile, "utf8"); + } catch (err) { + if (err.code !== "ENOENT") throw err; + } + + const lines = content.split(/\r?\n/).filter((l) => l && !l.startsWith(prefix)); + lines.push(line); + + const tmp = `${envFile}.${process.pid}.tmp`; + fs.writeFileSync(tmp, lines.join("\n") + "\n", "utf8"); + fs.renameSync(tmp, envFile); } function cleanupSessionJobs(cwd, sessionId) { @@ -75,9 +91,9 @@ function cleanupSessionJobs(cwd, sessionId) { } function handleSessionStart(input) { - appendEnvVar(SESSION_ID_ENV, input.session_id); - appendEnvVar(TRANSCRIPT_PATH_ENV, input.transcript_path); - appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]); + setEnv(SESSION_ID_ENV, input.session_id); + setEnv(TRANSCRIPT_PATH_ENV, input.transcript_path); + setEnv(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]); } async function handleSessionEnd(input) { From 362975882fbc0e593afb21a4ace228241dbbafe2 Mon Sep 17 00:00:00 2001 From: Soumya95 Date: Mon, 14 Sep 2026 13:01:24 +0530 Subject: [PATCH 49/53] fix: persist errorMessage on non-throwing turn failure and shorten summary (#757) Two gaps combined to make server-side turn failures illegible in status: 1. errorMessage was only written in the catch path, so a turn that failed with exitStatus != 0 but completed normally stored no reason anywhere reachable from 'status'. 2. The summary took the first line of the pretty-printed error body, which is a bare opening brace. Now: - tracked-jobs.mjs reads execution.errorMessage on the success branch and writes it to both the job file and the index when failed. - codex-companion.mjs task/review paths surface result.error.message as errorMessage and prefer it over rawOutput for the summary. - Summary and Codex error: progress line are passed through shorten() so multi-line JSON bodies cannot reduce to punctuation. - Regressions covered by two new state.test.mjs tests. --- plugins/codex/scripts/codex-companion.mjs | 14 +++- plugins/codex/scripts/lib/codex.mjs | 2 +- plugins/codex/scripts/lib/tracked-jobs.mjs | 4 + tests/state.test.mjs | 89 ++++++++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..408263dfb 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -399,7 +399,8 @@ async function executeReviewRun(request) { turnId: result.turnId, payload, rendered, - summary: firstMeaningfulLine(result.reviewText, `${reviewName} completed.`), + summary: shorten(firstMeaningfulLine(result.reviewText, `${reviewName} completed.`), 96), + errorMessage: result.error?.message ?? result.stderr ?? null, jobTitle: `Codex ${reviewName}`, jobClass: "review", targetLabel: target.label @@ -419,6 +420,7 @@ async function executeReviewRun(request) { status: result.status, failureMessage: result.error?.message ?? result.stderr }); + const failureMessage = result.error?.message ?? result.stderr ?? parsed.parseError ?? ""; const payload = { review: reviewName, target, @@ -450,7 +452,8 @@ async function executeReviewRun(request) { targetLabel: context.target.label, reasoningSummary: result.reasoningSummary }), - summary: parsed.parsed?.summary ?? parsed.parseError ?? firstMeaningfulLine(result.finalMessage, `${reviewName} finished.`), + summary: parsed.parsed?.summary ?? shorten(firstMeaningfulLine(failureMessage || result.finalMessage, `${reviewName} finished.`), 96), + errorMessage: failureMessage || null, jobTitle: `Codex ${reviewName}`, jobClass: "review", targetLabel: context.target.label @@ -515,6 +518,10 @@ async function executeTaskRun(request) { touchedFiles: result.touchedFiles, reasoningSummary: result.reasoningSummary }; + const summary = shorten( + firstMeaningfulLine(failureMessage || rawOutput, `${taskMetadata.title} finished.`), + 96 + ); return { exitStatus: result.status, @@ -522,7 +529,8 @@ async function executeTaskRun(request) { turnId: result.turnId, payload, rendered, - summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)), + summary, + errorMessage: failureMessage || null, jobTitle: taskMetadata.title, jobClass: "task", write: Boolean(request.write) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..d1c9e0b38 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -536,7 +536,7 @@ function applyTurnNotification(state, message) { break; case "error": state.error = message.params.error; - emitProgress(state.onProgress, `Codex error: ${message.params.error.message}`, "failed"); + emitProgress(state.onProgress, `Codex error: ${shorten(message.params.error.message, 96)}`, "failed"); break; case "turn/completed": if ((message.params.threadId ?? null) !== state.threadId) { diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 902869012..48efb6770 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/codex/scripts/lib/tracked-jobs.mjs @@ -154,6 +154,8 @@ export async function runTrackedJob(job, runner, options = {}) { try { const execution = await runner(); const completionStatus = execution.exitStatus === 0 ? "completed" : "failed"; + const errorMessage = + completionStatus === "failed" ? (execution.errorMessage ?? null) : null; const completedAt = nowIso(); writeJobFile(job.workspaceRoot, job.id, { ...runningRecord, @@ -163,6 +165,7 @@ export async function runTrackedJob(job, runner, options = {}) { pid: null, phase: completionStatus === "completed" ? "done" : "failed", completedAt, + errorMessage, result: execution.payload, rendered: execution.rendered }); @@ -174,6 +177,7 @@ export async function runTrackedJob(job, runner, options = {}) { summary: execution.summary, phase: completionStatus === "completed" ? "done" : "failed", pid: null, + errorMessage, completedAt }); appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 0f8f57cea..567d6c1b1 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import { makeTempDir } from "./helpers.mjs"; import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs"; +import { runTrackedJob } from "../plugins/codex/scripts/lib/tracked-jobs.mjs"; test("resolveStateDir uses a temp-backed per-workspace directory", () => { const workspace = makeTempDir(); @@ -103,3 +104,91 @@ test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", .sort() ); }); + +test("runTrackedJob persists errorMessage on a non-throwing failed execution", async () => { + const workspace = makeTempDir(); + const job = { + id: "task-fail-nonthrowing", + kind: "task", + kindLabel: "Task", + title: "Codex Task", + workspaceRoot: workspace, + jobClass: "task", + summary: "", + write: false, + createdAt: new Date().toISOString() + }; + + await runTrackedJob( + job, + async () => ({ + exitStatus: 400, + threadId: "thread-1", + turnId: "turn-1", + payload: { status: 400, rawOutput: "{\n \"type\": \"error\"\n}", touchedFiles: [] }, + rendered: "rendered output\n", + summary: "Unsupported value: 'x' is not supported.", + errorMessage: "Unsupported value: 'x' is not supported with the 'gpt-5.6-terra' model." + }), + {} + ); + + const storedJob = JSON.parse( + fs.readFileSync(resolveJobFile(workspace, job.id), "utf8") + ); + const state = JSON.parse(fs.readFileSync(resolveStateFile(workspace), "utf8")); + const indexed = state.jobs.find((entry) => entry.id === job.id); + + assert.equal(storedJob.status, "failed"); + assert.equal(storedJob.phase, "failed"); + assert.equal( + storedJob.errorMessage, + "Unsupported value: 'x' is not supported with the 'gpt-5.6-terra' model." + ); + assert.equal(indexed.status, "failed"); + assert.equal( + indexed.errorMessage, + "Unsupported value: 'x' is not supported with the 'gpt-5.6-terra' model." + ); + assert.equal(indexed.summary, "Unsupported value: 'x' is not supported."); +}); + +test("runTrackedJob stores no errorMessage for a completed execution", async () => { + const workspace = makeTempDir(); + const job = { + id: "task-ok", + kind: "task", + kindLabel: "Task", + title: "Codex Task", + workspaceRoot: workspace, + jobClass: "task", + summary: "", + write: false, + createdAt: new Date().toISOString() + }; + + await runTrackedJob( + job, + async () => ({ + exitStatus: 0, + threadId: "thread-2", + turnId: "turn-2", + payload: { status: 0, rawOutput: "OK", touchedFiles: [] }, + rendered: "OK\n", + summary: "OK", + errorMessage: null + }), + {} + ); + + const storedJob = JSON.parse( + fs.readFileSync(resolveJobFile(workspace, job.id), "utf8") + ); + const state = JSON.parse(fs.readFileSync(resolveStateFile(workspace), "utf8")); + const indexed = state.jobs.find((entry) => entry.id === job.id); + + assert.equal(storedJob.status, "completed"); + assert.equal(storedJob.errorMessage, null); + assert.equal(indexed.status, "completed"); + assert.equal(indexed.errorMessage, null); +}); From e3276ee985f4e4968b53a66628893b71e3d089a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 11:22:44 +0000 Subject: [PATCH 50/53] docs: bring the README up to date with the imported pull requests The landing page still described upstream's command surface, so several flags this fork supports were undocumented. - A "Commands At A Glance" table lists every slash command with its real flags, plus the accepted --effort values and the new warning for an unrecognised option. - The install snippet points at this fork's marketplace, and says how to get upstream instead (the marketplace name is the same, so only one of the two can be added at a time). - /codex:review and /codex:adversarial-review document --model/--effort (#746); /codex:rescue documents --resume-thread (#727) and the resume flags' exclusivity; /codex:transfer documents CLAUDE_CONFIG_DIR (#729); the review gate section explains the stop_hook_active skip (#565). - A "Differences From Upstream" section lists every imported PR with a link, grouped into broker lifecycle and command surface, and states that the plugin version stays at the upstream number. Both review commands' argument-hint now name --model/--effort, which the script already accepted. tests/commands.test.mjs asserted the adversarial hint as one adjacent string ("[--scope ...] [focus ...]"); it now asserts the scope, effort and focus fragments separately, so inserting a flag between them no longer fails a documentation test. Verified: full npm test 230/232, the two failures being the pre-existing tests/broker-lifecycle.test.mjs ones. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg --- README.md | 71 ++++++++++++++++++-- plugins/codex/commands/adversarial-review.md | 2 +- plugins/codex/commands/review.md | 2 +- tests/commands.test.mjs | 4 +- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b17aab077..c93eb4371 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,12 @@ Use Codex from inside Claude Code for code reviews or to delegate tasks to Codex This plugin is for Claude Code users who want an easy way to start using Codex from the workflow they already have. +> [!NOTE] +> This repository is a fork of [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin-cc). +> It tracks upstream `main` and carries a set of community pull requests that are still open +> upstream — mostly broker/background-job lifecycle fixes plus a few extra flags. See +> [Differences From Upstream](#differences-from-upstream) for the full list. + ## What You Get @@ -12,6 +18,24 @@ they already have. - `/codex:review` for a normal read-only Codex review - `/codex:adversarial-review` for a steerable challenge review - `/codex:rescue`, `/codex:transfer`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work, hand off sessions, and manage background jobs +- `/codex:setup` to check that Codex is installed and signed in, and to toggle the optional stop-time review gate + +### Commands At A Glance + +| Command | What it does | Main flags | +| --- | --- | --- | +| [`/codex:review`](#codexreview) | read-only Codex review of your current work | `--wait`, `--background`, `--base `, `--scope `, `--model `, `--effort ` | +| [`/codex:adversarial-review`](#codexadversarial-review) | steerable review that challenges the approach | same as `/codex:review`, plus free-form focus text | +| [`/codex:rescue`](#codexrescue) | delegate investigation or a fix to Codex | `--background`, `--wait`, `--resume`, `--resume-thread `, `--fresh`, `--model`, `--effort`, `--write`, `--sandbox `, `--read-root ` | +| [`/codex:transfer`](#codextransfer) | turn this Claude session into a resumable Codex thread | `--source ` | +| [`/codex:status`](#codexstatus) | show active and recent Codex jobs | `[job-id]`, `--wait`, `--timeout-ms `, `--all` | +| [`/codex:result`](#codexresult) | show the stored output of a finished job | `[job-id]` | +| [`/codex:cancel`](#codexcancel) | cancel an active background job | `[job-id]` | +| [`/codex:setup`](#codexsetup) | check the Codex install, manage the review gate | `--enable-review-gate`, `--disable-review-gate` | + +Accepted `--effort` values are `none`, `minimal`, `low`, `medium`, `high`, and `xhigh`. An +unrecognised `--flag` is not silently swallowed into the prompt: the plugin warns on stderr and +passes the token through as text. ## Requirements @@ -24,9 +48,12 @@ they already have. Add the marketplace in Claude Code: ```bash -/plugin marketplace add openai/codex-plugin-cc +/plugin marketplace add Edo771977/codex-plugin-cc ``` +(Use `openai/codex-plugin-cc` instead if you want upstream without the imported fixes. The +marketplace name is `openai-codex` either way, so only one of the two can be added at a time.) + Install the plugin: ```bash @@ -86,7 +113,7 @@ Use it when you want: - a review of your current uncommitted changes - a review of your branch compared to a base branch like `main` -Use `--base ` for branch review. It also supports `--wait` and `--background`. It is not steerable and does not take custom focus text. Use [`/codex:adversarial-review`](#codexadversarial-review) when you want to challenge a specific decision or risk area. +Use `--base ` for branch review. It also supports `--wait`, `--background`, `--scope `, and — like `/codex:rescue` — `--model ` and `--effort ` to pick the reviewing model and how hard it thinks. It is not steerable and does not take custom focus text. Use [`/codex:adversarial-review`](#codexadversarial-review) when you want to challenge a specific decision or risk area. Examples: @@ -94,6 +121,7 @@ Examples: /codex:review /codex:review --base main /codex:review --background +/codex:review --base main --model gpt-5.4-mini --effort high ``` This command is read-only and will not perform any changes. When run in the background you can use [`/codex:status`](#codexstatus) to check on the progress and [`/codex:cancel`](#codexcancel) to cancel the ongoing task. @@ -105,7 +133,7 @@ Runs a **steerable** review that questions the chosen implementation and design. It can be used to pressure-test assumptions, tradeoffs, failure modes, and whether a different approach would have been safer or simpler. It uses the same review target selection as `/codex:review`, including `--base ` for branch review. -It also supports `--wait` and `--background`. Unlike `/codex:review`, it can take extra focus text after the flags. +It also supports `--wait`, `--background`, `--model `, and `--effort `. Unlike `/codex:review`, it can take extra focus text after the flags. Use it when you want: @@ -137,7 +165,7 @@ Use it when you want Codex to: > [!NOTE] > Depending on the task and the model you choose these tasks might take a long time and it's generally recommended to force the task to be in the background or move the agent to the background. -It supports `--background`, `--wait`, `--resume`, `--fresh`, and `--sandbox `. If you omit `--resume` and `--fresh`, the plugin can offer to continue the latest rescue thread for this repo. +It supports `--background`, `--wait`, `--resume`, `--resume-thread `, `--fresh`, `--model `, `--effort `, `--write`, `--sandbox `, and repeatable `--read-root `. If you omit the resume flags, the plugin can offer to continue the latest rescue thread for this repo. Examples: @@ -145,6 +173,7 @@ Examples: /codex:rescue investigate why the tests started failing /codex:rescue fix the failing test with the smallest safe patch /codex:rescue --resume apply the top fix from the last run +/codex:rescue --resume-thread thr_0199... keep working on that specific thread /codex:rescue --model gpt-5.4-mini --effort medium investigate the flaky integration test /codex:rescue --model spark fix the issue quickly /codex:rescue --background investigate the regression @@ -164,6 +193,7 @@ Ask Codex to redesign the database connection to be more resilient. - if you do not pass `--model` or `--effort`, Codex chooses its own defaults. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo +- `--resume`/`--resume-last` continues the newest thread for this repository; `--resume-thread ` continues one specific thread (the id is printed by `/codex:status` and `/codex:result`). `--resume`, `--resume-thread`, and `--fresh` are mutually exclusive. - `--sandbox` applies to `/codex:rescue` only; the review commands stay read-only. It takes precedence over `--write` and counts only before the task text. Rescue runs edit files inside the repository by default (`workspace-write`); `read-only` blocks edits, and `danger-full-access` disables the Codex sandbox entirely, so Codex can write outside the repository and use the network without asking. Reserve it for tasks the sandbox blocks. - a resumed thread keeps the sandbox it was started with while the plugin's shared app-server still holds it, which is the normal case inside one Claude Code session (Codex CLI 0.153.2 applies a new mode only when it loads the thread again from disk). `task` refuses a resume whose sandbox differs from what the app-server reports; resume with the same `--sandbox`, or start a new thread with `--fresh`. - each `--read-root ` must name an existing directory and opts into an OS-enforced permission profile that denies local command reads outside the listed directories and Codex's minimal runtime paths @@ -184,7 +214,7 @@ Examples: /codex:transfer --source ~/.claude/projects/-Users-me-repo/.jsonl ``` -The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must be under `~/.claude/projects`, and older Codex versions that do not expose session import must be upgraded before using this command. +The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must live under a Claude projects root — `~/.claude/projects`, or `$CLAUDE_CONFIG_DIR/projects` when you have relocated your Claude config — and older Codex versions that do not expose session import must be upgraded before using this command. ### `/codex:status` @@ -242,6 +272,8 @@ You can also use `/codex:setup` to manage the optional review gate. When the review gate is enabled, the plugin uses a `Stop` hook to run a targeted Codex review based on Claude's response. If that review finds issues, the stop is blocked so Claude can address them first. +On the continuation turn that follows a block, the gate does not review again: Claude Code re-invokes the hook with `stop_hook_active`, and re-running the review there would just block again until the harness's retry cap ends the turn. The skip is reported as a system message, so run `/codex:review --wait` yourself when you want the fixes verified. + > [!WARNING] > The review gate can create a long-running Claude/Codex loop and may drain usage limits quickly. Only enable it when you plan to actively monitor the session. @@ -318,6 +350,35 @@ Delegated tasks and any [stop gate](#what-does-the-review-gate-do) run can also This way you can review the Codex work or continue the work there. +## Differences From Upstream + +This fork is [`openai/codex-plugin-cc`](https://github.com/openai/codex-plugin-cc) `main` plus a +set of community pull requests that are still open upstream. Each one is a separate merge commit, +so any of them can be reverted on its own. + +Broker and background-job lifecycle: + +| Upstream PR | What it fixes | +| --- | --- | +| [#541](https://github.com/openai/codex-plugin-cc/pull/541) | broker leaks, state races, and signal-masked command failures in the test runtime | +| [#623](https://github.com/openai/codex-plugin-cc/pull/623) | session end no longer tears down the shared broker while another session's jobs are still using it | +| [#652](https://github.com/openai/codex-plugin-cc/pull/652) | bounds the lifetime of detached brokers and task workers (see [Background Runtime Limits](#background-runtime-limits)) | + +Commands and flags: + +| Upstream PR | What it adds | +| --- | --- | +| [#565](https://github.com/openai/codex-plugin-cc/pull/565) | the stop-review gate honors `stop_hook_active` instead of re-blocking a continuation turn | +| [#724](https://github.com/openai/codex-plugin-cc/pull/724) | `--read-root ` for OS-enforced scoped reads | +| [#727](https://github.com/openai/codex-plugin-cc/pull/727) | `--resume-thread ` to continue one specific Codex thread | +| [#729](https://github.com/openai/codex-plugin-cc/pull/729) | `/codex:transfer` works with a relocated `CLAUDE_CONFIG_DIR` | +| [#742](https://github.com/openai/codex-plugin-cc/pull/742) | `--sandbox ` on `task` and `/codex:rescue` | +| [#746](https://github.com/openai/codex-plugin-cc/pull/746) | `--model`/`--effort` on the review commands, and a warning for unrecognised options | +| [#748](https://github.com/openai/codex-plugin-cc/pull/748) | `CLAUDE_ENV_FILE` keeps one export per key instead of growing on every session | + +Where two of these PRs disagreed, the merge commit says which side won and why. The plugin version +is deliberately left at the upstream number: these merges do not cut a release. + ## FAQ ### Do I need a separate Codex account for this plugin? diff --git a/plugins/codex/commands/adversarial-review.md b/plugins/codex/commands/adversarial-review.md index da440ab4d..262e64c1e 100644 --- a/plugins/codex/commands/adversarial-review.md +++ b/plugins/codex/commands/adversarial-review.md @@ -1,6 +1,6 @@ --- description: Run a Codex review that challenges the implementation approach and design choices -argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [focus ...]' +argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model ] [--effort ] [focus ...]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion --- diff --git a/plugins/codex/commands/review.md b/plugins/codex/commands/review.md index fb70a4876..d53c406c7 100644 --- a/plugins/codex/commands/review.md +++ b/plugins/codex/commands/review.md @@ -1,6 +1,6 @@ --- description: Run a Codex code review against local git state -argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch]' +argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model ] [--effort ]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion --- diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 27f6ee5af..0cd3f5875 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -49,7 +49,9 @@ test("adversarial review command uses AskUserQuestion and background Bash while assert.match(source, /```bash/); assert.match(source, /```typescript/); assert.match(source, /adversarial-review "\$ARGUMENTS"/); - assert.match(source, /\[--scope auto\|working-tree\|branch\] \[focus \.\.\.\]/); + assert.match(source, /\[--scope auto\|working-tree\|branch\]/); + assert.match(source, /\[--effort \]/); + assert.match(source, /\[focus \.\.\.\]/); assert.match(source, /run_in_background:\s*true/); assert.match(source, /command:\s*`node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/codex-companion\.mjs" adversarial-review "\$ARGUMENTS"`/); assert.match(source, /description:\s*"Codex adversarial review"/); From dd70d871e00d98e45a58e1e15080b44e44ff79aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 11:47:43 +0000 Subject: [PATCH 51/53] fix: a busy broker refusing shutdown is not an identity rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shutdownBrokerSessionLocked() treated every error reply to broker/shutdown as an identity mismatch, including the broker's BUSY refusal. The refusal is the normal answer when a client connects between the session-end guard check and the shutdown request, so SessionEnd exited 1 with "broker rejected shutdown identity" for a broker that had in fact recognized it and was simply still in use. It now returns { refused: true, exited: false } and leaves the process and the persisted record alone, which is what the hook's own pre-check already does with a refusal; a later session end, or the broker's idle shutdown, retires it. Every outcome carries `refused` so callers can discriminate without inspecting the error message. The two tests that had been failing since the earlier imports are both consequences of this area, and neither was a stale-assertion-only fix: - "shutdown closes idle half-open broker clients" (from openai/codex-plugin-cc#541) predates the busy check that openai/codex-plugin-cc#623 later added, which refuses shutdown while ANY other client is connected — deliberately, because a worker between requests has no in-flight request to detect. The test's own subject is that shutdown destroys a lingering half-open socket instead of waiting on it, so it now parks that socket as a peer shutdown requester, which the broker itself defines as "not work". Same coverage, current contract. It also awaits "end" rather than "close": an allowHalfOpen client never closes itself, so the old expectation could not have been met either way. - "shutdown request always uses a finite deadline" expected null from sendBrokerShutdown(), which has returned an outcome object since the lifecycle merges. It now asserts the shape that matters: a broker that accepts the connection and then says nothing is not delivered, not refused, and deliberately NOT unreachable, so teardown cannot reap a broker that may still be serving someone. Added "a busy broker refuses shutdown instead of failing it", which covers the fix directly: an ordinary connected client keeps the broker alive and the record intact without raising, and the same call retires it once that client goes away. Verified: full npm test twice, 233/233 both times (was 230/232). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg --- plugins/codex/CHANGELOG.md | 1 + .../codex/scripts/lib/broker-lifecycle.mjs | 17 +++- tests/broker-lifecycle.test.mjs | 92 +++++++++++++++++-- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index db7cc5585..de59abd18 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -8,6 +8,7 @@ - Use the built-in `:workspace` profile for scoped write tasks and require the approved read roots to cover the workspace. - Deny inherited system temp roots in scoped profiles so only approved directories and minimal runtime paths remain readable. - Reject `--read-root` together with `--sandbox danger-full-access`, which disables the sandbox entirely. +- Treat a busy broker's shutdown refusal as a refusal rather than an identity rejection, so SessionEnd leaves the shared runtime to the sessions still using it instead of exiting with an error. ## 1.0.0 diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index c75c5473f..2fc643df3 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -442,7 +442,7 @@ export async function shutdownBrokerSession(cwd, options = {}) { async function shutdownBrokerSessionLocked(cwd, options = {}) { const session = loadBrokerSession(cwd); if (!session) { - return { found: false, exited: true, forced: false, reclaimedStaleEndpoint: false }; + return { found: false, exited: true, forced: false, refused: false, reclaimedStaleEndpoint: false }; } const pid = resolveBrokerPid(session); @@ -469,7 +469,7 @@ async function shutdownBrokerSessionLocked(cwd, options = {}) { if (legacySession) { if (canDiscardUnownedSession(session, pid, livenessOptions)) { teardownAndClear(cwd, session, false); - return { found: true, exited: true, forced: false, reclaimedStaleEndpoint: false }; + return { found: true, exited: true, forced: false, refused: false, reclaimedStaleEndpoint: false }; } legacyProcessVerified = processMatchesLegacyBroker(session, pid, options); const legacyEndpointIsSafelyStale = @@ -486,6 +486,17 @@ async function shutdownBrokerSessionLocked(cwd, options = {}) { instanceToken: session.instanceToken }); } + // A busy refusal is not an identity rejection: the broker recognized us and + // declined because another connection is still admitted. That is the normal + // outcome when a client connects between the session-end guard check and + // this request, so it must not raise -- the hook's own pre-check treats a + // refusal as "leave the runtime for the survivors", and a throw here made + // SessionEnd exit(1) blaming the instance token instead. Leave the process + // and the persisted record alone; a later session end (or the broker's own + // idle shutdown) retires it. + if (shutdownResponse?.refused) { + return { found: true, exited: false, forced: false, refused: true, reclaimedStaleEndpoint: false }; + } if (shutdownResponse?.error) { throw new Error( `Codex app-server broker rejected shutdown identity; persisted state was preserved: ${ @@ -583,7 +594,7 @@ async function shutdownBrokerSessionLocked(cwd, options = {}) { const endpointIsOurs = endpointProven || reclaimedStaleEndpoint; teardownAndClear(cwd, session, endpointIsOurs); - return { found: true, exited: true, forced, reclaimedStaleEndpoint }; + return { found: true, exited: true, forced, refused: false, reclaimedStaleEndpoint }; } export async function ensureBrokerSession(cwd, options = {}) { diff --git a/tests/broker-lifecycle.test.mjs b/tests/broker-lifecycle.test.mjs index f2a047e55..4b8a09dbe 100644 --- a/tests/broker-lifecycle.test.mjs +++ b/tests/broker-lifecycle.test.mjs @@ -121,21 +121,38 @@ test("broker rejects a shutdown token that does not identify its instance", asyn assert.ok(loadBrokerSession(workspace)); }); -test("shutdown closes idle half-open broker clients", { skip: process.platform === "win32" }, async (t) => { +test("shutdown closes a lingering half-open peer requester", { skip: process.platform === "win32" }, async (t) => { const workspace = makeTempDir(); const binDir = makeTempDir(); installFakeCodex(binDir); const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir) }); - const idleClient = net.createConnection({ + + // A racing session-end hook that has already sent its own broker/shutdown is + // a peer requester, not work, so the busy check must skip it. It keeps its + // read side open (allowHalfOpen), which means end() alone would leave + // server.close() pending on it: only an explicit destroy lets the broker go. + const peer = net.createConnection({ path: session.endpoint.slice("unix:".length), allowHalfOpen: true }); - idleClient.on("end", () => {}); + peer.setEncoding("utf8"); + t.after(() => peer.destroy()); await new Promise((resolve, reject) => { - idleClient.once("connect", resolve); - idleClient.once("error", reject); + peer.once("connect", resolve); + peer.once("error", reject); + }); + const refusal = new Promise((resolve) => peer.once("data", resolve)); + peer.write( + `${JSON.stringify({ id: 1, method: "broker/shutdown", params: { instanceToken: "not-this-instance" } })}\n` + ); + assert.match(await refusal, /identity did not match/i); + // allowHalfOpen means the client never closes itself: it only learns the + // broker let go when the read side ends (or the socket dies outright). + const peerReleased = new Promise((resolve) => { + peer.once("end", resolve); + peer.once("close", resolve); + peer.once("error", resolve); }); - t.after(() => idleClient.destroy()); const outcome = await shutdownBrokerSession(workspace, { timeoutMs: 500, @@ -146,6 +163,50 @@ test("shutdown closes idle half-open broker clients", { skip: process.platform = assert.equal(outcome.exited, true); assert.equal(outcome.forced, false); assert.equal(loadBrokerSession(workspace), null); + // Released by the broker rather than waited on: this resolving at all is the + // assertion — a shutdown that waited on a half-open peer would hang here. + await peerReleased; +}); + +test("a busy broker refuses shutdown instead of failing it", { skip: process.platform === "win32" }, async (t) => { + const workspace = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + const session = await ensureBrokerSession(workspace, { env: buildEnv(binDir) }); + + // An ordinary connected client — a worker that has not sent its first + // request yet — is exactly what the busy check protects: it must keep the + // broker alive, without turning session end into an error. + const client = net.createConnection({ path: session.endpoint.slice("unix:".length) }); + t.after(() => client.destroy()); + await new Promise((resolve, reject) => { + client.once("connect", resolve); + client.once("error", reject); + }); + + const refused = await shutdownBrokerSession(workspace, { + timeoutMs: 500, + intervalMs: 10, + killProcess: terminateProcessTree + }); + + assert.equal(refused.refused, true); + assert.equal(refused.exited, false); + assert.equal(refused.forced, false); + assert.ok(loadBrokerSession(workspace), "a refused shutdown must preserve the persisted record"); + assert.equal(isProcessTreeRunning(session.pid), true); + + // Once the client is gone the very same call retires the broker. + client.destroy(); + await new Promise((resolve) => client.once("close", resolve)); + const retired = await shutdownBrokerSession(workspace, { + timeoutMs: 2000, + intervalMs: 10, + killProcess: terminateProcessTree + }); + assert.equal(retired.refused, false); + assert.equal(retired.exited, true); + assert.equal(loadBrokerSession(workspace), null); }); test("shutdown request always uses a finite deadline", { skip: process.platform === "win32" }, async (t) => { @@ -173,7 +234,16 @@ test("shutdown request always uses a finite deadline", { skip: process.platform instanceToken: "instance-token-1234567890", timeoutMs }); - assert.equal(response, null); + // A broker that accepts the connection and then says nothing is ambiguous: + // it may be alive and busy with its refusal lost on the wire. The outcome + // has to report that — not delivered, not refused, and deliberately not + // unreachable — so teardown never reaps a broker that may still be serving + // someone. + assert.equal(response.delivered, false); + assert.equal(response.refused, false); + assert.equal(response.unreachable, false); + assert.equal(response.result, null); + assert.equal(response.error, null); assert.ok(Date.now() - startedAt < 500, "shutdown request exceeded its deadline"); } assert.equal(fs.existsSync(socketPath), true); @@ -1021,7 +1091,13 @@ test("shutdown reclaims a tokened session whose dead leader left an orphaned gro const outcome = await shutdownBrokerSession(workspace, { timeoutMs: 40, killProcess }); assert.equal(killProcessCalled, false, "an abandoned orphaned tree must never be signaled"); - assert.deepEqual(outcome, { found: true, exited: true, forced: false, reclaimedStaleEndpoint: true }); + assert.deepEqual(outcome, { + found: true, + exited: true, + forced: false, + refused: false, + reclaimedStaleEndpoint: true + }); assert.equal(isProcessRunning(grandchildPid), true, "the grandchild must be left running untouched"); assert.equal(fs.existsSync(socketPath), false, "the stale socket must be removed"); assert.equal(fs.existsSync(pidFile), false, "the pid file must be removed"); From 4783ed105340cd0676f356d12563f2c0590b6837 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 12:04:57 +0000 Subject: [PATCH 52/53] docs: record the tier-B imports in the README and CHANGELOG Adds #731, #737, #747 and #763 to "Differences From Upstream", and notes in Requirements that Node no longer has to be on the system PATH now that the hooks go through scripts/run-node.sh (including CODEX_COMPANION_NODE for pinning one). Verified: full npm test 254/254. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg --- README.md | 7 ++++++- plugins/codex/CHANGELOG.md | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c93eb4371..710040311 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ passes the token through as text. - **ChatGPT subscription (incl. Free) or OpenAI API key.** - Usage will contribute to your Codex usage limits. [Learn more](https://developers.openai.com/codex/pricing). -- **Node.js 18.18 or later** +- **Node.js 18.18 or later.** + - It does not have to be on the system PATH: the hooks resolve Node through `scripts/run-node.sh`, which also looks in nvm, fnm, asdf, mise, Volta and Homebrew toolchains, preferring one that ships `codex` alongside it. Set `CODEX_COMPANION_NODE` to an executable path to pin a specific one. ## Install @@ -375,6 +376,10 @@ Commands and flags: | [#742](https://github.com/openai/codex-plugin-cc/pull/742) | `--sandbox ` on `task` and `/codex:rescue` | | [#746](https://github.com/openai/codex-plugin-cc/pull/746) | `--model`/`--effort` on the review commands, and a warning for unrecognised options | | [#748](https://github.com/openai/codex-plugin-cc/pull/748) | `CLAUDE_ENV_FILE` keeps one export per key instead of growing on every session | +| [#731](https://github.com/openai/codex-plugin-cc/pull/731) | the review-gate flag is persisted outside the transient state dir, so a different `CLAUDE_PLUGIN_DATA` no longer silently disables it | +| [#737](https://github.com/openai/codex-plugin-cc/pull/737) | hooks resolve Node through `scripts/run-node.sh`, so nvm/fnm/asdf/mise/Volta/Homebrew toolchains work under the minimal hook PATH | +| [#747](https://github.com/openai/codex-plugin-cc/pull/747) | `runCommand` sets an explicit 256 MiB `maxBuffer`, so a large `git diff` is no longer truncated at Node's 1 MiB default | +| [#763](https://github.com/openai/codex-plugin-cc/pull/763) | a turn that fails without throwing stores its error text, so `/codex:result` says why it failed | Where two of these PRs disagreed, the merge commit says which side won and why. The plugin version is deliberately left at the upstream number: these merges do not cut a release. diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index de59abd18..7905d0fe6 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -9,6 +9,10 @@ - Deny inherited system temp roots in scoped profiles so only approved directories and minimal runtime paths remain readable. - Reject `--read-root` together with `--sandbox danger-full-access`, which disables the sandbox entirely. - Treat a busy broker's shutdown refusal as a refusal rather than an identity rejection, so SessionEnd leaves the shared runtime to the sessions still using it instead of exiting with an error. +- Set an explicit 256 MiB `maxBuffer` for spawned commands so a large `git diff` is no longer truncated at Node's 1 MiB default. +- Persist the failure text of a turn that fails without throwing, and shorten job summaries to 96 characters. +- Keep the review-gate flag in a durable per-workspace file under `CODEX_HOME`, written privately and atomically. +- Resolve Node through `scripts/run-node.sh` in the hooks, preferring a user-managed toolchain over a system install. ## 1.0.0 From 4f5c23e23883e3332a2a563afd7465d456deb44e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 12:09:54 +0000 Subject: [PATCH 53/53] fix: satisfy the typecheck that has been failing on main CI's `npm run build` step rejects six type errors. None of them come from this branch: they reproduce identically on main (7c86c7f), which is where they arrived with the earlier lifecycle imports, merged while Actions was not running on this fork. The PR cannot go green without them, and each is an annotation with no runtime effect: - app-server.mjs:441 reached for `client.proc` on the client union, which only the spawned transport has. The base class now declares `proc` as a nullable ChildProcess, so the shared cleanup path is honest about both transports instead of reading a property half of them never carry. The spawned client still assigns the real child in its own constructor, after super(). - broker-lifecycle.mjs:74 read `error.code` off a socket error typed as plain Error; the callback parameter is annotated NodeJS.ErrnoException. - process.mjs terminateProcessTreeAndExit() took `beforeKill` with no default, so it was absent from the options type TypeScript inferred from `= {}`. A @param now states the shape. Verified: npx tsc -p tsconfig.app-server.json clean (it reported all six before, on this branch and on main alike); full npm test 254/254. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UXfvnjSC72HsM6EEPVt2Tg --- plugins/codex/scripts/lib/app-server.mjs | 6 ++++++ plugins/codex/scripts/lib/broker-lifecycle.mjs | 2 +- plugins/codex/scripts/lib/process.mjs | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/plugins/codex/scripts/lib/app-server.mjs b/plugins/codex/scripts/lib/app-server.mjs index 397de3fb0..d7a912f75 100644 --- a/plugins/codex/scripts/lib/app-server.mjs +++ b/plugins/codex/scripts/lib/app-server.mjs @@ -70,6 +70,12 @@ class AppServerClientBase { this.notificationHandler = null; this.lineBuffer = ""; this.transport = "unknown"; + // Only the spawned transport owns a child process; the broker transport + // talks to one it does not own. Declaring it here keeps the shared cleanup + // paths honest about the union instead of reaching for a property half the + // clients never have. + /** @type {import("node:child_process").ChildProcess | null} */ + this.proc = null; this.exitPromise = new Promise((resolve) => { this.resolveExit = resolve; diff --git a/plugins/codex/scripts/lib/broker-lifecycle.mjs b/plugins/codex/scripts/lib/broker-lifecycle.mjs index 2fc643df3..397d552e9 100644 --- a/plugins/codex/scripts/lib/broker-lifecycle.mjs +++ b/plugins/codex/scripts/lib/broker-lifecycle.mjs @@ -71,7 +71,7 @@ function probeEndpoint(endpoint, timeoutMs) { // promise pending forever on a connection that never settles. socket.setTimeout(Math.max(1, timeoutMs), () => finish("timeout")); socket.on("connect", () => finish("connect")); - socket.on("error", (error) => finish(error?.code ?? "error")); + socket.on("error", (/** @type {NodeJS.ErrnoException} */ error) => finish(error?.code ?? "error")); }); } diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index 7478301db..1f42896aa 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -400,6 +400,10 @@ export function processHasLaunchToken(pid, token, options = {}) { * Only meaningful for a group leader; a caller that is not one signals nothing, which is the * existing behaviour of `terminateProcessTree`. */ +/** + * @param {number} pid + * @param {{ graceMs?: number, exitCode?: number, beforeKill?: () => void }} [options] + */ export function terminateProcessTreeAndExit(pid, { graceMs = 5000, exitCode = 1, beforeKill } = {}) { if (pid === process.pid) { process.on("SIGTERM", () => {});