feat(subagent): support named Codex provider instances

This commit is contained in:
pku-xht 2026-08-18 02:56:44 +08:00
parent 49351cbf0e
commit db52686a96
27 changed files with 595 additions and 144 deletions

View file

@ -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: 2798431709307e50a1ee16c7fc595bcead223f59
2026-08-10-product-subagent-providers-in-shared-host.zh.md: 981b1e2cd305c1410dcd744e3aea5028eb283806
2026-08-10-product-subagent-providers-in-shared-host.md: 78d2bb675446030acfa3d69ee40c6d2db6302f2c
2026-08-10-product-subagent-providers-in-shared-host.zh.md: dcca082e04087250608ddf85f72f0419c7d77769

View file

@ -12,7 +12,7 @@ The placement decision must preserve two independent facts. Loading a provider m
## Decision
Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider package and mounts the required instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: Claude Code accepts multiple unique `providerName` values while preserving `claude-code` as its default; Codex still registers only its `codex` default. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry.
Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider package and mounts the required instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: both products accept multiple unique `providerName` values while preserving `codex` and `claude-code` as their defaults. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry.
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.
@ -22,7 +22,7 @@ Only a Profile that selects the Claude Code provider carries the Claude Agent SD
## Verification
The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition explicitly mounts both optional providers and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove the Codex-only path and a Host containing the default Codex instance plus two named Claude instances register without starting a product process. Keyless ACP snapshots pin the model-visible tool schemas for one product and for independently named product tools, while provider tests separately prove native executable resolution, configuration isolation, failure, cancellation, and process-tree quiescence.
The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition explicitly mounts both optional providers and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove two named instances of each product register without starting a product process. Keyless ACP snapshots pin each product's two-tool roster and the final four-tool combination, while provider tests separately prove native executable resolution, configuration isolation, failure, cancellation, and process-tree quiescence.
## Alternatives considered

View file

@ -12,7 +12,7 @@ Status: implemented
## 决策
产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方包,并在 host plane 挂载所需实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.md)负责每个配置项的注册身份:Claude Code 接受多个唯一的 `providerName`,同时保留 `claude-code` 作为默认值;Codex 仍只注册默认的 `codex`。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。
产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方包,并在 host plane 挂载所需实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.md)负责每个配置项的注册身份:两个产品都接受多个唯一的 `providerName`,同时保留 `codex` 与 `claude-code` 作为默认值。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。
本说明继续负责解释为什么已经挂载的产品提供方属于 host plane,而面向模型的工具属于 Agent Preset。生产安装排除决策负责哪些 Profile 安装这些可选包。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。
@ -22,7 +22,7 @@ Status: implemented
## 验证
base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装显式挂载两个可选提供方,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明 Codex-only 路径以及包含默认 Codex 实例与两个命名 Claude 实例的 Host 会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定单个产品与独立命名产品工具的模型可见 schema,提供方测试则另行证明原生可执行文件解析、配置隔离、失败、取消和进程树完全停稳。
base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装显式挂载两个可选提供方,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个产品的两个命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定每个产品的双工具集合与最终四工具组合,提供方测试则另行证明原生可执行文件解析、配置隔离、失败、取消和进程树完全停稳。
## 考虑过的替代方案

View file

@ -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: fb672f5c326ad240964e1e6c051a4f18d8d1552e
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 11f5c8f1c47be9e80386832efbe0b8b5675b3437
2026-08-04-claude-code-and-codex-subagent-backends.md: 547eebd931d90bc373cd6a0798347744078bd1d6
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 7519fa6952fdca5cccb9031a31b552c6ab665929

View file

@ -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 whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, 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. Claude Code accepts multiple named instances; Codex still registers its single default name. 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 whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, 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. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain 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,7 +34,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro
## Codex provider
`@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.
`@deepseek-ai/dsh-subagent-codex` registers a Profile-selected provider name that defaults to `codex` and starts `codex app-server --stdio` from `PATH`. Its public configuration contains a non-empty `providerName`, 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`. Each named instance retains those resolved values for its own runs. 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`, 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.
@ -60,7 +60,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract
## Distribution and evidence
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 the default Codex instance and two named Claude Code instances expose independent one-shot tools 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.
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 two named instances of each product expose four independent one-shot tools 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, 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`.

View file

@ -12,7 +12,7 @@ Status: implemented
## 决策
harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../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 模式选择与诊断生产。Claude Code 接受多个命名实例;Codex 仍只注册单个默认名称。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。
harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../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,7 +34,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro
## 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 字段。
`@deepseek-ai/dsh-subagent-codex` 注册由 Profile 选择、默认值为 `codex` 的提供方名称,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置包含非空的 `providerName`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。安装、登录、`CODEX_HOME`、模型选择、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approval/reviewer/sandbox 字段。
发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
@ -60,7 +60,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端
## 分发与证据
每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,验证默认 Codex 实例与两个命名 Claude Code 实例会和通用 Job 控制工具一起公开彼此独立的一次性工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。
每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,验证两个产品各自的两个命名实例会和通用 Job 控制工具一起公开四个彼此独立的一次性工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。
Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。

View file

@ -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-18-product-subagent-named-instances.md
2026-08-18-product-subagent-named-instances.md: 6b069727ebba7ebcf34444f9ebdb287c00bf315d
2026-08-18-product-subagent-named-instances.zh.md: dffa009296afde44126725fd65a2fc58977377fc
2026-08-18-product-subagent-named-instances.md: 759d3941ff8404138954c409f0fd4949e357200e
2026-08-18-product-subagent-named-instances.zh.md: 6faf0e70f639cbc6528e27b800b8e5f99f0d6c86

View file

