瀏覽代碼

feat(subagent): add Codex non-interactive permission modes

pku-xht 1 月之前
父節點
當前提交
7eb203069c
共有 29 個文件被更改,包括 824 次插入 和 109 次删除
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml
  5. 8 8
      .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md
  6. 8 8
      .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml
  8. 1 1
      .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md
  9. 1 1
      .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml
  11. 28 12
      .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md
  12. 28 12
      .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md
  13. 2 2
      docs/config-catalog.i18n.yaml
  14. 10 2
      docs/config-catalog.md
  15. 10 2
      docs/config-catalog.zh.md
  16. 2 0
      examples/acp-agent/product-subagent-both.cordis.snapshot.yml
  17. 2 0
      examples/acp-agent/product-subagent-both.cordis.yml
  18. 2 0
      examples/acp-agent/product-subagent-codex.cordis.snapshot.yml
  19. 2 0
      examples/acp-agent/product-subagent-codex.cordis.yml
  20. 2 0
      examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml
  21. 2 2
      packages/subagent/subagent-codex/README.i18n.yaml
  22. 18 10
      packages/subagent/subagent-codex/README.md
  23. 18 10
      packages/subagent/subagent-codex/README.zh.md
  24. 15 3
      packages/subagent/subagent-codex/src/index.ts
  25. 51 4
      packages/subagent/subagent-codex/src/run.ts
  26. 159 2
      packages/subagent/subagent-codex/src/wire.ts
  27. 67 10
      packages/subagent/subagent-codex/tests/real-product.spec.ts
  28. 6 0
      packages/subagent/subagent-codex/tests/responses-fixture.ts
  29. 372 10
      packages/subagent/subagent-codex/tests/subagent-codex.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md
-2026-08-10-product-subagent-providers-in-shared-host.md: dd5cd2b3b9c424da1f9f126d4ec9cb1fa4ca7083
-2026-08-10-product-subagent-providers-in-shared-host.zh.md: 0d946fa30240a130b27f85709382595cf29f5ead
+2026-08-10-product-subagent-providers-in-shared-host.md: 452ff1cca7e4e5f91f8c35092761ebe83f3ff174
+2026-08-10-product-subagent-providers-in-shared-host.zh.md: a62bf6faa3c9bba5326da1de20ecbc2946c02bcc

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md

