diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index d647561b..046bcae1 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Add `codex-companion task --resume-thread ` for routing a delegated + task continuation to an exact Codex thread. +- Add `codex-companion task --full-access` and `--sandbox ` for delegated + tasks that need an explicit Codex filesystem sandbox. + ## 1.0.0 - Initial version of the Codex plugin for Claude Code diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 35222fd5..4a053a08 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -77,7 +77,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|--full-access] [--sandbox ] [--resume-thread |--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", " node scripts/codex-companion.mjs cancel [job-id] [--json]" @@ -461,11 +461,17 @@ async function executeTaskRun(request) { const taskMetadata = buildTaskRunMetadata({ prompt: request.prompt, - resumeLast: request.resumeLast + resumeLast: request.resumeLast || Boolean(request.resumeThreadId) }); - let resumeThreadId = null; - if (request.resumeLast) { + let resumeThreadId = request.resumeThreadId ?? null; + if (resumeThreadId) { + const id = String(resumeThreadId).trim(); + if (!id || /\s/.test(id)) { + throw new Error(`Invalid --resume-thread value: ${resumeThreadId}`); + } + resumeThreadId = id; + } else if (request.resumeLast) { const latestThread = await resolveLatestTrackedTaskThread(workspaceRoot, { excludeJobId: request.jobId }); @@ -476,7 +482,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, --resume-thread, or use --resume-last."); } const result = await runAppServerTurn(workspaceRoot, { @@ -485,7 +491,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 || (request.write ? "workspace-write" : "read-only"), onProgress: request.onProgress, persistThread: true, threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) @@ -598,14 +604,16 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, write, sandbox, resumeLast, resumeThreadId, jobId }) { return { cwd, model, effort, prompt, write, + sandbox, resumeLast, + resumeThreadId, jobId }; } @@ -619,12 +627,33 @@ 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, resumeThreadId = null) { + if (!prompt && !resumeLast && !resumeThreadId) { + throw new Error("Provide a prompt, a prompt file, piped stdin, --resume-thread, or use --resume-last."); } } +function resolveTaskSandbox(options, write) { + const requested = typeof options.sandbox === "string" && options.sandbox.trim() + ? options.sandbox.trim() + : null; + const envFullAccess = process.env.CODEX_CLAUDE_COLLABORATION_FULL_ACCESS === "1" + || process.env.CODEX_HANDOFF_FULL_ACCESS === "1"; + if (options["full-access"] || envFullAccess) { + if (requested && requested !== "danger-full-access") { + throw new Error("Choose either --full-access/CODEX_CLAUDE_COLLABORATION_FULL_ACCESS or a non-full-access --sandbox, not both."); + } + return "danger-full-access"; + } + if (requested) { + if (!["read-only", "workspace-write", "danger-full-access"].includes(requested)) { + throw new Error("Invalid --sandbox value. Expected read-only, workspace-write, or danger-full-access."); + } + return requested; + } + return write ? "workspace-write" : "read-only"; +} + async function runForegroundCommand(job, runner, options = {}) { const { logFile, progress } = createTrackedProgress(job, { logFile: options.logFile, @@ -731,8 +760,8 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["model", "effort", "cwd", "prompt-file"], - booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], + valueOptions: ["model", "effort", "cwd", "prompt-file", "resume-thread", "sandbox"], + booleanOptions: ["json", "write", "full-access", "resume-last", "resume", "fresh", "background"], aliasMap: { m: "model" } @@ -745,19 +774,26 @@ async function handleTask(argv) { const prompt = readTaskPrompt(cwd, options, positionals); const resumeLast = Boolean(options["resume-last"] || options.resume); + const resumeThreadId = typeof options["resume-thread"] === "string" && options["resume-thread"].trim() + ? options["resume-thread"].trim() + : null; const fresh = Boolean(options.fresh); - if (resumeLast && fresh) { - throw new Error("Choose either --resume/--resume-last or --fresh."); + if ((resumeLast || resumeThreadId) && fresh) { + throw new Error("Choose either --resume/--resume-last/--resume-thread or --fresh."); + } + if (resumeLast && resumeThreadId) { + throw new Error("Choose either --resume-last/--resume or --resume-thread, not both."); } - const write = Boolean(options.write); + const write = Boolean(options.write || options["full-access"] || options.sandbox === "workspace-write" || options.sandbox === "danger-full-access"); + const sandbox = resolveTaskSandbox(options, write); const taskMetadata = buildTaskRunMetadata({ prompt, - resumeLast + resumeLast: resumeLast || Boolean(resumeThreadId) }); if (options.background) { ensureCodexAvailable(cwd); - requireTaskRequest(prompt, resumeLast); + requireTaskRequest(prompt, resumeLast, resumeThreadId); const job = buildTaskJob(workspaceRoot, taskMetadata, write); const request = buildTaskRequest({ @@ -766,7 +802,9 @@ async function handleTask(argv) { effort, prompt, write, + sandbox, resumeLast, + resumeThreadId, jobId: job.id }); const { payload } = enqueueBackgroundTask(cwd, job, request); @@ -784,7 +822,9 @@ async function handleTask(argv) { effort, prompt, write, + sandbox, resumeLast, + resumeThreadId, jobId: job.id, onProgress: progress }), diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index f2fe88bd..a0952cea 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -76,6 +76,13 @@ function buildResumeParams(threadId, cwd, options = {}) { }; } +function buildTurnSandboxPolicy(sandbox) { + if (sandbox === "danger-full-access") { + return { type: "dangerFullAccess" }; + } + return null; +} + /** @returns {UserInput[]} */ function buildTurnInput(prompt) { return [{ type: "text", text: prompt, text_elements: [] }]; @@ -1007,6 +1014,8 @@ export async function runAppServerTurn(cwd, options = {}) { input: buildTurnInput(prompt), model: options.model ?? null, effort: options.effort ?? null, + approvalPolicy: options.approvalPolicy ?? "never", + sandboxPolicy: buildTurnSandboxPolicy(options.sandbox), outputSchema: options.outputSchema ?? null }), { onProgress: options.onProgress } diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index debcadce..3723ba21 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -297,6 +297,12 @@ 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 = { + cwd: message.params.cwd, + sandbox: message.params.sandbox ?? null, + ephemeral: message.params.ephemeral ?? 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({ method: "thread/started", params: { thread: { id: thread.id } } }); break; @@ -330,6 +336,11 @@ rl.on("line", (line) => { } const thread = ensureThread(state, message.params.threadId); thread.updatedAt = now(); + state.lastThreadResume = { + threadId: message.params.threadId, + cwd: message.params.cwd, + 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 } }); break; @@ -375,13 +386,15 @@ rl.on("line", (line) => { .join("\\n"); const turnId = nextTurnId(state); thread.updatedAt = now(); - state.lastTurnStart = { - threadId: message.params.threadId, - turnId, - model: message.params.model ?? null, - effort: message.params.effort ?? null, - prompt - }; + state.lastTurnStart = { + threadId: message.params.threadId, + turnId, + model: message.params.model ?? null, + effort: message.params.effort ?? null, + approvalPolicy: message.params.approvalPolicy ?? null, + sandboxPolicy: message.params.sandboxPolicy ?? null, + prompt + }; saveState(state); send({ id: message.id, result: { turn: buildTurn(turnId) } }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 90408372..4c228ec6 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -369,6 +369,66 @@ 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 specified persisted task 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 firstRun = run("node", [SCRIPT, "task", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const result = run("node", [SCRIPT, "task", "--resume-thread", "thr_1", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); + assert.equal(fakeState.lastTurnStart.prompt, "follow up"); +}); + +test("task --full-access forwards danger-full-access sandbox", () => { + 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", "--full-access", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "danger-full-access"); + + const result = run("node", [SCRIPT, "task", "--resume-thread", "thr_1", "--full-access", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 0, result.stderr); + + 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.approvalPolicy, "never"); + assert.deepEqual(fakeState.lastTurnStart.sandboxPolicy, { type: "dangerFullAccess" }); +}); + test("task-resume-candidate returns the latest rescue thread from the current session", () => { const workspace = makeTempDir(); const stateDir = resolveStateDir(workspace);