@ -6,15 +6,15 @@ English | [中文](2026-08-18-product-subagent-named-instances.zh.md)
## Problem
A Profile can mount one Cordis plugin package in multiple rows, but the Claude Code product provider previously registered every row as `claude-code`. A second row therefore failed as a duplicate before its distinct permission mode, environment, or process-release settings could become usable. Deriving an implicit name from those settings would create a second identity rule, while choosing a provider during a tool call would let model input select deployment authority.
A Profile can mount one Cordis plugin package in multiple rows, but the Codex and Claude Code product providers previously registered every row under one fixed product name. A second row therefore failed as a duplicate before its distinct permission mode, environment, or process-release settings could become usable. Deriving an implicit name from those settings would create a second identity rule, while choosing a provider during a tool call would let model input select deployment authority.
The existing subagent registry already owns unique provider names, reversible registration, lifecycle events, and holder-owned published runs. The existing `dsh-tool-subagent` configuration already binds one provider name to one model-visible tool name. Product providers need to expose the missing Profile-owned identity without adding another registry or selection protocol.
## Decision
The Claude Code provider Config owns a non-empty `providerName` whose default remains `claude-code`. The resolved name is fixed when the plugin row loads and becomes the Provider object's `name`; registration, lookup, lifecycle events, run logs, and HMR removal therefore use the same value. Each mounted row retains its own `permissionMode`, `env`, `disposeGraceMs`, and run resources. The Codex provider still registers its single `codex` default name.
Each product provider Config owns a non-empty `providerName`; the defaults remain `codex` and `claude-code`. The resolved name is fixed when the plugin row loads and becomes the Provider object's `name`; registration, lookup, lifecycle events, run logs, and HMR removal therefore use the same value. Each mounted row retains its own `permissionMode`, `env`, `disposeGraceMs`, and run resources.
Profiles may mount multiple Claude Code rows when every row uses a distinct `providerName`. Each `dsh-tool-subagent` row continues to bind its existing `provider` field to that exact name and exposes an independently configured `toolName`. Tool calls carry no provider selector, alias, or permission input. A duplicate provider name fails through the existing `DUPLICATE_PROVIDER` path and leaves the first registration intact.
Profiles may mount multiple Codex or Claude Code rows when every row uses a distinct `providerName`. Each `dsh-tool-subagent` row continues to bind its existing `provider` field to that exact name and exposes an independently configured `toolName`. Tool calls carry no provider selector, alias, or permission input. A duplicate provider name fails through the existing `DUPLICATE_PROVIDER` path and leaves the first registration intact.
Removing one provider row blocks new starts and removes only tools bound to that name. Runs already published by the removed instance remain owned by their holders and settle or dispose independently. Sibling instances remain registered and keep their own environment, native permission mode, cancellation controller, product process, and cleanup grace.
@ -29,7 +29,7 @@ Removing one provider row blocks new starts and removes only tools bound to that
## Verification
Claude Code package tests pin the default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. The official SDK/CLI loopback test runs two named instances in one Host against separate model fixtures and proves independent unload and process-tree quiescence. The public Loader composition mounts two Claude Code rows and two distinct tools without starting either product, while the keyless ACP snapshot pins both static tool schemas and the absence of a dynamic provider parameter.
Both product packages pin their default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. The official product loopback tests run two named instances in one Host against separate model fixtures and prove independent unload and process-tree quiescence. Public Loader compositions mount two rows and two distinct tools for each product without starting either product, while keyless ACP snapshots pin the four-tool combined roster and the absence of a dynamic provider parameter.
## Alternatives considered
@ -43,6 +43,6 @@ Claude Code package tests pin the default and custom names, empty-name rejection
## Consequences
A Profile can expose several Claude Code tools backed by separate native permission modes and environments while existing configurations continue to resolve `claude-code`. Provider and tool names remain independent configuration facts, so changing one requires updating the binding that refers to it.
A Profile can expose several Codex and Claude Code tools backed by separate native permission modes and environments while existing configurations continue to resolve `codex` and `claude-code`. Provider and tool names remain independent configuration facts, so changing one requires updating the binding that refers to it.
The design adds no runtime renaming, model-visible selector, generated tool name, persistent instance directory, shared process pool, or compatibility alias. Correct multi-instance configurations require unique provider names and unique tool names; duplicate tool-name waiting remains a separate limitation.

View file

