فهرست منبع

refactor(subagent): narrow Codex runtime ownership

pku-xht 4 هفته پیش
والد
کامیت
72cb49dbbb

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

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

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

@@ -16,11 +16,11 @@ Each product Bundle loads its fixed provider exactly once in the shared Host pla
 
 The [production-closure decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) partially supersedes only this note's former default-inclusion choice: the base bundle excludes both providers, and each provider package owns its directly installable Bundle patch. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions.
 
-The Bundles have different executable owners. The Codex package pins the official wrapper and six platform aliases; the provider runs the package-declared wrapper, which selects the private native payload. The Claude Code package pins its Agent SDK and eight platform packages; the provider lets that SDK select the private native executable. Neither provider consults or falls back to a host product command, while native configuration and authentication remain authoritative. Loading either Bundle only registers the provider and creates no product state, probes no version or authentication, and adds no product-specific setting. A missing platform payload, authentication failure, and other product failures remain local to the attempted delegation.
+Each Bundle delegates executable selection to its package-owned product runtime: the Codex package runs its declared wrapper, while the Claude Code package lets its Agent SDK select the private native executable. Neither provider consults or falls back to a host product command, while native configuration and authentication remain authoritative. Loading either Bundle only registers the provider and creates no product state, probes no version or authentication, and adds no product-specific setting. A missing platform payload, authentication failure, and other product failures remain local to the attempted delegation.
 
 ## Verification
 
-Real composition loads no product Bundle, Codex only, Claude Code only, or both, then crosses that availability with Agent Presets that grant neither tool, either one, or both. It proves the Host registry and model-visible tools reflect those independent decisions, no product process starts during composition, and Preset edits affect only later Sessions. Package Loader and real-product tests separately prove each private runtime, missing-payload failure without host fallback, cancellation, and process-tree quiescence. Keyless ACP snapshots pin the model-visible tool schemas and generic Job controls.
+Real composition loads no product Bundle, Codex only, Claude Code only, or both, then crosses that availability with Agent Presets that grant neither tool, either one, or both. It proves the Host registry and model-visible tools reflect those independent decisions, no product process starts during composition, and Preset edits affect only later Sessions. The linked provider and background decisions own private-runtime, failure, teardown, tool-schema, and Job evidence.
 
 ## Alternatives considered
 

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