@@ -16,7 +16,7 @@ Product providers remain process-scoped host-plane registrations. The [productio
 
 This note continues to own why a mounted product provider belongs on the host plane while its model-facing tool belongs to an Agent Preset. The production-install exclusion decision owns which Profiles install those optional packages. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions.
 
-The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, or test authentication. It may supply the mounted Provider's deployment configuration, including the Claude Code `permissionMode` owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving that choice into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation.
+The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, or test authentication. It may supply each mounted Provider's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation.
 
 Only a Profile that selects the Claude Code provider carries the Claude Agent SDK's optional platform CLI payload. Production still resolves the host `claude`; the SDK payload remains provider-package installation cost rather than the production executable.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 本说明继续负责解释为什么已经挂载的产品提供方属于 host plane,而面向模型的工具属于 Agent Preset。生产安装排除决策负责哪些 Profile 安装这些可选包。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。
 
-这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供已挂载 Provider 的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的 Claude Code `permissionMode`,但不会把该选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。
+这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供每个已挂载 Provider 的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。
 
 只有选择 Claude Code 提供方的 Profile 才会携带 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷。生产环境仍解析宿主提供的 `claude`;这份 SDK 载荷是提供方包的安装成本,而不是生产可执行文件。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md
-2026-08-04-claude-code-and-codex-subagent-backends.md: d0e48bb2c048351f71687a66a31c8ecdda123328
-2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 3fd604927c447c24e9047424ab255eb9fd628226
+2026-08-04-claude-code-and-codex-subagent-backends.md: 49c3e3fc6a99cae23b606f5a680320307c79d08c
+2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dc3a0737b9cfe00a850697ca482fad2743105058

+ 8 - 8
.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md

@@ -34,15 +34,15 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product
 
 ## Codex provider
 
-`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities.
+`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a three-value native `permissionMode` that defaults to `never`. Installation, login, `CODEX_HOME`, model selection, base URL, and product-session settings remain native Codex or deployment responsibilities; the selected mode owns only the thread approval/reviewer/sandbox fields described by the non-interactive permissions decision.
 
-Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session.
+Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session.
 
-`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`.
+`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; a permission-related error may additionally carry the shared safe diagnostic. This version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted` without permission detail.
 
-For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply.
+For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply.
 
-An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable.
+An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Result failure and teardown failure stay independently observable.
 
 Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively.
 
@@ -62,7 +62,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract
 
 Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies both fixed one-shot tools expose optional background scheduling alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret.
 
-The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`.
+The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`.
 
 The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit.
 
@@ -82,7 +82,7 @@ The project owner's distribution authorization is scoped to the official `@anthr
 
 **Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture.
 
-**Plugin-managed login, product home, models, settings, sandbox rules, or fine-grained permission policy.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. Claude Code exposes only one native non-interactive mode choice in addition to environment and teardown configuration; it does not mirror product rules or add a human interaction channel.
+**Plugin-managed login, product home, models, settings, sandbox rules, or fine-grained permission policy.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. Each product exposes only one native non-interactive mode choice in addition to environment and teardown configuration; neither Provider mirrors product rules or adds a human interaction channel.
 
 **Continuation, progress, product-native background state, and shared parent context.** The provider payload remains one final answer for one self-contained task. The generic Job layer may add its id, status, notice, collection, and cancellation results, but product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and provider-specific background state need separate user contracts and are not prebuilt.
 
@@ -90,6 +90,6 @@ The project owner's distribution authorization is scoped to the official `@anthr
 
 Users delegate through two stable one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence.
 
-Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed Claude Code run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout.
+Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout.
 
 Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe.

+ 8 - 8
.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md

@@ -34,15 +34,15 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product
 
 ## Codex 提供方
 
-`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`,且后者不得大于仓库共享的 `MAX_TIMER_DELAY_MS`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。
+`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。安装、登录、`CODEX_HOME`、模型选择、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approval/reviewer/sandbox 字段。
 
-发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
+发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
 
-`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。
+`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;权限相关错误可以额外携带共享安全诊断。本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`,且不附带权限说明。
 
-对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。
+对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。
 
-若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。
+若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。结果失败与清理失败仍可彼此独立地观察。
 
 Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。
 
@@ -62,7 +62,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端
 
 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,在同一个上下文中验证两个固定一次性工具会与通用 Job 控制工具一起公开可选后台调度,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。
 
-Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。
+Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。
 
 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。
 
@@ -82,7 +82,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl
 
 **以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。
 
-**由插件管理登录、产品主目录、模型、设置、沙箱规则或细粒度权限策略。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。Claude Code 除环境和清理配置外只公开一个原生非交互模式选择;它不会镜像产品规则,也不会增加人工交互通道。
+**由插件管理登录、产品主目录、模型、设置、沙箱规则或细粒度权限策略。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。两个产品除环境和清理配置外都只公开一个原生非交互模式选择;任一提供方都不会镜像产品规则或增加人工交互通道。
 
 **续接、进度、产品原生后台状态和共享父级上下文。** 提供方载荷仍是一项自包含任务的一个最终回答。通用 Job 层可以额外提供 id、状态、通知、收集与取消结果,但产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和提供方专属后台状态都需要独立的用户约定,当前实现不会预先构建这些功能。
 
@@ -90,6 +90,6 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl
 
 用户通过官方产品集成支持的两个稳定一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。
 
-每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的 Claude Code 运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
+每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
 
 兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md
-2026-08-12-product-subagent-one-shot-background-tasks.md: e389c0b8b6587cf699ea3fd30e75531bb6069108
-2026-08-12-product-subagent-one-shot-background-tasks.zh.md: d424fa9d1ccb1f14fa73e342964e95b7181c8274
+2026-08-12-product-subagent-one-shot-background-tasks.md: 9aeccfadbad0d8f44ac2c294c4008b672f855027
+2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 74a0a614847543aff5f88cc5696246a76f2bb72f

+ 1 - 1
.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md

@@ -49,7 +49,7 @@ The ACP product compositions use the same fixed product rows and generic job con
 
 ## Verification
 
-The Web composition test explicitly mounts both optional providers from the repository examples dependency anchor, then boots four user-preset variants—neither product, Codex, Claude Code, and both—and checks that each enabled product tool exposes `run_in_background` alongside `job_output`, `job_list`, and `job_kill`. The two package-owned Loader compositions run with an empty `PATH`, inspect the same schemas and controls, and prove that explicit provider loading starts no product process. ACP keyless snapshots pin the assembled explicit product schemas, while the existing `dsh-tool-subagent` and job suites pin foreground defaulting, Job registration, final-output collection, shared diagnostic presentation, cancellation, completion notices, owner disposal, and provider disposal.
+The Web composition test explicitly mounts both optional providers from the repository examples dependency anchor, then boots four user-preset variants—neither product, Codex, Claude Code, and both—and checks that each enabled product tool exposes `run_in_background` alongside `job_output`, `job_list`, and `job_kill`. The two package-owned Loader compositions run with an empty `PATH`, inspect the same schemas and controls, and prove that explicit provider loading starts no product process. ACP keyless snapshots pin the assembled explicit product schemas, while the existing `dsh-tool-subagent` and job suites pin foreground defaulting, Job registration, final-output collection, shared diagnostic presentation, cancellation, completion notices, owner disposal, and provider disposal. The two real product-provider suites independently prove that their native permission failures enter that same shared result before either scheduling path consumes it.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md

@@ -49,7 +49,7 @@ ACP 产品组装使用相同的固定产品行与通用作业控制工具。其
 
 ## 验证
 
-Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供方,再启动四种用户 preset 变体——不启用产品、只启用 Codex、只启用 Claude Code,以及同时启用两者——并检查每个已启用产品工具都会与 `job_output`、`job_list` 和 `job_kill` 一起公开 `run_in_background`。两个由包负责的 Loader 组装会在空 `PATH` 下运行,检查相同 schema 与控制工具,并证明显式加载提供方不会启动产品进程。ACP 无密钥快照会固定显式组装后的产品 schema,而现有 `dsh-tool-subagent` 与作业测试套件会固定前台默认值、Job 登记、最终输出收集、共享诊断呈现、取消、完成通知、owner 资源释放与提供方资源释放。
+Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供方,再启动四种用户 preset 变体——不启用产品、只启用 Codex、只启用 Claude Code,以及同时启用两者——并检查每个已启用产品工具都会与 `job_output`、`job_list` 和 `job_kill` 一起公开 `run_in_background`。两个由包负责的 Loader 组装会在空 `PATH` 下运行,检查相同 schema 与控制工具,并证明显式加载提供方不会启动产品进程。ACP 无密钥快照会固定显式组装后的产品 schema,而现有 `dsh-tool-subagent` 与作业测试套件会固定前台默认值、Job 登记、最终输出收集、共享诊断呈现、取消、完成通知、owner 资源释放与提供方资源释放。两个真实产品提供方测试套件还会分别证明各自的原生权限失败先进入同一个共享结果,再由任一调度路径消费。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md
-2026-08-15-product-subagent-noninteractive-permissions.md: f382bc7ad058fefd8001da6181824fc9b6f767d4
-2026-08-15-product-subagent-noninteractive-permissions.zh.md: 76cf53c7c9af791db6e54a8b779a7284187d0716
+2026-08-15-product-subagent-noninteractive-permissions.md: 3615f2b719522bab0eafe59ed8335fff7c2b3cb1
+2026-08-15-product-subagent-noninteractive-permissions.zh.md: bd8b7fadd48bb67ca17dc7db2568f8107d3993e6

+ 28 - 12
.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md

@@ -1,4 +1,4 @@
-# Agent Note: Claude Code subagents use Profile-selected non-interactive permissions
+# Agent Note: Product subagents use Profile-selected non-interactive permissions
 
 Status: implemented
 
@@ -6,13 +6,17 @@ English | [中文](2026-08-15-product-subagent-noninteractive-permissions.zh.md)
 
 ## Problem
 
-The [Claude Code product provider](2026-08-04-claude-code-and-codex-subagent-backends.md) runs without a human interface. Native permission prompts, user dialogs, or MCP elicitation therefore cannot wait for a person, but relying on the product's ambient default can still select an interactive mode. A deployment also needs to choose broader native modes without giving the parent model or one tool call a way to raise its own authority.
+The [Claude Code and Codex product providers](2026-08-04-claude-code-and-codex-subagent-backends.md) run without a human interface. Native permission prompts, user dialogs, or MCP elicitation therefore cannot wait for a person, but relying on either product's ambient default can still select an interactive mode. A deployment also needs to choose broader native modes without giving the parent model or one tool call a way to raise its own authority.
 
 A failed product run previously reached the [subagent seam](2026-06-21-subagent-capability-seam.md) only as a stop reason. Logs could retain the product error, but the foreground parent and a [one-shot background Job](2026-08-12-product-subagent-one-shot-background-tasks.md) could not distinguish a permission refusal from another failure. Reusing assistant output for that fact would misattribute infrastructure detail to the child model.
 
 ## Decision
 
-The Claude Code Provider owns one Profile-level `permissionMode` value. It defaults to `dontAsk` and accepts only the native non-interactive modes supported by the pinned Agent SDK:
+Each product Provider owns its own Profile-level `permissionMode` value. The two Config fields deliberately use the products' native names rather than a shared restricted/automatic/full abstraction. The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it.
+
+### Claude Code
+
+Claude Code defaults to `dontAsk` and accepts only the native non-interactive modes supported by the pinned Agent SDK:
 
 | Value | Native behavior |
 | --- | --- |
@@ -22,15 +26,27 @@ The Claude Code Provider owns one Profile-level `permissionMode` value. It defau
 | `plan` | Use Claude Code's planning-only mode without tool execution. |
 | `bypassPermissions` | Set the SDK's explicit dangerous confirmation and bypass permission checks. |
 
-The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode.
+The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode.
 
 Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop.
 
+### Codex
+
+Codex defaults to `never` and accepts the three native non-interactive modes exposed by Codex 0.147.0. The Provider starts the fixed app-server command, then maps the selected mode into official `thread/start` fields because CLI-global permission flags do not configure threads created later by an app-server client:
+
+| Value | `thread/start` fields | Native behavior |
+| --- | --- | --- |
+| `never` | `approvalPolicy: never`; sandbox omitted | Never prompt; execution failures return to the model under the native sandbox. |
+| `approve-for-me` | `approvalPolicy: on-request`, `approvalsReviewer: auto_review`, `sandbox: workspace-write` | Route permission requests through Codex automatic review. |
+| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`, `sandbox: danger-full-access` | Skip approval and sandbox enforcement. |
+
+The Provider overrides only those thread fields. `CODEX_HOME`, project configuration, model/provider selection, MCP, hooks, skills, authentication, and sandbox facts not selected by the mode remain native Codex state. The wire still denies any unexpected approval, permission, user-input, or MCP request rather than opening a dynamic allow path.
+
 ### Failure diagnostic
 
 `SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character.
 
-Claude Code records only the effective mode, request category, unattended decision, and a fixed safe reason. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`.
+Each product records only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`.
 
 The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior.
 
@@ -38,16 +54,16 @@ The foreground consumer presents the stop-reason headline, then the optional dia
 
 | Fact or resource | Owner | Observable behavior |
 | --- | --- | --- |
-| Profile permission choice | Claude Code Provider Config | Invalid, interactive, or unknown values fail during configuration. |
-| Permission and sandbox semantics | Claude Code and its Agent SDK | The Provider passes one native mode and does not mirror product policy. |
-| Interaction decisions and safe diagnostic | One Claude Code run | Concurrent runs keep independent mode, callback, and diagnostic state. |
+| Profile permission choice | Each product Provider Config | Invalid, interactive, or unknown values fail during configuration. |
+| Permission and sandbox semantics | Claude Code Agent SDK or Codex app-server | Each Provider passes one native mode and does not mirror product policy. |
+| Interaction decisions and safe diagnostic | One product run | Concurrent runs keep independent mode, protocol, and diagnostic state. |
 | Diagnostic type and byte limit | `dsh-subagent` | Consumers receive a bounded optional field separate from assistant output. |
 | Foreground and Job presentation | `dsh-tool-subagent` and the generic Job runtime | Scheduling choice does not change the underlying failure fact. |
 | Process cancellation and quiescence | Product Provider and `dsh-subprocess` | Result settlement still precedes idempotent whole-tree disposal. |
 
 ## Verification
 
-Package tests pin every allowed and rejected Config value, the exact SDK option mapping, bypass confirmation, callback terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, and disposal behavior. The real Agent SDK/CLI fixture proves that the default overrides an interactive native setting, denies an out-of-workspace write with safe diagnostic detail, executes an explicit bypass write only inside suite-owned temporary storage, and leaves the full process tree quiescent. Loader composition proves a non-default mode can be published without starting either product, and the keyless ACP snapshot records the same diagnostic in a foreground tool error and one-shot `job_output` while the model-facing product tool schema contains no permission parameter.
+Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared diagnostic presentation while the model-facing product tool schemas contain no permission parameter.
 
 ## Alternatives considered
 
@@ -55,7 +71,7 @@ Package tests pin every allowed and rejected Config value, the exact SDK option
 
 **Put permission mode in the model-facing tool or each start request.** That would let task content select authority and would duplicate a Profile deployment decision on every call.
 
-**Copy Claude settings or map the parent Harness sandbox.** The products do not share one permission vocabulary. Mirroring their state would create a second authority and obscure the native sandbox consequences of `auto` and bypass modes.
+**Copy product settings or map the parent Harness sandbox.** The products do not share one permission vocabulary. Mirroring their state would create a second authority and obscure the native sandbox consequences of automatic and bypass modes.
 
 **Forward prompts to a parent, Web client, or CLI.** The one-shot product run has no owned human-interaction lifecycle. Adding one would require durable request identity, routing, cancellation, and timeout semantics beyond this decision.
 
@@ -65,8 +81,8 @@ Package tests pin every allowed and rejected Config value, the exact SDK option
 
 ## Consequences
 
-Profiles can select Claude Code's native restricted, automatic, planning, edit-accepting, or bypass behavior before the Provider starts, while the safe default never asks a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences.
+Profiles can select each product's native restricted, automatic, planning/edit-accepting where supported, or bypass behavior before the Provider starts, while both safe defaults never ask a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences.
 
 Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. That diagnostic can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound it before result settlement.
 
-The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Codex and other Providers remain valid without producing a diagnostic or exposing a permission-mode Config.
+The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Other Providers remain valid without producing a diagnostic or exposing a permission-mode Config.

+ 28 - 12
.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: Claude Code subagent 使用 Profile 选择的非交互权限
+# Agent Note: 产品 subagent 使用 Profile 选择的非交互权限
 
 Status: implemented
 
@@ -6,13 +6,17 @@ Status: implemented
 
 ## Problem
 
-[Claude Code 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)在没有人工界面的情况下运行。因此,原生权限提示、用户对话或 MCP elicitation 不能等待人员响应,但依赖产品环境中的默认值仍可能选择交互模式。部署也需要选择更宽松的原生模式,同时不能让父模型或单次工具调用提升自身权限。
+[Claude Code 与 Codex 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)都在没有人工界面的情况下运行。因此,原生权限提示、用户对话或 MCP elicitation 不能等待人员响应,但依赖任一产品环境中的默认值仍可能选择交互模式。部署也需要选择更宽松的原生模式,同时不能让父模型或单次工具调用提升自身权限。
 
 失败的产品运行此前只能把终止原因送入 [subagent seam](2026-06-21-subagent-capability-seam.md)。日志可以保留产品错误,但前台父 agent 与[一次性后台 Job](2026-08-12-product-subagent-one-shot-background-tasks.md)无法区分权限拒绝和其他失败。若复用 assistant 输出承载该事实,则会把基础设施说明错误归因给子模型。
 
 ## Decision
 
-Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认使用 `dontAsk`,而且只接受锁定版本 Agent SDK 支持的原生非交互模式:
+每个产品提供方分别拥有自己的 Profile 级 `permissionMode` 值。两个 Config 字段有意使用各产品的原生名称,而不是共享的受限/自动/完全抽象。提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。
+
+### Claude Code
+
+Claude Code 默认使用 `dontAsk`,而且只接受锁定版本 Agent SDK 支持的原生非交互模式:
 
 | 值 | 原生行为 |
 | --- | --- |
@@ -22,15 +26,27 @@ Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认
 | `plan` | 使用 Claude Code 的仅规划模式,不执行工具。 |
 | `bypassPermissions` | 设置 SDK 的显式危险确认并跳过权限检查。 |
 
-提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。
+提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。
 
 每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。
 
+### Codex
+
+Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交互模式。提供方启动固定的 app-server 命令,再把所选模式映射为官方 `thread/start` 字段,因为 CLI 全局权限 flag 不会配置之后由 app-server 客户端创建的线程:
+
+| 值 | `thread/start` 字段 | 原生行为 |
+| --- | --- | --- |
+| `never` | `approvalPolicy: never`;省略 sandbox | 永不弹出提示;执行失败会在原生 sandbox 下返回模型。 |
+| `approve-for-me` | `approvalPolicy: on-request`、`approvalsReviewer: auto_review`、`sandbox: workspace-write` | 由 Codex 自动评审权限请求。 |
+| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`、`sandbox: danger-full-access` | 跳过审批与 sandbox。 |
+
+提供方只覆盖这些线程字段。`CODEX_HOME`、项目配置、模型/provider 选择、MCP、hook、skill、身份验证,以及模式未选择的 sandbox 事实仍属于 Codex 原生状态。wire 仍会拒绝任何意外到达的审批、权限、用户输入或 MCP 请求,而不会开放动态 allow 通道。
+
 ### 失败诊断
 
 `SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。
 
-Claude Code 只记录有效模式、请求类别、无人值守决定与固定的安全原因。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。
+每个产品都只记录有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。
 
 前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。
 
@@ -38,16 +54,16 @@ Claude Code 只记录有效模式、请求类别、无人值守决定与固定
 
 | 事实或资源 | Owner | 可观察行为 |
 | --- | --- | --- |
-| Profile 权限选择 | Claude Code 提供方 Config | 配置阶段会拒绝无效、交互式或未知值。 |
-| 权限与沙箱语义 | Claude Code 及其 Agent SDK | 提供方传入一个原生模式,不镜像产品策略。 |
-| 交互决定与安全诊断 | 单次 Claude Code 运行 | 并发运行分别拥有独立的模式、回调与诊断状态。 |
+| Profile 权限选择 | 各产品提供方 Config | 配置阶段会拒绝无效、交互式或未知值。 |
+| 权限与沙箱语义 | Claude Code Agent SDK 或 Codex app-server | 各提供方传入一个原生模式,不镜像产品策略。 |
+| 交互决定与安全诊断 | 单次产品运行 | 并发运行分别拥有独立的模式、协议与诊断状态。 |
 | 诊断类型与字节上限 | `dsh-subagent` | 消费方收到与 assistant 输出分离的有界可选字段。 |
 | 前台与 Job 呈现 | `dsh-tool-subagent` 和通用 Job 运行时 | 调度选择不会改变底层失败事实。 |
 | 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的完整进程树资源释放。 |
 
 ## Verification
 
-包测试固定所有允许与拒绝的 Config 值、准确的 SDK 选项映射、bypass 确认、回调终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail 和资源释放行为。真实 Agent SDK/CLI fixture 证明默认值会覆盖交互式原生设置,越出工作区的写入会被拒绝并返回安全诊断,显式 bypass 写入只会发生在测试拥有的临时存储中,而且完整进程树会完全停稳。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录同一诊断如何出现在前台工具错误与一次性 `job_output` 中,同时面向模型的产品工具 schema 不包含权限参数。
+包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录共享诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。
 
 ## Alternatives considered
 
@@ -55,7 +71,7 @@ Claude Code 只记录有效模式、请求类别、无人值守决定与固定
 
 **把权限模式放入面向模型的工具或每次 start 请求。** 这会让任务内容选择权限,并在每次调用中重复一个 Profile 部署决定。
 
-**复制 Claude 设置或映射父级 Harness 沙箱。** 各产品并不共享同一套权限词汇。镜像这些状态会创建第二个权威,并掩盖 `auto` 与 bypass 模式的原生沙箱后果。
+**复制产品设置或映射父级 Harness 沙箱。** 各产品并不共享同一套权限词汇。镜像这些状态会创建第二个权威,并掩盖自动模式与 bypass 模式的原生沙箱后果。
 
 **把提示转发给父 agent、Web 客户端或 CLI。** 一次性产品运行没有由其拥有的人工交互生命周期。新增该能力需要持久请求身份、路由、取消与 timeout 语义,超出本决策范围。
 
@@ -65,8 +81,8 @@ Claude Code 只记录有效模式、请求类别、无人值守决定与固定
 
 ## Consequences
 
-Profile 可以在提供方启动前选择 Claude Code 原生的受限、自动、仅规划、编辑放行或 bypass 行为,而安全默认值绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。
+Profile 可以在提供方启动前选择各产品原生的受限、自动、在产品支持时仅规划/编辑放行,或 bypass 行为,而两个安全默认值都绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。
 
 权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。该诊断可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前完成脱敏和限长。
 
-本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。Codex 与其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。
+本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 8294c2187f2b80fbf36787c784ad8b73a16206c1
-config-catalog.zh.md: f35392a5b005212067c9b593b7fa2818202466dd
+config-catalog.md: 8cdfc06094c75792e7f906e905ae617df8af2848
+config-catalog.zh.md: bb31b54a914ecafd6d29cbf43cfccecd31fdcb39

+ 10 - 2
docs/config-catalog.md

@@ -2112,19 +2112,27 @@ Source: [`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/s
 Requires: `subagents` · `subprocess`
 
 ```ts config-catalog
-/** Deployment-owned environment and process-release bound. */
+/** Deployment-owned permission, environment, and process-release settings. */
 export interface Config {
   /**
    * Explicit environment entries layered over the subprocess seam's
    * credential-scrubbed parent environment.
    */
   env?: Record<string, string>
+  /** Native non-interactive permission mode fixed for this Provider instance. */
+  permissionMode?: CodexPermissionMode
   /** Grace in milliseconds for app-server process-tree termination. */
   disposeGraceMs?: number
 }
+
+/** Profile-selectable non-interactive Codex permission mode. */
+export type CodexPermissionMode =
+  | 'never'
+  | 'approve-for-me'
+  | 'dangerously-bypass-approvals-and-sandbox'
 ```
 
-Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts)
+Source: [`packages/subagent/subagent-codex/src/index.ts:33`](../packages/subagent/subagent-codex/src/index.ts)
 
 <a id="deepseek-aidsh-subagent-dsh-sdk"></a>
 

+ 10 - 2
docs/config-catalog.zh.md

@@ -2114,19 +2114,27 @@ export type ClaudeCodePermissionMode =
 需要:`subagents` · `subprocess`
 
 ```ts config-catalog
-/** Deployment-owned environment and process-release bound. */
+/** Deployment-owned permission, environment, and process-release settings. */
 export interface Config {
   /**
    * Explicit environment entries layered over the subprocess seam's
    * credential-scrubbed parent environment.
    */
   env?: Record<string, string>
+  /** Native non-interactive permission mode fixed for this Provider instance. */
+  permissionMode?: CodexPermissionMode
   /** Grace in milliseconds for app-server process-tree termination. */
   disposeGraceMs?: number
 }
+
+/** Profile-selectable non-interactive Codex permission mode. */
+export type CodexPermissionMode =
+  | 'never'
+  | 'approve-for-me'
+  | 'dangerously-bypass-approvals-and-sandbox'
 ```
 
-来源:[`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts)
+来源:[`packages/subagent/subagent-codex/src/index.ts:33`](../packages/subagent/subagent-codex/src/index.ts)
 
 <a id="deepseek-aidsh-subagent-dsh-sdk"></a>
 

+ 2 - 0
examples/acp-agent/product-subagent-both.cordis.snapshot.yml

@@ -20,6 +20,8 @@
                     - id: deepseek-v4-pro
           - id: subagent-codex
             name: '@deepseek-ai/dsh-subagent-codex'
+            config:
+              permissionMode: approve-for-me
           - id: subagent-claude-code
             name: '@deepseek-ai/dsh-subagent-claude-code'
             config:

+ 2 - 0
examples/acp-agent/product-subagent-both.cordis.yml

@@ -9,6 +9,8 @@
       - insert:
           - id: subagent-codex
             name: '@deepseek-ai/dsh-subagent-codex'
+            config:
+              permissionMode: approve-for-me
           - id: subagent-claude-code
             name: '@deepseek-ai/dsh-subagent-claude-code'
             config:

+ 2 - 0
examples/acp-agent/product-subagent-codex.cordis.snapshot.yml

@@ -20,6 +20,8 @@
                     - id: deepseek-v4-pro
           - id: subagent-codex
             name: '@deepseek-ai/dsh-subagent-codex'
+            config:
+              permissionMode: approve-for-me
           - id: tool-subagent-codex
             name: '@deepseek-ai/dsh-tool-subagent'
             config:

+ 2 - 0
examples/acp-agent/product-subagent-codex.cordis.yml

@@ -9,6 +9,8 @@
       - insert:
           - id: subagent-codex
             name: '@deepseek-ai/dsh-subagent-codex'
+            config:
+              permissionMode: approve-for-me
           - id: tool-subagent-codex
             name: '@deepseek-ai/dsh-tool-subagent'
             config:

+ 2 - 0
examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml

@@ -11,6 +11,8 @@
 
 - id: subagent-codex
   name: '@deepseek-ai/dsh-subagent-codex'
+  config:
+    permissionMode: approve-for-me
 
 - id: tool-subagent-codex
   name: '@deepseek-ai/dsh-tool-subagent'

+ 2 - 2
packages/subagent/subagent-codex/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md
-README.md: 848d170585710b682fa4ce331010fce7080de673
-README.zh.md: 34e9105e6a78bc16f16997c7df89d4f6412eb50c
+README.md: 645479474599eb4cb72c0bf73838a6341c98adb7
+README.zh.md: 1e9d21882b4c84312ea60eff3510bd2295d5334e

+ 18 - 10
packages/subagent/subagent-codex/README.md

@@ -2,17 +2,17 @@
 
 English | [中文](README.zh.md)
 
-This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract.
+This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns either the selected final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract.
 
 ## Start and ownership
 
-`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`.
+`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`.
 
 The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error.
 
-For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run.
+For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. The wire records only the effective mode, request category, decision, and fixed safe reason. It also recognizes declined command/file items and `sandboxError` terminals. Codex 0.147.0 writes some early `never` rejections and sandbox violations only to structured stderr, so the Provider pipes stderr, forwards it unchanged to the host, and matches two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic.
 
-Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate.
+Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. A permission-related error may additionally carry the bounded, non-assistant `SubagentResult.diagnostic`; successful and locally cancelled runs omit it. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Result failure and independent teardown failure remain separate.
 
 ## Capabilities and context
 
@@ -23,9 +23,16 @@ The provider advertises no optional start-time capabilities and reports `inherit
 | Key | Default | Meaning |
 |---|---|---|
 | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. |
+| `permissionMode` | `never` | Native non-interactive approval and sandbox mode fixed for every thread from this Provider instance. |
 | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
 
-Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
+| `permissionMode` value | `thread/start` fields | Native behavior |
+|---|---|---|
+| `never` | `approvalPolicy: never`; sandbox omitted | Never ask for approval; execution failures return to the model under the native sandbox. |
+| `approve-for-me` | `approvalPolicy: on-request`, `approvalsReviewer: auto_review`, `sandbox: workspace-write` | Route permission requests through Codex automatic review without a human. |
+| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`, `sandbox: danger-full-access` | Skip approval and sandbox enforcement; this value must be selected explicitly. |
+
+Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The Provider overrides only the selected thread approval/reviewer/sandbox fields; all other `CODEX_HOME`, project, model, provider, MCP, hook, skill, and account settings remain native. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
 
 Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and mount it once on the host plane; loading the provider starts no Codex process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
 
@@ -35,6 +42,7 @@ The standalone composition below shows the complete explicit capability. A Profi
 - id: subagent-codex
   name: '@deepseek-ai/dsh-subagent-codex'
   config:
+    permissionMode: approve-for-me
     env:
       OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
 
@@ -55,7 +63,7 @@ The standalone composition below shows the complete explicit capability. A Profi
 
 ## Product compatibility and evidence
 
-The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`.
+The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. Real-product coverage proves that thread-level `never` overrides an ambient `on-request`, automatic review starts through the official app-server, dangerous bypass writes only in suite-owned temporary storage, safe diagnostics exclude raw commands and paths, and every wrapper/native process exits.
 
 ## Model Experience
 
@@ -63,7 +71,7 @@ The production wire intentionally implements only the app-server methods require
 
 #### What the model sees
 
-The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration.
+The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd; its model, system instructions, tools, and authentication come from the native Codex installation and configuration, while the Provider's Profile configuration fixes the thread's non-interactive approval and sandbox mode.
 
 #### Token effect
 
@@ -77,7 +85,7 @@ Independent of the parent request cache. Reuse depends only on Codex's own provi
 
 #### What the model sees
 
-Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or the consumer's exact error for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer and status through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, stderr, workspace diffs, usage, and product ids are not copied into the parent Session.
+Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, raw stderr, workspace diffs, usage, product ids, commands, paths, and protocol payloads are not copied into the parent Session.
 
 #### Token effect
 
@@ -92,7 +100,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while
 - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence.
 - **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate.
 - **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests.
-- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package.
-- **Product payload is final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime.
+- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; the three Profile modes never create a DSH interaction channel or per-call allow policy.
+- **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, commentary, intermediate messages, tool traffic, usage, raw stderr, and workspace diffs remain outside the parent Session, while generic Job ids, notices, and status come from the shared job runtime.
 - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider.
 - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored.

+ 18 - 10
packages/subagent/subagent-codex/README.zh.md

@@ -2,17 +2,17 @@
 
 [English](README.md) | 中文
 
-本包注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。
+本包注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回选定的最终答案或安全失败说明。
 
 ## 启动与所有权
 
-`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。
+`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。
 
 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。
 
-对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。
+对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。wire 只记录有效模式、请求类别、决定与固定的安全原因,也会识别被拒绝的命令/文件 item 和 `sandboxError` 终态。Codex 0.147.0 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe stderr、原样转发给 Host,并在每次运行的有界尾缓冲中匹配两个固定签名;原始 stderr 不会进入诊断。
 
-本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
+本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。权限相关错误可以额外携带有界、非 assistant 的 `SubagentResult.diagnostic`;成功和本地取消不会附带它。`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。结果失败与独立的清理失败仍彼此分离。
 
 ## 能力与上下文
 
@@ -23,9 +23,16 @@
 | 配置键 | 默认值 | 含义 |
 |---|---|---|
 | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 |
+| `permissionMode` | `never` | 为该提供方实例的每个线程固定原生非交互审批与沙箱模式。 |
 | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
 
-生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。
+| `permissionMode` 值 | `thread/start` 字段 | 原生行为 |
+|---|---|---|
+| `never` | `approvalPolicy: never`;省略 sandbox | 永不请求审批;执行失败会在原生 sandbox 下返回模型。 |
+| `approve-for-me` | `approvalPolicy: on-request`、`approvalsReviewer: auto_review`、`sandbox: workspace-write` | 由 Codex 自动评审权限请求,不等待人工。 |
+| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`、`sandbox: danger-full-access` | 跳过审批与 sandbox;必须显式选择该值。 |
+
+生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。提供方只覆盖选定线程的 approval/reviewer/sandbox 字段;其他 `CODEX_HOME`、项目、模型、provider、MCP、hook、skill 与账户设置仍由原生机制负责。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。
 
 生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Codex 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。
 
@@ -35,6 +42,7 @@
 - id: subagent-codex
   name: '@deepseek-ai/dsh-subagent-codex'
   config:
+    permissionMode: approve-for-me
     env:
       OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
 
@@ -55,7 +63,7 @@
 
 ## 产品兼容性与证据
 
-生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。
+生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。真实产品覆盖会证明线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,安全诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。
 
 ## 模型体验
 
@@ -63,7 +71,7 @@
 
 #### 模型看到的内容
 
-Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。
+Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具和身份验证来自原生 Codex 安装与配置,而提供方的 Profile 配置会固定该线程的非交互审批与沙箱模式。
 
 #### 对 token 的影响
 
@@ -77,7 +85,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些
 
 #### 模型看到的内容
 
-通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案,或者在结果未完成时看到消费方给出的原样错误。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案与状态,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。
+通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、原始 stderr、工作区差异、用量信息、产品标识符、命令、路径和协议载荷均不会复制到父会话。
 
 #### 对 token 的影响
 
@@ -92,7 +100,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些
 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。
 - **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。
 - **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。
-- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。
-- **产品载荷仅包含最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部;通用 Job id、通知与状态来自共享作业运行时。
+- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。
+- **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、过程说明、中间消息、工具通信、用量信息、原始 stderr 和工作区差异不会进入父会话,通用 Job id、通知与状态来自共享作业运行时。
 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。
 - **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。

+ 15 - 3
packages/subagent/subagent-codex/src/index.ts

@@ -18,27 +18,34 @@ import {
   type SubagentProvider,
 } from '@deepseek-ai/dsh-subagent'
 import {
+  CODEX_PERMISSION_MODES,
+  DEFAULT_CODEX_PERMISSION_MODE,
   DEFAULT_DISPOSE_GRACE_MS,
   startCodexRun,
+  type CodexPermissionMode,
   type CodexRunSpec,
 } from './run.ts'
 
 export const name = 'subagent-codex'
 export const inject = ['subagents', 'subprocess']
 
-/** Deployment-owned environment and process-release bound. */
+/** Deployment-owned permission, environment, and process-release settings. */
 export interface Config {
   /**
    * Explicit environment entries layered over the subprocess seam's
    * credential-scrubbed parent environment.
    */
   env?: Record<string, string>
+  /** Native non-interactive permission mode fixed for this Provider instance. */
+  permissionMode?: CodexPermissionMode
   /** Grace in milliseconds for app-server process-tree termination. */
   disposeGraceMs?: number
 }
 
 export const Config: z<Config> = z.object({
   env: z.dict(z.string()).default({}),
+  permissionMode: z.union([...CODEX_PERMISSION_MODES])
+    .default(DEFAULT_CODEX_PERMISSION_MODE),
   disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
 })
 
@@ -67,6 +74,7 @@ class CodexProvider implements SubagentProvider {
         undefined,
         parentCwd,
       ),
+      permissionMode: this.config.permissionMode,
       env: this.config.env,
       disposeGraceMs: this.config.disposeGraceMs,
       spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
@@ -83,10 +91,14 @@ class CodexProvider implements SubagentProvider {
 /**
  * Register the fixed `codex` provider.
  * @param ctx - context carrying shared subagent and subprocess services.
- * @param config - explicit child environment and disposal grace.
+ * @param config - permission mode, child environment, and disposal grace.
  */
 export function apply(ctx: Context, config: Config): void {
-  const resolved = config as ResolvedConfig
+  const resolved: ResolvedConfig = {
+    env: config.env as Record<string, string>,
+    permissionMode: config.permissionMode ?? DEFAULT_CODEX_PERMISSION_MODE,
+    disposeGraceMs: config.disposeGraceMs as number,
+  }
   assertPositiveFinite(
     'subagent-codex',
     'disposeGraceMs',

+ 51 - 4
packages/subagent/subagent-codex/src/run.ts

@@ -24,6 +24,22 @@ import { CodexAppServerWire } from './wire.ts'
 /** Default POSIX grace between subprocess termination tiers. */
 export const DEFAULT_DISPOSE_GRACE_MS = 3_000
 
+/** Profile-selectable non-interactive Codex permission mode. */
+export type CodexPermissionMode =
+  | 'never'
+  | 'approve-for-me'
+  | 'dangerously-bypass-approvals-and-sandbox'
+
+/** Codex CLI permission modes that cannot wait for a human response. */
+export const CODEX_PERMISSION_MODES = [
+  'never',
+  'approve-for-me',
+  'dangerously-bypass-approvals-and-sandbox',
+] as const satisfies readonly CodexPermissionMode[]
+
+/** Safe default for unattended Codex runs. */
+export const DEFAULT_CODEX_PERMISSION_MODE: CodexPermissionMode = 'never'
+
 /**
  * Resolve the fixed app-server command for a platform.
  *
@@ -45,6 +61,8 @@ export function codexAppServerArgv(
 export interface CodexRunSpec {
   /** Parent Session workspace, also supplied to `thread/start`. */
   readonly cwd: string
+  /** Profile-selected native non-interactive permission mode. */
+  readonly permissionMode: CodexPermissionMode
   /** Explicit deployment/test environment layered after the shared scrub. */
   readonly env: Record<string, string>
   /** Subprocess termination grace passed to the shared process-tree owner. */
@@ -125,7 +143,7 @@ export async function startCodexRun(
   const child = spec.spawn({
     argv: codexAppServerArgv(),
     cwd: spec.cwd,
-    stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
+    stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
     graceMs: spec.disposeGraceMs,
     env: spec.env,
   })
@@ -133,8 +151,27 @@ export async function startCodexRun(
   const wire = new CodexAppServerWire(
     child.stdout as NonNullable<SubprocessHandle['stdout']>,
     child.stdin as NonNullable<SubprocessHandle['stdin']>,
+    spec.permissionMode,
   )
-  const disposeProcess = (): Promise<void> => disposeCodexChild(wire, child)
+  const onStderr = (chunk: Buffer | string): void => {
+    process.stderr.write(chunk)
+    wire.observeStderr(chunk.toString())
+  }
+  const stderrFailure = Promise.withResolvers<never>()
+  const onStderrError = (error: Error): void => {
+    stderrFailure.reject(error)
+  }
+  void stderrFailure.promise.catch(() => {})
+  child.stderr?.on('data', onStderr)
+  child.stderr?.on('error', onStderrError)
+  const disposeProcess = async (): Promise<void> => {
+    try {
+      await disposeCodexChild(wire, child)
+    } finally {
+      child.stderr?.off('data', onStderr)
+      child.stderr?.off('error', onStderrError)
+    }
+  }
 
   const processFailure: Promise<never> = child.done.then(
     outcome => Promise.reject(new Error(
@@ -158,8 +195,16 @@ export async function startCodexRun(
 
   try {
     wire.start()
-    await Promise.race([wire.initialize(request.signal), processFailure])
-    await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure])
+    await Promise.race([
+      wire.initialize(request.signal),
+      processFailure,
+      stderrFailure.promise,
+    ])
+    await Promise.race([
+      wire.startThread(spec.cwd, request.signal),
+      processFailure,
+      stderrFailure.promise,
+    ])
   } catch (error: unknown) {
     request.signal.removeEventListener('abort', onAbort)
     try {
@@ -181,8 +226,10 @@ export async function startCodexRun(
     attempt: () => Promise.race([
       wire.runTurn(texts, runAbort.signal),
       processFailure,
+      stderrFailure.promise,
     ]),
     collectOutput,
+    collectDiagnostic: () => wire.collectDiagnostic(),
     cancelled: () => runAbort.signal.aborted,
     onError: spec.onError,
     signal: request.signal,

+ 159 - 2
packages/subagent/subagent-codex/src/wire.ts

@@ -11,9 +11,42 @@ import type { Readable, Writable } from 'node:stream'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
 import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
+import type { CodexPermissionMode } from './run.ts'
 
 type JsonObject = Record<string, unknown>
 
+const THREAD_PERMISSION_PARAMS: Readonly<Record<CodexPermissionMode, JsonObject>> = {
+  never: { approvalPolicy: 'never' },
+  'approve-for-me': {
+    approvalPolicy: 'on-request',
+    approvalsReviewer: 'auto_review',
+    sandbox: 'workspace-write',
+  },
+  'dangerously-bypass-approvals-and-sandbox': {
+    approvalPolicy: 'never',
+    sandbox: 'danger-full-access',
+  },
+}
+
+const STDERR_PERMISSION_SIGNATURES = [
+  {
+    text: 'approval policy is Never; reject command',
+    request: 'command execution',
+    decision: 'denied',
+    reason: 'Codex rejected an escalation because the selected policy never asks for approval',
+  },
+  {
+    text: 'recorded sandbox violation:',
+    request: 'sandbox execution',
+    decision: 'failed',
+    reason: 'Codex reported a sandbox violation',
+  },
+] as const
+
+const STDERR_SIGNATURE_TAIL_CHARS = Math.max(
+  ...STDERR_PERMISSION_SIGNATURES.map(signature => signature.text.length),
+) - 1
+
 function object(value: unknown, label: string): JsonObject {
   if (value === null || typeof value !== 'object' || Array.isArray(value)) {
     throw new Error(`subagent-codex: app-server returned invalid ${label}`)
@@ -47,6 +80,24 @@ function isContextWindowExceeded(turn: JsonObject): boolean {
     && (error as JsonObject).codexErrorInfo === 'contextWindowExceeded'
 }
 
+function isSandboxFailure(turn: JsonObject): boolean {
+  if (turn.status !== 'failed') return false
+  const error = turn.error
+  return error !== null
+    && typeof error === 'object'
+    && !Array.isArray(error)
+    && (error as JsonObject).codexErrorInfo === 'sandboxError'
+}
+
+function unattendedDiagnostic(
+  mode: CodexPermissionMode,
+  request: 'command approval' | 'file approval' | 'permission grant' | 'user input' | 'MCP elicitation' | 'command execution' | 'file change' | 'sandbox execution',
+  decision: 'cancelled' | 'declined' | 'denied' | 'empty response' | 'failed',
+  reason: string,
+): string {
+  return `Codex unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}`
+}
+
 function thrown(value: unknown): Error {
   /* v8 ignore next -- typed protocol and stream failures reject with Error. */
   return value instanceof Error ? value : new Error(String(value))
@@ -93,11 +144,14 @@ export class CodexAppServerWire {
   }> = []
   private lastFinalAnswer: string | undefined
   private lastUnphasedAnswer: string | undefined
+  private diagnostic: string | undefined
+  private stderrTail = ''
   private closed = false
 
   constructor(
     private readonly input: Readable,
     output: Writable,
+    private readonly permissionMode: CodexPermissionMode = 'never',
   ) {
     this.transport = new JsonRpcLineTransport(input, output)
     // Fatal protocol state can arrive after the current guarded operation has
@@ -154,6 +208,7 @@ export class CodexAppServerWire {
     const response = object(await this.guarded(this.transport.request('thread/start', {
       cwd,
       ephemeral: true,
+      ...THREAD_PERMISSION_PARAMS[this.permissionMode],
     }, signal), signal), 'thread/start response')
     const thread = object(response.thread, 'thread/start thread')
     const id = string(thread.id, 'thread/start thread id')
@@ -191,8 +246,18 @@ export class CodexAppServerWire {
       return { output: this.collectOutput(), stopReason: 'max-tokens' }
     }
     if (status !== 'completed') {
+      const sandboxFailure = isSandboxFailure(terminal)
+      if (sandboxFailure) {
+        this.recordDiagnostic(
+          'sandbox execution',
+          'failed',
+          'Codex reported a sandbox failure',
+        )
+      }
       const detail = status === 'failed'
-        ? `: ${JSON.stringify(terminal.error)}`
+        ? sandboxFailure
+          ? ': sandboxError'
+          : ': error'
         : ''
       throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`)
     }