@ -6,15 +6,15 @@ Status: implemented
## 问题
Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Claude Code 产品提供方此前会把每个配置项都注册为 `claude-code`。因此,第二个配置项会在其独立权限模式、环境或进程释放设置可用前因名称重复而失败。根据这些设置隐式派生名称会建立第二套身份规则,而在工具调用期间选择提供方会让模型输入决定部署权限。
Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Codex 与 Claude Code 产品提供方此前会把每个配置项都注册到一个固定产品名称下。因此,第二个配置项会在其独立权限模式、环境或进程释放设置可用前因名称重复而失败。根据这些设置隐式派生名称会建立第二套身份规则,而在工具调用期间选择提供方会让模型输入决定部署权限。
现有 subagent 注册表已经拥有提供方名称唯一性、可逆注册、生命周期事件和由持有方拥有的已发布运行。现有 `dsh-tool-subagent` 配置也已经把一个提供方名称绑定到一个模型可见工具名称。产品提供方只需公开缺失的 Profile 所有身份,无需增加另一套注册表或选择协议。
## 决策
Claude Code 提供方 Config 拥有非空的 `providerName`,其默认值仍为 `claude-code`。插件配置项加载时会固定解析后的名称,并把它作为 Provider 对象的 `name`;注册、查找、生命周期事件、运行日志和 HMR(热模块替换)移除因此使用同一个值。每个已挂载配置项保留自己的 `permissionMode`、`env`、`disposeGraceMs` 和运行资源。Codex 提供方仍只注册默认名称 `codex`。
每个产品提供方 Config 都拥有非空的 `providerName`;默认值仍分别为 `codex` 与 `claude-code`。插件配置项加载时会固定解析后的名称,并把它作为 Provider 对象的 `name`;注册、查找、生命周期事件、运行日志和 HMR(热模块替换)移除因此使用同一个值。每个已挂载配置项保留自己的 `permissionMode`、`env`、`disposeGraceMs` 和运行资源。
当每个配置项使用不同的 `providerName` 时,Profile 可以挂载多个 Claude Code 配置项。每个 `dsh-tool-subagent` 配置项继续用已有的 `provider` 字段绑定这个准确名称,并公开独立配置的 `toolName`。工具调用不携带提供方选择器、别名或权限输入。重复提供方名称沿用现有 `DUPLICATE_PROVIDER` 路径失败,而且不会替换第一个注册项。
当每个配置项使用不同的 `providerName` 时,Profile 可以挂载多个 Codex 或 Claude Code 配置项。每个 `dsh-tool-subagent` 配置项继续用已有的 `provider` 字段绑定这个准确名称,并公开独立配置的 `toolName`。工具调用不携带提供方选择器、别名或权限输入。重复提供方名称沿用现有 `DUPLICATE_PROVIDER` 路径失败,而且不会替换第一个注册项。
移除一个提供方配置项会阻止新的启动,并且只移除绑定到该名称的工具。该实例已经发布的运行仍由其持有方拥有,并会独立结算或 dispose(资源释放)。兄弟实例继续保持注册,并保留各自的环境、原生权限模式、取消控制器、产品进程和清理宽限期。
@ -29,7 +29,7 @@ Claude Code 提供方 Config 拥有非空的 `providerName`,其默认值仍为
## 验证
Claude Code 包测试固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方 SDK/CLI 回环测试会在同一个 Host 中针对独立模型 fixture(测试前置数据)运行两个命名实例,并证明独立卸载与进程树完全停稳。公共 Loader 组合会挂载两个 Claude Code 配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定两个静态工具 schema,并证明没有动态提供方参数。
两个产品包测试都会固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方产品回环测试会在同一个 Host 中针对独立模型 fixture(测试前置数据)运行两个命名实例,并证明独立卸载与进程树完全停稳。公共 Loader 组合会为每个产品挂载两个配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定最终四工具组合,并证明没有动态提供方参数。
## 考虑过的替代方案
@ -43,6 +43,6 @@ Claude Code 包测试固定默认与自定义名称、空名称拒绝、重复
## 结果
Profile 可以公开多个由不同原生权限模式与环境支持的 Claude Code 工具,而现有配置仍会解析为 `claude-code`。提供方名称与工具名称继续是彼此独立的配置事实,因此修改其中一项时必须同时更新引用它的绑定。
Profile 可以公开多个由不同原生权限模式与环境支持的 Codex 与 Claude Code 工具,而现有配置仍会解析为 `codex` 与 `claude-code`。提供方名称与工具名称继续是彼此独立的配置事实,因此修改其中一项时必须同时更新引用它的绑定。
本设计不增加运行时改名、模型可见选择器、自动生成的工具名称、持久实例目录、共享进程池或兼容别名。正确的多实例配置要求提供方名称与工具名称都保持唯一;重复工具名称的等待问题仍是独立限制。

View file

@ -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: 33fc261e971f9055f666e5005080e01b31c6d708
config-catalog.zh.md: 24ad1fdb5d0d2eb7470785de7b913d7b33f6c9aa
config-catalog.md: 59f5009e44bb826bc3301b8f5e313efd7032f666
config-catalog.zh.md: 85f7152bb39d8f8b8bdcbecb27da9a7c41df494a

View file