@@ -16,11 +16,11 @@ Status: implemented
 
 [生产依赖闭包决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只部分取代本说明先前关于默认包含提供方的选择:base 组合包排除两个提供方,每个提供方包都拥有可直接安装的 Bundle patch。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。
 
-两个 Bundle 的可执行文件归属不同。Codex 包锁定官方 wrapper 与六个平台 alias;提供方运行包所声明的 wrapper,再由它选择私有原生载荷。Claude Code 包锁定 Agent SDK 与八个平台包;提供方让 SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令,原生配置与身份验证仍保持权威。加载任一 Bundle 只会完成提供方注册,不会创建产品状态、探测版本或身份验证,也不会新增产品专属设置。平台载荷缺失、身份验证失败和其他产品故障仍局限于发生问题的那次委派。
+每个 Bundle 都把可执行文件选择交给包自有的产品运行时:Codex 包运行自身声明的 wrapper,Claude Code 包则让 Agent SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令,原生配置与身份验证仍保持权威。加载任一 Bundle 只会完成提供方注册,不会创建产品状态、探测版本或身份验证,也不会新增产品专属设置。平台载荷缺失、身份验证失败和其他产品故障仍局限于发生问题的那次委派。
 
 ## 验证
 
-真实组装会覆盖未安装产品 Bundle、仅安装 Codex、仅安装 Claude Code 或两者都安装四种状态,再与不授权工具、只授权其中一个或同时授权两者的 Agent Preset 交叉。测试证明 Host 注册表与模型可见工具会反映这两个独立决策,组装期间不会启动产品进程,而且 Preset 编辑只影响后续 Session。包级 Loader 与真实产品测试分别证明两个私有运行时、载荷缺失时不回退宿主命令、取消和进程树完全停稳。无密钥 ACP(Agent Client Protocol)快照固定模型可见工具 schema 与通用 Job 控制
+真实组装会覆盖未安装产品 Bundle、仅安装 Codex、仅安装 Claude Code 或两者都安装四种状态,再与不授权工具、只授权其中一个或同时授权两者的 Agent Preset 交叉。测试证明 Host 注册表与模型可见工具会反映这两个独立决策,组装期间不会启动产品进程,而且 Preset 编辑只影响后续 Session。已链接的提供方与后台执行决策分别拥有私有运行时、失败、清理、工具 schema 及 Job 证据
 
 ## 考虑过的替代方案
 

+ 5 - 22
packages/subagent/subagent-codex/src/run.ts

@@ -28,26 +28,9 @@ import { CodexAppServerWire } from './wire.ts'
 export const DEFAULT_DISPOSE_GRACE_MS = 3_000
 
 interface CodexPackageManifest {
-  readonly bin?: string | Readonly<Record<string, string>>
-}
-
-/**
- * Resolve the official package's declared `codex` bin relative to its manifest.
- * @param packageJsonPath - absolute path to the official package manifest.
- * @param manifest - parsed manifest carrying the declared bin entry.
- * @returns absolute path to the package-local JavaScript wrapper.
- */
-export function codexPackageBinPath(
-  packageJsonPath: string,
-  manifest: CodexPackageManifest,
-): string {
-  const declared = typeof manifest.bin === 'string'
-    ? manifest.bin
-    : manifest.bin?.codex
-  if (declared === undefined || declared.length === 0) {
-    throw new Error('@openai/codex does not declare its codex bin')
+  readonly bin: {
+    readonly codex: string
   }
-  return resolve(dirname(packageJsonPath), declared)
 }
 
 const codexPackageJsonPath = createRequire(import.meta.url).resolve('@openai/codex/package.json')
@@ -56,9 +39,9 @@ const codexPackageManifest = JSON.parse(
 ) as CodexPackageManifest
 
 /** Absolute package-local JavaScript wrapper selected by the package manifest. */
-export const CODEX_PACKAGE_BIN = codexPackageBinPath(
-  codexPackageJsonPath,
-  codexPackageManifest,
+const CODEX_PACKAGE_BIN = resolve(
+  dirname(codexPackageJsonPath),
+  codexPackageManifest.bin.codex,
 )
 
 /**

+ 7 - 7
packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts

@@ -7,9 +7,9 @@ import {
   rmSync,
   writeFileSync,
 } from 'node:fs'
+import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
-import { join, resolve } from 'node:path'
-import { fileURLToPath } from 'node:url'
+import { dirname, join, resolve } from 'node:path'
 import { promisify } from 'node:util'
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -18,18 +18,18 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent'
 import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
 import * as codex from '../src/index.ts'
-import { CODEX_PACKAGE_BIN } from '../src/run.ts'
 import {
   startDeepSeekResponsesBridge,
   type DeepSeekResponsesBridge,
 } from './deepseek-responses-bridge.ts'
 
 const execFileAsync = promisify(execFile)
-const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
+const codexPackageJson = createRequire(import.meta.url).resolve('@openai/codex/package.json')
 const codexPackage = JSON.parse(readFileSync(
-  join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'),
+  codexPackageJson,
   'utf8',
-)) as { version: string }
+)) as { version: string; bin: { codex: string } }
+const codexEntry = resolve(dirname(codexPackageJson), codexPackage.bin.codex)
 
 const roots: string[] = []
 const contexts: Context[] = []
@@ -106,7 +106,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)(
         return handle
       })
       await ctx.plugin(codex, { env, disposeGraceMs: 2_000 })
-      const version = await execFileAsync(process.execPath, [CODEX_PACKAGE_BIN, '--version'], {
+      const version = await execFileAsync(process.execPath, [codexEntry, '--version'], {
         env: { ...process.env, ...env },
       })
       expect(codexPackage.version).toBe('0.147.0')

+ 6 - 5
packages/subagent/subagent-codex/tests/real-product.spec.ts

@@ -8,6 +8,7 @@ import {
   writeFileSync,
 } from 'node:fs'
 import { rm } from 'node:fs/promises'
+import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
 import { dirname, join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
@@ -19,7 +20,6 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent'
 import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
 import * as codex from '../src/index.ts'
-import { CODEX_PACKAGE_BIN } from '../src/run.ts'
 import {
   startResponsesFixture,
   type ResponsesBehavior,
@@ -29,12 +29,13 @@ import {
 const execFileAsync = promisify(execFile)
 const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
 const codexBinDir = join(packageRoot, 'node_modules', '.bin')
-const codexEntry = CODEX_PACKAGE_BIN
-const codexPackageRoot = dirname(dirname(codexEntry))
+const codexPackageJson = createRequire(import.meta.url).resolve('@openai/codex/package.json')
 const codexPackage = JSON.parse(readFileSync(
-  join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'),
+  codexPackageJson,
   'utf8',
-)) as { version: string }
+)) as { version: string; bin: { codex: string } }
+const codexEntry = resolve(dirname(codexPackageJson), codexPackage.bin.codex)
+const codexPackageRoot = dirname(dirname(codexEntry))
 
 const roots: string[] = []
 const fixtures: ResponsesFixture[] = []

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

@@ -1,5 +1,5 @@
 import { readFileSync } from 'node:fs'
-import { resolve } from 'node:path'
+import { dirname, resolve } from 'node:path'
 import { PassThrough } from 'node:stream'
 import { fileURLToPath } from 'node:url'
 import { Context } from '@deepseek-ai/cordis'
@@ -19,9 +19,7 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
 import * as codex from '../src/index.ts'
 import * as invariant from '../src/invariant.ts'
 import {
-  CODEX_PACKAGE_BIN,
   codexAppServerArgv,
-  codexPackageBinPath,
   DEFAULT_DISPOSE_GRACE_MS,
   disposeCodexChild,
   startCodexRun,
@@ -295,7 +293,7 @@ describe('task admission and package contracts', () => {
     const codexPackageJson = fileURLToPath(import.meta.resolve('@openai/codex/package.json'))
     const codexManifest = JSON.parse(readFileSync(codexPackageJson, 'utf8')) as {
       version: string
-      bin: Record<string, string>
+      bin: { codex: string }
       optionalDependencies: Record<string, string>
     }
     expect(codexManifest.version).toBe(CODEX_VERSION)
@@ -306,7 +304,12 @@ describe('task admission and package contracts', () => {
         `npm:@openai/codex@${CODEX_VERSION}-${packageName.slice('@openai/codex-'.length)}`,
       ]),
     ))
-    expect(CODEX_PACKAGE_BIN).toBe(codexPackageBinPath(codexPackageJson, codexManifest))
+    expect(codexAppServerArgv()).toEqual([
+      process.execPath,
+      resolve(dirname(codexPackageJson), codexManifest.bin.codex),
+      'app-server',
+      '--stdio',
+    ])
 
     const lockfile = readFileSync(resolve(root, '../../../pnpm-lock.yaml'), 'utf8')
     for (const packageName of CODEX_PLATFORM_PACKAGES) {
@@ -329,23 +332,8 @@ describe('task admission and package contracts', () => {
   })
 
   it('uses only the official package-declared wrapper for app-server', () => {
-    expect(codexPackageBinPath(
-      '/package/node_modules/@openai/codex/package.json',
-      { bin: 'bin/codex.js' },
-    )).toBe('/package/node_modules/@openai/codex/bin/codex.js')
-    expect(codexPackageBinPath(
-      '/package/node_modules/@openai/codex/package.json',
-      { bin: { codex: 'bin/codex.js' } },
-    )).toBe('/package/node_modules/@openai/codex/bin/codex.js')
-    expect(() => codexPackageBinPath('/package/package.json', {}))
-      .toThrow('does not declare its codex bin')
-    expect(codexAppServerArgv()).toEqual([
-      process.execPath,
-      CODEX_PACKAGE_BIN,
-      'app-server',
-      '--stdio',
-    ])
-    expect(codexAppServerArgv()).not.toContain('codex')
+    expect(codexAppServerArgv()[0]).toBe(process.execPath)
+    expect(codexAppServerArgv().slice(2)).toEqual(['app-server', '--stdio'])
   })
 
   it('accepts one or more text blocks and rejects empty or non-text tasks', () => {