diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 155fd25736..a6bd9e82bd 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 27e805b142ed3541d5157e2440cb0a0d57ae461b -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 633a0de7653b8e985b5bd6dcf5b9424d1764bf43 +2026-08-04-claude-code-and-codex-subagent-backends.md: e576065d7629bad4f41cabf4dbce3a99d0119f52 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 1209d73c8c0a1a99061fa951ea25afb010289d61 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 27e805b142..e576065d76 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -33,7 +33,7 @@ fixed tool → shared subagent service → product provider → official 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`. 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 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. 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. @@ -49,7 +49,7 @@ Codex 0.146.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`. The SDK's platform `optionalDependency` supplies the real Claude Code 2.1.220 CLI. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` command, arguments, cwd, environment, and forwarded signal unchanged 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`. 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 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 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`. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 633a0de765..1209d73c8c 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -33,7 +33,7 @@ fixed tool → shared subagent service → product provider → official product ## Codex 提供方 -`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 +`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`,且后者不得大于仓库共享的 `MAX_TIMER_DELAY_MS`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 @@ -49,7 +49,7 @@ Codex 0.146.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 `@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。SDK 的平台 `optionalDependency` 提供真实的 Claude Code 2.1.220 CLI。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 -公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 +公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 245ff31e52..c018dacb6a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1599,7 +1599,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-codex/src/index.ts:29`](../packages/subagent/subagent-codex/src/index.ts) +Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts) ## `@deepseek-ai/dsh-subagent-dsh-sdk` diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index 39e7915bea..e801564260 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/subprocess.md -subprocess.md: a97e407290aa12881e7d6e847d51fec13c134135 -subprocess.zh.md: e0b6f3fc2249d7095b5e540f91a0f13b544db978 +subprocess.md: 8189795c5acf2fc1882d9e5bc1c006f49c327e2f +subprocess.zh.md: c506dfe4e998be0e7b87e9ef8a62ea1e05e2d24e diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index a97e407290..8189795c5a 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -101,10 +101,11 @@ interface SubprocessSpawnSpec { /** Per-stream stdio dispositions. */ stdio: SubprocessStdio /** - * Grace period in milliseconds for the {@link SubprocessHandle.terminate} - * escalation and for draining still-open collected pipes after the process - * exits (an inherited descriptor held by a surviving descendant cannot hold - * the outcome open indefinitely). + * Positive finite grace period in milliseconds, no greater than + * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation + * and for draining still-open collected pipes after the process exits (an + * inherited descriptor held by a surviving descendant cannot hold the + * outcome open indefinitely). */ graceMs: number /** diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index e0b6f3fc22..c506dfe4e9 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -101,10 +101,11 @@ interface SubprocessSpawnSpec { /** Per-stream stdio dispositions. */ stdio: SubprocessStdio /** - * Grace period in milliseconds for the {@link SubprocessHandle.terminate} - * escalation and for draining still-open collected pipes after the process - * exits (an inherited descriptor held by a surviving descendant cannot hold - * the outcome open indefinitely). + * Positive finite grace period in milliseconds, no greater than + * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation + * and for draining still-open collected pipes after the process exits (an + * inherited descriptor held by a surviving descendant cannot hold the + * outcome open indefinitely). */ graceMs: number /** diff --git a/docs/module-graph.md b/docs/module-graph.md index fb0457958c..667b9a76f1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -344,6 +344,7 @@ flowchart TD pkg_storage_sqlite --> pkg_storage pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess + pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry pkg_llm_deepseek --> pkg_credentials @@ -1015,6 +1016,7 @@ flowchart TD pkg_subagent_codex --> pkg_session pkg_subagent_codex --> pkg_subagent pkg_subagent_codex --> pkg_subprocess + pkg_subagent_codex --> pkg_timeout pkg_subagent_fork --> pkg_agent pkg_subagent_fork --> pkg_invariants pkg_subagent_fork --> pkg_session @@ -1130,7 +1132,7 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | @@ -1261,7 +1263,7 @@ flowchart TD | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 0b6f38373b..43c08d5394 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: 59d7dcd8406cb244854bbb704f644af1a6aa8589 -README.zh.md: 90ea0a316b91da3249b934984a3f512b71b8b3b2 +README.md: 5bfc8ff3ba539b6caf891577cee5820d983106cd +README.zh.md: 6db9148228ba30c0f84beb14caa7a84aa0dcd9f5 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 59d7dcd840..5bfc8ff3ba 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -27,7 +27,7 @@ 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. | -| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | +| `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 uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. 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 endpoint intended for the child must be supplied there; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. @@ -51,7 +51,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe ## Product compatibility and evidence -The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. The keyless real-product spec drives that official SDK and CLI against a loopback Anthropic Messages SSE service with a non-empty fake key. It proves the exact task, authentication, byte-exact answer, inherited host-setting marker, process failure, cancellation, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration alongside the Codex provider with neither product command available, verifies both fixed foreground-only tools, and records zero child starts. A credentialed e2e maps a runtime-only DeepSeek key to the official Claude Code environment contract, starts the production provider and real SDK/CLI against the fixed official `/anthropic` endpoint, requires a unique nonce, and proves process-tree exit without persisting the key or calling the Messages API directly from the test. +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. Required evidence exercises that official distribution through a keyless loopback product path and a credentialed DeepSeek path, while Loader composition proves that both opt-in product packages coexist without starting either product. The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 90ea0a316b..6db9148228 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -27,7 +27,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | -| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | 生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量仍然可用。 @@ -51,7 +51,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。无密钥真实产品测试使用非空伪密钥,驱动该官方 SDK 与 CLI 连接回环 Anthropic Messages SSE 服务,并证明原始任务、身份验证、逐字节一致的答案、继承的宿主设置标记、进程失败、取消与进程树退出。独立的 Loader 装配 e2e 会在两个产品命令均不可用时,与 Codex 提供方一起启动和 README 所示形态相同的用户配置,验证两个固定且只支持前台执行的工具,并记录零次子级启动。带密钥 e2e 会将仅在运行时提供的 DeepSeek 密钥映射到 Claude Code 官方环境契约,启动生产提供方与真实 SDK 和 CLI 并连接固定的官方 `/anthropic` 端点,要求获得一个唯一随机数,并在不持久化密钥且不由测试直接调用 Messages API 的情况下证明进程树退出。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。强制证据会通过无密钥回环产品路径与带密钥 DeepSeek 路径运行该官方发行版,而 Loader 组合则证明两个选择启用的产品包能够共存,且不会启动任一产品。 项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index f3dad8d5bd..eefdd98453 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -32,6 +32,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -48,6 +49,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index fe6d0a89a0..1ba7266e0f 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -8,6 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { assertPositiveFinite, NO_START_CAPABILITIES, @@ -90,6 +91,11 @@ export function apply(ctx: Context, config: Config): void { 'disposeGraceMs', resolved.disposeGraceMs, ) + if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `subagent-claude-code: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } ctx.subagents.registerProvider(new ClaudeCodeProvider(ctx, resolved)) } /* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts index 720954f153..6f84fd2395 100644 --- a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts +++ b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts @@ -83,18 +83,17 @@ function complete( /** * Start a loopback-only Anthropic Messages SSE fixture. - * @param script - one behavior per Messages request. + * @param behavior - the single response behavior for this fixture. * @returns the bound server and its recorded requests. */ export async function startMessagesFixture( - script: readonly MessagesBehavior[], + behavior: MessagesBehavior, ): Promise { const requests: RecordedMessagesRequest[] = [] let requestStartedResolve!: () => void const requestStarted = new Promise((resolve) => { requestStartedResolve = resolve }) - let behaviorIndex = 0 const server = createServer((request, response) => { const chunks: Buffer[] = [] request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) @@ -117,18 +116,6 @@ export async function startMessagesFixture( body, }) requestStartedResolve() - const behavior = script[behaviorIndex++] - if (behavior === undefined) { - response.writeHead(500, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ - type: 'error', - error: { - type: 'api_error', - message: 'Messages fixture script was exhausted', - }, - })) - return - } if (behavior.kind === 'complete') { complete(response, body, behavior.text) } diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 99b0a69a20..a0d1c1a02d 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -64,7 +64,7 @@ interface RealHarness { readonly env: Record } -async function realHarness(script: readonly MessagesBehavior[]): Promise<{ +async function realHarness(behavior: MessagesBehavior): Promise<{ readonly harness: RealHarness readonly fixture: MessagesFixture }> { @@ -80,7 +80,7 @@ async function realHarness(script: readonly MessagesBehavior[]): Promise<{ join(claudeConfig, 'settings.json'), `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, ) - const fixture = await startMessagesFixture(script) + const fixture = await startMessagesFixture(behavior) fixtures.push(fixture) const env = { ANTHROPIC_API_KEY: fakeKey, @@ -149,9 +149,10 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { it('inherits host settings and sends the exact task and fake key to local Messages', async () => { const sentinel = 'REAL_CLAUDE_CODE_SENTINEL_2_1_220' const task = 'Return the fixture sentinel exactly.' - const { harness, fixture } = await realHarness([ - { kind: 'complete', text: sentinel }, - ]) + const { harness, fixture } = await realHarness({ + kind: 'complete', + text: sentinel, + }) expect(sdkPackage.version).toBe('0.3.220') expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') @@ -191,7 +192,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { }) it('maps a real CLI process failure to error', async () => { - const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const { harness, fixture } = await realHarness({ kind: 'hold' }) const run = await startRequest(harness, 'Exercise the failure path.') await fixture.requestStarted expect(harness.handles).toHaveLength(1) @@ -207,7 +208,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { }) it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => { - const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const { harness, fixture } = await realHarness({ kind: 'hold' }) const controller = new AbortController() const run = await startRequest( harness, diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 3dab6ab5cd..7a2942b084 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -27,6 +27,7 @@ import type { SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import * as claudeCode from '../src/index.ts' import * as invariant from '../src/invariant.ts' import { @@ -74,8 +75,6 @@ async function nextTask(): Promise { interface FakeChildOptions { readonly pid?: number - readonly stdin?: PassThrough | undefined - readonly stdout?: PassThrough | undefined readonly exitOnTerminate?: boolean readonly waitForExitError?: Error readonly doneError?: Error @@ -145,8 +144,8 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { }) const handle: SubprocessHandle = { pid: options.pid ?? 1234, - stdin: options.stdin === undefined ? stdin : options.stdin, - stdout: options.stdout === undefined ? stdout : options.stdout, + stdin, + stdout, stderr: undefined, collected: {}, done, @@ -232,7 +231,6 @@ function sdkSpawnOptions( interface FakeRun { readonly child: FakeChild - readonly query: Query readonly close: ReturnType readonly spawnSpecs: SubprocessSpawnSpec[] readonly options: Options[] @@ -262,7 +260,7 @@ function fakeRun( params.options.spawnClaudeCodeProcess!(sdkSpawnOptions()) return query }) - return { child, query, close, spawnSpecs, options, spec } + return { child, close, spawnSpecs, options, spec } } beforeEach(() => { @@ -318,6 +316,11 @@ describe('task admission and package contracts', () => { await expect(ctx.plugin(claudeCode, { disposeGraceMs })) .rejects.toThrow('disposeGraceMs must be a positive finite number') } + await expect(ctx.plugin(claudeCode, { + disposeGraceMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow( + `disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, + ) await ctx.fiber.dispose() }) diff --git a/packages/subagent/subagent-claude-code/tsconfig.json b/packages/subagent/subagent-claude-code/tsconfig.json index 61e81d7fcc..751aa08a9e 100644 --- a/packages/subagent/subagent-claude-code/tsconfig.json +++ b/packages/subagent/subagent-claude-code/tsconfig.json @@ -27,6 +27,9 @@ { "path": "../../subprocess/subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index a40167b79a..97c2b9f705 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md -README.md: 1dde57e10e27786ae06d395c7088976bf6f60ece -README.zh.md: cb56281d9018e7a400ceb770e31da8a60d10d54f +README.md: c25ee90edf8972da66448fe84cb659b0aec79e6f +README.zh.md: 10c8fcc47a9ab04bca983857bd44ede265c23435 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 1dde57e10e..c25ee90edf 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -23,7 +23,7 @@ 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. | -| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | +| `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. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index cb56281d90..10c8fcc47a 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -23,7 +23,7 @@ | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | -| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 10bf7ee5f4..cc1d016225 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -48,6 +49,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@openai/codex": "0.146.0", "cordis": "^4.0.0-rc.7" } diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 00fe95d817..09ece5e22e 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -8,6 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { assertPositiveFinite, NO_START_CAPABILITIES, @@ -85,5 +86,10 @@ export function apply(ctx: Context, config: Config): void { 'disposeGraceMs', resolved.disposeGraceMs, ) + if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `subagent-codex: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } ctx.subagents.registerProvider(new CodexProvider(ctx, resolved)) } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index f933c1a04b..460eeae877 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -14,22 +14,6 @@ import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' type JsonObject = Record -interface Deferred { - readonly promise: Promise - readonly resolve: (value: T) => void - readonly reject: (reason?: unknown) => void -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((settle, fail) => { - resolve = settle - reject = fail - }) - return { promise, resolve, reject } -} - 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}`) @@ -98,11 +82,11 @@ async function raceAbort(pending: Promise, signal: AbortSignal): Promise() + private readonly fatal = Promise.withResolvers() private threadId: string | undefined private turnId: string | undefined private pendingTurnId: string | undefined - private turnCompleted: Deferred | undefined + private turnCompleted: PromiseWithResolvers | undefined private readonly earlyTurnNotifications: Array<{ readonly method: string readonly params: JsonObject @@ -193,7 +177,7 @@ export class CodexAppServerWire { signal: AbortSignal, cancelled: () => boolean, ): Promise { - const completion = deferred() + const completion = Promise.withResolvers() this.turnCompleted = completion const threadId = this.threadId as string const response = object(await this.guarded(this.transport.request('turn/start', { @@ -340,7 +324,8 @@ export class CodexAppServerWire { private handleNotification(method: string, params: JsonObject): void { if (method === 'turn/started') { - if (params.threadId !== this.threadId) return + const threadId = string(params.threadId, 'turn/started thread id') + if (threadId !== this.threadId) return const turn = object(params.turn, 'turn/started turn') if (this.turnCompleted !== undefined && this.turnId === undefined) { this.observePendingTurnId(string(turn.id, 'turn/started turn id')) @@ -348,7 +333,8 @@ export class CodexAppServerWire { return } if (method === 'item/completed') { - if (params.threadId !== this.threadId) return + const threadId = string(params.threadId, 'item/completed thread id') + if (threadId !== this.threadId) return const id = string(params.turnId, 'item/completed turn id') if (this.turnId === undefined) { if (this.turnCompleted !== undefined) { @@ -373,7 +359,8 @@ export class CodexAppServerWire { return } if (method !== 'turn/completed') return - if (params.threadId !== this.threadId) return + const threadId = string(params.threadId, 'turn/completed thread id') + if (threadId !== this.threadId) return const turn = object(params.turn, 'turn/completed turn') const id = string(turn.id, 'turn/completed turn id') const turnCompleted = this.turnCompleted diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 181ddb919e..01cc28e59e 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -6,6 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SubagentService from '@deepseek-ai/dsh-subagent' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessHandle, SubprocessOutcome, @@ -294,6 +295,8 @@ describe('task admission and package contracts', () => { await expect(ctx.plugin(codex, { disposeGraceMs })) .rejects.toThrow('disposeGraceMs must be a positive finite number') } + await expect(ctx.plugin(codex, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) await ctx.fiber.dispose() }) @@ -513,6 +516,16 @@ describe('CodexAppServerWire', () => { } }) + it('fails closed when terminal notification params are not an object', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal, () => false) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send({ method: 'turn/completed', params: null }) + await expect(result).rejects.toThrow('invalid turn/completed thread id') + wire.close() + }) + it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => { const { child, wire } = await initializeWire() const result = wire.runTurn(['task'], new AbortController().signal, () => false) diff --git a/packages/subagent/subagent-codex/tsconfig.json b/packages/subagent/subagent-codex/tsconfig.json index 6034bf5fbe..b9f33967ba 100644 --- a/packages/subagent/subagent-codex/tsconfig.json +++ b/packages/subagent/subagent-codex/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../subprocess/subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 72ff50c422..871b4cfac6 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -29,11 +29,13 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index d3cad162fb..462da41382 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { CollectedOutput, SubprocessCollect, @@ -55,47 +56,6 @@ function sleepTick(): Promise { return sleepMs(15) } -/** Largest delay Node schedules without collapsing it to one millisecond. */ -const MAX_TIMER_DELAY_MS = 2_147_483_647n - -/** - * Schedule a positive finite millisecond delay across as many Node-safe timer - * segments as necessary. Fractional milliseconds round up so a grace never - * expires earlier than configured. - * @param delayMs - positive finite delay in milliseconds. - * @param callback - work to run after the complete delay. - * @returns a handle that cancels the active segment and all future segments. - */ -export function scheduleFiniteTimeout( - delayMs: number, - callback: () => void, -): { cancel(): void } { - let remaining = BigInt(Math.ceil(delayMs)) - let timer: ReturnType | undefined - const arm = (): void => { - const chunk = remaining > MAX_TIMER_DELAY_MS - ? MAX_TIMER_DELAY_MS - : remaining - remaining -= chunk - timer = setTimeout(() => { - timer = undefined - if (remaining === 0n) { - callback() - } else { - arm() - } - }, Number(chunk)) - } - arm() - return { - cancel(): void { - if (timer === undefined) return - clearTimeout(timer) - timer = undefined - }, - } -} - let spillCounter = 0 let defaultSpillDir: string | undefined @@ -339,8 +299,12 @@ function signalTree( * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. * @param internals - test-only spill-directory, platform, and taskkill overrides. * @returns live subprocess handle. + * @throws when `graceMs` cannot be represented by one Node timer. */ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { + if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } const spillDir = internals.spillDir ?? privateSpillDir() const platform = internals.platform ?? process.platform const taskkill = internals.taskkill ?? taskkillProcessTree @@ -382,7 +346,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') const stderrCollector = collectStream(errMode, child.stderr, 'stderr') - let graceTimer: ReturnType | undefined + let graceTimer: ReturnType | undefined let treeExitObserved = false let treeExitObservation: Promise | undefined let settled = false @@ -426,7 +390,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter treeExitObservation ??= (async () => { while (treeAlive()) await sleepTick() treeExitObserved = true - graceTimer?.cancel() + if (graceTimer !== undefined) clearTimeout(graceTimer) graceTimer = undefined })() return treeExitObservation @@ -457,7 +421,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // kill() re-probes tree liveness before force-killing. It stays ref'd: // the pending SIGKILL is a commitment, and a parent exiting before it // fires would orphan a trapped survivor. Self-bounds at graceMs. - graceTimer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') }) + graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } // The caller owns timeout classification; this layer only reacts to abort. @@ -472,7 +436,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } const done = new Promise((resolve, reject) => { - let pipeDrainTimer: ReturnType | undefined + let pipeDrainTimer: ReturnType | undefined const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { if (settled) return settled = true @@ -495,15 +459,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // A surviving descendant that inherited a pipe must not hold the // outcome open indefinitely: after exit, the same bounded grace that // governs kills also bounds the close wait. - pipeDrainTimer = scheduleFiniteTimeout(spec.graceMs, () => { + pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) - }) + }, spec.graceMs) }) child.on('close', settle) function cleanup(): void { // graceTimer deliberately NOT cleared: the SIGKILL escalation must be // able to reach tree survivors after the direct child settles. - pipeDrainTimer?.cancel() + if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) spec.signal?.removeEventListener('abort', onAbort) } }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 224476b8d5..08b6b1dbf8 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -5,11 +5,11 @@ import { describe, expect, it, vi } from 'vitest' import { killGroup, OutputCollector, - scheduleFiniteTimeout, spawnSubprocess, taskkillProcessTree, } from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, @@ -107,31 +107,15 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) } -describe('scheduleFiniteTimeout', () => { - it('rounds fractions up, chains Node-safe segments, and cancels idempotently', async () => { - vi.useFakeTimers() - try { - const fired = vi.fn() - const chained = scheduleFiniteTimeout(2_147_483_647.25, fired) - await vi.advanceTimersByTimeAsync(2_147_483_647) - expect(fired).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(fired).toHaveBeenCalledOnce() - chained.cancel() - - const cancelled = vi.fn() - const timer = scheduleFiniteTimeout(0.25, cancelled) - timer.cancel() - timer.cancel() - await vi.advanceTimersByTimeAsync(1) - expect(cancelled).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) -}) - describe('spawnSubprocess', () => { + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1])( + 'rejects an invalid grace before spawning: %s', + (graceMs) => { + expect(() => spawnSubprocess(spec('true', { graceMs }))) + .toThrow(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + }, + ) + it('captures stdout on success', async () => { const result = await finish(spawnSubprocess(spec('echo hello'))) expect(result.exitCode).toBe(0) @@ -194,17 +178,6 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe('SIGKILL') }) - it('cancels a larger-than-Node escalation timer once SIGTERM removes the tree', async () => { - const running = spawnSubprocess(spec('echo ready; sleep 60', { - graceMs: Number.MAX_VALUE, - })) - await waitForStdout(running, 'ready\n') - running.terminate() - const result = await running.done - expect(result.signal).toBe('SIGTERM') - await expect(running.waitForExit()).resolves.toBe(true) - }) - it('cancels escalation when the terminated group vanishes before collected pipes drain', async () => { const pidFile = join(spillDir, `escaped-pipe-holder-${Date.now()}.pid`) const graceMs = 160 diff --git a/packages/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json index 5a8dea211b..5272a4f78d 100644 --- a/packages/subprocess/subprocess-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index a5e47747f0..7b2d549f44 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess/README.md -README.md: 13c634429bfae9408dc732aea69df673e5da87aa -README.zh.md: fe7b28d3a8f256e0eb9b4cbb98093bac33816fdf +README.md: e59dd96df036826f36bd0286c977438d2d87d1cf +README.zh.md: 2268af3cf2cfdac87ce9677c8d6d81c6be4c9631 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 13c634429b..e59dd96df0 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -7,7 +7,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes ## Contract - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. -- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. +- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). Grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so the implementation can represent it with one Node timer instead of accepting a value that Node collapses to one millisecond. `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. - Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a string deliberately forwards or overrides a value, while an `undefined` tombstone removes an ordinary ambient entry. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index fe7b28d3a8..2268af3cf2 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -7,7 +7,7 @@ ## 契约 - `spawn(spec)` 立即返回一个活动句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 -- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 +- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样实现便可用一个 Node 定时器表示它,而不会接受会被 Node 折叠为 1 毫秒的值。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 - 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清理后合并且不做命名空间校验——字符串会有意转发或覆盖某个值,而 `undefined` tombstone 则会删除普通的环境条目。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)会导入该环境清理定义。 diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index a21f6ffb0d..6084cc8c8e 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -80,10 +80,11 @@ export interface SubprocessSpawnSpec { /** Per-stream stdio dispositions. */ stdio: SubprocessStdio /** - * Grace period in milliseconds for the {@link SubprocessHandle.terminate} - * escalation and for draining still-open collected pipes after the process - * exits (an inherited descriptor held by a surviving descendant cannot hold - * the outcome open indefinitely). + * Positive finite grace period in milliseconds, no greater than + * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation + * and for draining still-open collected pipes after the process exits (an + * inherited descriptor held by a surviving descendant cannot hold the + * outcome open indefinitely). */ graceMs: number /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1c092a864..6456f24d97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5125,6 +5125,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -5162,6 +5165,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@openai/codex': specifier: 0.146.0 version: 0.146.0 @@ -5503,6 +5509,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis