diff --git a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts index f6c14b0b0a..e65feb4f08 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexAgentService.ts @@ -4,7 +4,7 @@ * * CLI 调用方式: * codex exec --json --sandbox danger-full-access --add-dir .git --config approval_policy="on-request" "prompt" - * codex exec resume SESSION_ID --json --config approval_policy="on-request" "prompt" + * codex exec resume SESSION_ID --json --config sandbox_mode="danger-full-access" --config approval_policy="on-request" "prompt" * * NDJSON 事件格式: * thread.started → session_init (含 thread_id) @@ -437,6 +437,7 @@ export class CodexAgentService implements AgentService { const approvalPolicy = getCodexApprovalPolicy(); const effortLevel = getCatEffort(this.catId as string, undefined, 'openai'); const reasoningArgs = ['--config', `model_reasoning_effort="${effortLevel}"`]; + const sandboxConfigArgs = ['--config', `sandbox_mode=${toTomlString(sandboxMode)}`]; const approvalArgs = ['--config', `approval_policy="${approvalPolicy}"`]; const ctxConfig = getCatContextWindowConfig(this.catId as string); const contextWindowArgs: string[] = ctxConfig @@ -530,10 +531,12 @@ export class CodexAgentService implements AgentService { } const developerInstructionsArgs = l0Result.args; - // resume 子命令不接受 --sandbox(sandbox 在创建时已锁定) + // resume 子命令不接受 --sandbox / --add-dir, but it does accept + // sandbox_mode through --config. Replay the configured sandbox there so + // resumed Codex turns cannot drift back to a CLI default sandbox on Windows. // --add-dir .git: 允许写入 .git/ 目录(index.lock、objects、refs),解锁 git commit - // 注意:旧 session resume 时沿用创建时的沙箱参数,不会带 --add-dir。 - // 这是预期行为——新建会话即可获得 .git 写入权限。 + // 注意:旧 session resume 时仍不会带 --add-dir。这是预期行为——新建会话 + // 才能获得额外目录授权。 // Incident 2026-05-29 (cross-thread-context-contamination): prompt 正文经 stdin // 传入(见下方 cliOpts.stdinInput),绝不进 argv —— 否则 `ps -o command=` / // /proc//cmdline 会把完整对话历史(含跨 thread/猫/用户内容)暴露给任何 @@ -568,6 +571,7 @@ export class CodexAgentService implements AgentService { ...dedup(modelArgs), ...dedup(reasoningArgs), ...dedup(contextWindowArgs), + ...dedup(sandboxConfigArgs), ...dedup(approvalArgs), ...dedup(developerInstructionsArgs), ...dedup(customProviderArgs), diff --git a/packages/api/test/codex-agent-service.test.js b/packages/api/test/codex-agent-service.test.js index 45c561cce3..2a3b1f924b 100644 --- a/packages/api/test/codex-agent-service.test.js +++ b/packages/api/test/codex-agent-service.test.js @@ -237,12 +237,13 @@ describe('CodexAgentService Tests (CLI mode)', { concurrency: false }, () => { // Incident 2026-05-29: prompt 走 stdin,argv 末尾是 '-'(codex 从 stdin 读 PROMPT) assert.equal(args.at(-1), '-', 'prompt 走 stdin,argv 末尾是 -'); assert.equal(proc.stdinData, 'Continue', 'prompt 经 stdin 传入'); - // resume 子命令不接受 --sandbox(sandbox 在创建时已锁定) + // resume 子命令不接受 --sandbox;sandbox mode is replayed through --config. assert.ok(!args.includes('--sandbox'), 'resume args must not include --sandbox'); assert.ok(args.includes('--json'), 'resume args must include --json'); const modelFlagIndex = args.indexOf('--model'); assert.ok(modelFlagIndex >= 0, 'resume args must include --model'); assert.equal(args[modelFlagIndex + 1], 'gpt-5.3-codex'); + assert.ok(args.includes('sandbox_mode="danger-full-access"'), 'resume args must preserve default sandbox mode'); assert.ok(args.includes('--config'), 'resume args must include approval policy override'); assert.ok(args.includes('approval_policy="on-request"'), 'default approval policy should be on-request'); assert.ok(!args.includes('approval_policy=\\"on-request\\"'), 'argv should not contain literal backslash escapes'); @@ -559,6 +560,32 @@ describe('CodexAgentService Tests (CLI mode)', { concurrency: false }, () => { } }); + test('uses env-configured sandbox for resume through config override', async () => { + const oldSandbox = process.env.CAT_CODEX_SANDBOX_MODE; + process.env.CAT_CODEX_SANDBOX_MODE = 'read-only'; + + try { + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = new CodexAgentService({ l0CompilerFn: fakeL0Compiler, spawnFn }); + + const promise = collect(service.invoke('resume configurable', { sessionId: 'thread-config-resume' })); + emitCodexEvents(proc, [{ type: 'thread.started', thread_id: 'thread-config-resume' }]); + await promise; + + const args = spawnFn.mock.calls[0].arguments[1]; + assert.equal(args[1], 'resume'); + assert.ok(!args.includes('--sandbox'), 'resume args must not include --sandbox'); + assert.ok(args.includes('sandbox_mode="read-only"'), 'resume sandbox should follow CAT_CODEX_SANDBOX_MODE'); + } finally { + if (oldSandbox === undefined) { + delete process.env.CAT_CODEX_SANDBOX_MODE; + } else { + process.env.CAT_CODEX_SANDBOX_MODE = oldSandbox; + } + } + }); + test('falls back to defaults for invalid sandbox/approval env values', async () => { const oldSandbox = process.env.CAT_CODEX_SANDBOX_MODE; const oldApproval = process.env.CAT_CODEX_APPROVAL_POLICY; @@ -607,7 +634,7 @@ describe('CodexAgentService Tests (CLI mode)', { concurrency: false }, () => { assert.ok(args.includes('--sandbox'), 'new session must still include --sandbox'); }); - test('resume session does NOT include --add-dir (sandbox locked at creation)', async () => { + test('resume session does NOT include --add-dir but preserves sandbox mode via config', async () => { const proc = createMockProcess(); const spawnFn = createMockSpawnFn(proc); const service = new CodexAgentService({ l0CompilerFn: fakeL0Compiler, spawnFn }); @@ -619,6 +646,7 @@ describe('CodexAgentService Tests (CLI mode)', { concurrency: false }, () => { const args = spawnFn.mock.calls[0].arguments[1]; assert.ok(!args.includes('--add-dir'), 'resume args must not include --add-dir'); assert.ok(!args.includes('--sandbox'), 'resume args must not include --sandbox'); + assert.ok(args.includes('sandbox_mode="danger-full-access"'), 'resume args must preserve sandbox mode'); }); test('custom provider: model passed via --config as-is', async () => {