@ -2116,6 +2116,8 @@ Requires: `subagents` · `subprocess`
```ts config-catalog
/** Deployment-owned permission, environment, and process-release settings. */
export interface Config {
/** Provider name on `ctx.subagents` (default `codex`). */
providerName?: string
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment.
@ -2134,7 +2136,7 @@ export type CodexPermissionMode =
| 'dangerously-bypass-approvals-and-sandbox'
```
Source: [`packages/subagent/subagent-codex/src/index.ts:33`](../packages/subagent/subagent-codex/src/index.ts)
Source: [`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts)
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>

View file

@ -2118,6 +2118,8 @@ export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[numbe
```ts config-catalog
/** Deployment-owned permission, environment, and process-release settings. */
export interface Config {
/** Provider name on `ctx.subagents` (default `codex`). */
providerName?: string
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment.
@ -2136,7 +2138,7 @@ export type CodexPermissionMode =
| 'dangerously-bypass-approvals-and-sandbox'
```
来源:[`packages/subagent/subagent-codex/src/index.ts:33`](../packages/subagent/subagent-codex/src/index.ts)
来源:[`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts)
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>

View file

@ -1,5 +1,5 @@
# Keyless twin of product-subagent-both.cordis.yml: preserve all named product
# tools while replacing only the external model adapter.
# Keyless twin of product-subagent-both.cordis.yml: preserve all four named
# product tools while replacing only the external model adapter.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
@ -18,10 +18,20 @@
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro
- id: subagent-codex
- id: subagent-codex-safe
name: '@deepseek-ai/dsh-subagent-codex'
config:
permissionMode: approve-for-me
providerName: codex-safe
permissionMode: never
env:
DSH_CODEX_INSTANCE: safe
- id: subagent-codex-bypass
name: '@deepseek-ai/dsh-subagent-codex'
config:
providerName: codex-bypass
permissionMode: dangerously-bypass-approvals-and-sandbox
env:
DSH_CODEX_INSTANCE: bypass
- id: subagent-claude-safe
name: '@deepseek-ai/dsh-subagent-claude-code'
config:
@ -36,11 +46,18 @@
permissionMode: bypassPermissions
env:
DSH_CLAUDE_INSTANCE: bypass
- id: tool-subagent-codex
- id: tool-subagent-codex-safe
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
provider: codex-safe
toolName: subagent_codex_safe
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-codex-bypass
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex-bypass
toolName: subagent_codex_bypass
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-claude-safe

View file

@ -1,16 +1,26 @@
# Add the native Codex provider, two named Claude Code instances, and the
# Add two named Codex providers, two named Claude Code providers, and the
# independent one-shot tool rows an Agent Preset may contribute. Loading the
# composition starts neither product; the scenario pins all three schemas.
# composition starts neither product; the scenario pins all four schemas.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
path: ./cordis.yml
patches:
- insert:
- id: subagent-codex
- id: subagent-codex-safe
name: '@deepseek-ai/dsh-subagent-codex'
config:
permissionMode: approve-for-me
providerName: codex-safe
permissionMode: never
env:
DSH_CODEX_INSTANCE: safe
- id: subagent-codex-bypass
name: '@deepseek-ai/dsh-subagent-codex'
config:
providerName: codex-bypass
permissionMode: dangerously-bypass-approvals-and-sandbox
env:
DSH_CODEX_INSTANCE: bypass
- id: subagent-claude-safe
name: '@deepseek-ai/dsh-subagent-claude-code'
config:
@ -25,11 +35,18 @@
permissionMode: bypassPermissions
env:
DSH_CLAUDE_INSTANCE: bypass
- id: tool-subagent-codex
- id: tool-subagent-codex-safe
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
provider: codex-safe
toolName: subagent_codex_safe
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-codex-bypass
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex-bypass
toolName: subagent_codex_bypass
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-claude-safe

View file

@ -1,5 +1,5 @@
# Keyless twin of product-subagent-codex.cordis.yml: keep the same product
# provider/tool composition and replace only the external model adapter.
# Keyless twin of product-subagent-codex.cordis.yml: keep both named product
# providers and tools while replacing only the external model adapter.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
@ -18,14 +18,31 @@
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro
- id: subagent-codex
- id: subagent-codex-safe
name: '@deepseek-ai/dsh-subagent-codex'
config:
permissionMode: approve-for-me
- id: tool-subagent-codex
providerName: codex-safe
permissionMode: never
env:
DSH_CODEX_INSTANCE: safe
- id: subagent-codex-bypass
name: '@deepseek-ai/dsh-subagent-codex'
config:
providerName: codex-bypass
permissionMode: dangerously-bypass-approvals-and-sandbox
env:
DSH_CODEX_INSTANCE: bypass
- id: tool-subagent-codex-safe
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
provider: codex-safe
toolName: subagent_codex_safe
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-codex-bypass
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex-bypass
toolName: subagent_codex_bypass
backgroundMode: one-shot
maxDepth: provider-managed

View file

@ -1,20 +1,37 @@
# Add the native Codex product provider and its preset-shaped one-shot tool to
# the real ACP composition. The model is told not to call it; the scenario pins
# the assembled request schema without starting Codex.
# Add two named Codex product providers and their preset-shaped one-shot tools
# to the real ACP composition. The model is told not to call them; the scenario
# pins both assembled request schemas without starting Codex.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
path: ./cordis.yml
patches:
- insert:
- id: subagent-codex
- id: subagent-codex-safe
name: '@deepseek-ai/dsh-subagent-codex'
config:
permissionMode: approve-for-me
- id: tool-subagent-codex
providerName: codex-safe
permissionMode: never
env:
DSH_CODEX_INSTANCE: safe
- id: subagent-codex-bypass
name: '@deepseek-ai/dsh-subagent-codex'
config:
providerName: codex-bypass
permissionMode: dangerously-bypass-approvals-and-sandbox
env:
DSH_CODEX_INSTANCE: bypass
- id: tool-subagent-codex-safe
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
provider: codex-safe
toolName: subagent_codex_safe
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-codex-bypass
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex-bypass
toolName: subagent_codex_bypass
backgroundMode: one-shot
maxDepth: provider-managed

View file

@ -1,4 +1,4 @@
# Test-only composition of the public opt-in provider and one-shot task tool.
# Test-only composition of two named Codex instances and their one-shot tools.
# The owning e2e boots this tree but never invokes the model or Codex.
- id: fixture
name: './fixture.ts'
@ -9,16 +9,29 @@
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: subagent-codex
- id: subagent-codex-primary
name: '@deepseek-ai/dsh-subagent-codex'
config:
permissionMode: approve-for-me
providerName: codex-primary
- id: tool-subagent-codex
- id: subagent-codex-secondary
name: '@deepseek-ai/dsh-subagent-codex'
config:
providerName: codex-secondary
- id: tool-subagent-codex-primary
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
provider: codex-primary
toolName: subagent_codex_primary
backgroundMode: one-shot
maxDepth: 'provider-managed'
- id: tool-subagent-codex-secondary
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex-secondary
toolName: subagent_codex_secondary
backgroundMode: one-shot
maxDepth: 'provider-managed'

View file

@ -23,14 +23,36 @@ const ctx = await boot(
)
try {
const provider = ctx.subagents.getProvider('codex')
if (provider === undefined) throw new Error('Codex provider was not registered')
const tool = ctx.tools.schemas().find(schema => schema.name === 'subagent_codex')
if (tool === undefined) throw new Error('subagent_codex tool was not registered')
const properties = tool.parameters.properties
if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) {
throw new Error('subagent_codex tool has invalid parameter properties')
}
const providerNames = ['codex-primary', 'codex-secondary'] as const
const toolNames = ['subagent_codex_primary', 'subagent_codex_secondary'] as const
const providers = providerNames.map((providerName) => {
const provider = ctx.subagents.getProvider(providerName)
if (provider === undefined) {
throw new Error(`${providerName} provider was not registered`)
}
return {
name: provider.name,
capabilities: provider.capabilities,
inheritsParentContext: provider.inheritsParentContext,
}
})
const tools = toolNames.map((toolName) => {
const tool = ctx.tools.schemas().find(schema => schema.name === toolName)
if (tool === undefined) throw new Error(`${toolName} tool was not registered`)
const properties = tool.parameters.properties
if (
typeof properties !== 'object'
|| properties === null
|| Array.isArray(properties)
) {
throw new Error(`${toolName} has invalid parameter properties`)
}
return {
name: tool.name,
parameterNames: Object.keys(properties).sort(),
required: tool.parameters.required,
}
})
const jobTools = ctx.tools.schemas()
.map(schema => schema.name)
.filter(name => name === 'job_kill' || name === 'job_list' || name === 'job_output')
@ -38,16 +60,8 @@ try {
process.stdout.write(`${JSON.stringify({
providers: ctx.subagents.list(),
provider: {
name: provider.name,
capabilities: provider.capabilities,
inheritsParentContext: provider.inheritsParentContext,
},
tool: {
name: tool.name,
parameterNames: Object.keys(properties).sort(),
required: tool.parameters.required,
},
providerDetails: providers,
tools,
jobTools,
starts,
})}\n`)

View file

@ -357,7 +357,32 @@
}
},
{
"name": "subagent_codex",
"name": "subagent_codex_bypass",
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A short (3-5 word) description of the delegated task, for display."
},
"prompt": {
"type": "string",
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
},
"run_in_background": {
"type": "boolean",
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
}
},
"required": [
"description",
"prompt"
]
}
},
{
"name": "subagent_codex_safe",
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.",
"parameters": {
"type": "object",

View file

@ -307,7 +307,32 @@
}
},
{
"name": "subagent_codex",
"name": "subagent_codex_bypass",
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A short (3-5 word) description of the delegated task, for display."
},
"prompt": {
"type": "string",
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
},
"run_in_background": {
"type": "boolean",
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
}
},
"required": [
"description",
"prompt"
]
}
},
{
"name": "subagent_codex_safe",
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.",
"parameters": {
"type": "object",

View file

@ -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: 645479474599eb4cb72c0bf73838a6341c98adb7
README.zh.md: 1e9d21882b4c84312ea60eff3510bd2295d5334e
README.md: 85358a3fbbab216bccccb3340be47a1c5b1433ef
README.zh.md: 03b74233f18d55c7fa81b327e2de96cf85b816cc

View file

@ -2,7 +2,7 @@
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 either the selected final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract.
This package registers a Profile-named Codex subagent provider whose default name is `codex`. 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
@ -22,6 +22,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
| Key | Default | Meaning |
|---|---|---|
| `providerName` | `codex` | Non-empty registry name on `ctx.subagents`; each mounted instance needs a unique value. |
| `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. |
@ -34,15 +35,24 @@ The provider advertises no optional start-time capabilities and reports `inherit
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.
Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and may mount one or more host-plane rows with distinct `providerName`, `permissionMode`, and `env` values; omitting `providerName` keeps the `codex` default. Loading an instance starts no Codex process until a bound tool calls it. Each `dsh-tool-subagent` row names one provider and needs its own `toolName`, so the model sees static tools rather than a dynamic provider selector. Full Agent Presets carry a matching default 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.
The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services.
The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider and tool rows, and does not mount duplicate Job services.
```yaml
- id: subagent-codex
- id: subagent-codex-safe
name: '@deepseek-ai/dsh-subagent-codex'
config:
permissionMode: approve-for-me
providerName: codex-safe
permissionMode: never
env:
OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
- id: subagent-codex-bypass
name: '@deepseek-ai/dsh-subagent-codex'
config:
providerName: codex-bypass
permissionMode: dangerously-bypass-approvals-and-sandbox
env:
OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
@ -52,18 +62,26 @@ The standalone composition below shows the complete explicit capability. A Profi
- id: tool-jobs
name: '@deepseek-ai/dsh-tool-jobs'
- id: tool-subagent-codex
- id: tool-subagent-codex-safe
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
provider: codex-safe
toolName: subagent_codex_safe
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-codex-bypass
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex-bypass
toolName: subagent_codex_bypass
backgroundMode: one-shot
maxDepth: provider-managed
```
## 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`. 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.
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 two named instances retain separate environments and native modes, 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
@ -71,7 +89,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; 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.
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 selected Provider instance's Profile configuration fixes the thread's environment, non-interactive approval policy, and sandbox mode.
#### Token effect
@ -98,6 +116,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while
## Known Limitations and Deferred Work
- **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence.
- **Static instance selection** — Profile rows fix provider names and tool bindings; calls cannot choose a provider dynamically, and every exposed tool needs a unique `toolName`.
- **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; the three Profile modes never create a DSH interaction channel or per-call allow policy.

View file

@ -2,7 +2,7 @@
[English](README.md) | 中文
本包注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回选定的最终答案或安全失败说明。
本包注册由 Profile 命名、默认名称为 `codex` 的 Codex subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回选定的最终答案或安全失败说明。
## 启动与所有权
@ -22,6 +22,7 @@
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `providerName` | `codex` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 |
| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 |
| `permissionMode` | `never` | 为该提供方实例的每个线程固定原生非交互审批与沙箱模式。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
@ -34,15 +35,24 @@
生产环境会从 `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 已提供通用作业注册表和控制工具。
生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并可在 host plane(宿主平面)挂载一个或多个具有不同 `providerName`、`permissionMode` 与 `env` 的配置项;省略 `providerName` 时仍使用默认的 `codex`。加载实例本身不会在绑定工具调用前启动 Codex 进程。每个 `dsh-tool-subagent` 配置项指定一个提供方,并需要独立的 `toolName`,因此模型看到的是静态工具,而不是动态提供方选择器。完整 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 已提供通用作业注册表和控制工具。
下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。
下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 配置项,新增产品提供方与工具配置项,而且不重复挂载 Job 服务。
```yaml
- id: subagent-codex
- id: subagent-codex-safe
name: '@deepseek-ai/dsh-subagent-codex'
config:
permissionMode: approve-for-me
providerName: codex-safe
permissionMode: never
env:
OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
- id: subagent-codex-bypass
name: '@deepseek-ai/dsh-subagent-codex'
config:
providerName: codex-bypass
permissionMode: dangerously-bypass-approvals-and-sandbox
env:
OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
@ -52,18 +62,26 @@
- id: tool-jobs
name: '@deepseek-ai/dsh-tool-jobs'
- id: tool-subagent-codex
- id: tool-subagent-codex-safe
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
provider: codex-safe
toolName: subagent_codex_safe
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-codex-bypass
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex-bypass
toolName: subagent_codex_bypass
backgroundMode: one-shot
maxDepth: provider-managed
```
## 产品兼容性与证据
生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。真实产品覆盖会证明线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,安全诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。
生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。真实产品覆盖会证明两个命名实例保留彼此独立的环境与原生模式,线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,安全诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。
## 模型体验
@ -71,7 +89,7 @@
#### 模型看到的内容
Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具和身份验证来自原生 Codex 安装与配置,而提供方的 Profile 配置会固定该线程的非交互审批与沙箱模式。
Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具和身份验证来自原生 Codex 安装与配置,而所选提供方实例的 Profile 配置会固定该线程的环境、非交互审批策略与沙箱模式。
#### 对 token 的影响
@ -98,6 +116,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些
## 已知限制与后续工作
- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。
- **静态选择实例**:Profile 配置项固定提供方名称与工具绑定;调用无法动态选择提供方,而且每个公开工具都需要唯一的 `toolName`。
- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。
- **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。
- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。

View file

@ -1,7 +1,7 @@
/**
* Fixed Codex one-shot subagent provider. Every accepted run starts a fresh
* official `codex app-server --stdio` process in the delegating Session's
* workspace and publishes only after an ephemeral thread exists.
* Profile-named Codex one-shot subagent provider. Every accepted run starts a
* fresh official `codex app-server --stdio` process in the delegating
* Session's workspace and publishes only after an ephemeral thread exists.
*
* @module @deepseek-ai/dsh-subagent-codex
*/
@ -29,8 +29,12 @@ import {
export const name = 'subagent-codex'
export const inject = ['subagents', 'subprocess']
const DEFAULT_PROVIDER_NAME = 'codex'
/** Deployment-owned permission, environment, and process-release settings. */
export interface Config {
/** Provider name on `ctx.subagents` (default `codex`). */
providerName?: string
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment.
@ -43,6 +47,7 @@ export interface Config {
}
export const Config: z<Config> = z.object({
providerName: z.string().min(1).default(DEFAULT_PROVIDER_NAME),
env: z.dict(z.string()).default({}),
permissionMode: z.union([...CODEX_PERMISSION_MODES])
.default(DEFAULT_CODEX_PERMISSION_MODE),
@ -52,11 +57,11 @@ export const Config: z<Config> = z.object({
type ResolvedConfig = Required<Config>
class CodexProvider implements SubagentProvider {
readonly name = 'codex'
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
readonly inheritsParentContext = false
constructor(
readonly name: string,
private readonly ctx: Context,
private readonly config: ResolvedConfig,
) {}
@ -80,7 +85,7 @@ class CodexProvider implements SubagentProvider {
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
onError: (error, stopReason) => {
this.ctx.logger.warn(
`subagent-codex: child run failed (${stopReason}): ${error.message}`,
`subagent-codex "${this.name}": child run failed (${stopReason}): ${error.message}`,
)
},
}
@ -89,12 +94,13 @@ class CodexProvider implements SubagentProvider {
}
/**
* Register the fixed `codex` provider.
* Register one Profile-named Codex provider.
* @param ctx - context carrying shared subagent and subprocess services.
* @param config - permission mode, child environment, and disposal grace.
* @param config - registry name, permission mode, child environment, and disposal grace.
*/
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = {
providerName: config.providerName ?? DEFAULT_PROVIDER_NAME,
env: config.env as Record<string, string>,
permissionMode: config.permissionMode ?? DEFAULT_CODEX_PERMISSION_MODE,
disposeGraceMs: config.disposeGraceMs as number,
@ -109,5 +115,9 @@ export function apply(ctx: Context, config: Config): void {
`subagent-codex: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
ctx.subagents.registerProvider(new CodexProvider(ctx, resolved))
ctx.subagents.registerProvider(new CodexProvider(
resolved.providerName,
ctx,
resolved,
))
}

View file

@ -15,7 +15,7 @@ const configPath = join(fixtureDir, 'cordis.yml')
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
describe('Codex provider public Loader composition', () => {
it('loads the opt-in package, one-shot task tool, and job controls without starting Codex', async () => {
it('loads two named instances, their tools, and job controls without starting Codex', async () => {
const { stdout, stderr } = await runLoaderSmoke({
label: 'subagent-codex Loader composition',
tempDirPrefix: 'dsh-subagent-codex-loader-',
@ -31,22 +31,41 @@ describe('Codex provider public Loader composition', () => {
expect(stderr).toBe('')
expect(JSON.parse(stdout)).toEqual({
providers: ['codex'],
provider: {
name: 'codex',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
providers: ['codex-primary', 'codex-secondary'],
providerDetails: [
{
name: 'codex-primary',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
},
inheritsParentContext: false,
},
tool: {
name: 'subagent_codex',
parameterNames: ['description', 'prompt', 'run_in_background'],
required: ['description', 'prompt'],
},
{
name: 'codex-secondary',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
},
],
tools: [
{
name: 'subagent_codex_primary',
parameterNames: ['description', 'prompt', 'run_in_background'],
required: ['description', 'prompt'],
},
{
name: 'subagent_codex_secondary',
parameterNames: ['description', 'prompt', 'run_in_background'],
required: ['description', 'prompt'],
},
],
jobTools: ['job_kill', 'job_list', 'job_output'],
starts: 0,
})

View file

@ -15,7 +15,10 @@ import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import type {
SubprocessHandle,
SubprocessSpawnSpec,
} 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'
@ -50,17 +53,20 @@ interface RealHarness {
readonly ctx: Context
readonly handles: SubprocessHandle[]
readonly parent: Agent
readonly providerName: string
readonly env: Record<string, string>
readonly workspace: string
}
async function realHarness(
script: readonly ResponsesBehavior[],
permissionMode?: CodexPermissionMode,
): Promise<{
readonly harness: RealHarness
interface RealInstanceFixture {
readonly fixture: ResponsesFixture
}> {
readonly env: Record<string, string>
readonly workspace: string
}
async function realInstanceFixture(
script: readonly ResponsesBehavior[],
): Promise<RealInstanceFixture> {
const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-'))
roots.push(root)
const workspace = join(root, 'workspace')
@ -99,27 +105,63 @@ async function realHarness(
ALL_PROXY: '',
NO_PROXY: '127.0.0.1,localhost',
}
return { fixture, env, workspace }
}
interface RealRuntime {
readonly ctx: Context
readonly handles: SubprocessHandle[]
readonly spawnSpecs: SubprocessSpawnSpec[]
}
async function realRuntime(): Promise<RealRuntime> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const handles: SubprocessHandle[] = []
const spawnSpecs: SubprocessSpawnSpec[] = []
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
spawnSpecs.push(spec)
const handle = spawn(spec)
handles.push(handle)
return handle
})
return { ctx, handles, spawnSpecs }
}
async function realHarness(
script: readonly ResponsesBehavior[],
permissionMode?: CodexPermissionMode,
providerName = 'codex',
): Promise<{
readonly harness: RealHarness
readonly fixture: ResponsesFixture
}> {
const instance = await realInstanceFixture(script)
const { ctx, handles } = await realRuntime()
await ctx.plugin(codex, {
env,
providerName,
env: instance.env,
...permissionMode === undefined ? {} : { permissionMode },
disposeGraceMs: 2_000,
})
const parent = {
id: 'real-parent',
session: { header: { cwd: workspace } },
session: { header: { cwd: instance.workspace } },
} as unknown as Agent
return { harness: { ctx, handles, parent, env, workspace }, fixture }
return {
harness: {
ctx,
handles,
parent,
providerName,
env: instance.env,
workspace: instance.workspace,
},
fixture: instance.fixture,
}
}
async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise<void> {
@ -181,6 +223,79 @@ describe('real @openai/codex 0.147.0 product', () => {
await expectQuiescent(harness.handles)
}, 60_000)
it('runs two named instances concurrently and unloads one without revoking its run', async () => {
const safeInstance = await realInstanceFixture([{ kind: 'hold' }])
const bypassInstance = await realInstanceFixture([{
kind: 'complete',
text: 'NAMED_CODEX_BYPASS_RESULT',
}])
const { ctx, handles, spawnSpecs } = await realRuntime()
const safeFiber = await ctx.plugin(codex, {
providerName: 'codex-safe',
env: safeInstance.env,
permissionMode: 'never',
disposeGraceMs: 2_000,
})
const bypassFiber = await ctx.plugin(codex, {
providerName: 'codex-bypass',
env: bypassInstance.env,
permissionMode: 'dangerously-bypass-approvals-and-sandbox',
disposeGraceMs: 2_000,
})
const safeParent = {
id: 'safe-parent',
session: { header: { cwd: safeInstance.workspace } },
} as unknown as Agent
const bypassParent = {
id: 'bypass-parent',
session: { header: { cwd: bypassInstance.workspace } },
} as unknown as Agent
const safeController = new AbortController()
const [safeRun, bypassRun] = await Promise.all([
ctx.subagents.start('codex-safe', {
prompt: [{ type: 'text', text: 'Hold the safe instance.' }],
parent: safeParent,
signal: safeController.signal,
}),
ctx.subagents.start('codex-bypass', {
prompt: [{ type: 'text', text: 'Complete the bypass instance.' }],
parent: bypassParent,
signal: new AbortController().signal,
}),
])
await safeInstance.fixture.requestStarted
await safeFiber.dispose()
expect(ctx.subagents.list()).toEqual(['codex-bypass'])
await expect(ctx.subagents.start('codex-safe', {
prompt: [{ type: 'text', text: 'This start must fail.' }],
parent: safeParent,
signal: new AbortController().signal,
})).rejects.toMatchObject({ code: 'NO_PROVIDER' })
await expect(bypassRun.result).resolves.toEqual({
output: [{ type: 'text', text: 'NAMED_CODEX_BYPASS_RESULT' }],
stopReason: 'completed',
})
safeController.abort(new Error('cancel only the published safe run'))
await expect(safeRun.result).resolves.toEqual({
output: [],
stopReason: 'aborted',
})
await Promise.all([safeRun.dispose(), bypassRun.dispose()])
expect(safeInstance.fixture.requests).toHaveLength(1)
expect(bypassInstance.fixture.requests).toHaveLength(1)
expect(safeInstance.fixture.requests[0]?.body.input)
.not.toEqual(bypassInstance.fixture.requests[0]?.body.input)
expect(spawnSpecs.map(spec => spec.env?.CODEX_HOME).sort()).toEqual([
safeInstance.env.CODEX_HOME,
bypassInstance.env.CODEX_HOME,
].sort())
await expectQuiescent(handles)
await bypassFiber.dispose()
expect(ctx.subagents.list()).toEqual([])
}, 60_000)
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'

View file

@ -10,6 +10,7 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type {
SubprocessHandle,
SubprocessOutcome,
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as codex from '../src/index.ts'
@ -333,7 +334,7 @@ describe('task admission and package contracts', () => {
.toThrow('must not be empty')
})
it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
it('registers the default descriptor, validates config, and unregisters on HMR', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
@ -362,7 +363,125 @@ describe('task admission and package contracts', () => {
await ctx.fiber.dispose()
})
it('keeps named instances, runs, and HMR ownership isolated', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const safeChild = fakeChild()
const bypassChild = fakeChild()
const spawnSpecs: SubprocessSpawnSpec[] = []
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
spawnSpecs.push(spec)
return spec.env?.DSH_CODEX_INSTANCE === 'safe'
? safeChild.handle
: bypassChild.handle
})
const added: string[] = []
const started: string[] = []
const ended: string[] = []
const removed: string[] = []
ctx.on('subagent/provider-added', provider => void added.push(provider.name))
ctx.on('subagent/start', info => void started.push(info.provider))
ctx.on('subagent/end', info => void ended.push(info.provider))
ctx.on('subagent/provider-removed', providerName => void removed.push(providerName))
const safeFiber = await ctx.plugin(codex, {
providerName: 'codex-safe',
env: { DSH_CODEX_INSTANCE: 'safe' },
permissionMode: 'never',
disposeGraceMs: 11,
})
const bypassFiber = await ctx.plugin(codex, {
providerName: 'codex-bypass',
env: { DSH_CODEX_INSTANCE: 'bypass' },
permissionMode: 'dangerously-bypass-approvals-and-sandbox',
disposeGraceMs: 29,
})
expect(ctx.subagents.list()).toEqual(['codex-safe', 'codex-bypass'])
expect(added).toEqual(['codex-safe', 'codex-bypass'])
const safeController = new AbortController()
const safeStarting = ctx.subagents.start(
'codex-safe',
request(undefined, safeController.signal),
)
const bypassStarting = ctx.subagents.start('codex-bypass', request())
for (const child of [safeChild, bypassChild]) {
const initialize = await child.peer.nextMethod('initialize')
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
await child.peer.nextMethod('initialized')
const threadStart = await child.peer.nextMethod('thread/start')
child.peer.respond(threadStart, {
thread: { id: 'thread-1', ephemeral: true },
})
}
const [safeRun, bypassRun] = await Promise.all([
safeStarting,
bypassStarting,
])
await safeFiber.dispose()
expect(ctx.subagents.list()).toEqual(['codex-bypass'])
expect(removed).toEqual(['codex-safe'])
await expect(ctx.subagents.start('codex-safe', request()))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
const safeTurn = await safeChild.peer.nextMethod('turn/start')
const bypassTurn = await bypassChild.peer.nextMethod('turn/start')
safeChild.peer.respond(safeTurn, { turn: { id: 'turn-safe' } })
bypassChild.peer.send(
{ id: bypassTurn.id, result: { turn: { id: 'turn-bypass' } } },
agentMessage('bypass answer', 'final_answer', 'turn-bypass'),
turnCompleted('completed', 'turn-bypass'),
)
await expect(bypassRun.result).resolves.toEqual({
output: [{ type: 'text', text: 'bypass answer' }],
stopReason: 'completed',
})
safeController.abort(new Error('stop only the safe instance'))
await expect(safeRun.result).resolves.toEqual({
output: [],
stopReason: 'aborted',
})
expect(spawnSpecs.map(spec => ({
instance: spec.env?.DSH_CODEX_INSTANCE,
graceMs: spec.graceMs,
}))).toEqual([
{ instance: 'safe', graceMs: 11 },
{ instance: 'bypass', graceMs: 29 },
])
await Promise.all([safeRun.dispose(), bypassRun.dispose()])
expect([...started].sort()).toEqual(['codex-bypass', 'codex-safe'])
expect([...ended].sort()).toEqual(['codex-bypass', 'codex-safe'])
expect(safeChild.terminate).toHaveBeenCalledOnce()
expect(bypassChild.terminate).toHaveBeenCalledOnce()
await bypassFiber.dispose()
expect(removed).toEqual(['codex-safe', 'codex-bypass'])
await ctx.fiber.dispose()
})
it('rejects duplicate provider names without replacing the first instance', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const firstFiber = await ctx.plugin(codex, {
providerName: 'codex-duplicate',
})
const first = ctx.subagents.getProvider('codex-duplicate')
await expect(ctx.plugin(codex, {
providerName: 'codex-duplicate',
permissionMode: 'dangerously-bypass-approvals-and-sandbox',
})).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' })
expect(ctx.subagents.getProvider('codex-duplicate')).toBe(first)
expect(ctx.subagents.list()).toEqual(['codex-duplicate'])
await firstFiber.dispose()
await ctx.fiber.dispose()
})
it('accepts only the three fixed non-interactive permission modes', () => {
expect(codex.Config({}).providerName).toBe('codex')
expect(codex.Config({ providerName: 'codex-safe' }).providerName)
.toBe('codex-safe')
expect(() => codex.Config({ providerName: '' })).toThrow()
expect(codex.Config({}).permissionMode).toBe(DEFAULT_CODEX_PERMISSION_MODE)
for (const permissionMode of CODEX_PERMISSION_MODES) {
expect(codex.Config({ permissionMode }).permissionMode).toBe(permissionMode)
@ -1586,11 +1705,12 @@ describe('run lifecycle and quiescence', () => {
warnings.push(String(message))
}) as typeof ctx.logger.warn
await ctx.plugin(codex, {
providerName: 'codex-diagnostic',
env: { OPENAI_API_KEY: 'fake' },
permissionMode: 'approve-for-me',
disposeGraceMs: 25,
})
const starting = ctx.subagents.start('codex', {
const starting = ctx.subagents.start('codex-diagnostic', {
prompt: [{ type: 'text', text: 'task' }],
parent: fakeParent,
signal: new AbortController().signal,
@ -1638,7 +1758,7 @@ describe('run lifecycle and quiescence', () => {
cwd: process.cwd(),
}))
expect(warnings).toEqual([
expect.stringContaining('subagent-codex: child run failed (error): subagent-codex: Codex turn ended with status failed: error'),
expect.stringContaining('subagent-codex "codex-diagnostic": child run failed (error): subagent-codex: Codex turn ended with status failed: error'),
])
expect(warnings.join('\n')).not.toContain('SECRET_TOKEN')
expect(warnings.join('\n')).not.toContain('/private/secret.txt')