@@ -226,6 +291,36 @@ export class CodexAppServerWire {
       : []
   }
 
+  /**
+   * The latest safe unattended permission fact observed for this run.
+   * @returns provider-authored diagnostic text, when one was observed.
+   */
+  collectDiagnostic(): string | undefined {
+    return this.diagnostic
+  }
+
+  /**
+   * Observe product stderr while retaining only enough tail to recognize fixed
+   * permission signatures. The raw text is never copied into the diagnostic.
+   * @param chunk - one decoded stderr chunk already forwarded to the host.
+   */
+  observeStderr(chunk: string): void {
+    const observed = `${this.stderrTail}${chunk}`
+    let latestIndex = -1
+    let latest: (typeof STDERR_PERMISSION_SIGNATURES)[number] | undefined
+    for (const signature of STDERR_PERMISSION_SIGNATURES) {
+      const index = observed.lastIndexOf(signature.text)
+      if (index > latestIndex) {
+        latestIndex = index
+        latest = signature
+      }
+    }
+    if (latest !== undefined) {
+      this.recordDiagnostic(latest.request, latest.decision, latest.reason)
+    }
+    this.stderrTail = observed.slice(-STDERR_SIGNATURE_TAIL_CHARS)
+  }
+
   /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */
   close(): void {
     if (this.closed) return
@@ -291,21 +386,67 @@ export class CodexAppServerWire {
     }
   }
 
