From 6d8095825b9bb1ed7703dd011ef0eeb98b7170bc Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 5 Aug 2026 07:32:27 +0800 Subject: [PATCH] fix(subagent-claude-code): tighten provider evidence --- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 2 +- .../2026-06-21-subagent-capability-seam.zh.md | 2 +- THIRD_PARTY_NOTICES.md | 2 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 2 +- .../subagent-claude-code/README.zh.md | 2 +- .../subagent-claude-code/src/index.ts | 8 +++- .../tests/messages-fixture.ts | 2 +- .../tests/real-product.spec.ts | 47 ++++++++++++++++++- .../tests/subagent-claude-code.spec.ts | 11 +++++ scripts/gen-third-party-notices.spec.ts | 4 +- scripts/gen-third-party-notices.ts | 2 +- 13 files changed, 78 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 80508d16b4..1884c12c93 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 35fe7b7aaf..752e639b09 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.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) diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 221335859c..6009aeda67 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.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 的步骤和工具调用均留在父日志之外。 ### 同步收集(首版) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d6910daedc..96ce7ecd37 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/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. diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 43c08d5394..6bc638bdc4 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: 5bfc8ff3ba539b6caf891577cee5820d983106cd -README.zh.md: 6db9148228ba30c0f84beb14caa7a84aa0dcd9f5 +README.md: e62f60fceea16749296a91377785b81d94d751ca +README.zh.md: e171524157b2b1696df31753816210d41637a911 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 5bfc8ff3ba..e62f60fcee 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/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. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 6db9148228..e171524157 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/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`。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 1ba7266e0f..e4d6fbac5f 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/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, diff --git a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts index 6f84fd2395..d8a04cf953 100644 --- a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts +++ b/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', diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index a0d1c1a02d..f76b4038f6 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/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[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 { + 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) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 7a2942b084..8c4ac1708d 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -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() diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index e6e199c21a..d0c427c8f6 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/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) }) }) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 3e2e0917f2..20f3bd04f0 100644 --- a/scripts/gen-third-party-notices.ts +++ b/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.