mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #2589 from deepseek-harness/codex/product-subagent-noninteractive-permissions-codex
feat(subagent): add Codex non-interactive permission modes
This commit is contained in:
+2
-2
@@ -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: a747d641ae112d114912958c289fe00b592e6ea5
|
||||
2026-08-10-product-subagent-providers-in-shared-host.zh.md: fef69e8a2d18135cbc9d5f0d80134fa5701bbbd0
|
||||
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
@@ -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, test authentication, or add product-specific settings. 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
@@ -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 不会安装产品、创建产品状态、探测版本、测试身份验证,也不会新增产品专属设置。命令缺失和产品故障仍局限于发生问题的那次委派。
|
||||
这些提供方使用宿主环境已经选定的产品。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
@@ -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/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md
|
||||
2026-08-10-subagent-empty-terminal-message-output.md: 693013f6810005ce02b08bd82f1f6a18511c40fb
|
||||
2026-08-10-subagent-empty-terminal-message-output.zh.md: 64d61af21f838ef3f515db8af116cbdd74e96179
|
||||
2026-08-10-subagent-empty-terminal-message-output.md: 24bab01ad844a5b48e0bf6fe0fc54df6403f4bb7
|
||||
2026-08-10-subagent-empty-terminal-message-output.zh.md: ab3488806a4f7c019d783e563c79e06aeeb86f33
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ The agent loop appends an empty-content `assistant/message` when a `max-tokens`
|
||||
|
||||
`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty assistant message; without one, select the accumulated `text-delta` stream; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)` for session-event transports, `pushText(text)` for chunk-only transports, and `collect()` for selection. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason.
|
||||
|
||||
The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message appends the child's partial text after the stop-reason headline so the parent model receives both the failure and available output.
|
||||
The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message presents the optional safe Provider diagnostic owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md) after the stop-reason headline and appends the child's partial text afterward. The parent model receives the failure, separate infrastructure detail, and available assistant output without conflating them.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:选取最后一条非空 assistant 消息;没有时选取累积的 `text-delta` 流;忽略空内容消息。增量的 `AssistantOutputFold` 通过 `push(event)` 处理会话事件传输,通过 `pushText(text)` 处理仅分片传输,并通过 `collect()` 完成选取。`finalAssistantOutput(events)` 把规则应用于完整的事件后缀,供进程内 `readResult` 与 Activation capture 使用。SDK 后端折叠通知事件;ACP 后端不暴露完整的 assistant 消息,而是折叠原始分片文本。`SubagentResult.output` 定义结果约定,`subagent/end.lastAssistantMessage` 使用同一规则。子 agent 不产生这两种输出中的任何一种时,一次性与 continuable 运行的生命周期字段都会缺省,而不是空数组。`max-tokens` 或 `aborted` 结果保留实际的终止原因。
|
||||
|
||||
前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后附上子 agent 的部分文本,让父模型同时接收失败信息与已有输出。
|
||||
前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后呈现由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的可选安全提供方诊断,再附上子 agent 的部分文本。父模型会同时收到失败、独立的基础设施说明与已有 assistant 输出,而且不会把它们混为一体。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -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-06-21-subagent-capability-seam.md
|
||||
2026-06-21-subagent-capability-seam.md: fa3b4f570bfccdc849a38b3eda16c1c8dd7b1827
|
||||
2026-06-21-subagent-capability-seam.zh.md: b25fe64377f98af92dbccb87f755627926975ef2
|
||||
2026-06-21-subagent-capability-seam.md: bc84d88d701a5f3018bf00f0ecf8b60750917407
|
||||
2026-06-21-subagent-capability-seam.zh.md: baec829a1a01018b490992fa0982bb23e43ba740
|
||||
|
||||
@@ -54,11 +54,11 @@ Fresh and forked children are separate providers, not a request flag. `dsh-subag
|
||||
|
||||
### Child isolation and the parent log
|
||||
|
||||
Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output), while child steps and tool calls remain outside the parent log.
|
||||
Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output, or a failed result with optional provider diagnostic), while child steps and tool calls remain outside the parent log.
|
||||
|
||||
### Synchronous collect (first cut)
|
||||
|
||||
`dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output, and independent result and disposal rejections retain both diagnostics.
|
||||
`dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output; they present the optional safe diagnostic owned by the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) separately from partial assistant text. Independent result and disposal rejections remain independently observable.
|
||||
|
||||
### Provider selection is config, not model-facing
|
||||
|
||||
|
||||
@@ -54,11 +54,11 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在
|
||||
|
||||
### 子 agent 隔离与父日志
|
||||
|
||||
每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出),而子 agent 的步骤和工具调用均留在父日志之外。
|
||||
每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出,或带可选提供方诊断的失败结果),而子 agent 的步骤和工具调用均留在父日志之外。
|
||||
|
||||
### 同步收集(首版)
|
||||
|
||||
`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;结果与 dispose 的拒绝相互独立,且两项诊断信息都会保留。
|
||||
`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;它会把由[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责的可选安全诊断与部分 assistant 文本分开呈现。结果与 dispose 的拒绝仍可彼此独立地观察。
|
||||
|
||||
### 提供方选择是配置,不面向模型
|
||||
|
||||
|
||||
@@ -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-07-08-background-subagent-tasks.md
|
||||
2026-07-08-background-subagent-tasks.md: 412ec61dcdecae1a273c5993d25a4a099a22e864
|
||||
2026-07-08-background-subagent-tasks.zh.md: 9d108440c992e150ed62edaef8bf860813a471b8
|
||||
2026-07-08-background-subagent-tasks.md: 4dcd961ee5a5db925f8f6ad83e97890eaedb8e63
|
||||
2026-07-08-background-subagent-tasks.zh.md: dd8ba47e018bdeeaffccca1fdaf52df75728057b
|
||||
|
||||
@@ -8,7 +8,7 @@ English | [中文](2026-07-08-background-subagent-tasks.zh.md)
|
||||
|
||||
The [subagent seam](2026-06-21-subagent-capability-seam.md) returns a `SubagentRun`, but the model-facing tool originally collected every run synchronously. Independent, slow delegations therefore held the parent call open or ran serially.
|
||||
|
||||
Subagents need the same start, collect, list, stop, ownership, notification, and cleanup behavior as other long-running tools without adopting process-stream semantics. The child session remains the detailed trace; the parent needs the final answer and job status. A background child also outlives its starting tool call, so its cancellation and owner-disposal contracts must be explicit.
|
||||
Subagents need the same start, collect, list, stop, ownership, notification, and cleanup behavior as other long-running tools without adopting process-stream semantics. The child session remains the detailed trace; the parent needs the final answer or safe failure detail plus job status. A background child also outlives its starting tool call, so its cancellation and owner-disposal contracts must be explicit.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -16,7 +16,7 @@ Each `dsh-tool-subagent` instance may expose `run_in_background`, controlled by
|
||||
|
||||
Background subagents use the [generic background job runtime](../architecture/2026-06-20-generic-long-running-tool-runtime.md). Collection, listing, cancellation, completion notices, and prompt guidance come from `job_output`, `job_list`, and `job_kill`; there are no subagent-specific companion tools.
|
||||
|
||||
Foreground calls retain their synchronous contract: await provider startup and `run.result`, return final text only for `completed`, map other terminal reasons to an errored tool result, and always dispose the run before returning.
|
||||
Foreground calls retain their synchronous contract: await provider startup and `run.result`, return final text only for `completed`, map other terminal reasons to an errored tool result with the optional safe diagnostic described by the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md), and always dispose the run before returning.
|
||||
|
||||
For a background call, the tool validates the parent and refuses an already-aborted execution signal before calling `ctx.jobs.start()`. The job runtime preflights the control API and owner cleanup before invoking the producer starter. That starter creates an independent `AbortController` and begins `ctx.subagents.start()`; after the id is returned, the tool-call signal no longer owns the child.
|
||||
|
||||
@@ -24,7 +24,7 @@ The task registration maps the subagent seam as follows:
|
||||
|
||||
- `kind` is `subagent`, `label` is the model-supplied description, and `owner` is the parent agent.
|
||||
- `cancel(reason?)` aborts the task-owned controller. The same signal covers pending provider startup and the published run's remaining work.
|
||||
- `done` awaits provider startup, the child result, and `run.dispose()`. Completed runs return final text, aborted runs become `killed`, and other stop reasons become `failed`. Startup, result, and disposal failures become failed outcomes rather than rejected task promises.
|
||||
- `done` awaits provider startup, the child result, and `run.dispose()`. Completed runs return final text, aborted runs become `killed`, and other stop reasons become `failed` with the Provider diagnostic when present. Startup, result, and disposal failures become failed outcomes rather than rejected task promises.
|
||||
- `readOutput` is absent. While live, `job_output` returns status only; after settlement, it returns final output idempotently. Intermediate child activity remains in the child session.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
@@ -8,7 +8,7 @@ Status: implemented
|
||||
|
||||
[subagent seam](2026-06-21-subagent-capability-seam.md) 会返回 `SubagentRun`,但原先面向模型的工具会同步收集每一次运行。因此,各自独立的慢速委派要么一直占用父调用,要么按串行方式运行。
|
||||
|
||||
subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案和任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放约定。
|
||||
subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案或安全失败说明,以及任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放约定。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -16,7 +16,7 @@ subagent 需要与其他长时间运行的工具相同的启动、收集、列
|
||||
|
||||
后台 subagent 使用[通用后台任务运行时](../architecture/2026-06-20-generic-long-running-tool-runtime.md)。`job_output`、`job_list` 和 `job_kill` 负责收集、列出、取消、完成通知和提示词引导;系统不提供 subagent 专用的配套工具。
|
||||
|
||||
前台调用保留其同步约定:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果;并且始终在返回前释放该运行。
|
||||
前台调用保留其同步约定:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果,并在存在时附上由[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)描述的可选安全诊断;而且始终在返回前释放该运行。
|
||||
|
||||
对于后台调用,工具会验证父级,并在调用 `ctx.jobs.start()` 前拒绝已中止的执行信号。任务运行时会在调用生产者启动器前,预检控制 API 和拥有者清理。该启动器创建独立的 `AbortController` 并启动 `ctx.subagents.start()`;返回 id 之后,工具调用的信号不再拥有该子级。
|
||||
|
||||
@@ -24,7 +24,7 @@ subagent 需要与其他长时间运行的工具相同的启动、收集、列
|
||||
|
||||
- `kind` 为 `subagent`,`label` 为模型提供的描述,`owner` 为父 agent(智能体)。
|
||||
- `cancel(reason?)` 中止任务自有的控制器。同一个信号同时覆盖尚未完成的提供方启动和已发布 run 的剩余工作。
|
||||
- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。
|
||||
- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`,并在存在时携带提供方诊断。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。
|
||||
- `readOutput` 不存在。任务存活期间,`job_output` 只返回状态;结算后,它以幂等方式返回最终输出。中间的子级活动仍保留在子会话中。
|
||||
|
||||
## 生命周期
|
||||
|
||||
+2
-2
@@ -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: 666945c4d8039874729a7f9da34d9cf82bfd479d
|
||||
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 35b8dd51f1c45200a495c68f19f055a224123263
|
||||
2026-08-04-claude-code-and-codex-subagent-backends.md: f65c0626ad22db8f3e7d2a543c7aa87e58df54d4
|
||||
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 97ac527b8e89cc07d65aa28102ba43d648b1b64c
|
||||
|
||||
+12
-12
@@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance
|
||||
|
||||
## Decision
|
||||
|
||||
The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, and the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration.
|
||||
The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration.
|
||||
|
||||
Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -50,9 +50,9 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp
|
||||
|
||||
`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe /v:off` as a quoted per-spawn environment expansion, so percent, ampersand, and exclamation path components remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires.
|
||||
|
||||
The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own.
|
||||
The public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own.
|
||||
|
||||
The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted`.
|
||||
The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. When a permission denial or unattended callback contributes to that failure, the result may additionally carry the bounded, non-assistant diagnostic owned by the non-interactive permissions decision. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without permission detail.
|
||||
|
||||
Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. Query-close failure, process failure, and teardown failure remain independently observable.
|
||||
|
||||
@@ -62,11 +62,11 @@ 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.
|
||||
|
||||
The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`.
|
||||
The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, safe permission diagnostics, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`.
|
||||
|
||||
The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test.
|
||||
|
||||
@@ -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, or permissions.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only an explicit environment overlay and teardown grace; unattended interaction fails closed.
|
||||
**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. The product payload reaching the parent is final text only; 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, and workspace settings. 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.
|
||||
|
||||
+12
-12
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。
|
||||
harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。
|
||||
|
||||
这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。
|
||||
|
||||
@@ -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 的证据。
|
||||
|
||||
@@ -50,9 +50,9 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端
|
||||
|
||||
`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。
|
||||
|
||||
公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。
|
||||
公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。
|
||||
|
||||
只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`。
|
||||
只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。当权限拒绝或无人值守回调参与了该失败时,结果还可以携带由非交互权限决策负责的有界、非 assistant 诊断。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带权限说明。
|
||||
|
||||
启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。Query 关闭失败、进程失败和清理失败仍可彼此独立地观察。
|
||||
|
||||
@@ -62,11 +62,11 @@ 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,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。
|
||||
|
||||
Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。
|
||||
Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、安全权限诊断、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。
|
||||
|
||||
带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。
|
||||
|
||||
@@ -82,7 +82,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl
|
||||
|
||||
**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。
|
||||
|
||||
**由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。
|
||||
**由插件管理登录、产品主目录、模型、设置、沙箱规则或细粒度权限策略。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。两个产品除环境和清理配置外都只公开一个原生非交互模式选择;任一提供方都不会镜像产品规则或增加人工交互通道。
|
||||
|
||||
**续接、进度、产品原生后台状态和共享父级上下文。** 提供方载荷仍是一项自包含任务的一个最终回答。通用 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)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。
|
||||
|
||||
每次委派都要承担新建产品进程和独立模型上下文的开销。到达父级的产品载荷仍只有最终文本;后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
|
||||
每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
|
||||
|
||||
兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。
|
||||
|
||||
+2
-2
@@ -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: b8865cf94852396c32dd6da996bc9f5c2c7d806b
|
||||
2026-08-12-product-subagent-one-shot-background-tasks.zh.md: bbc18ebb6cf0de1b04f0c2a10ddf51cea282c24e
|
||||
2026-08-12-product-subagent-one-shot-background-tasks.md: 248bb943f8ee46a7050c373b6b7c3f7dec65d566
|
||||
2026-08-12-product-subagent-one-shot-background-tasks.zh.md: d6867a97561e7efbe2b152b6c45991553393b4e7
|
||||
|
||||
+6
-6
@@ -14,9 +14,9 @@ Exposing background execution must not add a product session, product-specific j
|
||||
|
||||
Production `dsh` does not install the optional product providers. A Profile that opts in installs and mounts `dsh-subagent-codex`, `dsh-subagent-claude-code`, or both once on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion.
|
||||
|
||||
The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence.
|
||||
The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production.
|
||||
|
||||
No provider configuration, service interface, event, wire field, persistence format, or product identifier is added. Foreground and background differ only in which existing consumer waits for the same one-shot run.
|
||||
This scheduling decision adds no provider configuration, service interface, event, wire field, persistence format, or product identifier. A Provider may define its own Profile configuration independently; foreground and background still differ only in which existing consumer waits for the same one-shot run.
|
||||
|
||||
### Ownership and lifecycle
|
||||
|
||||
@@ -37,7 +37,7 @@ product tool call
|
||||
| Product selection and exposure | Agent Preset | Bind one fixed tool name to one fixed provider | Enabling one row exposes only that product tool |
|
||||
| Foreground or background choice | `dsh-tool-subagent` | Resolve `run_in_background` under `one-shot` policy | Omission is foreground; explicit `true` returns a Job id |
|
||||
| Job id, state, output, cancellation, and notice | `ctx.jobs` and `dsh-tool-jobs` | Register and present the existing one-shot run | Generic job tools collect or stop the run for the exact parent |
|
||||
| Native answer and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one process tree | Job settlement and foreground return both wait for disposal |
|
||||
| Native result, optional diagnostic, and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one process tree | Job settlement and foreground return consume the same result and both wait for disposal |
|
||||
|
||||
## Published composition
|
||||
|
||||
@@ -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, 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
|
||||
|
||||
@@ -65,6 +65,6 @@ The Web composition test explicitly mounts both optional providers from the repo
|
||||
|
||||
## Consequences
|
||||
|
||||
Agents can continue useful work while Codex or Claude Code handles an independent one-shot task, then collect the final answer or cancel it through the same Job controls used by other background producers. Foreground callers retain their existing result and error behavior.
|
||||
Agents can continue useful work while Codex or Claude Code handles an independent one-shot task, then collect the final answer or cancel it through the same Job controls used by other background producers. Foreground and one-shot background consumers present the same safe Provider diagnostic when a failed result supplies one.
|
||||
|
||||
Every product delegation still starts a fresh native process or query, produces final text as its only product payload, and ends with provider disposal and whole-tree exit. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available.
|
||||
Every product delegation still starts a fresh native process or query, produces final assistant text as its only assistant payload, and ends with provider disposal and whole-tree exit. A failed result may separately carry a safe diagnostic. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available.
|
||||
|
||||
+6
-6
@@ -14,9 +14,9 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回
|
||||
|
||||
生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装 `dsh-subagent-codex`、`dsh-subagent-claude-code` 或两者,并在 host plane(宿主平面)各挂载一次。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。
|
||||
|
||||
[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳。
|
||||
[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 配置与诊断生产。
|
||||
|
||||
本决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。前台与后台的区别仅在于由哪个现有消费方等待同一个 one-shot 运行。
|
||||
本调度决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。提供方可以独立定义自己的 Profile 配置;前台与后台的区别仍然只在于由哪个现有消费方等待同一个 one-shot 运行。
|
||||
|
||||
### 归属与生命周期
|
||||
|
||||
@@ -37,7 +37,7 @@ product tool call
|
||||
| 产品选择与公开 | Agent Preset | 把一个固定工具名绑定到一个固定提供方 | 启用一行只会公开对应产品工具 |
|
||||
| 前台或后台选择 | `dsh-tool-subagent` | 按 `one-shot` 策略解析 `run_in_background` | 省略参数时在前台运行;显式传入 `true` 时返回 Job id |
|
||||
| Job id、状态、输出、取消与通知 | `ctx.jobs` 与 `dsh-tool-jobs` | 登记并展示现有 one-shot 运行 | 通用作业工具为准确父级收集或停止运行 |
|
||||
| 原生答案与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一棵进程树 | Job 结算与前台返回都会等待资源释放 |
|
||||
| 原生结果、可选诊断与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一棵进程树 | Job 结算与前台返回消费同一结果,且都会等待资源释放 |
|
||||
|
||||
## 发布组装
|
||||
|
||||
@@ -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 资源释放与提供方资源释放。两个真实产品提供方测试套件还会分别证明各自的原生权限失败先进入同一个共享结果,再由任一调度路径消费。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -65,6 +65,6 @@ Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供
|
||||
|
||||
## 后果
|
||||
|
||||
agent 可以在 Codex 或 Claude Code 处理独立 one-shot 任务时继续推进其他工作,随后通过其他后台 producer 共用的 Job 控制工具收集最终回答或取消运行。前台调用方继续获得既有结果与错误行为。
|
||||
agent 可以在 Codex 或 Claude Code 处理独立 one-shot 任务时继续推进其他工作,随后通过其他后台 producer 共用的 Job 控制工具收集最终回答或取消运行。若失败结果提供了安全的提供方诊断,前台与一次性后台消费方会呈现同一内容。
|
||||
|
||||
每次产品委托仍会启动一个全新的原生进程或 query,把最终文本作为唯一产品载荷,并以提供方资源释放和整棵进程树退出结束。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。
|
||||
每次产品委托仍会启动一个全新的原生进程或 query,把最终 assistant 文本作为唯一 assistant 载荷,并以提供方资源释放和整棵进程树退出结束。失败结果可以另行携带安全诊断。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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: df1f0d9939e951f16070729615a3779f1f7c2ddc
|
||||
2026-08-15-product-subagent-noninteractive-permissions.zh.md: 982b4409e08a506dec828db15c8c4aa5fcc36883
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# Agent Note: Product subagents use Profile-selected non-interactive permissions
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-15-product-subagent-noninteractive-permissions.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
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
|
||||
|
||||
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 |
|
||||
| --- | --- |
|
||||
| `dontAsk` | Deny operations that are not already authorized instead of prompting. |
|
||||
| `acceptEdits` | Accept edits; deny any remaining permission prompt through the unattended callback. |
|
||||
| `auto` | Let Claude Code's native classifier allow or deny permission requests. |
|
||||
| `plan` | Use planning mode, deny execution approval, and return the completed plan as the final answer. |
|
||||
| `bypassPermissions` | Set the SDK's explicit dangerous confirmation and bypass permission checks. |
|
||||
|
||||
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`; plan mode also places `ExitPlanMode` in `disallowedTools`, so native allow rules cannot switch the unattended query back to execution. 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.
|
||||
|
||||
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.
|
||||
|
||||
### Ownership and lifecycle
|
||||
|
||||
| Fact or resource | Owner | Observable behavior |
|
||||
| --- | --- | --- |
|
||||
| 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 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
|
||||
|
||||
**Use the product's ambient permission default.** A native setting may select an interactive mode and make unattended behavior deployment-dependent. The Provider must choose a non-interactive mode explicitly for every query.
|
||||
|
||||
**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 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.
|
||||
|
||||
**Return raw product errors, stderr, or tool inputs.** Those values can contain commands, paths, workspace data, environment values, or credentials. A fixed safe diagnostic keeps the failure actionable without exposing the product transcript.
|
||||
|
||||
**Store a separate Job diagnostic.** The Job is only a scheduling adapter for the same `SubagentRun`; a second field would let foreground and background failure meanings drift.
|
||||
|
||||
## 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. Other Providers remain valid without producing a diagnostic or exposing a permission-mode Config.
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# Agent Note: 产品 subagent 使用 Profile 选择的非交互权限
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-15-product-subagent-noninteractive-permissions.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
[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
|
||||
|
||||
每个产品提供方分别拥有自己的 Profile 级 `permissionMode` 值。两个 Config 字段有意使用各产品的原生名称,而不是共享的受限/自动/完全抽象。提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。
|
||||
|
||||
### Claude Code
|
||||
|
||||
Claude Code 默认使用 `dontAsk`,而且只接受锁定版本 Agent SDK 支持的原生非交互模式:
|
||||
|
||||
| 值 | 原生行为 |
|
||||
| --- | --- |
|
||||
| `dontAsk` | 不弹出提示,直接拒绝尚未获授权的操作。 |
|
||||
| `acceptEdits` | 接受编辑;其余权限提示由无人值守回调拒绝。 |
|
||||
| `auto` | 由 Claude Code 原生分类器允许或拒绝权限请求。 |
|
||||
| `plan` | 使用规划模式,拒绝执行审批,并把完整计划作为最终答案返回。 |
|
||||
| `bypassPermissions` | 设置 SDK 的显式危险确认并跳过权限检查。 |
|
||||
|
||||
提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。
|
||||
|
||||
每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;plan 模式还会把 `ExitPlanMode` 放入 `disallowedTools`,因此原生 allow 规则无法把无人值守 query 切回执行模式。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 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。
|
||||
|
||||
前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。
|
||||
|
||||
### 所有权与生命周期
|
||||
|
||||
| 事实或资源 | Owner | 可观察行为 |
|
||||
| --- | --- | --- |
|
||||
| Profile 权限选择 | 各产品提供方 Config | 配置阶段会拒绝无效、交互式或未知值。 |
|
||||
| 权限与沙箱语义 | Claude Code Agent SDK 或 Codex app-server | 各提供方传入一个原生模式,不镜像产品策略。 |
|
||||
| 交互决定与安全诊断 | 单次产品运行 | 并发运行分别拥有独立的模式、协议与诊断状态。 |
|
||||
| 诊断类型与字节上限 | `dsh-subagent` | 消费方收到与 assistant 输出分离的有界可选字段。 |
|
||||
| 前台与 Job 呈现 | `dsh-tool-subagent` 和通用 Job 运行时 | 调度选择不会改变底层失败事实。 |
|
||||
| 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的完整进程树资源释放。 |
|
||||
|
||||
## Verification
|
||||
|
||||
包测试固定所有允许与拒绝的 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
|
||||
|
||||
**使用产品环境中的权限默认值。** 原生设置可能选择交互模式,使无人值守行为依赖部署环境。提供方必须为每次 query 显式选择非交互模式。
|
||||
|
||||
**把权限模式放入面向模型的工具或每次 start 请求。** 这会让任务内容选择权限,并在每次调用中重复一个 Profile 部署决定。
|
||||
|
||||
**复制产品设置或映射父级 Harness 沙箱。** 各产品并不共享同一套权限词汇。镜像这些状态会创建第二个权威,并掩盖自动模式与 bypass 模式的原生沙箱后果。
|
||||
|
||||
**把提示转发给父 agent、Web 客户端或 CLI。** 一次性产品运行没有由其拥有的人工交互生命周期。新增该能力需要持久请求身份、路由、取消与 timeout 语义,超出本决策范围。
|
||||
|
||||
**返回原始产品错误、stderr 或工具输入。** 这些值可能包含命令、路径、工作区数据、环境值或凭证。固定的安全诊断既保留可操作性,也不会暴露产品 transcript。
|
||||
|
||||
**单独保存 Job 诊断。** Job 只是同一 `SubagentRun` 的调度适配器;第二个字段会让前台和后台的失败含义发生漂移。
|
||||
|
||||
## Consequences
|
||||
|
||||
Profile 可以在提供方启动前选择各产品原生的受限、自动、在产品支持时仅规划/编辑放行,或 bypass 行为,而两个安全默认值都绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。
|
||||
|
||||
权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。该诊断可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前完成脱敏和限长。
|
||||
|
||||
本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。
|
||||
@@ -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: 51e76b195bcbf8ece5e1a60396f2e60f4ff71916
|
||||
config-catalog.zh.md: 2755827cca5ccc2461bfb427f1cc96ffee0a2387
|
||||
config-catalog.md: 3a471ea06e911d3d29ebef4ce80353b15a21cb43
|
||||
config-catalog.zh.md: f1b53d4a2b3c15abb3176c7af5de9c577f74825f
|
||||
|
||||
+22
-4
@@ -2090,19 +2090,29 @@ Source: [`packages/subagent/subagent-acp/src/index.ts:27`](../packages/subagent/
|
||||
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 mode fixed for this Provider instance. Defaults to
|
||||
* `dontAsk`; `acceptEdits` accepts edits, `auto` uses the native classifier,
|
||||
* `plan` returns a plan without approving execution, and
|
||||
* `bypassPermissions` explicitly skips permission checks.
|
||||
*/
|
||||
permissionMode?: ClaudeCodePermissionMode
|
||||
/** Grace in milliseconds for Claude Code process-tree termination. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
/** Profile-selectable non-interactive Claude Code permission mode. */
|
||||
export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number]
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent-claude-code/src/index.ts:32`](../packages/subagent/subagent-claude-code/src/index.ts)
|
||||
Source: [`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-subagent-codex"></a>
|
||||
|
||||
@@ -2111,19 +2121,27 @@ Source: [`packages/subagent/subagent-claude-code/src/index.ts:32`](../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>
|
||||
|
||||
|
||||
@@ -2092,19 +2092,29 @@ export type PermissionPolicy = 'allow' | 'reject'
|
||||
需要:`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 mode fixed for this Provider instance. Defaults to
|
||||
* `dontAsk`; `acceptEdits` accepts edits, `auto` uses the native classifier,
|
||||
* `plan` returns a plan without approving execution, and
|
||||
* `bypassPermissions` explicitly skips permission checks.
|
||||
*/
|
||||
permissionMode?: ClaudeCodePermissionMode
|
||||
/** Grace in milliseconds for Claude Code process-tree termination. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
/** Profile-selectable non-interactive Claude Code permission mode. */
|
||||
export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number]
|
||||
```
|
||||
|
||||
来源:[`packages/subagent/subagent-claude-code/src/index.ts:32`](../packages/subagent/subagent-claude-code/src/index.ts)
|
||||
来源:[`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-subagent-codex"></a>
|
||||
|
||||
@@ -2113,19 +2123,27 @@ export interface Config {
|
||||
需要:`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,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/subsystems/subagent.md
|
||||
subagent.md: 07ed5ce4a1da9fd68678ec14949d9ba249d7805f
|
||||
subagent.zh.md: e1b3ab465841ab16d2dc81bb12d8c49663533aa7
|
||||
subagent.md: fee5f95d4a8810959e452653ecf9d7bcf36b7387
|
||||
subagent.zh.md: 38136f27172c071af95bf3b3cce7986af499368f
|
||||
|
||||
@@ -307,7 +307,7 @@ type SubagentDescendantListEntry = SubagentListEntry & {
|
||||
|
||||
## The terminal result: `SubagentResult`
|
||||
|
||||
The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success.
|
||||
The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A provider may attach a safe, non-assistant `diagnostic` to a non-`completed` result; the provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads and limits the complete value to 4096 UTF-8 bytes before consumers present it separately from `output`. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -330,6 +330,13 @@ interface SubagentResult {
|
||||
* schema-agnostic.
|
||||
*/
|
||||
readonly structured?: unknown
|
||||
/**
|
||||
* Provider-authored, non-assistant failure detail for a non-`completed`
|
||||
* result. Providers keep this text free of tool inputs, file contents,
|
||||
* environment values, credentials, and raw protocol payloads, and limit it
|
||||
* to 4096 UTF-8 bytes. Consumers present it separately from {@link output}.
|
||||
*/
|
||||
readonly diagnostic?: string
|
||||
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
|
||||
readonly stopReason: SubagentStopReason
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ type SubagentDescendantListEntry = SubagentListEntry & {
|
||||
|
||||
## 终态结果:`SubagentResult`
|
||||
|
||||
单次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。
|
||||
单次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。提供方可以为非 `completed` 结果附带安全且不属于 assistant 内容的 `diagnostic`;在消费方将它与 `output` 分开呈现前,提供方会排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整值限制在 4096 个 UTF-8 字节以内。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -330,6 +330,13 @@ interface SubagentResult {
|
||||
* schema-agnostic.
|
||||
*/
|
||||
readonly structured?: unknown
|
||||
/**
|
||||
* Provider-authored, non-assistant failure detail for a non-`completed`
|
||||
* result. Providers keep this text free of tool inputs, file contents,
|
||||
* environment values, credentials, and raw protocol payloads, and limit it
|
||||
* to 4096 UTF-8 bytes. Consumers present it separately from {@link output}.
|
||||
*/
|
||||
readonly diagnostic?: string
|
||||
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
|
||||
readonly stopReason: SubagentStopReason
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@
|
||||
- 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:
|
||||
permissionMode: acceptEdits
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
- 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:
|
||||
permissionMode: acceptEdits
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Keyless twin of subagent-result-diagnostic.cordis.yml: keep the same test
|
||||
# provider/tool and replace only the external model adapter.
|
||||
- id: base
|
||||
name: '@deepseek-ai/cordis-plugin-include'
|
||||
config:
|
||||
path: ./cordis.yml
|
||||
patches:
|
||||
- insert:
|
||||
- id: llm-replay
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
config:
|
||||
providers:
|
||||
- id: deepseek-official
|
||||
name: DeepSeek
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
- id: deepseek-v4-pro
|
||||
- id: subagent-result-diagnostic
|
||||
name: './tests/fixtures/subagent-result-diagnostic.ts'
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: snapshot-diagnostic
|
||||
toolName: subagent_codex
|
||||
backgroundMode: one-shot
|
||||
maxDepth: provider-managed
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
disabled: true
|
||||
@@ -0,0 +1,17 @@
|
||||
# Test-only product-shaped composition: mount a deterministic provider behind
|
||||
# the same one-shot tool schema as the public Codex example.
|
||||
- id: base
|
||||
name: '@deepseek-ai/cordis-plugin-include'
|
||||
config:
|
||||
path: ./cordis.yml
|
||||
patches:
|
||||
- insert:
|
||||
- id: subagent-result-diagnostic
|
||||
name: './tests/fixtures/subagent-result-diagnostic.ts'
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: snapshot-diagnostic
|
||||
toolName: subagent_codex
|
||||
backgroundMode: one-shot
|
||||
maxDepth: provider-managed
|
||||
@@ -75,6 +75,9 @@ const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath(
|
||||
)
|
||||
const PRODUCT_SUBAGENT_CODEX_CONFIG = fileURLToPath(new URL('../product-subagent-codex.cordis.yml', import.meta.url))
|
||||
const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-both.cordis.yml', import.meta.url))
|
||||
const PRODUCT_SUBAGENT_RESULT_DIAGNOSTIC_CONFIG = fileURLToPath(
|
||||
new URL('../subagent-result-diagnostic.cordis.yml', import.meta.url),
|
||||
)
|
||||
const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url))
|
||||
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
|
||||
const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny'
|
||||
@@ -167,6 +170,14 @@ const SCENARIOS: Scenario[] = [
|
||||
systemPromptSource: 'product-subagent-codex',
|
||||
configPath: PRODUCT_SUBAGENT_BOTH_CONFIG,
|
||||
},
|
||||
{
|
||||
name: 'product-subagent-result-diagnostic',
|
||||
hasModelTurn: true,
|
||||
recorded: false,
|
||||
overridden: true,
|
||||
headerClass: 'product-subagent-codex',
|
||||
configPath: PRODUCT_SUBAGENT_RESULT_DIAGNOSTIC_CONFIG,
|
||||
},
|
||||
{
|
||||
name: 'session-title-after-turn',
|
||||
hasModelTurn: true,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Deterministic provider for model-visible foreground and Job diagnostic snapshots. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
NO_START_CAPABILITIES,
|
||||
type ResolvedSubagentStartRequest,
|
||||
type SubagentProvider,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export const name = 'subagent-result-diagnostic'
|
||||
export const inject = ['subagents']
|
||||
|
||||
const DIAGNOSTIC = 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt'
|
||||
|
||||
class DiagnosticProvider implements SubagentProvider {
|
||||
readonly name = 'snapshot-diagnostic'
|
||||
readonly capabilities = NO_START_CAPABILITIES
|
||||
readonly inheritsParentContext = false
|
||||
private starts = 0
|
||||
|
||||
async start(request: ResolvedSubagentStartRequest) {
|
||||
if (request.signal.aborted) {
|
||||
throw new Error('snapshot diagnostic provider start aborted')
|
||||
}
|
||||
const index = this.starts++
|
||||
if (index > 1) {
|
||||
throw new Error('snapshot diagnostic provider expected exactly two starts')
|
||||
}
|
||||
return {
|
||||
id: SessionId(index === 0
|
||||
? '00000000-0000-4000-8000-0000000000d1'
|
||||
: '00000000-0000-4000-8000-0000000000d2'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({
|
||||
output: index === 0
|
||||
? [{ type: 'text' as const, text: 'partial assistant text' }]
|
||||
: [],
|
||||
diagnostic: DIAGNOSTIC,
|
||||
stopReason: 'error' as const,
|
||||
}),
|
||||
dispose: async () => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the fixed provider behind the public Codex-shaped snapshot tool. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.subagents.registerProvider(new DiagnosticProvider())
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
- id: subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-subagent-claude-code'
|
||||
config:
|
||||
permissionMode: acceptEdits
|
||||
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools." }
|
||||
]
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "text" },
|
||||
{ "type": "text-delta", "index": 0, "text": "PARENT_OBSERVED_DIAGNOSTICS" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "text", "text": "PARENT_OBSERVED_DIAGNOSTICS" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } },
|
||||
{ "type": "finish", "reason": { "kind": "stop" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"}]}}
|
||||
{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Use subagent_codex in the foreground","messageSeqs":[4],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"92e33995-2f02-4ad5-aec1-9df82cf4d583"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}
|
||||
{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_diagnostic_foreground"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"4e84e7b3-40c1-488e-b119-45e8bd7ce448"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":17,"time":1786781990613,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":18,"time":1786781990618,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":19,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}}}
|
||||
{"type":"assistant/chunk","seq":22,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":23,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"2fb444e2-7a52-4963-988e-b1ecbc3744d5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}
|
||||
{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"}]}}
|
||||
{"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_diagnostic_background"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"3377f724-b4a7-4ce1-bed7-774f174917d6"}},"sourceEventSeqs":[25],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":28,"time":1786781990627,"data":{"turn":1,"step":2}}
|
||||
{"type":"agent/inbox/spliced","seq":29,"time":1786781990627,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":30,"time":1786781990632,"data":{"turn":1,"step":3}}
|
||||
{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"},"surfaceOp":"append"}
|
||||
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}
|
||||
{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}
|
||||
{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f43f988b-bc08-4811-8671-8edc0613f0d0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}
|
||||
{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_diagnostic_output"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]"}],"isError":false}],"role":"user","id":"6785120f-ae46-48d0-9f3f-d6cd1e6fc5d7"}},"sourceEventSeqs":[38],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":40,"time":1786781990640,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":41,"time":1786781990645,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":43,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DIAGNOSTICS"}}}
|
||||
{"type":"assistant/chunk","seq":44,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}}
|
||||
{"type":"assistant/chunk","seq":45,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
|
||||
{"type":"assistant/chunk","seq":46,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":47,"time":1786781990649,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"49b868e8-2608-47e0-aaf8-b308ffe8194d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":48,"time":1786781990650,"data":{"turn":1,"step":4}}
|
||||
{"type":"turn/end","seq":49,"time":1786781990650,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -52,6 +52,7 @@
|
||||
"acp-agent/tests/fixtures/parent-sandbox-override.ts",
|
||||
"acp-agent/tests/fixtures/partial-landlock-sandbox.ts",
|
||||
"acp-agent/tests/fixtures/subagent-durability-failure.ts",
|
||||
"acp-agent/tests/fixtures/subagent-result-diagnostic.ts",
|
||||
"acp-agent/tests/fixtures/subagent-report-fence.ts",
|
||||
"acp-agent/tests/fixtures/subagent-settlement-marker.ts",
|
||||
"acp-agent/tests/fixtures/workspace-context-compaction.ts",
|
||||
|
||||
@@ -4237,7 +4237,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SubagentResult',
|
||||
declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}',
|
||||
declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly diagnostic?: string;\n readonly stopReason: SubagentStopReason;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubagentRun',
|
||||
|
||||
@@ -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-claude-code/README.md
|
||||
README.md: 1a0d6e32b8610769dcc5d8342a4fe88d0c884085
|
||||
README.zh.md: 78dab14e5eaddc06ccd07b69dc952a09380e0428
|
||||
README.md: be3b2262addc487e545fed1f792600a9a5ca24c0
|
||||
README.zh.md: 7ea1b8ca7243790afd387b04d776088cea012718
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, 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 `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, submits one self-contained text task, and returns either the strict final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract.
|
||||
|
||||
## Start and ownership
|
||||
|
||||
@@ -14,9 +14,9 @@ Local cancellation wins the result race and maps to `aborted`. `dispose()` is id
|
||||
|
||||
## Native settings and interaction
|
||||
|
||||
The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state.
|
||||
The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks.
|
||||
|
||||
Each query sets `persistSession: false` and disables `AskUserQuestion`. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK instead of waiting for a user interface this provider does not own.
|
||||
Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. Plan mode also places `ExitPlanMode` in the SDK's `disallowedTools`, so native settings cannot pre-approve a transition back to execution and the model must return the completed plan as its final answer. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail.
|
||||
|
||||
## Capabilities and context
|
||||
|
||||
@@ -27,8 +27,17 @@ The provider advertises no optional start-time capabilities and reports `inherit
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. |
|
||||
| `permissionMode` | `dontAsk` | Native non-interactive permission policy fixed for every run 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. |
|
||||
|
||||
| `permissionMode` value | Native behavior |
|
||||
|---|---|
|
||||
| `dontAsk` | Deny operations that are not already authorized instead of prompting. |
|
||||
| `acceptEdits` | Accept file edits; any remaining permission prompt is denied by the unattended callback. |
|
||||
| `auto` | Let Claude Code's native classifier allow or deny permission requests. |
|
||||
| `plan` | Run in native planning mode, deny execution approval, and return the completed plan as the final answer. |
|
||||
| `bypassPermissions` | Explicitly set the SDK's dangerous confirmation and bypass permission checks. |
|
||||
|
||||
Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden.
|
||||
|
||||
Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-claude-code` and mount it once on the host plane; loading the provider starts no Claude 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_claude_code` 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.
|
||||
@@ -39,6 +48,7 @@ The standalone composition below shows the complete explicit capability. A Profi
|
||||
- id: subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-subagent-claude-code'
|
||||
config:
|
||||
permissionMode: acceptEdits
|
||||
env:
|
||||
ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY
|
||||
|
||||
@@ -69,7 +79,7 @@ The project owner's identity-scoped distribution authorization covers the offici
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from the host's native Claude settings and product installation.
|
||||
The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd; its model, system instructions, tools, sandbox, and authentication come from the host's native Claude settings and product installation, while the Provider's Profile configuration fixes the query's non-interactive permission mode.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -83,7 +93,7 @@ Independent of the parent request cache. Reuse depends only on Claude Code's own
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code 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. Claude Code reasoning, tool activity, intermediate messages, 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 strict final Claude Code 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. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, product ids, tool inputs, and raw protocol payloads are not copied into the parent Session.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -99,7 +109,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while
|
||||
- **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode.
|
||||
- **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow.
|
||||
- **The SDK platform CLI remains in the install closure** — production ignores it in favor of the host `claude`, but the current SDK optional dependency is still installed and supplies the keyless compatibility fixture. Removing that payload belongs to the separate product installation-closure follow-up.
|
||||
- **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending.
|
||||
- **Product payload is final text only** — reasoning, 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 interaction path** — `AskUserQuestion` is disabled, permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of suspending.
|
||||
- **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local, 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。
|
||||
本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回严格的最终答案或安全的失败说明。
|
||||
|
||||
## 启动与所有权
|
||||
|
||||
@@ -14,9 +14,9 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK
|
||||
|
||||
## 原生设置与交互
|
||||
|
||||
提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。
|
||||
提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。
|
||||
|
||||
每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。
|
||||
每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。Plan 模式还会把 `ExitPlanMode` 放入 SDK 的 `disallowedTools`,因此原生 settings 无法预先放行回到执行模式的转换,模型必须把完整计划作为最终答案返回。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。
|
||||
|
||||
## 能力与上下文
|
||||
|
||||
@@ -27,8 +27,17 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK
|
||||
| 配置键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 |
|
||||
| `permissionMode` | `dontAsk` | 为该提供方实例的每次运行固定原生非交互权限策略。 |
|
||||
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
|
||||
|
||||
| `permissionMode` 值 | 原生行为 |
|
||||
|---|---|
|
||||
| `dontAsk` | 不弹出提示,直接拒绝尚未获授权的操作。 |
|
||||
| `acceptEdits` | 接受文件编辑;其余权限提示由无人值守回调拒绝。 |
|
||||
| `auto` | 由 Claude Code 原生分类器允许或拒绝权限请求。 |
|
||||
| `plan` | 使用原生规划模式,拒绝执行审批,并把完整计划作为最终答案返回。 |
|
||||
| `bypassPermissions` | 显式设置 SDK 的危险确认并跳过权限检查。 |
|
||||
|
||||
生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。
|
||||
|
||||
生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-claude-code`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Claude 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。
|
||||
@@ -39,6 +48,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK
|
||||
- id: subagent-claude-code
|
||||
name: '@deepseek-ai/dsh-subagent-claude-code'
|
||||
config:
|
||||
permissionMode: acceptEdits
|
||||
env:
|
||||
ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY
|
||||
|
||||
@@ -69,7 +79,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、权限和身份验证来自宿主机原生 Claude 设置与产品安装。
|
||||
Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自宿主机原生 Claude 设置与产品安装,而提供方的 Profile 配置会固定该 query 的非交互权限模式。
|
||||
|
||||
#### 对 token 的影响
|
||||
|
||||
@@ -83,7 +93,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案,或者在结果未完成时看到消费方给出的原样错误。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案与状态,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。
|
||||
通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息、产品标识符、工具输入和原始协议载荷均不会复制到父会话。
|
||||
|
||||
#### 对 token 的影响
|
||||
|
||||
@@ -99,7 +109,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。
|
||||
- **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。
|
||||
- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。
|
||||
- **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。
|
||||
- **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。
|
||||
- **产品载荷仅包含最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部;通用 Job id、通知与状态来自共享作业运行时。
|
||||
- **没有人工交互路径**:`AskUserQuestion` 被禁用,权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败而不会挂起。
|
||||
- **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部,通用 Job id、通知与状态来自共享作业运行时。
|
||||
- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。
|
||||
- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。
|
||||
|
||||
@@ -18,29 +18,41 @@ import {
|
||||
type SubagentProvider,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
CLAUDE_CODE_PERMISSION_MODES,
|
||||
DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
|
||||
DEFAULT_DISPOSE_GRACE_MS,
|
||||
startClaudeCodeRun,
|
||||
type ClaudeCodePermissionMode,
|
||||
type ClaudeCodeRunSpec,
|
||||
} from './run.ts'
|
||||
|
||||
export const name = 'subagent-claude-code'
|
||||
export const inject = ['subagents', 'subprocess']
|
||||
|
||||
/* jscpd:ignore-start -- sibling product providers intentionally expose the
|
||||
* same two deployment-owned fields without adding a shared config owner. */
|
||||
/** Deployment-owned environment and process-release bound. */
|
||||
/* jscpd:ignore-start -- sibling product providers intentionally expose
|
||||
* overlapping deployment-owned fields without adding a shared config owner. */
|
||||
/** 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 mode fixed for this Provider instance. Defaults to
|
||||
* `dontAsk`; `acceptEdits` accepts edits, `auto` uses the native classifier,
|
||||
* `plan` returns a plan without approving execution, and
|
||||
* `bypassPermissions` explicitly skips permission checks.
|
||||
*/
|
||||
permissionMode?: ClaudeCodePermissionMode
|
||||
/** Grace in milliseconds for Claude Code process-tree termination. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
env: z.dict(z.string()).default({}),
|
||||
permissionMode: z.union([...CLAUDE_CODE_PERMISSION_MODES])
|
||||
.default(DEFAULT_CLAUDE_CODE_PERMISSION_MODE),
|
||||
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
|
||||
})
|
||||
|
||||
@@ -78,6 +90,7 @@ class ClaudeCodeProvider implements SubagentProvider {
|
||||
parentCwd,
|
||||
),
|
||||
executable,
|
||||
permissionMode: this.config.permissionMode,
|
||||
env: this.config.env,
|
||||
disposeGraceMs: this.config.disposeGraceMs,
|
||||
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
|
||||
@@ -94,10 +107,14 @@ class ClaudeCodeProvider implements SubagentProvider {
|
||||
/**
|
||||
* Register the fixed `claude-code` 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_CLAUDE_CODE_PERMISSION_MODE,
|
||||
disposeGraceMs: config.disposeGraceMs as number,
|
||||
}
|
||||
assertPositiveFinite(
|
||||
'subagent-claude-code',
|
||||
'disposeGraceMs',
|
||||
|
||||
@@ -38,6 +38,34 @@ import {
|
||||
/** Default POSIX grace between subprocess termination tiers. */
|
||||
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
|
||||
|
||||
/** Claude Code permission modes that cannot wait for a human response. */
|
||||
export const CLAUDE_CODE_PERMISSION_MODES = [
|
||||
'dontAsk',
|
||||
'acceptEdits',
|
||||
'auto',
|
||||
'plan',
|
||||
'bypassPermissions',
|
||||
] as const satisfies readonly NonNullable<Options['permissionMode']>[]
|
||||
|
||||
/** Profile-selectable non-interactive Claude Code permission mode. */
|
||||
export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number]
|
||||
|
||||
/** Safe default for unattended Claude Code runs. */
|
||||
export const DEFAULT_CLAUDE_CODE_PERMISSION_MODE: ClaudeCodePermissionMode = 'dontAsk'
|
||||
|
||||
const SUPPORTED_UNATTENDED_DIALOG_KINDS = [
|
||||
'refusal_fallback_prompt',
|
||||
] satisfies NonNullable<Options['supportedDialogKinds']>
|
||||
|
||||
function unattendedDiagnostic(
|
||||
mode: ClaudeCodePermissionMode,
|
||||
request: 'tool permission' | 'MCP elicitation' | 'user dialog',
|
||||
decision: 'denied' | 'declined' | 'cancelled',
|
||||
reason: string,
|
||||
): string {
|
||||
return `Claude Code unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}`
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- sibling providers intentionally keep product-private
|
||||
* run inputs and error normalization instead of adding a shared lifecycle owner. */
|
||||
/** Fully resolved inputs for one official Claude Agent SDK query. */
|
||||
@@ -46,6 +74,8 @@ export interface ClaudeCodeRunSpec {
|
||||
readonly cwd: string
|
||||
/** Exact native Claude Code executable resolved from the host PATH. */
|
||||
readonly executable: string
|
||||
/** Profile-selected native non-interactive permission mode. */
|
||||
readonly permissionMode: ClaudeCodePermissionMode
|
||||
/** Explicit deployment/test environment layered after shared scrubbing. */
|
||||
readonly env: Record<string, string>
|
||||
/** Subprocess termination grace passed to the shared process-tree owner. */
|
||||
@@ -107,13 +137,19 @@ export function successfulResult(message: SDKResultMessage): string {
|
||||
* Consume the complete SDK stream and require one strict success plus normal
|
||||
* iterator completion.
|
||||
* @param query - published official SDK query.
|
||||
* @param onPermissionDenied - records a safe fact when the SDK reports native denial.
|
||||
* @returns the completed shared result.
|
||||
*/
|
||||
export async function consumeClaudeQuery(
|
||||
query: AsyncIterable<SDKMessage>,
|
||||
onPermissionDenied?: () => void,
|
||||
): Promise<SubagentResult> {
|
||||
let answer: string | undefined
|
||||
for await (const message of query) {
|
||||
if (message.type === 'system' && message.subtype === 'permission_denied') {
|
||||
onPermissionDenied?.()
|
||||
continue
|
||||
}
|
||||
if (message.type !== 'result') continue
|
||||
answer = successfulResult(message)
|
||||
}
|
||||
@@ -172,12 +208,14 @@ export async function disposeClaudeCodeChild(
|
||||
* @param spec - Workspace, environment, process service, and disposal policy.
|
||||
* @param controller - per-run cancellation owner.
|
||||
* @param capture - receives the real managed child synchronously from the SDK hook.
|
||||
* @param captureDiagnostic - receives safe facts from unattended interaction callbacks.
|
||||
* @returns options that inherit native settings while disabling persistence and user questions.
|
||||
*/
|
||||
export function claudeQueryOptions(
|
||||
spec: ClaudeCodeRunSpec,
|
||||
controller: AbortController,
|
||||
capture: (child: SubprocessHandle) => void,
|
||||
captureDiagnostic: (diagnostic: string) => void,
|
||||
): Options {
|
||||
return {
|
||||
abortController: controller,
|
||||
@@ -185,7 +223,45 @@ export function claudeQueryOptions(
|
||||
pathToClaudeCodeExecutable: spec.executable,
|
||||
env: { ...scrubbedParentEnv(), ...spec.env },
|
||||
persistSession: false,
|
||||
disallowedTools: ['AskUserQuestion'],
|
||||
disallowedTools: spec.permissionMode === 'plan'
|
||||
? ['AskUserQuestion', 'ExitPlanMode']
|
||||
: ['AskUserQuestion'],
|
||||
permissionMode: spec.permissionMode,
|
||||
...spec.permissionMode === 'bypassPermissions'
|
||||
? { allowDangerouslySkipPermissions: true }
|
||||
: {
|
||||
canUseTool: () => {
|
||||
captureDiagnostic(unattendedDiagnostic(
|
||||
spec.permissionMode,
|
||||
'tool permission',
|
||||
'denied',
|
||||
'the provider does not request human approval',
|
||||
))
|
||||
return Promise.resolve({
|
||||
behavior: 'deny' as const,
|
||||
message: 'This unattended Claude Code subagent cannot request human approval.',
|
||||
})
|
||||
},
|
||||
},
|
||||
onElicitation: () => {
|
||||
captureDiagnostic(unattendedDiagnostic(
|
||||
spec.permissionMode,
|
||||
'MCP elicitation',
|
||||
'declined',
|
||||
'the provider does not collect interactive MCP input',
|
||||
))
|
||||
return Promise.resolve({ action: 'decline' })
|
||||
},
|
||||
onUserDialog: () => {
|
||||
captureDiagnostic(unattendedDiagnostic(
|
||||
spec.permissionMode,
|
||||
'user dialog',
|
||||
'cancelled',
|
||||
'the provider does not render blocking dialogs',
|
||||
))
|
||||
return Promise.resolve({ behavior: 'cancelled' as const })
|
||||
},
|
||||
supportedDialogKinds: SUPPORTED_UNATTENDED_DIALOG_KINDS,
|
||||
spawnClaudeCodeProcess: (options: SpawnOptions) => {
|
||||
const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs))
|
||||
capture(child)
|
||||
@@ -220,12 +296,21 @@ export async function startClaudeCodeRun(
|
||||
|
||||
let child: SubprocessHandle | undefined
|
||||
let query: Query | undefined
|
||||
let diagnostic: string | undefined
|
||||
const captureDiagnostic = (value: string): void => {
|
||||
diagnostic = value
|
||||
}
|
||||
try {
|
||||
query = officialQuery({
|
||||
prompt,
|
||||
options: claudeQueryOptions(spec, controller, (captured) => {
|
||||
child = captured
|
||||
}),
|
||||
options: claudeQueryOptions(
|
||||
spec,
|
||||
controller,
|
||||
(captured) => {
|
||||
child = captured
|
||||
},
|
||||
captureDiagnostic,
|
||||
),
|
||||
})
|
||||
if (child === undefined || child.pid <= 0) {
|
||||
throw new Error(
|
||||
@@ -268,8 +353,16 @@ export async function startClaudeCodeRun(
|
||||
const publishedQuery = query
|
||||
const publishedChild = child
|
||||
const result = settleRunResult({
|
||||
attempt: () => consumeClaudeQuery(publishedQuery),
|
||||
attempt: () => consumeClaudeQuery(publishedQuery, () => {
|
||||
captureDiagnostic(unattendedDiagnostic(
|
||||
spec.permissionMode,
|
||||
'tool permission',
|
||||
'denied',
|
||||
'Claude Code denied the request before an interactive prompt',
|
||||
))
|
||||
}),
|
||||
collectOutput: () => [],
|
||||
collectDiagnostic: () => diagnostic,
|
||||
cancelled: () => controller.signal.aborted,
|
||||
onError: spec.onError,
|
||||
signal: request.signal,
|
||||
|
||||
@@ -4,6 +4,12 @@ import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'nod
|
||||
export type MessagesBehavior =
|
||||
| { readonly kind: 'complete'; readonly text: string }
|
||||
| { readonly kind: 'hold' }
|
||||
| {
|
||||
readonly kind: 'tool-use'
|
||||
readonly toolName: string
|
||||
readonly input: Record<string, unknown>
|
||||
readonly finalText?: string
|
||||
}
|
||||
|
||||
/** One recorded Anthropic Messages request. */
|
||||
interface RecordedMessagesRequest {
|
||||
@@ -81,6 +87,67 @@ function complete(
|
||||
response.end()
|
||||
}
|
||||
|
||||
function toolUse(
|
||||
response: ServerResponse,
|
||||
body: Record<string, unknown>,
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
): void {
|
||||
const model = typeof body.model === 'string' ? body.model : 'fixture-model'
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive',
|
||||
})
|
||||
event(response, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: 'msg_dsh_fixture_tool_use',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 7,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
event(response, 'content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: 'toolu_dsh_fixture',
|
||||
name: toolName,
|
||||
input: {},
|
||||
},
|
||||
})
|
||||
event(response, 'content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: JSON.stringify(input),
|
||||
},
|
||||
})
|
||||
event(response, 'content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: 0,
|
||||
})
|
||||
event(response, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'tool_use', stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
})
|
||||
event(response, 'message_stop', { type: 'message_stop' })
|
||||
response.end()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a loopback-only Anthropic Messages SSE fixture.
|
||||
* @param behavior - the single response behavior for this fixture.
|
||||
@@ -118,8 +185,15 @@ export async function startMessagesFixture(
|
||||
requestStartedResolve()
|
||||
if (behavior.kind === 'complete') {
|
||||
complete(response, body, behavior.text)
|
||||
} else if (behavior.kind === 'tool-use' && requests.length === 1) {
|
||||
toolUse(response, body, behavior.toolName, behavior.input)
|
||||
} else if (
|
||||
behavior.kind === 'tool-use'
|
||||
&& behavior.finalText !== undefined
|
||||
) {
|
||||
complete(response, body, behavior.finalText)
|
||||
}
|
||||
// A hold deliberately leaves the response pending until client abort.
|
||||
// A hold, or a tool-use without final text, waits for client abort.
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
@@ -23,6 +24,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as claudeCode from '../src/index.ts'
|
||||
import type { ClaudeCodePermissionMode } from '../src/run.ts'
|
||||
import {
|
||||
startMessagesFixture,
|
||||
type MessagesBehavior,
|
||||
@@ -122,7 +124,11 @@ interface RealHarness {
|
||||
readonly executable: string
|
||||
}
|
||||
|
||||
async function realHarness(behavior: MessagesBehavior): Promise<{
|
||||
async function realHarness(
|
||||
behavior: MessagesBehavior,
|
||||
permissionMode?: ClaudeCodePermissionMode,
|
||||
nativeAllow: readonly string[] = [],
|
||||
): Promise<{
|
||||
readonly harness: RealHarness
|
||||
readonly fixture: MessagesFixture
|
||||
}> {
|
||||
@@ -144,7 +150,13 @@ async function realHarness(behavior: MessagesBehavior): Promise<{
|
||||
}
|
||||
writeFileSync(
|
||||
join(claudeConfig, 'settings.json'),
|
||||
`${JSON.stringify({ model: settingsModel }, null, 2)}\n`,
|
||||
`${JSON.stringify({
|
||||
model: settingsModel,
|
||||
permissions: {
|
||||
defaultMode: 'default',
|
||||
...nativeAllow.length === 0 ? {} : { allow: nativeAllow },
|
||||
},
|
||||
}, null, 2)}\n`,
|
||||
)
|
||||
const fixture = await startMessagesFixture(behavior)
|
||||
fixtures.push(fixture)
|
||||
@@ -177,7 +189,11 @@ async function realHarness(behavior: MessagesBehavior): Promise<{
|
||||
handles.push(handle)
|
||||
return handle
|
||||
})
|
||||
await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 })
|
||||
await ctx.plugin(claudeCode, {
|
||||
env,
|
||||
...permissionMode === undefined ? {} : { permissionMode },
|
||||
disposeGraceMs: 3_000,
|
||||
})
|
||||
const parent = {
|
||||
id: 'real-parent',
|
||||
session: { header: { cwd: workspace } },
|
||||
@@ -294,6 +310,80 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220
|
||||
await expectQuiescent(harness.handles)
|
||||
})
|
||||
|
||||
it('overrides interactive settings, denies a write, and returns a safe diagnostic', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-denied-target-'))
|
||||
roots.push(root)
|
||||
const target = join(root, 'denied.txt')
|
||||
const { harness } = await realHarness({
|
||||
kind: 'tool-use',
|
||||
toolName: 'Write',
|
||||
input: {
|
||||
file_path: target,
|
||||
content: 'SECRET_TOKEN must not reach the diagnostic',
|
||||
},
|
||||
})
|
||||
const run = await startRequest(harness, 'Write the requested fixture file.')
|
||||
await vi.waitFor(() => {
|
||||
expect(observedSdkMessages.some(message =>
|
||||
message.type === 'system'
|
||||
&& message.subtype === 'permission_denied')).toBe(true)
|
||||
}, { timeout: 30_000 })
|
||||
expect(existsSync(target)).toBe(false)
|
||||
harness.handles[0]!.terminate()
|
||||
const result = await run.result
|
||||
expect(result).toEqual({
|
||||
output: [],
|
||||
diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt',
|
||||
stopReason: 'error',
|
||||
})
|
||||
expect(result.diagnostic).not.toContain(target)
|
||||
expect(result.diagnostic).not.toContain('SECRET_TOKEN')
|
||||
await run.dispose()
|
||||
await expectQuiescent(harness.handles)
|
||||
})
|
||||
|
||||
it('runs an explicitly selected bypass write in the isolated workspace', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-bypass-target-'))
|
||||
roots.push(root)
|
||||
const target = join(root, 'bypass.txt')
|
||||
const { harness } = await realHarness({
|
||||
kind: 'tool-use',
|
||||
toolName: 'Write',
|
||||
input: {
|
||||
file_path: target,
|
||||
content: 'bypass write completed',
|
||||
},
|
||||
finalText: 'write complete',
|
||||
}, 'bypassPermissions')
|
||||
const run = await startRequest(harness, 'Write the requested fixture file.')
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'write complete' }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
expect(readFileSync(target, 'utf8')).toBe('bypass write completed')
|
||||
await run.dispose()
|
||||
await expectQuiescent(harness.handles)
|
||||
})
|
||||
|
||||
it('returns the completed plan without approving execution', async () => {
|
||||
const { harness, fixture } = await realHarness({
|
||||
kind: 'tool-use',
|
||||
toolName: 'ExitPlanMode',
|
||||
input: {},
|
||||
finalText: 'PLAN_ONLY_RESULT',
|
||||
}, 'plan', ['ExitPlanMode'])
|
||||
const run = await startRequest(harness, 'Design the fixture change without implementing it.')
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'PLAN_ONLY_RESULT' }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
expect(fixture.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(fixture.requests[1]?.body.messages))
|
||||
.toContain('ExitPlanMode exists but is not enabled in this context')
|
||||
await run.dispose()
|
||||
await expectQuiescent(harness.handles)
|
||||
})
|
||||
|
||||
it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => {
|
||||
const { harness, fixture } = await realHarness({ kind: 'hold' })
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
Options,
|
||||
Query,
|
||||
SDKMessage,
|
||||
SDKPermissionDeniedMessage,
|
||||
SDKResultMessage,
|
||||
SpawnOptions,
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
@@ -36,6 +37,8 @@ import {
|
||||
sdkEnvironmentOverlay,
|
||||
} from '../src/process.ts'
|
||||
import {
|
||||
CLAUDE_CODE_PERMISSION_MODES,
|
||||
DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
|
||||
claudeQueryOptions,
|
||||
consumeClaudeQuery,
|
||||
disposeClaudeCodeChild,
|
||||
@@ -189,6 +192,20 @@ function failure(
|
||||
} as SDKResultMessage
|
||||
}
|
||||
|
||||
function permissionDenied(): SDKPermissionDeniedMessage {
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'permission_denied',
|
||||
tool_name: 'Bash',
|
||||
tool_use_id: 'tool-secret',
|
||||
decision_reason_type: 'mode',
|
||||
decision_reason: 'contains /private/secret.txt',
|
||||
message: 'command with SECRET_TOKEN was denied',
|
||||
uuid: '00000000-0000-4000-8000-000000000001',
|
||||
session_id: 'session-secret',
|
||||
}
|
||||
}
|
||||
|
||||
function queryFrom(
|
||||
messages: readonly SDKMessage[],
|
||||
after?: Error,
|
||||
@@ -249,6 +266,7 @@ function fakeRun(
|
||||
const spec: ClaudeCodeRunSpec = {
|
||||
cwd: '/workspace',
|
||||
executable: '/native/claude',
|
||||
permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
|
||||
env: { ANTHROPIC_API_KEY: 'fake-key' },
|
||||
disposeGraceMs: 5,
|
||||
spawn: (spawnSpec) => {
|
||||
@@ -325,6 +343,27 @@ describe('task admission and package contracts', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('accepts only the five fixed non-interactive permission modes', () => {
|
||||
expect(claudeCode.Config({}).permissionMode)
|
||||
.toBe(DEFAULT_CLAUDE_CODE_PERMISSION_MODE)
|
||||
for (const permissionMode of CLAUDE_CODE_PERMISSION_MODES) {
|
||||
expect(claudeCode.Config({ permissionMode }).permissionMode)
|
||||
.toBe(permissionMode)
|
||||
}
|
||||
for (const permissionMode of ['default', 'interactive', 'future-mode']) {
|
||||
expect(() => claudeCode.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)
|
||||
claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
|
||||
expect(ctx.subagents.getProvider('claude-code')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('starts through the registered provider with its resolved config and diagnostics', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
@@ -341,6 +380,7 @@ describe('task admission and package contracts', () => {
|
||||
CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config',
|
||||
HOME: '/private/tmp/dsh-claude-code-unit-home',
|
||||
},
|
||||
permissionMode: 'auto',
|
||||
disposeGraceMs: 29,
|
||||
})
|
||||
|
||||
@@ -377,6 +417,7 @@ describe('task admission and package contracts', () => {
|
||||
)
|
||||
expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable)
|
||||
.toBe('/native/claude')
|
||||
expect(queryMock.mock.calls[0]?.[0].options.permissionMode).toBe('auto')
|
||||
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
cwd: process.cwd(),
|
||||
graceMs: 29,
|
||||
@@ -530,16 +571,18 @@ describe('official spawn projection', () => {
|
||||
})
|
||||
|
||||
describe('query options and result mapping', () => {
|
||||
it('builds the fixed unattended options over the scrubbed environment', () => {
|
||||
it('builds the fixed unattended options over the scrubbed environment', async () => {
|
||||
vi.stubEnv('HOST_VISIBLE', 'visible')
|
||||
vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak')
|
||||
vi.stubEnv('DSH_INTERNAL', 'must-not-leak')
|
||||
const child = fakeChild()
|
||||
const spawn = vi.fn(() => child.handle)
|
||||
const captured: SubprocessHandle[] = []
|
||||
const diagnostics: string[] = []
|
||||
const spec: ClaudeCodeRunSpec = {
|
||||
cwd: '/workspace',
|
||||
executable: '/native/claude',
|
||||
permissionMode: 'acceptEdits',
|
||||
env: {
|
||||
HOST_VISIBLE: 'overridden',
|
||||
ANTHROPIC_API_KEY: 'explicit-fake-key',
|
||||
@@ -548,9 +591,14 @@ describe('query options and result mapping', () => {
|
||||
spawn,
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const options = claudeQueryOptions(spec, controller, (value) => {
|
||||
captured.push(value)
|
||||
})
|
||||
const options = claudeQueryOptions(
|
||||
spec,
|
||||
controller,
|
||||
(value) => {
|
||||
captured.push(value)
|
||||
},
|
||||
value => diagnostics.push(value),
|
||||
)
|
||||
|
||||
expect(options).toMatchObject({
|
||||
abortController: controller,
|
||||
@@ -558,22 +606,55 @@ describe('query options and result mapping', () => {
|
||||
pathToClaudeCodeExecutable: '/native/claude',
|
||||
persistSession: false,
|
||||
disallowedTools: ['AskUserQuestion'],
|
||||
permissionMode: 'acceptEdits',
|
||||
supportedDialogKinds: ['refusal_fallback_prompt'],
|
||||
})
|
||||
expect(options).not.toHaveProperty('allowDangerouslySkipPermissions')
|
||||
expect(options.env).toMatchObject({
|
||||
HOST_VISIBLE: 'overridden',
|
||||
ANTHROPIC_API_KEY: 'explicit-fake-key',
|
||||
})
|
||||
expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN')
|
||||
expect(options.env).not.toHaveProperty('DSH_INTERNAL')
|
||||
for (const omitted of [
|
||||
'settingSources',
|
||||
'canUseTool',
|
||||
'onElicitation',
|
||||
'onUserDialog',
|
||||
'supportedDialogKinds',
|
||||
]) {
|
||||
expect(options).not.toHaveProperty(omitted)
|
||||
}
|
||||
expect(options).not.toHaveProperty('settingSources')
|
||||
|
||||
const callbackSignal = new AbortController().signal
|
||||
await expect(options.canUseTool!(
|
||||
'Bash',
|
||||
{ command: 'cat /private/secret.txt', token: 'SECRET_TOKEN' },
|
||||
{
|
||||
signal: callbackSignal,
|
||||
toolUseID: 'tool-1',
|
||||
requestId: 'request-1',
|
||||
blockedPath: '/private/secret.txt',
|
||||
decisionReason: 'SECRET_TOKEN in /private/secret.txt',
|
||||
},
|
||||
)).resolves.toEqual({
|
||||
behavior: 'deny',
|
||||
message: 'This unattended Claude Code subagent cannot request human approval.',
|
||||
})
|
||||
await expect(options.onElicitation!(
|
||||
{
|
||||
serverName: 'private-server',
|
||||
message: 'enter SECRET_TOKEN',
|
||||
requestedSchema: { secret: true },
|
||||
},
|
||||
{ signal: callbackSignal },
|
||||
)).resolves.toEqual({ action: 'decline' })
|
||||
await expect(options.onUserDialog!(
|
||||
{
|
||||
dialogKind: 'refusal_fallback_prompt',
|
||||
payload: { path: '/private/secret.txt', token: 'SECRET_TOKEN' },
|
||||
},
|
||||
{ signal: callbackSignal },
|
||||
)).resolves.toEqual({ behavior: 'cancelled' })
|
||||
expect(diagnostics).toEqual([
|
||||
'Claude Code unattended decision (mode: acceptEdits; request: tool permission; decision: denied): the provider does not request human approval',
|
||||
'Claude Code unattended decision (mode: acceptEdits; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
|
||||
'Claude Code unattended decision (mode: acceptEdits; request: user dialog; decision: cancelled): the provider does not render blocking dialogs',
|
||||
])
|
||||
expect(diagnostics.join('\n')).not.toContain('SECRET_TOKEN')
|
||||
expect(diagnostics.join('\n')).not.toContain('/private/secret.txt')
|
||||
|
||||
const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions())
|
||||
expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess)
|
||||
@@ -585,6 +666,48 @@ describe('query options and result mapping', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it.each(CLAUDE_CODE_PERMISSION_MODES)(
|
||||
'maps the %s mode and only confirms the dangerous bypass',
|
||||
(permissionMode) => {
|
||||
const child = fakeChild()
|
||||
const options = claudeQueryOptions({
|
||||
cwd: '/workspace',
|
||||
executable: '/native/claude',
|
||||
permissionMode,
|
||||
env: {},
|
||||
disposeGraceMs: 17,
|
||||
spawn: () => child.handle,
|
||||
}, new AbortController(), () => {}, () => {})
|
||||
expect(options.permissionMode).toBe(permissionMode)
|
||||
expect(options.disallowedTools).toEqual(permissionMode === 'plan'
|
||||
? ['AskUserQuestion', 'ExitPlanMode']
|
||||
: ['AskUserQuestion'])
|
||||
if (permissionMode === 'bypassPermissions') {
|
||||
expect(options.allowDangerouslySkipPermissions).toBe(true)
|
||||
expect(options).not.toHaveProperty('canUseTool')
|
||||
} else {
|
||||
expect(options).not.toHaveProperty('allowDangerouslySkipPermissions')
|
||||
expect(options.canUseTool).toBeTypeOf('function')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('disallows ExitPlanMode before native plan-mode allow rules', () => {
|
||||
const child = fakeChild()
|
||||
const options = claudeQueryOptions({
|
||||
cwd: '/workspace',
|
||||
executable: '/native/claude',
|
||||
permissionMode: 'plan',
|
||||
env: {},
|
||||
disposeGraceMs: 17,
|
||||
spawn: () => child.handle,
|
||||
}, new AbortController(), () => {}, () => {})
|
||||
expect(options.disallowedTools).toEqual([
|
||||
'AskUserQuestion',
|
||||
'ExitPlanMode',
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts only a non-error success with a non-blank final result', () => {
|
||||
expect(successfulResult(success('exact final'))).toBe('exact final')
|
||||
expect(() => successfulResult(success('answer', true)))
|
||||
@@ -614,6 +737,16 @@ describe('query options and result mapping', () => {
|
||||
await expect(consumeClaudeQuery(
|
||||
queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
|
||||
)).rejects.toThrow('ended without a result')
|
||||
|
||||
const onPermissionDenied = vi.fn()
|
||||
await expect(consumeClaudeQuery(queryFrom([
|
||||
permissionDenied(),
|
||||
success('after denial'),
|
||||
]), onPermissionDenied)).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'after denial' }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
expect(onPermissionDenied).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -667,6 +800,62 @@ describe('run publication, cancellation, and settlement', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches a safe diagnostic when a permission denial precedes failure', async () => {
|
||||
const fixture = fakeRun([
|
||||
permissionDenied(),
|
||||
failure('error_during_execution'),
|
||||
])
|
||||
const run = await startClaudeCodeRun(request(), fixture.spec)
|
||||
const result = await run.result
|
||||
expect(result).toEqual({
|
||||
output: [],
|
||||
diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt',
|
||||
stopReason: 'error',
|
||||
})
|
||||
expect(result.diagnostic).not.toContain('SECRET_TOKEN')
|
||||
expect(result.diagnostic).not.toContain('/private/secret.txt')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('omits captured diagnostics on success and isolates concurrent runs', async () => {
|
||||
const children = [fakeChild(), fakeChild()]
|
||||
let childIndex = 0
|
||||
const spec: ClaudeCodeRunSpec = {
|
||||
cwd: '/workspace',
|
||||
executable: '/native/claude',
|
||||
permissionMode: 'dontAsk',
|
||||
env: {},
|
||||
disposeGraceMs: 5,
|
||||
spawn: () => children[childIndex++]!.handle,
|
||||
}
|
||||
queryMock.mockImplementation(({ prompt, options }) => {
|
||||
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
|
||||
return prompt === 'denied then completed'
|
||||
? queryFrom([permissionDenied(), success('completed answer')])
|
||||
: queryFrom([failure('error_during_execution')])
|
||||
})
|
||||
|
||||
const [completed, failed] = await Promise.all([
|
||||
startClaudeCodeRun(
|
||||
request([{ type: 'text', text: 'denied then completed' }]),
|
||||
spec,
|
||||
),
|
||||
startClaudeCodeRun(
|
||||
request([{ type: 'text', text: 'unrelated failure' }]),
|
||||
spec,
|
||||
),
|
||||
])
|
||||
await expect(completed.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'completed answer' }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
await expect(failed.result).resolves.toEqual({
|
||||
output: [],
|
||||
stopReason: 'error',
|
||||
})
|
||||
await Promise.all([completed.dispose(), failed.dispose()])
|
||||
})
|
||||
|
||||
it('fails closed when iteration rejects after a result', async () => {
|
||||
const fixture = fakeRun(
|
||||
[success('partial final')],
|
||||
@@ -704,6 +893,7 @@ describe('run publication, cancellation, and settlement', () => {
|
||||
const spec: ClaudeCodeRunSpec = {
|
||||
cwd: '/workspace',
|
||||
executable: '/native/claude',
|
||||
permissionMode: 'dontAsk',
|
||||
env: {},
|
||||
disposeGraceMs: 5,
|
||||
spawn: () => children[index++]!.handle,
|
||||
@@ -755,6 +945,7 @@ describe('run publication, cancellation, and settlement', () => {
|
||||
{
|
||||
cwd: '/workspace',
|
||||
executable: '/native/claude',
|
||||
permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
|
||||
env: {},
|
||||
disposeGraceMs: 5,
|
||||
spawn: () => child.handle,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 深度强制约束。
|
||||
- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
@@ -24,6 +25,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'
|
||||
|
||||
/** Native non-interactive Codex modes mapped to official `thread/start` fields. */
|
||||
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 +62,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 +144,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 +152,33 @@ 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 => {
|
||||
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
||||
wire.observeStderr(bytes.toString())
|
||||
try {
|
||||
// Synchronous fd forwarding preserves byte order without owning a
|
||||
// backpressure queue. A slow host sink can block this event-loop turn.
|
||||
writeFileSync(process.stderr.fd, bytes)
|
||||
} catch {
|
||||
// Host stderr is an observation sink, not a child-run failure authority.
|
||||
}
|
||||
}
|
||||
const onStderrError = (): void => {
|
||||
// Stderr observation is auxiliary. JSON-RPC and child.done remain the
|
||||
// only terminal authorities if the diagnostic stream itself fails.
|
||||
}
|
||||
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(
|
||||
@@ -178,11 +222,21 @@ export async function startCodexRun(
|
||||
|
||||
const collectOutput = (): ContentBlock[] => wire.collectOutput()
|
||||
const result: Promise<SubagentResult> = settleRunResult({
|
||||
attempt: () => Promise.race([
|
||||
wire.runTurn(texts, runAbort.signal),
|
||||
processFailure,
|
||||
]),
|
||||
attempt: async () => {
|
||||
try {
|
||||
return await Promise.race([
|
||||
wire.runTurn(texts, runAbort.signal),
|
||||
processFailure,
|
||||
])
|
||||
} catch (error: unknown) {
|
||||
// Give stderr data already queued in Node one turn to reach the wire
|
||||
// before settlement snapshots the diagnostic; later OS data is best-effort.
|
||||
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
collectOutput,
|
||||
collectDiagnostic: () => wire.collectDiagnostic(),
|
||||
cancelled: () => runAbort.signal.aborted,
|
||||
onError: spec.onError,
|
||||
signal: request.signal,
|
||||
|
||||
@@ -11,9 +11,57 @@ 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 stderrSignatureTail(value: string): string {
|
||||
for (
|
||||
let length = Math.min(STDERR_SIGNATURE_TAIL_CHARS, value.length)
|
||||
; length > 0
|
||||
; length -= 1
|
||||
) {
|
||||
const tail = value.slice(-length)
|
||||
if (STDERR_PERMISSION_SIGNATURES.some(signature =>
|
||||
tail.length < signature.text.length && signature.text.startsWith(tail))) {
|
||||
return tail
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
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 +95,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))
|
||||
@@ -86,18 +152,33 @@ export class CodexAppServerWire {
|
||||
private threadId: string | undefined
|
||||
private turnId: string | undefined
|
||||
private pendingTurnId: string | undefined
|
||||
private turnCompleted: PromiseWithResolvers<JsonObject> | undefined
|
||||
private turnCompleted: PromiseWithResolvers<{
|
||||
readonly params: JsonObject
|
||||
readonly order: number
|
||||
}> | undefined
|
||||
private readonly earlyTurnNotifications: Array<{
|
||||
readonly method: string
|
||||
readonly params: JsonObject
|
||||
readonly order: number
|
||||
}> = []
|
||||
private lastFinalAnswer: string | undefined
|
||||
private lastUnphasedAnswer: string | undefined
|
||||
private diagnostic: string | undefined
|
||||
private diagnosticOrder = 0
|
||||
private observationOrder = 0
|
||||
private pendingDiagnostic: {
|
||||
readonly order: number
|
||||
readonly request: Parameters<typeof unattendedDiagnostic>[1]
|
||||
readonly decision: Parameters<typeof unattendedDiagnostic>[2]
|
||||
readonly reason: string
|
||||
} | undefined
|
||||
private stderrTail = ''
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
private readonly input: Readable,
|
||||
output: Writable,
|
||||
private readonly permissionMode: CodexPermissionMode,
|
||||
) {
|
||||
this.transport = new JsonRpcLineTransport(input, output)
|
||||
// Fatal protocol state can arrive after the current guarded operation has
|
||||
@@ -154,6 +235,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')
|
||||
@@ -174,7 +256,10 @@ export class CodexAppServerWire {
|
||||
texts: readonly string[],
|
||||
signal: AbortSignal,
|
||||
): Promise<SubagentResult> {
|
||||
const completion = Promise.withResolvers<JsonObject>()
|
||||
const completion = Promise.withResolvers<{
|
||||
readonly params: JsonObject
|
||||
readonly order: number
|
||||
}>()
|
||||
this.turnCompleted = completion
|
||||
const threadId = this.threadId as string
|
||||
const response = object(await this.guarded(this.transport.request('turn/start', {
|
||||
@@ -185,14 +270,25 @@ export class CodexAppServerWire {
|
||||
this.commitTurnId(string(turn.id, 'turn/start turn id'))
|
||||
|
||||
const completed = await this.guarded(completion.promise, signal)
|
||||
const terminal = object(completed.turn, 'turn/completed turn')
|
||||
const terminal = object(completed.params.turn, 'turn/completed turn')
|
||||
const status = terminal.status
|
||||
if (isContextWindowExceeded(terminal)) {
|
||||
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',
|
||||
completed.order,
|
||||
)
|
||||
}
|
||||
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 +322,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 = stderrSignatureTail(observed)
|
||||
}
|
||||
|
||||
/** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
@@ -270,42 +396,162 @@ export class CodexAppServerWire {
|
||||
throw new Error('subagent-codex: turn/start response did not match the active turn')
|
||||
}
|
||||
this.turnId = id
|
||||
const pendingDiagnostic = this.pendingDiagnostic
|
||||
this.pendingDiagnostic = undefined
|
||||
if (pendingDiagnostic !== undefined) {
|
||||
this.recordDiagnostic(
|
||||
pendingDiagnostic.request,
|
||||
pendingDiagnostic.decision,
|
||||
pendingDiagnostic.reason,
|
||||
pendingDiagnostic.order,
|
||||
)
|
||||
}
|
||||
const notifications = this.earlyTurnNotifications.splice(0)
|
||||
for (const notification of notifications) {
|
||||
this.handleNotification(notification.method, notification.params)
|
||||
this.handleNotification(
|
||||
notification.method,
|
||||
notification.params,
|
||||
notification.order,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private validateRunIds(params: JsonObject, nullableTurn = false): void {
|
||||
/**
|
||||
* Validate the request's thread and turn association.
|
||||
* @returns `true` when the matching turn is still provisional, so the caller
|
||||
* defers its diagnostic until `commitTurnId()`.
|
||||
*/
|
||||
private validateRunIds(
|
||||
params: JsonObject,
|
||||
nullableTurn = false,
|
||||
): boolean {
|
||||
if (params.threadId !== this.threadId) {
|
||||
throw new Error('subagent-codex: app-server request referenced another thread')
|
||||
}
|
||||
if (nullableTurn && params.turnId === null) return
|
||||
if (nullableTurn && params.turnId === null) return false
|
||||
const id = string(params.turnId, 'server request turn id')
|
||||
if (this.turnId === undefined) {
|
||||
this.observePendingTurnId(id)
|
||||
return
|
||||
return true
|
||||
}
|
||||
if (id !== this.turnId) {
|
||||
throw new Error('subagent-codex: app-server request referenced another turn')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private recordRequestDiagnostic(
|
||||
provisional: boolean,
|
||||
request: Parameters<typeof unattendedDiagnostic>[1],
|
||||
decision: Parameters<typeof unattendedDiagnostic>[2],
|
||||
reason: string,
|
||||
): void {
|
||||
const order = this.nextObservationOrder()
|
||||
if (provisional) {
|
||||
this.pendingDiagnostic = {
|
||||
order,
|
||||
request,
|
||||
decision,
|
||||
reason,
|
||||
}
|
||||
return
|
||||
}
|
||||
this.recordDiagnostic(request, decision, reason, order)
|
||||
}
|
||||
|
||||
private recordDiagnostic(
|
||||
request: Parameters<typeof unattendedDiagnostic>[1],
|
||||
decision: Parameters<typeof unattendedDiagnostic>[2],
|
||||
reason: string,
|
||||
order = this.nextObservationOrder(),
|
||||
): void {
|
||||
if (order < this.diagnosticOrder) return
|
||||
this.diagnosticOrder = order
|
||||
this.diagnostic = unattendedDiagnostic(
|
||||
this.permissionMode,
|
||||
request,
|
||||
decision,
|
||||
reason,
|
||||
)
|
||||
}
|
||||
|
||||
private nextObservationOrder(): number {
|
||||
this.observationOrder += 1
|
||||
return this.observationOrder
|
||||
}
|
||||
|
||||
private recordDeclinedItem(item: JsonObject, order?: number): boolean {
|
||||
if (item.type === 'commandExecution' && item.status === 'declined') {
|
||||
this.recordDiagnostic(
|
||||
'command execution',
|
||||
'declined',
|
||||
'Codex declined the command under the selected permission mode',
|
||||
order,
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (item.type === 'fileChange' && item.status === 'declined') {
|
||||
this.recordDiagnostic(
|
||||
'file change',
|
||||
'declined',
|
||||
'Codex declined the file change under the selected permission mode',
|
||||
order,
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private handleServerRequest(method: string, params: JsonObject): Promise<unknown> {
|
||||
try {
|
||||
switch (method) {
|
||||
case 'item/commandExecution/requestApproval':
|
||||
{
|
||||
const provisional = this.validateRunIds(params)
|
||||
const decision = unattendedDecision(params)
|
||||
this.recordRequestDiagnostic(
|
||||
provisional,
|
||||
'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 provisional = this.validateRunIds(params)
|
||||
const decision = unattendedDecision(params)
|
||||
this.recordRequestDiagnostic(
|
||||
provisional,
|
||||
'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.recordRequestDiagnostic(
|
||||
this.validateRunIds(params),
|
||||
'permission grant',
|
||||
'denied',
|
||||
'the provider grants no additional turn permissions',
|
||||
)
|
||||
return Promise.resolve({ permissions: {}, scope: 'turn' })
|
||||
case 'item/tool/requestUserInput':
|
||||
this.validateRunIds(params)
|
||||
this.recordRequestDiagnostic(
|
||||
this.validateRunIds(params),
|
||||
'user input',
|
||||
'empty response',
|
||||
'the provider does not collect interactive answers',
|
||||
)
|
||||
return Promise.resolve({ answers: {} })
|
||||
case 'mcpServer/elicitation/request':
|
||||
this.validateRunIds(params, true)
|
||||
this.recordRequestDiagnostic(
|
||||
this.validateRunIds(params, true),
|
||||
'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)}`)
|
||||
@@ -317,7 +563,11 @@ export class CodexAppServerWire {
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotification(method: string, params: JsonObject): void {
|
||||
private handleNotification(
|
||||
method: string,
|
||||
params: JsonObject,
|
||||
order?: number,
|
||||
): void {
|
||||
if (method === 'turn/started') {
|
||||
const threadId = string(params.threadId, 'turn/started thread id')
|
||||
if (threadId !== this.threadId) return
|
||||
@@ -334,12 +584,17 @@ export class CodexAppServerWire {
|
||||
if (this.turnId === undefined) {
|
||||
if (this.turnCompleted !== undefined) {
|
||||
this.observePendingTurnId(id)
|
||||
this.earlyTurnNotifications.push({ method, params })
|
||||
this.earlyTurnNotifications.push({
|
||||
method,
|
||||
params,
|
||||
order: this.nextObservationOrder(),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (id !== this.turnId) return
|
||||
const item = object(params.item, 'item/completed item')
|
||||
if (this.recordDeclinedItem(item, order)) return
|
||||
if (item.type !== 'agentMessage') return
|
||||
const text = typeof item.text === 'string'
|
||||
? item.text
|
||||
@@ -362,13 +617,20 @@ export class CodexAppServerWire {
|
||||
if (turnCompleted === undefined) return
|
||||
if (this.turnId === undefined) {
|
||||
this.observePendingTurnId(id)
|
||||
this.earlyTurnNotifications.push({ method, params })
|
||||
this.earlyTurnNotifications.push({
|
||||
method,
|
||||
params,
|
||||
order: this.nextObservationOrder(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (id !== this.turnId) return
|
||||
if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) {
|
||||
throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`)
|
||||
}
|
||||
turnCompleted.resolve(params)
|
||||
turnCompleted.resolve({
|
||||
params,
|
||||
order: order ?? this.nextObservationOrder(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
@@ -24,6 +26,38 @@ import {
|
||||
} from '../src/run.ts'
|
||||
import { CodexAppServerWire } from '../src/wire.ts'
|
||||
|
||||
const { hostStderrWrite } = vi.hoisted(() => ({
|
||||
hostStderrWrite: {
|
||||
capture: false,
|
||||
failNext: false,
|
||||
chunks: [] as Buffer[],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
return {
|
||||
...actual,
|
||||
writeFileSync(
|
||||
fd: number,
|
||||
value: string | Uint8Array,
|
||||
): void {
|
||||
if (fd === 2 && hostStderrWrite.capture) {
|
||||
if (hostStderrWrite.failNext) {
|
||||
hostStderrWrite.failNext = false
|
||||
throw Object.assign(new Error('host stderr broke'), { code: 'EIO' })
|
||||
}
|
||||
const bytes = typeof value === 'string'
|
||||
? Buffer.from(value)
|
||||
: Buffer.from(value.buffer, value.byteOffset, value.byteLength)
|
||||
hostStderrWrite.chunks.push(bytes)
|
||||
return
|
||||
}
|
||||
actual.writeFileSync(fd, value)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
const fakeParent = {
|
||||
@@ -101,6 +135,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 +145,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 +195,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
|
||||
pid: options.pid ?? 1234,
|
||||
stdin: toChild,
|
||||
stdout: fromChild,
|
||||
stderr: undefined,
|
||||
stderr,
|
||||
collected: {},
|
||||
done,
|
||||
terminate,
|
||||
@@ -170,6 +206,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
|
||||
peer,
|
||||
fromChild,
|
||||
toChild,
|
||||
stderr,
|
||||
settle,
|
||||
fail,
|
||||
terminate,
|
||||
@@ -177,12 +214,21 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultWire(child: FakeChild): CodexAppServerWire {
|
||||
return new CodexAppServerWire(
|
||||
child.handle.stdout!,
|
||||
child.handle.stdin!,
|
||||
DEFAULT_CODEX_PERMISSION_MODE,
|
||||
)
|
||||
}
|
||||
|
||||
function runSpec(
|
||||
child: FakeChild,
|
||||
overrides: Partial<CodexRunSpec> = {},
|
||||
): CodexRunSpec {
|
||||
return {
|
||||
cwd: process.cwd(),
|
||||
permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
|
||||
env: {},
|
||||
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
||||
spawn: () => child.handle,
|
||||
@@ -195,7 +241,7 @@ async function initializeWire(): Promise<{
|
||||
readonly wire: CodexAppServerWire
|
||||
}> {
|
||||
const child = fakeChild()
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
wire.start()
|
||||
const initializing = wire.initialize(new AbortController().signal)
|
||||
const initialize = await child.peer.nextMethod('initialize')
|
||||
@@ -260,7 +306,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 +319,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 +362,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)
|
||||
@@ -363,7 +466,7 @@ describe('task admission and package contracts', () => {
|
||||
describe('CodexAppServerWire', () => {
|
||||
it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => {
|
||||
const child = fakeChild()
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
expect(wire.collectOutput()).toEqual([])
|
||||
wire.start()
|
||||
|
||||
@@ -386,7 +489,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
|
||||
|
||||
@@ -473,7 +580,7 @@ describe('CodexAppServerWire', () => {
|
||||
it('rejects invalid handshake, thread, and turn response shapes', async () => {
|
||||
{
|
||||
const child = fakeChild()
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
wire.start()
|
||||
const pending = wire.initialize(new AbortController().signal)
|
||||
const frame = await child.peer.nextMethod('initialize')
|
||||
@@ -483,7 +590,7 @@ describe('CodexAppServerWire', () => {
|
||||
}
|
||||
{
|
||||
const child = fakeChild()
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
wire.start()
|
||||
const pending = wire.startThread('/workspace', new AbortController().signal)
|
||||
const frame = await child.peer.nextMethod('thread/start')
|
||||
@@ -586,15 +693,31 @@ 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()).toBeUndefined()
|
||||
|
||||
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
|
||||
await nextTask()
|
||||
expect(wire.collectDiagnostic()).toBe(
|
||||
'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
|
||||
)
|
||||
const requests = [
|
||||
{
|
||||
id: 'command-decline',
|
||||
method: 'item/commandExecution/requestApproval',
|
||||
params: {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
availableDecisions: ['decline'],
|
||||
},
|
||||
result: { decision: 'decline' },
|
||||
diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: declined): the provider does not grant interactive approval',
|
||||
},
|
||||
{
|
||||
id: 'file',
|
||||
method: 'item/fileChange/requestApproval',
|
||||
@@ -604,30 +727,46 @@ 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-cancel',
|
||||
method: 'item/fileChange/requestApproval',
|
||||
params: {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
availableDecisions: ['cancel'],
|
||||
},
|
||||
result: { decision: 'cancel' },
|
||||
diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: cancelled): 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,13 +774,205 @@ 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 declined command and file items without retaining their payloads', 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
await nextTask()
|
||||
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('/private/secret.txt')
|
||||
|
||||
child.peer.send(
|
||||
{
|
||||
method: 'item/completed',
|
||||
params: {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
item: {
|
||||
type: 'fileChange',
|
||||
status: 'declined',
|
||||
patch: 'SECRET_TOKEN in /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: file change; decision: declined): Codex declined the file change 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('recorded sandbox violation:')
|
||||
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()
|
||||
})
|
||||
|
||||
it('keeps a newer request diagnostic after replaying an older early item', async () => {
|
||||
const { child, wire } = await initializeWire()
|
||||
const result = wire.runTurn(['task'], new AbortController().signal)
|
||||
const turnStart = await child.peer.nextMethod('turn/start')
|
||||
child.peer.send({
|
||||
method: 'item/completed',
|
||||
params: {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
item: { type: 'fileChange', status: 'declined' },
|
||||
},
|
||||
})
|
||||
await nextTask()
|
||||
child.peer.send({
|
||||
id: 'newer-command-request',
|
||||
method: 'item/commandExecution/requestApproval',
|
||||
params: {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
availableDecisions: ['cancel'],
|
||||
},
|
||||
})
|
||||
await child.peer.nextResponse('newer-command-request')
|
||||
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
|
||||
child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
|
||||
await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
expect(wire.collectDiagnostic()).toContain('request: command approval')
|
||||
wire.close()
|
||||
})
|
||||
|
||||
it('keeps a newer stderr fact after replaying an older early terminal', async () => {
|
||||
hostStderrWrite.capture = true
|
||||
hostStderrWrite.chunks.length = 0
|
||||
const { child, wire } = await initializeWire()
|
||||
const result = wire.runTurn(['task'], new AbortController().signal)
|
||||
const turnStart = await child.peer.nextMethod('turn/start')
|
||||
child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
|
||||
message: 'sandbox failure',
|
||||
codexErrorInfo: 'sandboxError',
|
||||
}))
|
||||
await nextTask()
|
||||
wire.observeStderr('approval policy is Never; reject command')
|
||||
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
|
||||
await expect(result).rejects.toThrow('sandboxError')
|
||||
expect(wire.collectDiagnostic()).toContain('request: command execution')
|
||||
wire.close()
|
||||
hostStderrWrite.capture = false
|
||||
})
|
||||
|
||||
it('fails the run on unknown requests or wrong request association', async () => {
|
||||
for (const serverRequest of [
|
||||
{
|
||||
@@ -704,6 +1035,44 @@ describe('CodexAppServerWire', () => {
|
||||
wire.close()
|
||||
})
|
||||
|
||||
it('does not retain a diagnostic from a mismatched early item', async () => {
|
||||
const { child, wire } = await initializeWire()
|
||||
const result = wire.runTurn(['task'], new AbortController().signal)
|
||||
const turnStart = await child.peer.nextMethod('turn/start')
|
||||
child.peer.send({
|
||||
method: 'item/completed',
|
||||
params: {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-early',
|
||||
item: { type: 'fileChange', status: 'declined' },
|
||||
},
|
||||
})
|
||||
child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
|
||||
await expect(result).rejects.toThrow('did not match the active turn')
|
||||
expect(wire.collectDiagnostic()).toBeUndefined()
|
||||
wire.close()
|
||||
})
|
||||
|
||||
it('does not retain a diagnostic from a mismatched provisional request', async () => {
|
||||
const { child, wire } = await initializeWire()
|
||||
const result = wire.runTurn(['task'], new AbortController().signal)
|
||||
const turnStart = await child.peer.nextMethod('turn/start')
|
||||
child.peer.send({
|
||||
id: 'provisional-approval',
|
||||
method: 'item/commandExecution/requestApproval',
|
||||
params: {
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-early',
|
||||
availableDecisions: ['cancel'],
|
||||
},
|
||||
})
|
||||
await child.peer.nextResponse('provisional-approval')
|
||||
child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
|
||||
await expect(result).rejects.toThrow('did not match the active turn')
|
||||
expect(wire.collectDiagnostic()).toBeUndefined()
|
||||
wire.close()
|
||||
})
|
||||
|
||||
it('rejects conflicting early notifications and requests before turn/start', async () => {
|
||||
{
|
||||
const { child, wire } = await initializeWire()
|
||||
@@ -790,7 +1159,7 @@ describe('CodexAppServerWire', () => {
|
||||
it('rejects pending work on abort, EOF, and stream error', async () => {
|
||||
{
|
||||
const child = fakeChild()
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
wire.start()
|
||||
const controller = new AbortController()
|
||||
controller.abort('pre-aborted')
|
||||
@@ -800,7 +1169,7 @@ describe('CodexAppServerWire', () => {
|
||||
}
|
||||
{
|
||||
const child = fakeChild()
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
wire.start()
|
||||
const controller = new AbortController()
|
||||
const pending = wire.initialize(controller.signal)
|
||||
@@ -811,7 +1180,7 @@ describe('CodexAppServerWire', () => {
|
||||
}
|
||||
{
|
||||
const child = fakeChild()
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
wire.start()
|
||||
const pending = wire.initialize(new AbortController().signal)
|
||||
await child.peer.nextMethod('initialize')
|
||||
@@ -821,7 +1190,7 @@ describe('CodexAppServerWire', () => {
|
||||
}
|
||||
{
|
||||
const child = fakeChild()
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
wire.start()
|
||||
const pending = wire.initialize(new AbortController().signal)
|
||||
await child.peer.nextMethod('initialize')
|
||||
@@ -831,7 +1200,7 @@ describe('CodexAppServerWire', () => {
|
||||
}
|
||||
{
|
||||
const child = fakeChild()
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
wire.start()
|
||||
const pending = wire.initialize(new AbortController().signal)
|
||||
await child.peer.nextMethod('initialize')
|
||||
@@ -864,7 +1233,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 +1298,114 @@ 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)
|
||||
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
|
||||
child.stderr.emit('error', new Error('stderr broke'))
|
||||
child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'answer' }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
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('drains queued stderr before settling a failed published run', async () => {
|
||||
hostStderrWrite.capture = true
|
||||
hostStderrWrite.chunks.length = 0
|
||||
const { child, run, turnStart } = await publishRun()
|
||||
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
|
||||
child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
|
||||
message: 'fixture terminal failure',
|
||||
codexErrorInfo: 'badRequest',
|
||||
}))
|
||||
setImmediate(() => {
|
||||
child.stderr.write('approval policy is Never; reject command')
|
||||
})
|
||||
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',
|
||||
})
|
||||
await run.dispose()
|
||||
hostStderrWrite.capture = false
|
||||
})
|
||||
|
||||
it('forwards stderr while extracting only a fixed safe permission signature', async () => {
|
||||
const child = fakeChild()
|
||||
hostStderrWrite.capture = true
|
||||
hostStderrWrite.chunks.length = 0
|
||||
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.stderr.emit('data', 'string stderr suffix')
|
||||
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(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN')
|
||||
expect(hostStderrWrite.chunks).toHaveLength(3)
|
||||
await run.dispose()
|
||||
expect(child.stderr.listenerCount('data')).toBe(0)
|
||||
hostStderrWrite.capture = false
|
||||
})
|
||||
|
||||
it('contains host stderr write failures without changing run settlement', async () => {
|
||||
const child = fakeChild()
|
||||
hostStderrWrite.capture = true
|
||||
hostStderrWrite.failNext = true
|
||||
const { run, turnStart } = await publishRun(child)
|
||||
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
|
||||
child.stderr.write('approval policy is Never; reject command')
|
||||
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',
|
||||
})
|
||||
await run.dispose()
|
||||
hostStderrWrite.capture = false
|
||||
})
|
||||
|
||||
it('rejects before spawn when pre-aborted and rolls back startup failures', async () => {
|
||||
@@ -939,6 +1416,7 @@ describe('run lifecycle and quiescence', () => {
|
||||
request(undefined, controller.signal),
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
|
||||
env: {},
|
||||
disposeGraceMs: 10,
|
||||
spawn,
|
||||
@@ -952,6 +1430,27 @@ 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))
|
||||
const stderrInitialize = await stderrChild.peer.nextMethod('initialize')
|
||||
stderrChild.stderr.emit('error', new Error('startup stderr broke'))
|
||||
stderrChild.peer.respond(stderrInitialize, { userAgent: 'codex-cli 0.147.0' })
|
||||
await stderrChild.peer.nextMethod('initialized')
|
||||
const stderrThreadStart = await stderrChild.peer.nextMethod('thread/start')
|
||||
stderrChild.peer.respond(stderrThreadStart, {
|
||||
thread: { id: 'thread-1', ephemeral: true },
|
||||
})
|
||||
const stderrRun = await stderrStarting
|
||||
const stderrTurnStart = await stderrChild.peer.nextMethod('turn/start')
|
||||
stderrChild.peer.send(
|
||||
{ id: stderrTurnStart.id, result: { turn: { id: 'turn-1' } } },
|
||||
agentMessage('answer', 'final_answer'),
|
||||
turnCompleted('completed'),
|
||||
)
|
||||
await expect(stderrRun.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
await stderrRun.dispose()
|
||||
expect(stderrChild.stderr.listenerCount('error')).toBe(0)
|
||||
})
|
||||
|
||||
it('rolls back an abort that wins immediately after thread creation', async () => {
|
||||
@@ -965,6 +1464,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')
|
||||
@@ -989,12 +1493,21 @@ describe('run lifecycle and quiescence', () => {
|
||||
})
|
||||
|
||||
it('keeps overlapping runs isolated', async () => {
|
||||
const first = fakeChild()
|
||||
const second = fakeChild()
|
||||
const runs = await Promise.all([
|
||||
publishRun(first),
|
||||
publishRun(second),
|
||||
])
|
||||
const initialStderrListeners = {
|
||||
error: process.stderr.listenerCount('error'),
|
||||
unpipe: process.stderr.listenerCount('unpipe'),
|
||||
close: process.stderr.listenerCount('close'),
|
||||
finish: process.stderr.listenerCount('finish'),
|
||||
}
|
||||
const runs = await Promise.all(
|
||||
Array.from({ length: 6 }, () => publishRun(fakeChild())),
|
||||
)
|
||||
expect({
|
||||
error: process.stderr.listenerCount('error'),
|
||||
unpipe: process.stderr.listenerCount('unpipe'),
|
||||
close: process.stderr.listenerCount('close'),
|
||||
finish: process.stderr.listenerCount('finish'),
|
||||
}).toEqual(initialStderrListeners)
|
||||
for (const [index, entry] of runs.entries()) {
|
||||
const id = `turn-${index + 1}`
|
||||
entry.child.peer.send(
|
||||
@@ -1004,14 +1517,64 @@ describe('run lifecycle and quiescence', () => {
|
||||
)
|
||||
}
|
||||
const results = await Promise.all(runs.map(entry => entry.run.result))
|
||||
expect(results.map(result => result.output)).toEqual([
|
||||
[{ type: 'text', text: 'answer-1' }],
|
||||
[{ type: 'text', text: 'answer-2' }],
|
||||
])
|
||||
expect(runs[0].run.id).not.toBe(runs[1].run.id)
|
||||
expect(results.map(result => result.output)).toEqual(
|
||||
Array.from({ length: 6 }, (_, index) => [
|
||||
{ type: 'text', text: `answer-${index + 1}` },
|
||||
]),
|
||||
)
|
||||
expect(runs[0]!.run.id).not.toBe(runs[1]!.run.id)
|
||||
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 +1587,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 +1599,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()
|
||||
})
|
||||
})
|
||||
@@ -1056,7 +1650,7 @@ describe('run lifecycle and quiescence', () => {
|
||||
describe('disposeCodexChild', () => {
|
||||
it('closes stdin, terminates, and waits for the managed tree', async () => {
|
||||
const child = fakeChild()
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
const end = vi.spyOn(child.toChild, 'end')
|
||||
await disposeCodexChild(wire, child.handle)
|
||||
expect(end).toHaveBeenCalled()
|
||||
@@ -1067,7 +1661,7 @@ describe('disposeCodexChild', () => {
|
||||
|
||||
it('does not finish disposal before the managed tree exits', async () => {
|
||||
const child = fakeChild({ exitOnTerminate: false })
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
let disposed = false
|
||||
const disposal = disposeCodexChild(wire, child.handle).then(() => {
|
||||
disposed = true
|
||||
@@ -1081,7 +1675,7 @@ describe('disposeCodexChild', () => {
|
||||
|
||||
it('contains a concurrently closed stdin error', async () => {
|
||||
const child = fakeChild()
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
vi.spyOn(child.toChild, 'end').mockImplementation(() => {
|
||||
throw new Error('already closed')
|
||||
})
|
||||
@@ -1094,7 +1688,7 @@ describe('disposeCodexChild', () => {
|
||||
pid: -1,
|
||||
doneError: new Error('spawn failed'),
|
||||
})
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
await expect(disposeCodexChild(wire, child.handle))
|
||||
.resolves.toBeUndefined()
|
||||
expect(child.terminate).not.toHaveBeenCalled()
|
||||
@@ -1106,14 +1700,14 @@ describe('disposeCodexChild', () => {
|
||||
const child = fakeChild({
|
||||
doneError: new Error('close observer failed'),
|
||||
})
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
await expect(disposeCodexChild(wire, child.handle))
|
||||
.rejects.toThrow('close observer failed')
|
||||
}
|
||||
{
|
||||
const child = fakeChild()
|
||||
const handle = { ...child.handle, stdin: undefined }
|
||||
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
|
||||
const wire = defaultWire(child)
|
||||
await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 25657a6a7dd6727bb5177c828241ec82192f20d8
|
||||
README.zh.md: 2f36f950e0a4b0bb86d97633dd0e841fd331ad50
|
||||
README.md: 99b28fb7fc62473fa02fef4f3977f29e9935edaa
|
||||
README.zh.md: 6970c8f21424cb73d170602c52fb98641d5d712d
|
||||
|
||||
@@ -65,7 +65,7 @@ Both in-process delegation paths fix the child's permission scope at the delegat
|
||||
|
||||
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`.
|
||||
|
||||
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the result contract).
|
||||
`SubagentRun.result` resolves to `{ output, structured?, diagnostic?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. A provider may add a safe `diagnostic` to a non-completed result after removing tool inputs, file contents, environment values, credentials, and raw protocol payloads and limiting the complete text to 4096 UTF-8 bytes. The field is not assistant output: consumers present it separately, and it does not enter `subagent/end.lastAssistantMessage`. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the terminal result contract).
|
||||
|
||||
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration.
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且在任何失败路径上都必须取消、回滚并使尚未发布的资源完全停稳。兑现后,run 的所有权转移给调用方;调用方必须在每条路径上调用 `dispose()`。剩余提示词和轮次工作属于 `SubagentRun.result`。
|
||||
|
||||
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(结果约定归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。
|
||||
`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage`。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。
|
||||
|
||||
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。
|
||||
|
||||
|
||||
@@ -16,6 +16,31 @@ import { isAbsolute, resolve } from 'node:path'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SubagentCapabilities, SubagentResult, SubagentRun, SubagentStopReason } from './types.ts'
|
||||
|
||||
/** Maximum UTF-8 size of {@link SubagentResult.diagnostic}. */
|
||||
const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096
|
||||
|
||||
const DIAGNOSTIC_TRUNCATION_SUFFIX = '\n[diagnostic truncated]'
|
||||
const utf8Encoder = new TextEncoder()
|
||||
const utf8Decoder = new TextDecoder()
|
||||
|
||||
/**
|
||||
* Limit provider-authored failure detail without splitting a UTF-8 sequence.
|
||||
* @param diagnostic - safe diagnostic text produced by the provider.
|
||||
* @returns the original text, or a visibly truncated value within the limit.
|
||||
*/
|
||||
function limitSubagentDiagnostic(diagnostic: string): string {
|
||||
const bytes = utf8Encoder.encode(diagnostic)
|
||||
if (bytes.byteLength <= MAX_SUBAGENT_DIAGNOSTIC_BYTES) return diagnostic
|
||||
|
||||
const suffixBytes = utf8Encoder.encode(DIAGNOSTIC_TRUNCATION_SUFFIX).byteLength
|
||||
let prefixBytes = MAX_SUBAGENT_DIAGNOSTIC_BYTES - suffixBytes
|
||||
while (((bytes[prefixBytes] as number) & 0b1100_0000) === 0b1000_0000) {
|
||||
prefixBytes -= 1
|
||||
}
|
||||
return utf8Decoder.decode(bytes.subarray(0, prefixBytes))
|
||||
+ DIAGNOSTIC_TRUNCATION_SUFFIX
|
||||
}
|
||||
|
||||
/**
|
||||
* The capability advertisement of an out-of-process backend: NONE. A child in
|
||||
* another process cannot honor parent-enforced start features
|
||||
@@ -134,6 +159,8 @@ export interface RunResultSettlement {
|
||||
attempt: () => Promise<SubagentResult>
|
||||
/** Snapshot the provider exposes when cancellation or failure wins settlement. */
|
||||
collectOutput: () => ContentBlock[]
|
||||
/** Snapshot safe provider-authored detail when a failure wins settlement. */
|
||||
collectDiagnostic?: (() => string | undefined) | undefined
|
||||
/** Whether local cancellation settled before the attempt's outcome is observed. */
|
||||
cancelled: () => boolean
|
||||
/** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */
|
||||
@@ -168,7 +195,15 @@ export async function settleRunResult(parts: RunResultSettlement): Promise<Subag
|
||||
} catch {
|
||||
// The diagnostic sink cannot reject the run result.
|
||||
}
|
||||
return { output: parts.collectOutput(), stopReason: 'error' }
|
||||
const collected = parts.collectDiagnostic?.()
|
||||
const diagnostic = collected === undefined
|
||||
? undefined
|
||||
: limitSubagentDiagnostic(collected)
|
||||
return {
|
||||
output: parts.collectOutput(),
|
||||
...diagnostic === undefined ? {} : { diagnostic },
|
||||
stopReason: 'error',
|
||||
}
|
||||
} finally {
|
||||
parts.signal.removeEventListener('abort', parts.onAbort)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ function finalText(blocks: ContentBlock[]): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Render a failed stop reason with optional provider-authored detail. */
|
||||
function failureDetail(result: SubagentResult): string {
|
||||
const stopReason = result.stopReason
|
||||
return result.diagnostic === undefined
|
||||
? stopReason
|
||||
: `${stopReason}; diagnostic: ${result.diagnostic}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a child result to the task outcome: completed carries final text,
|
||||
* aborted is killed, and every other reason is failed without partial output.
|
||||
@@ -33,10 +41,10 @@ function runOutcome(result: SubagentResult): JobOutcome {
|
||||
case 'error':
|
||||
case 'max-tokens':
|
||||
case 'refusal':
|
||||
return { status: 'failed', detail: result.stopReason }
|
||||
// Merge-extensible reasons remain failures with their raw detail.
|
||||
return { status: 'failed', detail: failureDetail(result) }
|
||||
// Merge-extensible reasons remain failures with provider-authored detail.
|
||||
default:
|
||||
return { status: 'failed', detail: String(result.stopReason) }
|
||||
return { status: 'failed', detail: failureDetail(result) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,13 @@ export interface SubagentResult {
|
||||
* schema-agnostic.
|
||||
*/
|
||||
readonly structured?: unknown
|
||||
/**
|
||||
* Provider-authored, non-assistant failure detail for a non-`completed`
|
||||
* result. Providers keep this text free of tool inputs, file contents,
|
||||
* environment values, credentials, and raw protocol payloads, and limit it
|
||||
* to 4096 UTF-8 bytes. Consumers present it separately from {@link output}.
|
||||
*/
|
||||
readonly diagnostic?: string
|
||||
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
|
||||
readonly stopReason: SubagentStopReason
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { settleRun } from '../src/index.ts'
|
||||
import {
|
||||
settleRun,
|
||||
settleRunResult,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096
|
||||
|
||||
describe('outcome mapping helpers', () => {
|
||||
it.each([
|
||||
@@ -62,4 +67,51 @@ describe('outcome mapping helpers', () => {
|
||||
detail: 'Error: result failed; dispose failed: Error: reap failed',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps provider diagnostics separate in failed background outcomes', async () => {
|
||||
await expect(settleRun({
|
||||
id: SessionId('child-diagnostic'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({
|
||||
output: [{ type: 'text', text: 'partial assistant text' }],
|
||||
diagnostic: 'Claude Code denied a tool request',
|
||||
stopReason: 'error',
|
||||
}),
|
||||
dispose: () => Promise.resolve(),
|
||||
})).resolves.toEqual({
|
||||
status: 'failed',
|
||||
detail: 'error; diagnostic: Claude Code denied a tool request',
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds multibyte diagnostics and marks truncation', async () => {
|
||||
const exact = 'x'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES)
|
||||
const oversized = '权限'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES)
|
||||
const controller = new AbortController()
|
||||
const exactResult = await settleRunResult({
|
||||
attempt: async () => { throw new Error('provider failed') },
|
||||
collectOutput: () => [],
|
||||
collectDiagnostic: () => exact,
|
||||
cancelled: () => false,
|
||||
signal: controller.signal,
|
||||
onAbort: () => {},
|
||||
})
|
||||
expect(exactResult.diagnostic).toBe(exact)
|
||||
|
||||
const result = await settleRunResult({
|
||||
attempt: async () => { throw new Error('provider failed') },
|
||||
collectOutput: () => [],
|
||||
collectDiagnostic: () => oversized,
|
||||
cancelled: () => false,
|
||||
signal: controller.signal,
|
||||
onAbort: () => {},
|
||||
})
|
||||
const limited = result.diagnostic ?? ''
|
||||
expect(Buffer.byteLength(limited, 'utf8'))
|
||||
.toBeLessThanOrEqual(MAX_SUBAGENT_DIAGNOSTIC_BYTES)
|
||||
expect(limited.endsWith('[diagnostic truncated]')).toBe(true)
|
||||
expect(limited).not.toContain('\uFFFD')
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.diagnostic).toBe(limited)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/tool-subagent/README.md
|
||||
README.md: 9d7ed2e364f6a9dff26a1c9006535f898bdaabcc
|
||||
README.zh.md: 8650ee35588c2615e4d6c016cb672030ee2e8194
|
||||
README.md: 28e6213b903ffffa7934e244b2a74ada519b32b2
|
||||
README.zh.md: deae0f0ff9e19b627a04704eccf4b8874f34068f
|
||||
|
||||
@@ -8,9 +8,9 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C
|
||||
|
||||
Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns.
|
||||
|
||||
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics.
|
||||
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text. Abort, refusal, token limit, and other failures become errored tool results whose message contains the stop-reason headline, an optional provider-authored `SubagentResult.diagnostic`, and then any preserved partial assistant text. The diagnostic remains separate from `SubagentResult.output`, so a truncated answer is never reported as success or confused with infrastructure detail. If result collection and disposal both reject, the errored result preserves both failures.
|
||||
|
||||
`backgroundMode` selects both the background route and the omitted `run_in_background` default. `one-shot` waits in the foreground by default; an explicit `true` registers a plain parent-owned Task and returns canonical `{ kind: 'background', jobId }`, rendered as `started background subagent job <id>`, even when the provider supports continuable children. Generic task tools own its later status, collection, cancellation, and notices. `continuable` runs in the background when the argument is omitted or `true`; an explicit `false` waits for the result in the foreground. Its background route requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent <childId>`. The route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The continuation service delivers one settlement notice whenever the child's Activation ends, containing its outcome and any final assistant message independently of `report`. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [background-first delegation Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md).
|
||||
`backgroundMode` selects both the background route and the omitted `run_in_background` default. `one-shot` waits in the foreground by default; an explicit `true` registers a plain parent-owned Task and returns canonical `{ kind: 'background', jobId }`, rendered as `started background subagent job <id>`, even when the provider supports continuable children. Generic task tools own its later status, collection, cancellation, and notices; a failed Task keeps the stop reason and the same optional provider diagnostic in its detail. `continuable` runs in the background when the argument is omitted or `true`; an explicit `false` waits for the result in the foreground. Its background route requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent <childId>`. The route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The continuation service delivers one settlement notice whenever the child's Activation ends, containing its outcome and any final assistant message independently of `report`. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [background-first delegation Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md).
|
||||
|
||||
`toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
|
||||
@@ -51,7 +51,7 @@ Prefix-stable while provider instances, names, descriptions, and schemas are unc
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: <message>`. Intermediate child steps stay out of the parent.
|
||||
The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: <stop reason>`, followed by a safe provider diagnostic when present and then any partial assistant text. Intermediate child steps stay out of the parent.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -65,7 +65,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Start returns exactly `started subagent <childId>` in configured continuable mode, or `started background subagent job <id>` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode this tool returns no result of its own; the child's settlement reaches the parent as a [service-owned notice](../subagent/README.md#settlement-notice), an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its detailed output.
|
||||
Start returns exactly `started subagent <childId>` in configured continuable mode, or `started background subagent job <id>` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices; failed status detail includes the provider diagnostic when the result supplied one. In continuable mode this tool returns no result of its own; the child's settlement reaches the parent as a [service-owned notice](../subagent/README.md#settlement-notice), an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its detailed output.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。
|
||||
|
||||
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子 agent 保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。
|
||||
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本。中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息依次包含终止原因标题、可选的提供方 `SubagentResult.diagnostic`,以及子 agent 保留下来的部分 assistant 文本。诊断与 `SubagentResult.output` 保持分离,因此被截断的回答不会被报告为成功,也不会与基础设施说明混淆。如果结果收集与 dispose(资源释放)都 reject,出错结果会保留两项失败。
|
||||
|
||||
`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', jobId }`,渲染为 `started background subagent job <id>`,即使提供方支持可继续子 agent 也不例外。通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`。该路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md)。
|
||||
`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', jobId }`,渲染为 `started background subagent job <id>`,即使提供方支持可继续子 agent 也不例外。通用 Task 工具负责其后续状态、收集、取消和通知;失败 Task 的 detail 会保留终止原因与同一份可选提供方诊断。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`。该路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md)。
|
||||
|
||||
`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
调用会保留描述和提示词。成功时只包含子 agent 的最终文本;其他结果变为 `Error: <message>`。子 agent 中间步骤不会进入父级。
|
||||
调用会保留描述和提示词。成功时只包含子 agent 的最终文本;其他结果会变为 `Error: <终止原因>`,随后在存在时附上安全的提供方诊断,再附上任何部分 assistant 文本。子 agent 中间步骤不会进入父级。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
在配置的可继续模式下,启动时返回内容恰为 `started subagent <childId>`;在配置的一次性模式下,则返回 `started background subagent job <id>`。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。
|
||||
在配置的可继续模式下,启动时返回内容恰为 `started subagent <childId>`;在配置的一次性模式下,则返回 `started background subagent job <id>`。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知;若结果携带提供方诊断,失败状态的 detail 会包含它。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -142,18 +142,25 @@ function stopReasonError(result: SubagentResult): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the child's preserved partial answer to a stop-reason error so a
|
||||
* truncated or cancelled child's real text still reaches the parent model.
|
||||
* Append provider-authored failure detail and the child's preserved partial
|
||||
* answer to a stop-reason error, keeping diagnostic text separate from the
|
||||
* child's assistant output.
|
||||
* @param error - the stop-reason headline.
|
||||
* @param output - the child's selected output (`SubagentResult.output`).
|
||||
* @returns the headline, extended with the partial text when any exists.
|
||||
* @param result - the child's terminal result.
|
||||
* @returns the headline, diagnostic, and partial text that are present.
|
||||
*/
|
||||
function withPartialText(error: string, output: ContentBlock[]): string {
|
||||
const text = output
|
||||
function withDiagnosticAndPartialText(error: string, result: SubagentResult): string {
|
||||
const diagnostic = result.diagnostic === undefined
|
||||
? ''
|
||||
: `\nDiagnostic: ${result.diagnostic}`
|
||||
const text = result.output
|
||||
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}`
|
||||
const partial = text.length === 0
|
||||
? ''
|
||||
: `\nPartial output before the run ended:\n${text}`
|
||||
return `${error}${diagnostic}${partial}`
|
||||
}
|
||||
|
||||
type ForegroundToolResult = {
|
||||
@@ -173,7 +180,7 @@ async function settleForegroundRun(run: SubagentRun): Promise<ForegroundToolResu
|
||||
if (error !== undefined) {
|
||||
// The registry converts this throw to isError; partial output is not
|
||||
// success, but the preserved partial answer still reaches the parent.
|
||||
throw new Error(withPartialText(error, result.output))
|
||||
throw new Error(withDiagnosticAndPartialText(error, result))
|
||||
}
|
||||
return {
|
||||
kind: 'foreground',
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface Config {
|
||||
reply?: string
|
||||
/** Terminal result reason. */
|
||||
stopReason?: SubagentStopReason
|
||||
/** Safe non-assistant detail for a non-completed result. */
|
||||
diagnostic?: string
|
||||
/** Start-time features advertised by the provider. */
|
||||
capabilities?: Partial<SubagentCapabilities>
|
||||
/** Whether tool descriptions say the child inherits completed turns. */
|
||||
@@ -65,11 +67,17 @@ class ScriptedSubagentProvider implements SubagentProvider {
|
||||
throw new Error('scripted subagent start aborted before publication')
|
||||
}
|
||||
|
||||
const resultFor = (): SubagentResult => ({
|
||||
output,
|
||||
...wantsStructured ? { structured: this.config.structured ?? { reply } } : {},
|
||||
stopReason: state.cancelled ? 'aborted' : stopReason,
|
||||
})
|
||||
const resultFor = (): SubagentResult => {
|
||||
const terminal = state.cancelled ? 'aborted' : stopReason
|
||||
return {
|
||||
output,
|
||||
...wantsStructured ? { structured: this.config.structured ?? { reply } } : {},
|
||||
...this.config.diagnostic !== undefined && terminal !== 'completed'
|
||||
? { diagnostic: this.config.diagnostic }
|
||||
: {},
|
||||
stopReason: terminal,
|
||||
}
|
||||
}
|
||||
const gate = Promise.resolve(this.config.onStart?.(request))
|
||||
const result = gate.then(() => new Promise<SubagentResult>((resolve) => {
|
||||
setTimeout(() => { resolve(resultFor()) }, 0)
|
||||
|
||||
@@ -185,6 +185,22 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(text(result)).toContain('scripted subagent reply')
|
||||
})
|
||||
|
||||
it('renders provider diagnostics before preserved partial assistant output', async () => {
|
||||
const ctx = await setup({ provider: 'mock' }, {
|
||||
reply: 'partial assistant text',
|
||||
diagnostic: 'Claude Code denied a tool request',
|
||||
stopReason: 'error',
|
||||
})
|
||||
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toBe(
|
||||
'Error: subagent run failed\n'
|
||||
+ 'Diagnostic: Claude Code denied a tool request\n'
|
||||
+ 'Partial output before the run ended:\npartial assistant text',
|
||||
)
|
||||
})
|
||||
|
||||
it('registers under a configurable toolName so multiple providers can coexist', async () => {
|
||||
// The defining multi-provider use case: two loads, two distinct tool names,
|
||||
// each bound to a different provider — the tool registry rejects duplicate
|
||||
@@ -852,6 +868,36 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
expect(text(again)).toBe('background answer\n[status: completed]')
|
||||
})
|
||||
|
||||
it('preserves provider diagnostics in one-shot background failure detail', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock' }, {
|
||||
reply: 'not background output',
|
||||
diagnostic: 'Claude Code cancelled an unattended dialog',
|
||||
stopReason: 'error',
|
||||
})
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
|
||||
const started = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('diagnostic-background-start'),
|
||||
name: 'subagent',
|
||||
arguments: { description: 'd', prompt: 'p', run_in_background: true },
|
||||
agent: parent,
|
||||
})
|
||||
expect(text(started)).toBe('started background subagent job subagent-1')
|
||||
|
||||
const output = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('diagnostic-background-output'),
|
||||
name: 'job_output',
|
||||
arguments: { job_id: 'subagent-1', wait: true },
|
||||
agent: parent,
|
||||
})
|
||||
expect(text(output)).toBe(
|
||||
'(no new output)\n'
|
||||
+ '[status: failed, error; diagnostic: Claude Code cancelled an unattended dialog]',
|
||||
)
|
||||
})
|
||||
|
||||
it('fails loud when the tasks runtime is not loaded', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true })
|
||||
|
||||
Reference in New Issue
Block a user