+  private recordDiagnostic(
+    request: Parameters<typeof unattendedDiagnostic>[1],
+    decision: Parameters<typeof unattendedDiagnostic>[2],
+    reason: string,
+  ): void {
+    this.diagnostic = unattendedDiagnostic(
+      this.permissionMode,
+      request,
+      decision,
+      reason,
+    )
+  }
+
   private handleServerRequest(method: string, params: JsonObject): Promise<unknown> {
     try {
       switch (method) {
         case 'item/commandExecution/requestApproval':
+          this.validateRunIds(params)
+          {
+            const decision = unattendedDecision(params)
+            this.recordDiagnostic(
+              'command approval',
+              decision === 'cancel' ? 'cancelled' : 'declined',
+              'the provider does not grant interactive approval',
+            )
+            return Promise.resolve({ decision })
+          }
         case 'item/fileChange/requestApproval':
           this.validateRunIds(params)
-          return Promise.resolve({ decision: unattendedDecision(params) })
+          {
+            const decision = unattendedDecision(params)
+            this.recordDiagnostic(
+              'file approval',
+              decision === 'cancel' ? 'cancelled' : 'declined',
+              'the provider does not grant interactive approval',
+            )
+            return Promise.resolve({ decision })
+          }
         case 'item/permissions/requestApproval':
           this.validateRunIds(params)
+          this.recordDiagnostic(
+            'permission grant',
+            'denied',
+            'the provider grants no additional turn permissions',
+          )
           return Promise.resolve({ permissions: {}, scope: 'turn' })
         case 'item/tool/requestUserInput':
           this.validateRunIds(params)
+          this.recordDiagnostic(
+            'user input',
+            'empty response',
+            'the provider does not collect interactive answers',
+          )
           return Promise.resolve({ answers: {} })
         case 'mcpServer/elicitation/request':
           this.validateRunIds(params, true)
+          this.recordDiagnostic(
+            'MCP elicitation',
+            'declined',
+            'the provider does not collect interactive MCP input',
+          )
           return Promise.resolve({ action: 'decline', content: null, _meta: null })
         default:
           throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`)
@@ -340,6 +481,22 @@ export class CodexAppServerWire {
       }
       if (id !== this.turnId) return
       const item = object(params.item, 'item/completed item')
+      if (item.type === 'commandExecution' && item.status === 'declined') {
+        this.recordDiagnostic(
+          'command execution',
+          'declined',
+          'Codex declined the command under the selected permission mode',
+        )
+        return
+      }
+      if (item.type === 'fileChange' && item.status === 'declined') {
+        this.recordDiagnostic(
+          'file change',
+          'declined',
+          'Codex declined the file change under the selected permission mode',
+        )
+        return
+      }
       if (item.type !== 'agentMessage') return
       const text = typeof item.text === 'string'
         ? item.text

+ 67 - 10
packages/subagent/subagent-codex/tests/real-product.spec.ts

@@ -18,6 +18,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent'
 import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
 import * as codex from '../src/index.ts'
+import type { CodexPermissionMode } from '../src/run.ts'
 import {
   startResponsesFixture,
   type ResponsesBehavior,
@@ -53,7 +54,10 @@ interface RealHarness {
   readonly workspace: string
 }
 
-async function realHarness(script: readonly ResponsesBehavior[]): Promise<{
+async function realHarness(
+  script: readonly ResponsesBehavior[],
+  permissionMode?: CodexPermissionMode,
+): Promise<{
   readonly harness: RealHarness
   readonly fixture: ResponsesFixture
 }> {
@@ -106,7 +110,11 @@ async function realHarness(script: readonly ResponsesBehavior[]): Promise<{
     handles.push(handle)
     return handle
   })
-  await ctx.plugin(codex, { env, disposeGraceMs: 2_000 })
+  await ctx.plugin(codex, {
+    env,
+    ...permissionMode === undefined ? {} : { permissionMode },
+    disposeGraceMs: 2_000,
+  })
   const parent = {
     id: 'real-parent',
     session: { header: { cwd: workspace } },
@@ -141,12 +149,12 @@ function responseInputTexts(body: Record<string, unknown>): string[] {
 }
 
 describe('real @openai/codex 0.147.0 product', () => {
-  it('passes the exact task and fake authentication to local Responses and returns exact text', async () => {
+  it('starts approve-for-me through the real app-server and returns exact text', async () => {
     const sentinel = 'REAL_CODEX_SENTINEL_0_147_0'
     const task = 'Return the fixture sentinel exactly.'
     const { harness, fixture } = await realHarness([
       { kind: 'complete', text: sentinel },
-    ])
+    ], 'approve-for-me')
     expect(codexPackage.version).toBe('0.147.0')
     const version = await execFileAsync(process.execPath, [codexEntry, '--version'], {
       env: { ...process.env, ...harness.env },
@@ -173,7 +181,7 @@ describe('real @openai/codex 0.147.0 product', () => {
     await expectQuiescent(harness.handles)
   }, 60_000)
 
-  it('cancels a real app-server command approval without executing the command', async () => {
+  it('overrides on-request with never and reports a denied command safely', async () => {
     const command = process.platform === 'win32'
       ? 'cmd /c type nul > approval-side-effect'
       : 'touch approval-side-effect'
@@ -200,6 +208,11 @@ describe('real @openai/codex 0.147.0 product', () => {
         kind: 'advertisedFunctionCall',
         choices: commandCalls,
       },
+      {
+        kind: 'error',
+        status: 400,
+        message: 'fixture terminal failure after permission denial',
+      },
     ])
     const sideEffect = join(harness.workspace, 'approval-side-effect')
     const run = await harness.ctx.subagents.start('codex', {
@@ -207,14 +220,20 @@ describe('real @openai/codex 0.147.0 product', () => {
       parent: harness.parent,
       signal: new AbortController().signal,
     })
-    await expect(run.result).resolves.toEqual({
-      output: [],
-      stopReason: 'error',
-    })
+    const result = await run.result
+    expect(result.output).toEqual([])
+    expect(result.stopReason).toBe('error')
+    expect([
+      'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
+      'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure',
+      'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
+    ]).toContain(result.diagnostic)
+    expect(result.diagnostic).not.toContain(command)
+    expect(result.diagnostic).not.toContain(harness.workspace)
     await run.dispose()
 
     expect(existsSync(sideEffect)).toBe(false)
-    expect(fixture.requests).toHaveLength(1)
+    expect(fixture.requests).toHaveLength(2)
     const tools = fixture.requests[0]!.body.tools as Array<Record<string, unknown>>
     expect(commandCalls.some(call => tools.some(tool => (
       tool.type === 'function' && tool.name === call.name
@@ -225,6 +244,44 @@ describe('real @openai/codex 0.147.0 product', () => {
     await expectQuiescent(harness.handles)
   }, 60_000)
 
+  it('executes an explicitly selected dangerous bypass write in the isolated workspace', async () => {
+    const sideEffect = 'bypass-side-effect'
+    const command = process.platform === 'win32'
+      ? `cmd /c echo bypass>${sideEffect}`
+      : `printf bypass > ${sideEffect}`
+    const commandCalls = [
+      {
+        name: 'exec_command',
+        arguments: {
+          cmd: command,
+        },
+      },
+      {
+        name: 'shell_command',
+        arguments: {
+          command,
+        },
+      },
+    ] as const
+    const { harness } = await realHarness([
+      { kind: 'advertisedFunctionCall', choices: commandCalls },
+      { kind: 'complete', text: 'bypass complete' },
+    ], 'dangerously-bypass-approvals-and-sandbox')
+    const target = join(harness.workspace, sideEffect)
+    const run = await harness.ctx.subagents.start('codex', {
+      prompt: [{ type: 'text', text: 'Create the fixture side effect.' }],
+      parent: harness.parent,
+      signal: new AbortController().signal,
+    })
+    await expect(run.result).resolves.toEqual({
+      output: [{ type: 'text', text: 'bypass complete' }],
+      stopReason: 'completed',
+    })
+    expect(readFileSync(target, 'utf8').trim()).toBe('bypass')
+    await run.dispose()
+    await expectQuiescent(harness.handles)
+  }, 60_000)
+
   it('settles cancellation locally and leaves the real app-server tree quiescent', async () => {
     const { harness, fixture } = await realHarness([{ kind: 'hold' }])
     const controller = new AbortController()

+ 6 - 0
packages/subagent/subagent-codex/tests/responses-fixture.ts

@@ -17,6 +17,7 @@ interface RecordedResponsesRequest {
 /** Behavior consumed by one Responses request. */
 export type ResponsesBehavior =
   | { readonly kind: 'complete'; readonly text: string }
+  | { readonly kind: 'error'; readonly status: number; readonly message: string }
   | {
     readonly kind: 'functionCall'
     readonly name: string
@@ -275,6 +276,11 @@ export async function startResponsesFixture(
         response.end(JSON.stringify({ error: { message: 'none of the fixture function calls was advertised' } }))
         return
       }
+      if (behavior.kind === 'error') {
+        response.writeHead(behavior.status, { 'content-type': 'application/json' })
+        response.end(JSON.stringify({ error: { message: behavior.message } }))
+        return
+      }
       response.writeHead(200, {
         'content-type': 'text/event-stream',
         'cache-control': 'no-cache',

+ 372 - 10
packages/subagent/subagent-codex/tests/subagent-codex.spec.ts

@@ -15,6 +15,8 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
 import * as codex from '../src/index.ts'
 import * as invariant from '../src/invariant.ts'
 import {
+  CODEX_PERMISSION_MODES,
+  DEFAULT_CODEX_PERMISSION_MODE,
   codexAppServerArgv,
   DEFAULT_DISPOSE_GRACE_MS,
   disposeCodexChild,
@@ -101,6 +103,7 @@ interface FakeChild {
   readonly peer: ProtocolPeer
   readonly fromChild: PassThrough
   readonly toChild: PassThrough
+  readonly stderr: PassThrough
   readonly settle: (outcome?: SubprocessOutcome) => void
   readonly fail: (error: Error) => void
   readonly terminate: () => void
@@ -110,6 +113,7 @@ interface FakeChild {
 function fakeChild(options: FakeChildOptions = {}): FakeChild {
   const fromChild = new PassThrough()
   const toChild = new PassThrough()
+  const stderr = new PassThrough()
   const peer = new ProtocolPeer(toChild, fromChild)
   let exited = false
   let resolveDone!: (outcome: SubprocessOutcome) => void
@@ -159,7 +163,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
     pid: options.pid ?? 1234,
     stdin: toChild,
     stdout: fromChild,
-    stderr: undefined,
+    stderr,
     collected: {},
     done,
     terminate,
@@ -170,6 +174,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
     peer,
     fromChild,
     toChild,
+    stderr,
     settle,
     fail,
     terminate,
@@ -183,6 +188,7 @@ function runSpec(
 ): CodexRunSpec {
   return {
     cwd: process.cwd(),
+    permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
     env: {},
     disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
     spawn: () => child.handle,
@@ -260,7 +266,10 @@ function turnCompleted(
 }
 
 describe('task admission and package contracts', () => {
-  it('resolves the fixed app-server command through the Windows npm shim boundary', () => {
+  it('keeps the app-server command fixed on POSIX and Windows', () => {
+    expect(codexAppServerArgv('linux')).toEqual([
+      'codex', 'app-server', '--stdio',
+    ])
     expect(codexAppServerArgv('win32')).toEqual([
       'cmd.exe',
       '/d',
@@ -270,7 +279,6 @@ describe('task admission and package contracts', () => {
       'app-server',
       '--stdio',
     ])
-    expect(codexAppServerArgv('linux')).toEqual(['codex', 'app-server', '--stdio'])
   })
 
   it('accepts one or more text blocks and rejects empty or non-text tasks', () => {
@@ -314,6 +322,61 @@ describe('task admission and package contracts', () => {
     await ctx.fiber.dispose()
   })
 
+  it('accepts only the three fixed non-interactive permission modes', () => {
+    expect(codex.Config({}).permissionMode).toBe(DEFAULT_CODEX_PERMISSION_MODE)
+    for (const permissionMode of CODEX_PERMISSION_MODES) {
+      expect(codex.Config({ permissionMode }).permissionMode).toBe(permissionMode)
+    }
+    for (const permissionMode of ['on-request', 'untrusted', 'future-mode']) {
+      expect(() => codex.Config({ permissionMode } as never)).toThrow()
+    }
+  })
+
+  it('resolves the safe permission default when apply is called directly', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SubagentRuntime)
+    await ctx.plugin(LocalSubprocessRuntime)
+    codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
+    expect(ctx.subagents.getProvider('codex')).toBeDefined()
+    await ctx.fiber.dispose()
+  })
+
+  it.each([
+    ['never', { approvalPolicy: 'never' }],
+    ['approve-for-me', {
+      approvalPolicy: 'on-request',
+      approvalsReviewer: 'auto_review',
+      sandbox: 'workspace-write',
+    }],
+    ['dangerously-bypass-approvals-and-sandbox', {
+      approvalPolicy: 'never',
+      sandbox: 'danger-full-access',
+    }],
+  ] as const)('maps %s to the official thread/start fields', async (permissionMode, expected) => {
+    const child = fakeChild()
+    const wire = new CodexAppServerWire(
+      child.handle.stdout!,
+      child.handle.stdin!,
+      permissionMode,
+    )
+    wire.start()
+    const initializing = wire.initialize(new AbortController().signal)
+    const initialize = await child.peer.nextMethod('initialize')
+    child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
+    await initializing
+    await child.peer.nextMethod('initialized')
+    const starting = wire.startThread('/workspace', new AbortController().signal)
+    const threadStart = await child.peer.nextMethod('thread/start')
+    expect(threadStart.params).toEqual({
+      cwd: '/workspace',
+      ephemeral: true,
+      ...expected,
+    })
+    child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
+    await starting
+    wire.close()
+  })
+
   it('requires a parent session cwd without suggesting unsupported config', async () => {
     const ctx = new Context()
     await ctx.plugin(SubagentRuntime)
@@ -386,7 +449,11 @@ describe('CodexAppServerWire', () => {
 
     const starting = wire.startThread('/workspace', new AbortController().signal)
     const threadStart = await child.peer.nextMethod('thread/start')
-    expect(threadStart.params).toEqual({ cwd: '/workspace', ephemeral: true })
+    expect(threadStart.params).toEqual({
+      cwd: '/workspace',
+      ephemeral: true,
+      approvalPolicy: 'never',
+    })
     child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
     await starting
 
@@ -586,11 +653,15 @@ describe('CodexAppServerWire', () => {
         threadId: 'thread-1',
         turnId: 'turn-1',
         availableDecisions: ['decline', 'cancel'],
+        command: 'cat /private/secret.txt',
       },
     })
     expect(await child.peer.nextResponse('command')).toMatchObject({
       result: { decision: 'cancel' },
     })
+    expect(wire.collectDiagnostic()).toBe(
+      'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
+    )
 
     child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
     await nextTask()
@@ -604,30 +675,35 @@ describe('CodexAppServerWire', () => {
           availableDecisions: ['decline'],
         },
         result: { decision: 'decline' },
+        diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
       },
       {
         id: 'file-default',
         method: 'item/fileChange/requestApproval',
         params: { threadId: 'thread-1', turnId: 'turn-1' },
         result: { decision: 'decline' },
+        diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
       },
       {
         id: 'permissions',
         method: 'item/permissions/requestApproval',
         params: { threadId: 'thread-1', turnId: 'turn-1' },
         result: { permissions: {}, scope: 'turn' },
+        diagnostic: 'Codex unattended decision (mode: never; request: permission grant; decision: denied): the provider grants no additional turn permissions',
       },
       {
         id: 'user-input',
         method: 'item/tool/requestUserInput',
         params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] },
         result: { answers: {} },
+        diagnostic: 'Codex unattended decision (mode: never; request: user input; decision: empty response): the provider does not collect interactive answers',
       },
       {
         id: 'mcp',
         method: 'mcpServer/elicitation/request',
         params: { threadId: 'thread-1', turnId: null },
         result: { action: 'decline', content: null, _meta: null },
+        diagnostic: 'Codex unattended decision (mode: never; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
       },
     ] as const
     for (const serverRequest of requests) {
@@ -635,8 +711,133 @@ describe('CodexAppServerWire', () => {
       expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({
         result: serverRequest.result,
       })
+      expect(wire.collectDiagnostic()).toBe(serverRequest.diagnostic)
     }
+    expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
 
+    child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
+    await expect(result).resolves.toEqual({
+      output: [{ type: 'text', text: 'answer' }],
+      stopReason: 'completed',
+    })
+    wire.close()
+  })
+
+  it('records only a safe diagnostic for an explicit sandbox failure', async () => {
+    const { child, wire } = await initializeWire()
+    const result = wire.runTurn(['task'], new AbortController().signal)
+    const turnStart = await child.peer.nextMethod('turn/start')
+    child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
+    child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
+      message: 'failed at /private/secret.txt with SECRET_TOKEN',
+      additionalDetails: 'raw command payload',
+      codexErrorInfo: 'sandboxError',
+    }))
+    await expect(result).rejects.toThrow('status failed')
+    expect(wire.collectDiagnostic()).toBe(
+      'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure',
+    )
+    expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
+    expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
+    wire.close()
+  })
+
+  it('records a declined command item without retaining its payload', async () => {
+    const { child, wire } = await initializeWire()
+    const result = wire.runTurn(['task'], new AbortController().signal)
+    const turnStart = await child.peer.nextMethod('turn/start')
+    child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
+    child.peer.send(
+      {
+        method: 'item/completed',
+        params: {
+          threadId: 'thread-1',
+          turnId: 'turn-1',
+          item: {
+            type: 'commandExecution',
+            status: 'declined',
+            command: 'cat /private/secret.txt',
+          },
+        },
+      },
+      turnCompleted('failed', 'turn-1', 'thread-1', {
+        message: 'SECRET_TOKEN in /private/secret.txt',
+        codexErrorInfo: 'other',
+      }),
+    )
+    await expect(result).rejects.toThrow('status failed')
+    expect(wire.collectDiagnostic()).toBe(
+      'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode',
+    )
+    expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
+    expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
+    wire.close()
+  })
+
+  it('recognizes large, split, and ordered stderr signatures without retaining raw text', () => {
+    const first = fakeChild()
+    const largeWire = new CodexAppServerWire(
+      first.handle.stdout!,
+      first.handle.stdin!,
+      'never',
+    )
+    largeWire.observeStderr(
+      `SECRET_TOKEN approval policy is Never; reject command${'x'.repeat(2_048)}`,
+    )
+    expect(largeWire.collectDiagnostic()).toBe(
+      'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
+    )
+    expect(largeWire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
+
+    const second = fakeChild()
+    const splitWire = new CodexAppServerWire(
+      second.handle.stdout!,
+      second.handle.stdin!,
+      'never',
+    )
+    splitWire.observeStderr('SECRET_TOKEN approval policy is Ne')
+    splitWire.observeStderr('ver; reject command — /private/secret.txt')
+    expect(splitWire.collectDiagnostic()).toBe(
+      'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
+    )
+    expect(splitWire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
+    expect(splitWire.collectDiagnostic()).not.toContain('/private/secret.txt')
+
+    const third = fakeChild()
+    const orderedWire = new CodexAppServerWire(
+      third.handle.stdout!,
+      third.handle.stdin!,
+      'dangerously-bypass-approvals-and-sandbox',
+    )
+    orderedWire.observeStderr(
+      'approval policy is Never; reject command; recorded sandbox violation: path=/private/secret.txt',
+    )
+    expect(orderedWire.collectDiagnostic()).toBe(
+      'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: sandbox execution; decision: failed): Codex reported a sandbox violation',
+    )
+    expect(orderedWire.collectDiagnostic()).not.toContain('/private/secret.txt')
+  })
+
+  it('does not reapply an old stderr signature after a newer request diagnostic', async () => {
+    const { child, wire } = await initializeWire()
+    wire.observeStderr('approval policy is Never; reject command')
+    const result = wire.runTurn(['task'], new AbortController().signal)
+    const turnStart = await child.peer.nextMethod('turn/start')
+    child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
+    await nextTask()
+    child.peer.send({
+      id: 'file-approval',
+      method: 'item/fileChange/requestApproval',
+      params: {
+        threadId: 'thread-1',
+        turnId: 'turn-1',
+        availableDecisions: ['decline'],
+      },
+    })
+    await child.peer.nextResponse('file-approval')
+    expect(wire.collectDiagnostic()).toContain('request: file approval')
+    wire.observeStderr('later benign stderr')
+    expect(wire.collectDiagnostic()).toContain('request: file approval')
     child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
     await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
     wire.close()
@@ -864,7 +1065,7 @@ describe('run lifecycle and quiescence', () => {
     expect(spawn).toHaveBeenCalledWith({
       argv: codexAppServerArgv(),
       cwd: process.cwd(),
-      stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
+      stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
       graceMs: DEFAULT_DISPOSE_GRACE_MS,
       env: { OPENAI_API_KEY: 'fake' },
     })
@@ -929,6 +1130,73 @@ describe('run lifecycle and quiescence', () => {
       await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
       await run.dispose()
     }
+    {
+      const child = fakeChild()
+      const { run, turnStart } = await publishRun(child, undefined, {
+        onError: (error) => { errors.push(error.message) },
+      })
+      child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
+      child.stderr.emit('error', new Error('stderr broke'))
+      await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
+      expect(errors.at(-1)).toContain('stderr broke')
+      await run.dispose()
+      expect(child.stderr.listenerCount('error')).toBe(0)
+    }
+  })
+
+  it('attaches a safe permission diagnostic when a published run fails', async () => {
+    const { child, run, turnStart } = await publishRun()
+    child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
+    await nextTask()
+    child.peer.send({
+      id: 'approval-diagnostic',
+      method: 'item/commandExecution/requestApproval',
+      params: {
+        threadId: 'thread-1',
+        turnId: 'turn-1',
+        availableDecisions: ['cancel'],
+        command: 'cat /private/secret.txt',
+      },
+    })
+    expect(await child.peer.nextResponse('approval-diagnostic')).toMatchObject({
+      result: { decision: 'cancel' },
+    })
+    child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
+      message: 'SECRET_TOKEN in /private/secret.txt',
+      codexErrorInfo: 'other',
+    }))
+    await expect(run.result).resolves.toEqual({
+      output: [],
+      diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
+      stopReason: 'error',
+    })
+    await run.dispose()
+  })
+
+  it('forwards stderr while extracting only a fixed safe permission signature', async () => {
+    const child = fakeChild()
+    const forwarded: string[] = []
+    const write = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
+      forwarded.push(String(chunk))
+      return true
+    })
+    const { run, turnStart } = await publishRun(child)
+    child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
+    child.stderr.write('SECRET_TOKEN approval policy is Ne')
+    child.stderr.write('ver; reject command — /private/secret.txt')
+    child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
+      message: 'fixture terminal failure',
+      codexErrorInfo: 'badRequest',
+    }))
+    await expect(run.result).resolves.toEqual({
+      output: [],
+      diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
+      stopReason: 'error',
+    })
+    expect(forwarded.join('')).toContain('SECRET_TOKEN')
+    await run.dispose()
+    expect(child.stderr.listenerCount('data')).toBe(0)
+    write.mockRestore()
   })
 
   it('rejects before spawn when pre-aborted and rolls back startup failures', async () => {
@@ -939,6 +1207,7 @@ describe('run lifecycle and quiescence', () => {
       request(undefined, controller.signal),
       {
         cwd: process.cwd(),
+        permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
         env: {},
         disposeGraceMs: 10,
         spawn,
@@ -952,6 +1221,14 @@ describe('run lifecycle and quiescence', () => {
     child.peer.respond(initialize, null)
     await expect(starting).rejects.toThrow('invalid initialize response')
     expect(child.terminate).toHaveBeenCalledTimes(1)
+
+    const stderrChild = fakeChild()
+    const stderrStarting = startCodexRun(request(), runSpec(stderrChild))
+    await stderrChild.peer.nextMethod('initialize')
+    stderrChild.stderr.emit('error', new Error('startup stderr broke'))
+    await expect(stderrStarting).rejects.toThrow('startup stderr broke')
+    expect(stderrChild.terminate).toHaveBeenCalledTimes(1)
+    expect(stderrChild.stderr.listenerCount('error')).toBe(0)
   })
 
   it('rolls back an abort that wins immediately after thread creation', async () => {
@@ -965,6 +1242,11 @@ describe('run lifecycle and quiescence', () => {
     child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
     await child.peer.nextMethod('initialized')
     const threadStart = await child.peer.nextMethod('thread/start')
+    expect(threadStart.params).toEqual({
+      cwd: process.cwd(),
+      ephemeral: true,
+      approvalPolicy: 'never',
+    })
     child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
     controller.abort('startup race')
     await expect(starting).rejects.toThrow('aborted before run publication')
@@ -1012,6 +1294,55 @@ describe('run lifecycle and quiescence', () => {
     await Promise.all(runs.map(entry => entry.run.dispose()))
   })
 
+  it('isolates permission modes and diagnostics across overlapping runs', async () => {
+    const first = await publishRun(fakeChild(), undefined, {
+      permissionMode: 'never',
+    })
+    const second = await publishRun(fakeChild(), undefined, {
+      permissionMode: 'dangerously-bypass-approvals-and-sandbox',
+    })
+    first.child.peer.respond(first.turnStart, { turn: { id: 'turn-never' } })
+    second.child.peer.respond(second.turnStart, { turn: { id: 'turn-bypass' } })
+    await nextTask()
+    first.child.peer.send({
+      id: 'never-approval',
+      method: 'item/commandExecution/requestApproval',
+      params: {
+        threadId: 'thread-1',
+        turnId: 'turn-never',
+        availableDecisions: ['cancel'],
+      },
+    })
+    second.child.peer.send({
+      id: 'bypass-elicitation',
+      method: 'mcpServer/elicitation/request',
+      params: { threadId: 'thread-1', turnId: null },
+    })
+    await Promise.all([
+      first.child.peer.nextResponse('never-approval'),
+      second.child.peer.nextResponse('bypass-elicitation'),
+    ])
+    first.child.peer.send(turnCompleted('failed', 'turn-never', 'thread-1', {
+      message: 'first failure',
+      codexErrorInfo: 'other',
+    }))
+    second.child.peer.send(turnCompleted('failed', 'turn-bypass', 'thread-1', {
+      message: 'second failure',
+      codexErrorInfo: 'other',
+    }))
+    await expect(first.run.result).resolves.toEqual({
+      output: [],
+      diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
+      stopReason: 'error',
+    })
+    await expect(second.run.result).resolves.toEqual({
+      output: [],
+      diagnostic: 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
+      stopReason: 'error',
+    })
+    await Promise.all([first.run.dispose(), second.run.dispose()])
+  })
+
   it('uses the registered provider config and logs flattened errors', async () => {
     const ctx = new Context()
     await ctx.plugin(SubagentRuntime)
@@ -1024,6 +1355,7 @@ describe('run lifecycle and quiescence', () => {
     }) as typeof ctx.logger.warn
     await ctx.plugin(codex, {
       env: { OPENAI_API_KEY: 'fake' },
+      permissionMode: 'approve-for-me',
       disposeGraceMs: 25,
     })
     const starting = ctx.subagents.start('codex', {
@@ -1035,20 +1367,50 @@ describe('run lifecycle and quiescence', () => {
     child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
     await child.peer.nextMethod('initialized')
     const threadStart = await child.peer.nextMethod('thread/start')
+    expect(threadStart.params).toEqual({
+      cwd: process.cwd(),
+      ephemeral: true,
+      approvalPolicy: 'on-request',
+      approvalsReviewer: 'auto_review',
+      sandbox: 'workspace-write',
+    })
     child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
     const run = await starting
-    await child.peer.nextMethod('turn/start')
-    child.settle({ exitCode: 1, signal: null })
-    await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
+    const turnStart = await child.peer.nextMethod('turn/start')
+    child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
+    await nextTask()
+    child.peer.send({
+      id: 'provider-approval',
+      method: 'item/commandExecution/requestApproval',
+      params: {
+        threadId: 'thread-1',
+        turnId: 'turn-1',
+        availableDecisions: ['cancel'],
+        command: 'cat /private/secret.txt',
+      },
+    })
+    await child.peer.nextResponse('provider-approval')
+    child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
+      message: 'SECRET_TOKEN in /private/secret.txt',
+      codexErrorInfo: 'other',
+    }))
+    await expect(run.result).resolves.toEqual({
+      output: [],
+      diagnostic: 'Codex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval',
+      stopReason: 'error',
+    })
     expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
+      argv: ['codex', 'app-server', '--stdio'],
       env: { OPENAI_API_KEY: 'fake' },
       graceMs: 25,
       cwd: process.cwd(),
     }))
     expect(warnings).toEqual([
-      expect.stringContaining('subagent-codex: child run failed (error):'),
+      expect.stringContaining('subagent-codex: child run failed (error): subagent-codex: Codex turn ended with status failed: error'),
     ])
-    await run.dispose().catch(() => {})
+    expect(warnings.join('\n')).not.toContain('SECRET_TOKEN')
+    expect(warnings.join('\n')).not.toContain('/private/secret.txt')
+    await run.dispose()
     await ctx.fiber.dispose()
   })
 })