Преглед изворни кода

fix(subagent-claude-code): tighten provider evidence

pku-xht пре 1 месец
родитељ
комит
6d8095825b

+ 2 - 2
.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.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-06-21-subagent-capability-seam.md
-2026-06-21-subagent-capability-seam.md: 35fe7b7aaf02d9d55012e3285b3f5a58bc76cde8
-2026-06-21-subagent-capability-seam.zh.md: 221335859cec104a55136201e4923d783d616e86
+2026-06-21-subagent-capability-seam.md: 752e639b09ea2ac0ba19841ddfe38b15b44d22e9
+2026-06-21-subagent-capability-seam.zh.md: 6009aeda6773357a4217f8956fb510c2116bbe3f

+ 1 - 1
.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md

@@ -54,7 +54,7 @@ Fresh and forked children are separate providers, not a request flag. `dsh-subag
 
 ### Child isolation and the parent log
 
-Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. The parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output) — the child's internal steps and tool calls stay in the child's own session, never injected into the parent log. This is the only design that is identical across transports: an ACP child's internal events physically cannot be injected into our parent log, so making in-process behave the same keeps the seam transport-agnostic.
+Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output), while child steps and tool calls remain outside the parent log.
 
 ### Synchronous collect (first cut)
 

+ 1 - 1
.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md

@@ -54,7 +54,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在
 
 ### 子 agent 隔离与父日志
 
-每个 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。父日志仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出)——子 agent 的内部步骤和工具调用留在子 agent 自己的会话中,绝不注入父日志。这是唯一在所有传输方式下行为一致的设计:ACP 子 agent 的内部事件在物理上无法注入我们的父日志,因此让进程内行为保持一致,使 seam 真正与传输方式无关
+每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出),而子 agent 的步骤和工具调用均留在父日志之外
 
 ### 同步收集(首版)
 

+ 1 - 1
THIRD_PARTY_NOTICES.md

@@ -3,7 +3,7 @@
 
 # Third-Party Notices
 
-DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
+DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
 
 This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check.
 

+ 2 - 2
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: 5bfc8ff3ba539b6caf891577cee5820d983106cd
-README.zh.md: 6db9148228ba30c0f84beb14caa7a84aa0dcd9f5
+README.md: e62f60fceea16749296a91377785b81d94d751ca
+README.zh.md: e171524157b2b1696df31753816210d41637a911

+ 1 - 1
packages/subagent/subagent-claude-code/README.md

@@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
 | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. |
 | `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.
+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 token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden.
 
 Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default.
 

+ 1 - 1
packages/subagent/subagent-claude-code/README.zh.md

@@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 
 | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 |
 | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
 
-生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量仍然可用
+生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承
 
 请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。
 

+ 7 - 1
packages/subagent/subagent-claude-code/src/index.ts

@@ -60,11 +60,17 @@ class ClaudeCodeProvider implements SubagentProvider {
   ) {}
 
   start(request: ResolvedSubagentStartRequest) {
+    const parentCwd = request.parent.session.header.cwd
+    if (parentCwd === undefined) {
+      throw new Error(
+        'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
+      )
+    }
     const spec: ClaudeCodeRunSpec = {
       cwd: resolveChildCwd(
         'subagent-claude-code',
         undefined,
-        request.parent.session.header.cwd,
+        parentCwd,
       ),
       env: this.config.env,
       disposeGraceMs: this.config.disposeGraceMs,

+ 1 - 1
packages/subagent/subagent-claude-code/tests/messages-fixture.ts

@@ -99,7 +99,7 @@ export async function startMessagesFixture(
     request.on('data', (chunk: Buffer) => { chunks.push(chunk) })
     request.on('end', () => {
       const path = request.url ?? ''
-      if (!path.startsWith('/v1/messages')) {
+      if (path !== '/v1/messages' && !path.startsWith('/v1/messages?')) {
         response.writeHead(404, { 'content-type': 'application/json' })
         response.end(JSON.stringify({
           type: 'error',

+ 46 - 1
packages/subagent/subagent-claude-code/tests/real-product.spec.ts

@@ -10,6 +10,11 @@ import { tmpdir } from 'node:os'
 import { dirname, join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { promisify } from 'node:util'
+import type {
+  Query,
+  SDKMessage,
+  SDKSystemMessage,
+} from '@anthropic-ai/claude-agent-sdk'
 import { Context } from 'cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -23,6 +28,39 @@ import {
   type MessagesFixture,
 } from './messages-fixture.ts'
 
+const observedSdkMessages = vi.hoisted((): SDKMessage[] => [])
+
+vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => {
+  const actual = await importOriginal<
+    typeof import('@anthropic-ai/claude-agent-sdk')
+  >()
+  return {
+    ...actual,
+    query(options: Parameters<typeof actual.query>[0]): Query {
+      const query = actual.query(options)
+      // Observe the real SDK stream without replacing its protocol or CLI.
+      return new Proxy(query, {
+        get(target, property) {
+          if (property === Symbol.asyncIterator) {
+            return async function* (): AsyncGenerator<SDKMessage, void> {
+              for await (const message of target) {
+                observedSdkMessages.push(message)
+                yield message
+              }
+            }
+          }
+          const value: unknown = Reflect.get(target, property, target)
+          if (typeof value === 'function') {
+            const method = value as (...args: unknown[]) => unknown
+            return method.bind(target)
+          }
+          return value
+        },
+      })
+    },
+  }
+})
+
 const execFileAsync = promisify(execFile)
 const sdkRoot = dirname(fileURLToPath(
   import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
@@ -54,6 +92,7 @@ afterEach(async () => {
   for (const root of roots.splice(0)) {
     rmSync(root, { recursive: true, force: true })
   }
+  observedSdkMessages.length = 0
 })
 
 interface RealHarness {
@@ -168,10 +207,16 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', {
     })
     await run.dispose()
 
+    const initMessage = observedSdkMessages.find(
+      (message): message is SDKSystemMessage =>
+        message.type === 'system' && message.subtype === 'init',
+    )
+    expect(initMessage?.claude_code_version).toBe('2.1.220')
+
     expect(fixture.requests).toHaveLength(1)
     const recorded = fixture.requests[0]!
     expect(recorded.method).toBe('POST')
-    expect(recorded.path).toMatch(/^\/v1\/messages(?:\\?|$)/)
+    expect(recorded.path).toMatch(/^\/v1\/messages(?:\?.*)?$/)
     expect(recorded.headers['x-api-key']).toBe(fakeKey)
     expect(recorded.body.model).toBe(settingsModel)
     expect(Array.isArray(recorded.body.messages)).toBe(true)

+ 11 - 0
packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts

@@ -341,6 +341,17 @@ describe('task admission and package contracts', () => {
       disposeGraceMs: 29,
     })
 
+    await expect(ctx.subagents.start('claude-code', {
+      ...request(),
+      parent: {
+        id: 'parent-without-cwd',
+        session: { header: {} },
+      } as unknown as Agent,
+    })).rejects.toThrow(
+      'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
+    )
+    expect(queryMock).not.toHaveBeenCalled()
+
     const run = await ctx.subagents.start('claude-code', request())
     child.settle({ exitCode: 9, signal: null })
     child.stdout.end()

+ 3 - 1
scripts/gen-third-party-notices.spec.ts

@@ -25,7 +25,9 @@ describe('THIRD_PARTY_NOTICES.md', () => {
   // Pre-commit regenerates the file whenever a manifest is staged, so reaching
   // this assertion means the notices were committed without that hook.
   it('matches what the generator produces from the current manifests', () => {
-    expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render())
+    const generated = render()
+    expect(generated).toContain('It depends on the third-party software listed below.')
+    expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
   })
 })
 

+ 1 - 1
scripts/gen-third-party-notices.ts

@@ -694,7 +694,7 @@ export function render(): string {
 
 # Third-Party Notices
 
-DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
+DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
 
 This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.