From ba37180946fcfbf2c0125b01856f96a13e72acac Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 12:59:41 +0800 Subject: [PATCH 01/37] feat(util): extract dsh-atomic-write and migrate settings-local writes writeFileAtomic: exclusive-create random-suffix temp + rename carrying the caller-stated mode; settings-local persistSection now consumes it. The credentials-local store shares it next. --- packages/settings/settings-local/package.json | 2 + packages/settings/settings-local/src/index.ts | 21 ++------ .../settings/settings-local/tsconfig.json | 3 ++ packages/util/atomic-write/README.md | 30 +++++++++++ packages/util/atomic-write/README.zh.md | 30 +++++++++++ packages/util/atomic-write/package.json | 37 ++++++++++++++ packages/util/atomic-write/src/index.ts | 50 +++++++++++++++++++ packages/util/atomic-write/src/invariant.ts | 30 +++++++++++ .../atomic-write/tests/atomic-write.spec.ts | 48 ++++++++++++++++++ .../util/atomic-write/tests/invariant.spec.ts | 18 +++++++ packages/util/atomic-write/tsconfig.json | 15 ++++++ pnpm-lock.yaml | 12 +++++ tsconfig.host.json | 1 + 13 files changed, 281 insertions(+), 16 deletions(-) create mode 100644 packages/util/atomic-write/README.md create mode 100644 packages/util/atomic-write/README.zh.md create mode 100644 packages/util/atomic-write/package.json create mode 100644 packages/util/atomic-write/src/index.ts create mode 100644 packages/util/atomic-write/src/invariant.ts create mode 100644 packages/util/atomic-write/tests/atomic-write.spec.ts create mode 100644 packages/util/atomic-write/tests/invariant.spec.ts create mode 100644 packages/util/atomic-write/tsconfig.json diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index aefb1ccd33..0040b65507 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", @@ -38,6 +39,7 @@ "yaml": "^2.9.0" }, "devDependencies": { + "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index b305f61fe7..057f974a5e 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -8,10 +8,10 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { randomBytes } from 'node:crypto' -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' -import { dirname, extname, join, resolve } from 'node:path' +import { readFile } from 'node:fs/promises' +import { extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { Settings, type SettingsNamespace } from '@deepseek-ai/dsh-settings' @@ -137,19 +137,8 @@ export class SettingsLocal extends Settings { const output = this.spec.format === 'yaml' ? this.renderYaml(ns, section) : this.renderJson(ns, section) - await mkdir(dirname(this.spec.filename), { recursive: true }) - // Exclusive-create (`wx`) a random-suffix sibling: the open refuses to - // follow any planted symlink at a guessable temp path, and the fresh inode - // carries owner-only permissions that survive the rename — a document that - // may hold personal values is never world-readable and never a symlink. - const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp` - try { - await writeFile(temp, output, { mode: 0o600, flag: 'wx' }) - await rename(temp, this.spec.filename) - } catch (error) { - await rm(temp, { force: true }) - throw error - } + // 0600: a document that may hold personal values is never world-readable. + await writeFileAtomic(this.spec.filename, output, { mode: 0o600 }) this.text = output } diff --git a/packages/settings/settings-local/tsconfig.json b/packages/settings/settings-local/tsconfig.json index 67a746c982..cf5b68fc11 100644 --- a/packages/settings/settings-local/tsconfig.json +++ b/packages/settings/settings-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../util/atomic-write" + }, { "path": "../../util/paths" }, diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md new file mode 100644 index 0000000000..42c65c820a --- /dev/null +++ b/packages/util/atomic-write/README.md @@ -0,0 +1,30 @@ +# dsh-atomic-write + +English | [中文](README.zh.md) + +Zero-dependency atomic file replacement shared by file-backed stores that must never leave partial, symlink-hijacked, or wider-than-intended content on disk — the user-settings document (`dsh-settings-local`) and the credentials store (`dsh-credentials-local`). + +## Surface + +```ts +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' + +await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) +``` + +One export. The contract, in the order failures would exploit it: + +- **Exclusive-create temp** (`wx`, random suffix): the open refuses to follow a symlink planted at a guessable temp path. +- **The fresh inode carries `mode` through the rename**: replacing a wider-permission file narrows it without a chmod race. `mode` is required so the permission decision stays visible at every call site (subject to the process umask, like every fresh inode). +- **`rename` replaces a symlinked target itself**, never writing through to its referent. +- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic. +- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content. + +## Model Experience + +None, as this is a pure filesystem primitive; nothing here reaches a model request. + +## Known Limitations and Deferred Work + +- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy. +- **String content only** — no `Buffer` or stream form until a consumer needs one. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md new file mode 100644 index 0000000000..4a59eaea9d --- /dev/null +++ b/packages/util/atomic-write/README.zh.md @@ -0,0 +1,30 @@ +# dsh-atomic-write + +[English](README.md) | 中文 + +零依赖的原子文件替换,供绝不允许在磁盘上留下半截内容、被符号链接劫持或权限过宽内容的文件型存储共用——用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。 + +## 接口面 + +```ts +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' + +await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) +``` + +仅一个导出。契约按攻击面利用顺序列出: + +- **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。 +- **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。 +- **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。 +- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。 +- 自动创建父目录;任何失败都会清理临时文件并重新抛出;读者只会看到旧内容或完整的新内容。 + +## Model Experience + +None, as this is a pure filesystem primitive; nothing here reaches a model request. + +## Known Limitations and Deferred Work + +- **原子但不保证落盘持久**——不对文件或目录做 `fsync`,崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,持久化策略留给调用方。 +- **仅支持字符串内容**——在出现真实消费者之前不提供 `Buffer` 或流式形态。 diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json new file mode 100644 index 0000000000..147ecb1e05 --- /dev/null +++ b/packages/util/atomic-write/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-atomic-write", + "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts new file mode 100644 index 0000000000..f4a20c10bc --- /dev/null +++ b/packages/util/atomic-write/src/index.ts @@ -0,0 +1,50 @@ +/** + * Zero-dependency atomic file replacement. `writeFileAtomic` writes a + * random-suffix sibling with exclusive create and the caller's permission + * bits, then renames it over the target, so readers observe either the old or + * the new complete content and a replaced file ends up with exactly the + * stated mode. + * @module @deepseek-ai/dsh-atomic-write + */ + +import { randomBytes } from 'node:crypto' +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +/** + * Filesystem options for {@link writeFileAtomic}; `mode` is required so the + * permission decision stays visible at every call site. + */ +export interface WriteFileAtomicOptions { + /** + * Permission bits stamped on the fresh temp inode and carried through the + * rename (subject to the process umask, like every fresh inode). + */ + mode: number +} + +/** + * Replace `filename` with `content` in one atomic step, creating parent + * directories. The content is first written to a random-suffix sibling opened + * with exclusive create (`wx`): the open refuses to follow a symlink planted + * at the temp path, and the fresh inode carries `options.mode` through the + * rename, so replacing a wider-permission file narrows it without a chmod + * race. The rename also replaces a symlinked target itself instead of writing + * through to its referent, and the same-directory sibling keeps the rename on + * one filesystem. On any failure the temp file is removed and the failure + * rethrown. Crash durability (fsync) is out of scope. + * @param filename - final path receiving the content. + * @param content - complete next file content. + * @param options - permission bits for the replacement inode. + */ +export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise { + await mkdir(dirname(filename), { recursive: true }) + const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp` + try { + await writeFile(temp, content, { mode: options.mode, flag: 'wx' }) + await rename(temp, filename) + } catch (error) { + await rm(temp, { force: true }) + throw error + } +} diff --git a/packages/util/atomic-write/src/invariant.ts b/packages/util/atomic-write/src/invariant.ts new file mode 100644 index 0000000000..4027dd9bda --- /dev/null +++ b/packages/util/atomic-write/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-atomic-write`. + * @module @deepseek-ai/dsh-atomic-write/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-atomic-write' + +/** Cordis companion plugin name. */ +export const name = 'atomic-write-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this pure filesystem primitive owns no event stream or mutable runtime + * data; its replacement contract is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts new file mode 100644 index 0000000000..2bc9d3ab6a --- /dev/null +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -0,0 +1,48 @@ +import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { writeFileAtomic } from '../src/index.ts' + +async function scratch(): Promise { + return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) +} + +describe('writeFileAtomic', () => { + it('creates the file and its parents with exactly the stated mode', async () => { + const dir = await scratch() + const target = join(dir, 'nested', 'deep', 'doc.yaml') + await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 }) + expect(await readFile(target, 'utf8')).toBe('a: 1\n') + expect((await stat(target)).mode & 0o777).toBe(0o600) + }) + + it('replaces existing content and narrows a wider-permission file to the stated mode', async () => { + const dir = await scratch() + const target = join(dir, 'doc.yaml') + await writeFile(target, 'old', { mode: 0o644 }) + await writeFileAtomic(target, 'new', { mode: 0o600 }) + expect(await readFile(target, 'utf8')).toBe('new') + expect((await stat(target)).mode & 0o777).toBe(0o600) + }) + + it('replaces a symlinked target itself without writing through to the referent', async () => { + const dir = await scratch() + const victim = join(dir, 'victim') + await writeFile(victim, 'victim-content') + const target = join(dir, 'doc.yaml') + await symlink(victim, target) + await writeFileAtomic(target, 'replaced', { mode: 0o600 }) + expect((await lstat(target)).isSymbolicLink()).toBe(false) + expect(await readFile(target, 'utf8')).toBe('replaced') + expect(await readFile(victim, 'utf8')).toBe('victim-content') + }) + + it('leaves no temp sibling and rethrows when the rename fails', async () => { + const dir = await scratch() + const target = join(dir, 'occupied') + await mkdir(target) + await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow() + expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([]) + }) +}) diff --git a/packages/util/atomic-write/tests/invariant.spec.ts b/packages/util/atomic-write/tests/invariant.spec.ts new file mode 100644 index 0000000000..c80346762c --- /dev/null +++ b/packages/util/atomic-write/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as AtomicWriteInvariant from '../src/invariant.ts' + +describe('atomic-write invariant companion', () => { + it('registers its explained empty runtime invariant', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + const fiber = await ctx.plugin(AtomicWriteInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-atomic-write', () => {}) + }).toThrow(/already registered/) + await fiber.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/util/atomic-write/tsconfig.json b/packages/util/atomic-write/tsconfig.json new file mode 100644 index 0000000000..d970a00263 --- /dev/null +++ b/packages/util/atomic-write/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86c78b285e..2036f68aa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3895,6 +3895,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4985,6 +4988,15 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/atomic-write: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/brand: devDependencies: '@deepseek-ai/dsh-invariants': diff --git a/tsconfig.host.json b/tsconfig.host.json index e01245b0f6..92eeea6a94 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -49,6 +49,7 @@ { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" }, + { "path": "./packages/util/atomic-write" }, { "path": "./packages/llm/llm" }, { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, From 3a794495ad1ab208e32a827bbb8f98379fb91aba Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:03:12 +0800 Subject: [PATCH 02/37] feat(credentials): abstract credential seam (ctx.credentials) References-not-values doctrine: settings carry env-shaped CredentialRefs, providers own storage. Per-operation resolve, UI-safe describe, fail-loud set/unset under read-only shadowing, credentials/updated commit event with a live-service invariant. --- packages/credentials/credentials/README.md | 45 +++++++ packages/credentials/credentials/README.zh.md | 45 +++++++ packages/credentials/credentials/package.json | 39 ++++++ packages/credentials/credentials/src/index.ts | 114 ++++++++++++++++++ .../credentials/credentials/src/invariant.ts | 38 ++++++ .../credentials/tests/credentials.spec.ts | 71 +++++++++++ .../credentials/tests/invariant.spec.ts | 37 ++++++ .../credentials/credentials/tests/memory.ts | 51 ++++++++ .../credentials/credentials/tsconfig.json | 24 ++++ pnpm-lock.yaml | 12 ++ tsconfig.base.json | 2 + tsconfig.host.json | 1 + 12 files changed, 479 insertions(+) create mode 100644 packages/credentials/credentials/README.md create mode 100644 packages/credentials/credentials/README.zh.md create mode 100644 packages/credentials/credentials/package.json create mode 100644 packages/credentials/credentials/src/index.ts create mode 100644 packages/credentials/credentials/src/invariant.ts create mode 100644 packages/credentials/credentials/tests/credentials.spec.ts create mode 100644 packages/credentials/credentials/tests/invariant.spec.ts create mode 100644 packages/credentials/credentials/tests/memory.ts create mode 100644 packages/credentials/credentials/tsconfig.json diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md new file mode 100644 index 0000000000..48b7b0f952 --- /dev/null +++ b/packages/credentials/credentials/README.md @@ -0,0 +1,45 @@ +# dsh-credentials + +English | [中文](README.zh.md) + +Abstract credential seam (`ctx.credentials`). One doctrine, three consequences: + +**Configuration carries references to secrets, never the secrets.** A settings section or `cordis.yml` entry says `apiKeyEnv: DEEPSEEK_API_KEY`; the value behind that reference lives with a credential provider. So the settings document stays safe to sync and to render in a configuration UI, `describe()` can answer "is this configured, where from, can I write it" without ever holding a value, and rotating a secret touches no configuration file. + +**Consumers resolve per operation.** `resolve(ref)` is called at the start of each operation (the LLM adapters resolve once per model request) and never cached across operations — that read is what makes a changed credential reach the very next request without restarting any plugin. + +**An empty stored value is absent.** Everywhere: `resolve` skips it, `describe` reports it unconfigured. A blank can never masquerade as a configured secret. + +## Surface + +```ts +import { credentialRef } from '@deepseek-ai/dsh-credentials' + +const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded +const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined +const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value +await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref +await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule +``` + +`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. + +The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front. + +## Providers + +[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets. + +## Model Experience + +Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **No enumeration** — the seam answers questions about references it is given; configuration surfaces learn the references from settings schemas, so a `list()` has no current consumer. +- **References are environment-variable-shaped** — one flat POSIX-identifier namespace until a provider needs richer addressing. +- **Process-environment changes are invisible** — no event can fire for them; a UI only re-reads `describe()` on its own navigation. diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md new file mode 100644 index 0000000000..ef9e32ebad --- /dev/null +++ b/packages/credentials/credentials/README.zh.md @@ -0,0 +1,45 @@ +# dsh-credentials + +[English](README.md) | 中文 + +抽象凭据 seam(`ctx.credentials`)。一条准则,三个推论: + +**配置只携带对秘密的引用,绝不携带秘密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答"配置了吗、来自哪层、能否写入";轮换秘密不触碰任何配置文件。 + +**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM adapter 每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 + +**空的存储值等于不存在。** 处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的秘密。 + +## 接口面 + +```ts +import { credentialRef } from '@deepseek-ai/dsh-credentials' + +const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell 标识符,品牌类型 +const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined +const info = await ctx.credentials.describe(ref) // { configured, source?, writable } —— 绝不含值 +await ctx.credentials.set(ref, 'sk-…') // 被只读来源遮蔽时拒绝 +await ctx.credentials.unset(ref) // 不存在时为 no-op;同样的遮蔽规则 +``` + +`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新"已配置"徽标。 + +`set`/`unset` 的遮蔽规则是刻意的 fail-loud:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 + +## Providers + +[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带秘密。 + +## Model Experience + +Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费者。 +- **引用限定为环境变量形状**——在有 provider 需要更丰富寻址前,保持单一扁平的 POSIX 标识符命名空间。 +- **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`。 diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json new file mode 100644 index 0000000000..d907b0a1bb --- /dev/null +++ b/packages/credentials/credentials/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-credentials", + "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts new file mode 100644 index 0000000000..2df89132ac --- /dev/null +++ b/packages/credentials/credentials/src/index.ts @@ -0,0 +1,114 @@ +/** + * Credential seam (`ctx.credentials`). Settings and composition files carry + * *references* to secrets — environment-variable names — while providers own + * the actual values and their storage. Consumers resolve a reference once per + * operation, so a changed credential reaches the next operation without any + * plugin restart, and configuration surfaces describe a reference without + * ever seeing its value. + * @module @deepseek-ai/dsh-credentials + */ + +import { Context, Service } from 'cordis' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +export type CredentialRef = Branded<'CredentialRef'> + +const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Brand a raw string as a {@link CredentialRef}. + * @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`. + * @returns the branded reference. + */ +export function credentialRef(value: string): CredentialRef { + if (!REF_PATTERN.test(value)) { + throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`) + } + return value as CredentialRef +} + +/** One resolved credential value and the source layer that supplied it. */ +export interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} + +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +export interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} + +declare module 'cordis' { + interface Context { + credentials: Credentials + } + + interface Events { + /** + * Committed change to a provider-managed credential source: a `set`, an + * `unset`, or an external edit observed in storage. Ambient + * process-environment changes are not observable and never emit. + * @param ref - the reference whose stored value changed. + * @mode emit + */ + 'credentials/updated'(ref: CredentialRef): void + } +} + +/** + * Abstract credential service. Providers implement the four operations over + * their source layers; one seam-wide rule binds them all: an empty stored + * value is absent everywhere — `resolve` skips it, `describe` reports it + * unconfigured — so a blank never masquerades as a configured secret. + */ +export abstract class Credentials extends Service { + constructor(ctx: Context) { + super(ctx, 'credentials') + } + + /** + * Resolve one reference to its current value. Resolution is per call: + * consumers re-resolve at each operation and must not cache across + * operations — that per-operation read is what makes a changed credential + * reach the next operation without a restart. + * @param ref - the reference to resolve. + * @returns the value and its source, or `undefined` while unconfigured. + */ + abstract resolve(ref: CredentialRef): Promise + + /** + * Describe one reference for configuration surfaces without exposing the + * value. + * @param ref - the reference to describe. + * @returns configured state, supplying source, and writability. + */ + abstract describe(ref: CredentialRef): Promise + + /** + * Durably store one value in the provider-managed writable source. Rejects + * while a read-only source shadows the reference — the write would appear + * to succeed while resolution keeps returning the shadowing value — and + * rejects an empty value (use {@link unset}). + * @param ref - the reference to store. + * @param value - the non-empty secret value. + */ + abstract set(ref: CredentialRef, value: string): Promise + + /** + * Remove one reference from the provider-managed writable source; removing + * an absent reference is a no-op. Rejects while a read-only source shadows + * the reference, like {@link set}. + * @param ref - the reference to remove. + */ + abstract unset(ref: CredentialRef): Promise +} + +export default Credentials diff --git a/packages/credentials/credentials/src/invariant.ts b/packages/credentials/credentials/src/invariant.ts new file mode 100644 index 0000000000..23c2dda45b --- /dev/null +++ b/packages/credentials/credentials/src/invariant.ts @@ -0,0 +1,38 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-credentials`. + * @module @deepseek-ai/dsh-credentials/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-credentials' + +/** Cordis companion plugin name. */ +export const name = 'credentials-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Install the commit-event lifecycle contract: `credentials/updated` names a + * committed provider-source change, so it can only fire while a credentials + * service is live — an emission after disposal means a provider leaked work + * past its teardown quiescence. The value relation itself (`describe` + * agreeing with `resolve`) is asynchronous provider I/O and stays pinned by + * each provider's own suite. + */ +const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { + ctx.on('credentials/updated', (ref) => { + if (ctx.get('credentials') === undefined) { + fail(`credentials/updated for "${ref}" emitted without a live credentials service`) + } + }) +} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/credentials/credentials/tests/credentials.spec.ts b/packages/credentials/credentials/tests/credentials.spec.ts new file mode 100644 index 0000000000..9b4cf7b1e8 --- /dev/null +++ b/packages/credentials/credentials/tests/credentials.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { credentialRef } from '../src/index.ts' +import type { CredentialRef } from '../src/index.ts' +import { MemoryCredentials } from './memory.ts' + +const REF = credentialRef('DEEPSEEK_API_KEY') + +async function boot(seed: Record = {}): Promise { + const ctx = new Context() + await ctx.plugin(MemoryCredentials, seed) + return ctx +} + +describe('credentialRef', () => { + it('brands POSIX shell identifiers', () => { + expect(credentialRef('DEEPSEEK_API_KEY')).toBe('DEEPSEEK_API_KEY') + expect(credentialRef('_private')).toBe('_private') + expect(credentialRef('lower_case9')).toBe('lower_case9') + }) + + it('rejects every other shape', () => { + for (const invalid of ['', '9LEADING', 'WITH-DASH', 'WITH SPACE', 'ns:key']) { + expect(() => credentialRef(invalid)).toThrow(TypeError) + } + }) +}) + +describe('the credentials seam through the memory provider', () => { + it('mounts as ctx.credentials and resolves a seeded reference with its source', async () => { + const ctx = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' }) + expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-seeded', source: 'memory' }) + expect(await ctx.credentials.describe(REF)).toEqual({ configured: true, source: 'memory', writable: true }) + }) + + it('treats an empty stored value as absent everywhere', async () => { + const ctx = await boot({ DEEPSEEK_API_KEY: '' }) + expect(await ctx.credentials.resolve(REF)).toBeUndefined() + expect(await ctx.credentials.describe(REF)).toEqual({ configured: false, writable: true }) + }) + + it('stores through set, removes through unset, and emits the committed change', async () => { + const ctx = await boot() + const events: CredentialRef[] = [] + ctx.on('credentials/updated', ref => void events.push(ref)) + + await ctx.credentials.set(REF, 'sk-live') + expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-live', source: 'memory' }) + await ctx.credentials.unset(REF) + expect(await ctx.credentials.resolve(REF)).toBeUndefined() + expect(events).toEqual([REF, REF]) + }) + + it('rejects an empty set and keeps an absent unset silent', async () => { + const ctx = await boot() + const events: CredentialRef[] = [] + ctx.on('credentials/updated', ref => void events.push(ref)) + + await expect(ctx.credentials.set(REF, '')).rejects.toThrow(/empty value/) + await ctx.credentials.unset(REF) + expect(events).toEqual([]) + }) + + it('removes the service with its fiber', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(MemoryCredentials) + expect(ctx.get('credentials')).toBeDefined() + await fiber.dispose() + expect(ctx.get('credentials')).toBeUndefined() + }) +}) diff --git a/packages/credentials/credentials/tests/invariant.spec.ts b/packages/credentials/credentials/tests/invariant.spec.ts new file mode 100644 index 0000000000..dccde4843f --- /dev/null +++ b/packages/credentials/credentials/tests/invariant.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { credentialRef } from '../src/index.ts' +import * as CredentialsInvariant from '../src/invariant.ts' +import { MemoryCredentials } from './memory.ts' + +const REF = credentialRef('DEEPSEEK_API_KEY') + +describe('credentials invariant companion', () => { + it('accepts a committed change emitted by a live service', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + await ctx.plugin(MemoryCredentials) + + await expect(ctx.credentials.set(REF, 'sk-live')).resolves.toBeUndefined() + }) + + it('fails an update event emitted without a live service', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + + expect(() => { ctx.emit('credentials/updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/) + }) + + it('reserves the package name against duplicate registration', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(CredentialsInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-credentials', () => {}) + }).toThrow(/already registered/) + }) +}) diff --git a/packages/credentials/credentials/tests/memory.ts b/packages/credentials/credentials/tests/memory.ts new file mode 100644 index 0000000000..c562d8ab0a --- /dev/null +++ b/packages/credentials/credentials/tests/memory.ts @@ -0,0 +1,51 @@ +import type { Context } from 'cordis' +import { Credentials } from '../src/index.ts' +import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/index.ts' + +/** + * In-memory credentials provider for interface and consumer tests: one + * always-writable `memory` source seeded from plugin config. + */ +export class MemoryCredentials extends Credentials { + private readonly store = new Map() + + constructor(ctx: Context, seed: Record = {}) { + super(ctx) + for (const [key, value] of Object.entries(seed)) this.store.set(key, value) + } + + override resolve(ref: CredentialRef): Promise { + const value = this.store.get(ref) + return Promise.resolve(value === undefined || value.length === 0 + ? undefined + : { value, source: 'memory' }) + } + + override describe(ref: CredentialRef): Promise { + const value = this.store.get(ref) + const configured = value !== undefined && value.length > 0 + return Promise.resolve({ + configured, + ...configured ? { source: 'memory' } : {}, + writable: true, + }) + } + + override set(ref: CredentialRef, value: string): Promise { + if (value.length === 0) { + return Promise.reject(new Error('memory credentials: an empty value cannot be stored; use unset')) + } + this.store.set(ref, value) + this.ctx.emit('credentials/updated', ref) + return Promise.resolve() + } + + override unset(ref: CredentialRef): Promise { + if (this.store.delete(ref)) { + this.ctx.emit('credentials/updated', ref) + } + return Promise.resolve() + } +} + +export default MemoryCredentials diff --git a/packages/credentials/credentials/tsconfig.json b/packages/credentials/credentials/tsconfig.json new file mode 100644 index 0000000000..5bc7a9fcf5 --- /dev/null +++ b/packages/credentials/credentials/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2036f68aa6..31ff52fe9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2062,6 +2062,18 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/credentials/credentials: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/examples/acp-demo: devDependencies: '@cordisjs/plugin-include': diff --git a/tsconfig.base.json b/tsconfig.base.json index a93c67b304..9ffb5618d7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -88,6 +88,7 @@ "./packages/session-projection/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", "./packages/settings/*/src/invariant.ts", + "./packages/credentials/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -173,6 +174,7 @@ "./packages/session-query/*/src", "./packages/session-title/*/src", "./packages/settings/*/src", + "./packages/credentials/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", "./packages/storage/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 92eeea6a94..67e340e898 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -64,6 +64,7 @@ { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/settings/settings" }, { "path": "./packages/settings/settings-local" }, + { "path": "./packages/credentials/credentials" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" }, From aee06097ee5311b81234b6cfe6eef5385732a959 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:14:20 +0800 Subject: [PATCH 03/37] feat(credentials): file-backed provider layering process env over $DSH_HOME/.env Live environment wins read-only (shadowed writes reject instead of appearing to succeed); the file is the writable source with byte-preserving line edits, a quoting ladder dotenv reads back verbatim, atomic 0600 writes, wholesale snapshot replacement on reload, and write-drain teardown. --- .../credentials/credentials-local/README.md | 46 +++ .../credentials-local/README.zh.md | 46 +++ .../credentials-local/package.json | 48 +++ .../credentials-local/src/index.ts | 332 ++++++++++++++++++ .../credentials-local/src/invariant.ts | 31 ++ .../credentials-local/tests/drain.spec.ts | 67 ++++ .../credentials-local/tests/local.spec.ts | 244 +++++++++++++ .../credentials-local/tests/watcher.spec.ts | 207 +++++++++++ .../credentials-local/tsconfig.json | 33 ++ pnpm-lock.yaml | 34 ++ tsconfig.host.json | 1 + 11 files changed, 1089 insertions(+) create mode 100644 packages/credentials/credentials-local/README.md create mode 100644 packages/credentials/credentials-local/README.zh.md create mode 100644 packages/credentials/credentials-local/package.json create mode 100644 packages/credentials/credentials-local/src/index.ts create mode 100644 packages/credentials/credentials-local/src/invariant.ts create mode 100644 packages/credentials/credentials-local/tests/drain.spec.ts create mode 100644 packages/credentials/credentials-local/tests/local.spec.ts create mode 100644 packages/credentials/credentials-local/tests/watcher.spec.ts create mode 100644 packages/credentials/credentials-local/tsconfig.json diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md new file mode 100644 index 0000000000..55b73c2ac6 --- /dev/null +++ b/packages/credentials/credentials-local/README.md @@ -0,0 +1,46 @@ +# dsh-credentials-local + +English | [中文](README.zh.md) + +File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence. + +| Layer | Source id | Writable | Wins | +|---|---|---|---| +| Live process environment | `env` | no | always | +| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise | + +The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back. + +## Config + +| Field | Default | Meaning | +|---|---|---| +| `path` | `/.env` | Credentials document location. | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. | +| `watch` | `true` | Hot-publish external edits. | +| `debounceMs` | `100` | Watcher write-settle window. | + +## The document + +dotenv format, parsed with `dotenv` and edited by a line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, comments and unrelated lines survive verbatim. Writes go through [`dsh-atomic-write`](../../util/atomic-write/README.md) with mode `0600`. + +Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule. + +## Hot reload + +External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address. + +## Model Experience + +Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; edit the file directly. +- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format. +- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there. +- **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md new file mode 100644 index 0000000000..2563c973a7 --- /dev/null +++ b/packages/credentials/credentials-local/README.zh.md @@ -0,0 +1,46 @@ +# dsh-credentials-local + +[English](README.md) | 中文 + +文件型[凭据](../credentials/README.zh.md) provider:两层来源,一条诚实的优先级。 + +| 层 | 来源 id | 可写 | 优先 | +|---|---|---|---| +| 活跃进程环境 | `env` | 否 | 恒定优先 | +| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | + +环境优先,因为启动时注入(`DEEPSEEK_API_KEY=… dsh`、CI secrets、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须**可见地**只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `path` | `/.env` | 凭据文档位置。 | +| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 | +| `watch` | `true` | 热发布外部编辑。 | +| `debounceMs` | `100` | watcher 写入沉降窗口。 | + +## 文档本身 + +dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释与无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.zh.md),权限 `0600`。 + +值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值、以及已经跨越多个物理行的条目,响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 + +## 热重载 + +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后一份好快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 + +## Model Experience + +Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface. + +#### KV Cache effect + +No direct invalidation; credentials never enter a request prefix. + +## Known Limitations and Deferred Work + +- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;请直接编辑文件。 +- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 +- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 +- **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json new file mode 100644 index 0000000000..0b8924d7f2 --- /dev/null +++ b/packages/credentials/credentials-local/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-credentials-local", + "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-atomic-write": "^0.0.1", + "@deepseek-ai/dsh-credentials": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "chokidar": "^4.0.3", + "dotenv": "^17.2.0", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts new file mode 100644 index 0000000000..853a1ce015 --- /dev/null +++ b/packages/credentials/credentials-local/src/index.ts @@ -0,0 +1,332 @@ +/** + * File-backed credentials provider layering the live process environment over + * a `$DSH_HOME/.env` document. The environment is authoritative and read-only + * (a launch-time override must win, and must be visibly read-only rather than + * silently shadow writes); the file is the provider-managed writable source: + * `set`/`unset` rewrite only their own line and preserve every other byte, + * external edits hot-publish through the seam, and each reload replaces the + * snapshot wholesale so a deleted entry never lingers in memory. + * @module @deepseek-ai/dsh-credentials-local + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { watch as chokidarWatch } from 'chokidar' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { parse } from 'dotenv' +import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' + +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Credentials document path; defaults to `.env` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} + +/** Fully resolved provider parameters; defaulting happens here, never inline. */ +interface ResolvedSpec { + filename: string + watch: boolean + debounceMs: number +} + +/** + * Resolve the runtime spec from plugin config: an explicit `path` wins, + * otherwise the document lives at `/.env`. + * @param config - raw plugin config. + * @returns the resolved file location and watch behavior. + */ +export function resolveSpec(config: Config): ResolvedSpec { + return { + filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')), + watch: config.watch ?? true, + debounceMs: config.debounceMs ?? 100, + } +} + +/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ +function isENOENT(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +/** Match the physical line(s) assigning one reference (ref chars need no escaping). */ +function refLinePattern(ref: CredentialRef): RegExp { + return new RegExp(`^\\s*(?:export\\s+)?${ref}\\s*=`) +} + +/** Values that survive a dotenv round-trip without quoting. */ +const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/ + +/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */ +function hasControlCharacters(value: string): boolean { + for (const char of value) { + if (char.charCodeAt(0) < 0x20) return true + } + return false +} + +/** + * Render one `KEY=value` line in the narrowest style dotenv reads back + * verbatim: bare, then single quotes (fully literal), then double quotes + * (safe only without backslashes, which double-quote reading expands). + * A value no style can represent fails loud instead of corrupting silently. + */ +function renderLine(ref: CredentialRef, value: string): string { + if (BARE_VALUE.test(value)) return `${ref}=${value}` + if (hasControlCharacters(value)) { + throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`) + } + if (!value.includes('\'')) return `${ref}='${value}'` + if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"` + throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`) +} + +/** + * Replace, insert, or delete one reference's assignment while preserving every + * other byte. The first matching line is rewritten in place; further matches + * are dropped (dotenv reads the last one, so duplicates are dead weight that + * would otherwise override the edit). + */ +function upsertLine(text: string | undefined, ref: CredentialRef, line: string | undefined): string { + const lines = text === undefined || text.length === 0 ? [] : text.split('\n') + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() + const matcher = refLinePattern(ref) + const out: string[] = [] + let placed = false + for (const current of lines) { + if (matcher.test(current)) { + if (line !== undefined && !placed) { + out.push(line) + placed = true + } + continue + } + out.push(current) + } + if (line !== undefined && !placed) out.push(line) + return out.length === 0 ? '' : `${out.join('\n')}\n` +} + +/** File-backed credentials provider (`$DSH_HOME/.env`). */ +export class CredentialsLocal extends Credentials { + static Config: z = z.object({ + path: z.string(), + dshHome: z.string(), + watch: z.boolean().default(true), + debounceMs: z.number().min(0).default(100), + }) + + private readonly spec: ResolvedSpec + /** + * Raw text of the last read or persisted document; `undefined` while the + * file is absent. Watcher events whose content equals this cache are no-ops, + * which is also the self-write suppression. + */ + private text: string | undefined + /** Parsed document snapshot; replaced wholesale on every reload. */ + private values = new Map() + /** Serializes watcher-triggered reloads so reads never interleave. */ + private refreshTask: Promise = Promise.resolve() + /** Serializes writes to the one document; settled tail. */ + private writeChain: Promise = Promise.resolve() + /** Set at dispose: refuse new writes and let in-flight work no-op. */ + private closed = false + + /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */ + private isClosed(): boolean { + return this.closed + } + + constructor(ctx: Context, public config: Config) { + super(ctx) + // Programmatic construction may bypass Schemastery normalization; resolve + // the same defaults in one explicit step either way. + this.spec = resolveSpec(config) + } + + async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { + yield async () => { + // Drain: refuse new writes, then settle the queued ones so disposal + // completes only once storage is quiescent. + this.closed = true + await this.writeChain + } + await this.loadInitial() + if (!this.spec.watch) return + const watcher = chokidarWatch(this.spec.filename, { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: this.spec.debounceMs, + pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), + }, + }) + watcher.on('all', () => { + if (this.closed) return + this.refreshTask = this.refreshTask.then(() => this.refresh()).catch((error: unknown) => { + // Only an invariant violation escaping the update fan-out can reject a + // refresh; keep the reload queue alive and surface it as an error so + // one poisoned commit cannot silently end hot reloading forever. + this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename) + this.ctx.logger.error(error) + }) + }) + watcher.on('error', (error) => { + this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) + this.ctx.logger.warn(error) + }) + yield async () => { + // Quiesce: stop accepting events, close the watcher, then wait out any + // queued or in-flight refresh so nothing publishes after disposal. + this.closed = true + await watcher.close() + await this.refreshTask + } + } + + override resolve(ref: CredentialRef): Promise { + const env = process.env[ref] + if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' }) + const stored = this.values.get(ref) + if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' }) + return Promise.resolve(undefined) + } + + override describe(ref: CredentialRef): Promise { + const env = process.env[ref] + if (env !== undefined && env.length > 0) { + return Promise.resolve({ configured: true, source: 'env', writable: false }) + } + const stored = this.values.get(ref) + if (stored !== undefined && stored.length > 0) { + return Promise.resolve({ configured: true, source: 'file', writable: true }) + } + return Promise.resolve({ configured: false, writable: true }) + } + + override async set(ref: CredentialRef, value: string): Promise { + if (value.length === 0) { + throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`) + } + await this.write(ref, value) + } + + override async unset(ref: CredentialRef): Promise { + await this.write(ref, undefined) + } + + /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */ + private async write(ref: CredentialRef, value: string | undefined): Promise { + const verb = value === undefined ? 'unset' : 'set' + if (this.isClosed()) { + throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`) + } + this.assertUnshadowed(ref, verb) + // The stored tail is settled on both outcomes, so chaining needs no catch + // and one rejected write can never poison the queue for later callers. + const previous = this.writeChain + const run = previous.then(async () => { + if (this.isClosed()) { + throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`) + } + // Re-judged at run time: the environment may have changed while queued. + this.assertUnshadowed(ref, verb) + const existing = this.values.get(ref) + if (value === undefined && existing === undefined) return + if (existing !== undefined && existing.includes('\n')) { + throw new Error( + `credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`, + ) + } + const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value)) + // 0600: a document holding secrets is never world-readable. + await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600 }) + this.text = nextText + if (value === undefined) this.values.delete(ref) + else this.values.set(ref, value) + this.ctx.emit('credentials/updated', ref) + }) + this.writeChain = run.then(() => undefined, () => undefined) + return run + } + + /** Reject a write the live environment would shadow into apparent no-effect. */ + private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void { + const env = process.env[ref] + if (env !== undefined && env.length > 0) { + throw new Error( + `credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be` + + ' shadowed; change the launching environment instead', + ) + } + } + + /** Boot read: an absent file is an empty store; any other failure is loud. */ + private async loadInitial(): Promise { + let text: string + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) throw error + return + } + this.text = text + this.values = new Map(Object.entries(parse(text))) + } + + /** + * Re-read the document after a watcher event. Unchanged content (including + * this provider's own writes) is a no-op; an unreadable document keeps the + * last good snapshot and warns — a live hot-reload must never take the + * process down. dotenv parsing is lenient by design and cannot fail. + */ + private async refresh(): Promise { + if (this.closed) return + let text: string | undefined + try { + text = await readFile(this.spec.filename, 'utf8') + } catch (error) { + if (!isENOENT(error)) { + this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename) + this.ctx.logger.warn(error) + return + } + text = undefined + } + if (text === this.text || this.isClosed()) return + const next = text === undefined ? new Map() : new Map(Object.entries(parse(text))) + const changed = this.changedRefs(this.values, next) + this.text = text + this.values = next + for (const ref of changed) this.ctx.emit('credentials/updated', ref) + } + + /** Seam-addressable entries whose effective (non-empty) value changed. */ + private changedRefs(prev: Map, next: Map): CredentialRef[] { + const changed: CredentialRef[] = [] + for (const key of new Set([...prev.keys(), ...next.keys()])) { + const before = prev.get(key) + const after = next.get(key) + const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined + const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined + if (effectiveBefore === effectiveAfter) continue + try { + changed.push(credentialRef(key)) + } catch (_unaddressableKey) { + // A key that is not a POSIX identifier is preserved file content the + // seam cannot address, so no observer could ever see it change. + } + } + return changed + } +} + +export default CredentialsLocal diff --git a/packages/credentials/credentials-local/src/invariant.ts b/packages/credentials/credentials-local/src/invariant.ts new file mode 100644 index 0000000000..9ec75ed21d --- /dev/null +++ b/packages/credentials/credentials-local/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-credentials-local`. + * @module @deepseek-ai/dsh-credentials-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-credentials-local' + +/** Cordis companion plugin name. */ +export const name = 'credentials-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the seam companion (`dsh-credentials/invariant`) owns the + * `credentials/updated` lifecycle contract; this provider's file/environment layering is + * asynchronous I/O pinned by its unit suite. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts new file mode 100644 index 0000000000..6c05759b54 --- /dev/null +++ b/packages/credentials/credentials-local/tests/drain.spec.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '../src/index.ts' + +// The atomic write is the only asynchronous hold point inside a queued write; +// gating it makes the dispose-versus-queued-write race fully deterministic. +vi.mock('@deepseek-ai/dsh-atomic-write', () => { + let gate: Promise = Promise.resolve() + return { + writeFileAtomic: vi.fn(() => gate), + __setGate: (next: Promise) => { + gate = next + }, + } +}) + +async function setGate(next: Promise): Promise { + const mocked = await import('@deepseek-ai/dsh-atomic-write') as unknown as { __setGate: (next: Promise) => void } + mocked.__setGate(next) +} + +const KEY = credentialRef('DSH_CRED_DRAIN_A') +const OTHER = credentialRef('DSH_CRED_DRAIN_B') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + await setGate(Promise.resolve()) + while (cleanups.length > 0) await cleanups.pop()!() +}) + +describe('write-drain teardown', () => { + it('lets the in-flight write land and fails the queued one after disposal', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await fiber + const service = ctx.credentials + + let release!: () => void + await setGate(new Promise((resolveGate) => { + release = resolveGate + })) + const first = service.set(KEY, 'one') + // Let the first task pass its liveness checks and park on the gate, so it + // is genuinely in-flight when disposal begins. + await new Promise(resolvePause => setTimeout(resolvePause, 5)) + // Attach the rejection handler up front: the queued write fails while the + // drain is still awaited, before any later `await expect` could run. + const secondRejects = expect(service.set(OTHER, 'two')).rejects.toThrow(/disposed before the queued/) + const disposal = fiber.dispose() + // Give the drain disposer its first turn (set closed) before opening the gate. + await new Promise(resolvePause => setTimeout(resolvePause, 10)) + release() + await disposal + + await expect(first).resolves.toBeUndefined() + await secondRejects + expect(await service.resolve(KEY)).toEqual({ value: 'one', source: 'file' }) + expect(await service.resolve(OTHER)).toBeUndefined() + }) +}) diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts new file mode 100644 index 0000000000..4ebaed1a0c --- /dev/null +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal, resolveSpec } from '../src/index.ts' + +const KEY = credentialRef('DSH_CRED_TEST') +const OTHER = credentialRef('DSH_CRED_OTHER') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + vi.unstubAllEnvs() + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-local-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, config) + cleanups.push(async () => { + await fiber.dispose() + }) + await fiber + return ctx +} + +function updates(ctx: Context): CredentialRef[] { + const seen: CredentialRef[] = [] + ctx.on('credentials/updated', (ref) => { + seen.push(ref) + }) + return seen +} + +describe('resolveSpec', () => { + it('defaults to .env under the harness home with watching on', () => { + const spec = resolveSpec({ dshHome: '/custom/home' }) + expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 }) + }) + + it('lets an explicit path win over the home', () => { + const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 }) + expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 }) + }) +}) + +describe('layering and reads', () => { + it('treats an absent file as an empty writable store', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, '.env'), watch: false }) + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + }) + + it('serves file entries, including export-prefixed and quoted values', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n') + const ctx = await boot({ path, watch: false }) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' }) + expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + }) + + it('lets a non-empty process environment win read-only over the file', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST=from-file\n') + const ctx = await boot({ path, watch: false }) + vi.stubEnv('DSH_CRED_TEST', 'from-env') + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' }) + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false }) + }) + + it('treats empty values as absent in both layers', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST=\n') + const ctx = await boot({ path, watch: false }) + vi.stubEnv('DSH_CRED_TEST', '') + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) + }) + + it('fails boot loud when the document exists but cannot be read', async () => { + const dir = await tempDir() + const path = join(dir, 'occupied') + await mkdir(path) + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow() + }) +}) + +describe('line-editing writes', () => { + it('appends a missing key to a fresh 0600 document and emits the commit', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + const seen = updates(ctx) + await ctx.credentials.set(KEY, 'sk-fresh') + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n') + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' }) + expect(seen).toEqual([KEY]) + }) + + it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older') + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(KEY, 'new value!') + expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n') + }) + + it('quotes hostile values so they round-trip through a fresh provider', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + const singleQuoted = 'with "quote", back\\slash and space' + const doubleQuoted = "it's got an apostrophe" + await ctx.credentials.set(KEY, singleQuoted) + await ctx.credentials.set(OTHER, doubleQuoted) + const reread = await boot({ path, watch: false }) + expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' }) + expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' }) + }) + + it('fails loud on values no .env quoting style reads back verbatim', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, '.env'), watch: false }) + await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/) + await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) + }) + + it('unsets only the owning line and keeps an absent unset silent', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n') + const ctx = await boot({ path, watch: false }) + const seen = updates(ctx) + await ctx.credentials.unset(KEY) + expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n') + await ctx.credentials.unset(KEY) + expect(seen).toEqual([KEY]) + }) + + it('rejects empty values, shadowed writes, and multi-line entries', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n') + const ctx = await boot({ path, watch: false }) + + await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) + await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/) + await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/) + + vi.stubEnv('DSH_CRED_TEST', 'shadowing') + await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/) + await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/) + }) + + it('leaves an empty document after unsetting the only entry', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST=only\n') + const ctx = await boot({ path, watch: false }) + await ctx.credentials.unset(KEY) + expect(await readFile(path, 'utf8')).toBe('') + }) + + it('chains past a rejected write so one bad value cannot poison the queue', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) + const good = ctx.credentials.set(OTHER, 'lands') + await bad + await good + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n') + }) + + it('serializes concurrent writes so both land in the one document', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + await Promise.all([ + ctx.credentials.set(KEY, 'one'), + ctx.credentials.set(OTHER, 'two'), + ]) + expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n') + }) + + it('refuses writes after disposal', async () => { + const dir = await tempDir() + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await fiber + // Capture the handle first: disposal also removes the ctx.credentials service. + const service = ctx.credentials + await fiber.dispose() + await expect(service.set(KEY, 'late')).rejects.toThrow(/disposed/) + }) +}) + +describe('real hot reload', () => { + it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + // Watching starts on an existing document: creation racing watcher setup + // is a chokidar readiness gap, not the reload contract under test. + await writeFile(path, 'DSH_CRED_TEST=boot\n') + const ctx = await boot({ path, debounceMs: 10 }) + const seen = updates(ctx) + + await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n') + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' }) + }) + + // Wholesale replacement: an entry deleted on disk never lingers in memory. + await writeFile(path, 'DSH_CRED_TEST=live\n') + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(OTHER)).toBeUndefined() + }) + + const before = seen.length + await ctx.credentials.set(KEY, 'self-written') + await new Promise(resolvePause => setTimeout(resolvePause, 200)) + // Exactly the committed write's own event: the watcher echo of our own + // content is recognized by the text cache and publishes nothing extra. + expect(seen.length).toBe(before + 1) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'self-written', source: 'file' }) + }) +}) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts new file mode 100644 index 0000000000..798cdc8a88 --- /dev/null +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '../src/index.ts' + +// chokidar is the nondeterministic OS boundary: faking it lets these tests +// drive the event pipeline (error events, races with unreadable files) +// deterministically. Real end-to-end watching stays covered by local.spec.ts. +vi.mock('chokidar', async () => { + const { EventEmitter } = await import('node:events') + class FakeWatcher extends EventEmitter { + close = vi.fn(() => Promise.resolve()) + } + const instances: Array<{ path: string; options: unknown; watcher: InstanceType }> = [] + return { + watch: vi.fn((path: string, options: unknown) => { + const watcher = new FakeWatcher() + instances.push({ path, options, watcher }) + return watcher + }), + __instances: instances, + } +}) + +interface FakeChokidar { + __instances: Array<{ + path: string + options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } } + watcher: import('node:events').EventEmitter + }> +} + +async function fakeInstances(): Promise { + const chokidar = await import('chokidar') as unknown as FakeChokidar + return chokidar.__instances +} + +const KEY = credentialRef('DSH_CRED_PIPE') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + ;(await fakeInstances()).length = 0 +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-watch-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, config) + cleanups.push(async () => { + await fiber.dispose() + }) + await fiber + return ctx +} + +describe('watcher pipeline', () => { + it('clamps the write-settle poll interval for a zero debounce', async () => { + const dir = await tempDir() + await boot({ path: join(dir, '.env'), debounceMs: 0 }) + const [instance] = await fakeInstances() + expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 }) + }) + + it('survives a watcher error and keeps publishing later edits', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, debounceMs: 5 }) + const [instance] = await fakeInstances() + + instance!.watcher.emit('error', new Error('watch backend failure')) + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + + await writeFile(path, 'DSH_CRED_PIPE=arrived\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' }) + }) + }) + + it('keeps the last good snapshot when the file turns unreadable at runtime', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_PIPE=good\n') + const ctx = await boot({ path, debounceMs: 5 }) + + await chmod(path, 0o000) + cleanups.push(() => chmod(path, 0o600)) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'change', path) + // The warn-and-keep path is asynchronous; give the serialized refresh a turn. + await new Promise(resolve => setTimeout(resolve, 50)) + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' }) + }) + + it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, debounceMs: 5 }) + let arm = true + ctx.on('credentials/updated', () => { + if (!arm) return + throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) + }) + const [instance] = await fakeInstances() + + await writeFile(path, 'DSH_CRED_PIPE=first\n') + instance!.watcher.emit('all', 'change', path) + // The snapshot commits before the fan-out, so the value lands even though + // the listener threw out of the refresh. + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' }) + }) + + arm = false + await writeFile(path, 'DSH_CRED_PIPE=second\n') + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) + }) + }) + + it('quiesces the refresh pipeline before dispose completes', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_PIPE=initial\n') + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 }) + await fiber + let disposed = false + let postDisposeCommits = 0 + ctx.on('credentials/updated', () => { + if (disposed) postDisposeCommits += 1 + }) + + await writeFile(path, 'DSH_CRED_PIPE=changed\n') + const [instance] = await fakeInstances() + // Two queued refreshes: dispose interrupts one mid-flight and the other + // before it starts, so both closed guards must hold. + instance!.watcher.emit('all', 'change', path) + instance!.watcher.emit('all', 'change', path) + await fiber.dispose() + disposed = true + instance!.watcher.emit('all', 'change', path) + await new Promise(resolve => setTimeout(resolve, 100)) + expect(postDisposeCommits).toBe(0) + }) + + it('empties the snapshot when the document is deleted and emits the removals', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_PIPE=doomed\n') + const ctx = await boot({ path, debounceMs: 5 }) + const seen: string[] = [] + ctx.on('credentials/updated', (ref) => { + seen.push(ref) + }) + + await rm(path) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'unlink', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + }) + expect(seen).toEqual([KEY]) + }) + + it('publishes only seam-addressable keys and preserves the rest untouched', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n') + const ctx = await boot({ path, debounceMs: 5 }) + const seen: string[] = [] + ctx.on('credentials/updated', (ref) => { + seen.push(ref) + }) + + await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n') + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'change', path) + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' }) + }) + // The dash-named key is preserved file content the seam cannot address: + // its change publishes nothing and breaks nothing. + expect(seen).toEqual([KEY]) + }) + + it('treats an event for a still-absent file as a no-op', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, debounceMs: 5 }) + const [instance] = await fakeInstances() + instance!.watcher.emit('all', 'add', path) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(await ctx.credentials.resolve(KEY)).toBeUndefined() + }) +}) diff --git a/packages/credentials/credentials-local/tsconfig.json b/packages/credentials/credentials-local/tsconfig.json new file mode 100644 index 0000000000..3acfbdeffe --- /dev/null +++ b/packages/credentials/credentials-local/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/atomic-write" + }, + { + "path": "../../util/paths" + }, + { + "path": "../credentials" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31ff52fe9d..52e7b070b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2074,6 +2074,34 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/credentials/credentials-local: + dependencies: + chokidar: + specifier: ^4.0.3 + version: 4.0.3 + dotenv: + specifier: ^17.2.0 + version: 17.4.2 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../credentials + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/examples/acp-demo: devDependencies: '@cordisjs/plugin-include': @@ -8637,6 +8665,10 @@ packages: dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -13610,6 +13642,8 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dotenv@17.4.2: {} + dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 diff --git a/tsconfig.host.json b/tsconfig.host.json index 67e340e898..6bfe4d2cad 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -65,6 +65,7 @@ { "path": "./packages/settings/settings" }, { "path": "./packages/settings/settings-local" }, { "path": "./packages/credentials/credentials" }, + { "path": "./packages/credentials/credentials-local" }, { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/storage/storage" }, { "path": "./packages/storage/storage-json" }, From f05ab3f9450e8b398a3f6c66e0e348713babe709 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:26:52 +0800 Subject: [PATCH 04/37] feat(llm-deepseek): per-request connection resolution over settings + credentials The adapter takes an options thunk and a per-stream credential resolver instead of frozen construction facts: base URL, catalog, defaults, idle budget, and the API key re-resolve at each operation, so a settings or credential change reaches the very next request while in-flight streams keep the facts they started with. resolveAdapterOptions is the one explicit resolve step (entry config fails loud at load; a live snapshot failing a beyond-schema bound keeps the last good options). The plugin layers its entry config under the optional llm-deepseek settings section and resolves keys literal-first through ctx.credentials with an ambient env fallback; a missing key now registers the route, warns, and fails each request with actionable MISSING_CREDENTIAL instead of failing plugin load. The registration-captured retry policy re-registers the route in place when it changes. --- packages/llm/llm-deepseek/package.json | 4 + packages/llm/llm-deepseek/src/adapter.ts | 109 ++++++----- packages/llm/llm-deepseek/src/index.ts | 170 +++++++++++++--- .../llm/llm-deepseek/tests/adapter.spec.ts | 182 ++++++------------ .../llm-deepseek/tests/dynamic-config.spec.ts | 163 ++++++++++++++++ .../llm/llm-deepseek/tests/mock-server.ts | 82 ++++++++ packages/llm/llm-deepseek/tsconfig.json | 6 + pnpm-lock.yaml | 6 + 8 files changed, 522 insertions(+), 200 deletions(-) create mode 100644 packages/llm/llm-deepseek/tests/dynamic-config.spec.ts create mode 100644 packages/llm/llm-deepseek/tests/mock-server.ts diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index d233ef7764..2c39e2d920 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -27,8 +27,10 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -37,8 +39,10 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index ff5ce9bf72..0163dd3cab 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -1,21 +1,23 @@ /** * `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible) - * chat-completions endpoint, emitting harness StreamChunks. + * chat-completions endpoint, emitting harness StreamChunks. The adapter is + * transport-only: connection facts arrive through a thunk resolved once per + * operation and the bearer token through a per-request resolver, so the + * registering plugin owns validation, layering, and credential policy. * * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, ResolvedRetryPolicy, - RetryPolicyConfig, StreamChunk, } from '@deepseek-ai/dsh-llm' -import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' import { parseSse } from './sse.ts' @@ -34,22 +36,37 @@ export interface DeepSeekCatalogModel { contextWindow?: number } -/** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */ -export interface DeepSeekAdapterOptions { - /** Bearer token sent in the `authorization` header on every request. */ - apiKey: string +/** + * Validated connection facts for one operation. The plugin's + * `resolveAdapterOptions` is the one explicit resolve step producing this + * shape; the adapter trusts it and re-reads it per operation, which is what + * makes a configuration change reach the next request without re-registration. + */ +export interface DeepSeekConnectionOptions { /** Endpoint base; `/chat/completions` is appended. */ baseURL: string /** Request defaults applied to every call (thinking mode, effort). */ - defaults?: RequestDefaults + defaults: RequestDefaults /** Positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number /** Advisory models exposed to discovery consumers; requests remain unrestricted. */ - models?: readonly DeepSeekCatalogModel[] + models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ - streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ - retryPolicy?: RetryPolicyConfig + streamIdleTimeoutMs: number + /** Provider-owned model-request retry policy, already resolved. */ + retryPolicy: ResolvedRetryPolicy +} + +/** Constructor options for {@link DeepSeekAdapter}: the two resolution seams the plugin owns. */ +export interface DeepSeekAdapterOptions { + /** Current validated connection facts; called once per operation. */ + options: () => DeepSeekConnectionOptions + /** + * Resolve the bearer token for one request; called once per stream call and + * frozen for that call. Throws `LlmError` `MISSING_CREDENTIAL` when no key + * is available anywhere. + */ + resolveApiKey: () => Promise } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -118,29 +135,8 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin * map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`. */ export class DeepSeekAdapter extends LlmAdapter { - private readonly streamIdleTimeoutMs: number - private readonly retryPolicy: ResolvedRetryPolicy - - constructor(private readonly options: DeepSeekAdapterOptions) { + constructor(private readonly config: DeepSeekAdapterOptions) { super() - if (options.defaults?.thinking === 'disabled' - && options.defaults.reasoningEffort !== undefined - && options.defaults.reasoningEffort !== 'off') { - throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled') - } - if (options.defaultContextWindow !== undefined - && (!Number.isInteger(options.defaultContextWindow) || options.defaultContextWindow <= 0)) { - throw new Error('llm-deepseek: defaultContextWindow must be a positive integer') - } - this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS - if (!Number.isFinite(this.streamIdleTimeoutMs) - || this.streamIdleTimeoutMs <= 0 - || this.streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { - throw new Error( - `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, - ) - } - this.retryPolicy = resolveRetryPolicy(options.retryPolicy, 'llm-deepseek: retryPolicy') } override providerInfo(provider: string): LlmProviderInfo { @@ -148,11 +144,11 @@ export class DeepSeekAdapter extends LlmAdapter { } override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { - return this.retryPolicy + return this.config.options().retryPolicy } override listModels(provider: string): Promise { - return Promise.resolve((this.options.models ?? []).map(model => modelInfo(provider, model))) + return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model))) } override resolveModel( @@ -160,15 +156,16 @@ export class DeepSeekAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const configured = this.options.models?.find(entry => entry.id === model) + const connection = this.config.options() + const configured = connection.models.find(entry => entry.id === model) const contextWindow = configured?.contextWindow - ?? this.options.defaultContextWindow + ?? connection.defaultContextWindow return Promise.resolve({ ...configured === undefined ? { provider, id: model, name: model } : modelInfo(provider, configured), ...contextWindow === undefined ? {} : { context: { contextWindow } }, - ...this.options.defaults?.thinking === 'disabled' + ...connection.defaults.thinking === 'disabled' ? { reasoning: { efforts: OFF_ONLY_REASONING_EFFORTS, @@ -178,9 +175,9 @@ export class DeepSeekAdapter extends LlmAdapter { : { reasoning: { efforts: REASONING_EFFORTS, - defaultEffort: this.options.defaults?.reasoningEffort === 'off' + defaultEffort: connection.defaults.reasoningEffort === 'off' ? OFF_REASONING_EFFORT - : this.options.defaults?.reasoningEffort === 'max' + : connection.defaults.reasoningEffort === 'max' ? MAX_REASONING_EFFORT : HIGH_REASONING_EFFORT, }, @@ -189,12 +186,17 @@ export class DeepSeekAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable { + // One resolution per stream call: connection facts and the credential + // freeze here and hold for this whole request, so an in-flight stream + // never observes a configuration change and the next call re-resolves. + const connection = this.config.options() + const apiKey = await this.config.resolveApiKey() const consumer = new AbortController() const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]) - using watchdog = idleWatchdog(upstream, this.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) - const iterator = this.request(options, watchdog.signal)[Symbol.asyncIterator]() + using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) + const iterator = this.request(options, watchdog.signal, connection, apiKey)[Symbol.asyncIterator]() let exhausted = false try { while (true) { @@ -208,7 +210,7 @@ export class DeepSeekAdapter extends LlmAdapter { } catch (error: unknown) { if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) { throw new LlmError( - `DeepSeek stream idle timeout after ${this.streamIdleTimeoutMs}ms`, + `DeepSeek stream idle timeout after ${connection.streamIdleTimeoutMs}ms`, 'TIMEOUT', { cause: error }, ) @@ -217,7 +219,7 @@ export class DeepSeekAdapter extends LlmAdapter { throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error }) } if (error instanceof LlmError) throw error - throw new LlmError(`DeepSeek API stream from ${this.options.baseURL} failed`, 'TRANSPORT', { cause: error }) + throw new LlmError(`DeepSeek API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error }) } finally { consumer.abort('DeepSeek stream consumer stopped') if (!exhausted && iterator.return !== undefined) { @@ -230,13 +232,18 @@ export class DeepSeekAdapter extends LlmAdapter { } } - private async * request(options: GenerateOptions, signal: AbortSignal): AsyncIterable { - const body = serializeRequest(options, this.options.defaults ?? {}) + private async * request( + options: GenerateOptions, + signal: AbortSignal, + connection: DeepSeekConnectionOptions, + apiKey: string, + ): AsyncIterable { + const body = serializeRequest(options, connection.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) const headers = { - 'authorization': `Bearer ${this.options.apiKey}`, + 'authorization': `Bearer ${apiKey}`, 'content-type': 'application/json', 'accept': 'text/event-stream', ...attributionHeaders(), @@ -252,7 +259,7 @@ export class DeepSeekAdapter extends LlmAdapter { // outweighs its additional runtime dependencies. let response: Response try { - response = await fetch(`${this.options.baseURL}/chat/completions`, { + response = await fetch(`${connection.baseURL}/chat/completions`, { method: 'POST', headers, body: payload, @@ -266,7 +273,7 @@ export class DeepSeekAdapter extends LlmAdapter { // lives on `cause`. Wrapping with the endpoint and chaining the cause // lets `errorChain` render the full diagnosis at every reporting seam. throw new LlmError( - `DeepSeek API request to ${this.options.baseURL} failed`, + `DeepSeek API request to ${connection.baseURL} failed`, 'TRANSPORT', { cause: error }, ) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 00db46d642..054f1984b0 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,41 +1,56 @@ /** - * Register a {@link DeepSeekAdapter} for the `deepseek` provider route on `ctx.llm`. Configuration uses - * Cordis schemastery; pass secrets from environment variables through `cordis.yml` with `!!js`, - * as shown in the package README, rather than reading ad hoc files. + * Register a {@link DeepSeekAdapter} for the `deepseek` provider route on + * `ctx.llm`, with connection facts resolved per request instead of frozen at + * load: the plugin layers its `cordis.yml` entry config under the optional + * `llm-deepseek` user-settings section (`ctx.settings`) and resolves the API + * key through the optional credential seam (`ctx.credentials`), so a changed + * base URL, catalog, or key reaches the very next request without restarting + * anything, while an in-flight stream keeps the facts it started with. The + * one registration-captured fact — the retry policy — re-registers the route + * in place when it changes. * @module @deepseek-ai/dsh-llm-deepseek */ import type { Context } from 'cordis' import z from 'schemastery' -import { RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' +import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' -import type { DeepSeekCatalogModel } from './adapter.ts' +import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' export { DeepSeekAdapter } from './adapter.ts' -export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts' +export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' export type { RequestDefaults } from './serialize.ts' export type * from './types.ts' export const name = 'llm-deepseek' export const inject = ['llm'] +const NS = settingsNamespace('llm-deepseek') +const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' + const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 }, { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 256_000 }, ] /** - * Plugin config, validated by the same-named schemastery schema. Every field - * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), omitted thinking - * mode uses the provider default, and omitted reasoning effort resolves to - * `high`. + * Plugin config, validated by the same-named schemastery schema and doubling + * as the `llm-deepseek` settings-section shape. Every field is optional in + * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each + * request (a request without any key fails with `MISSING_CREDENTIAL`, not at + * plugin load), omitted thinking mode uses the provider default, and omitted + * reasoning effort resolves to `high`. */ export interface Config { - /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ + /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ + apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ @@ -60,7 +75,8 @@ const catalogModel: z = z.object({ }) export const Config: z = z.object({ - apiKey: z.string(), + apiKey: z.string().role('secret'), + apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV), baseURL: z.string(), thinking: z.union(['enabled', 'disabled']), reasoningEffort: z.union(['off', 'high', 'max']), @@ -73,6 +89,12 @@ export const Config: z = z.object({ /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ export const PUBLIC_BASE_URL = 'https://api.deepseek.com' +/** Connection facts plus the plugin-consumed credential reference. */ +export interface ResolvedDeepSeekOptions extends DeepSeekConnectionOptions { + /** Reference resolved per request when no literal key is configured. */ + apiKeyEnv: CredentialRef +} + /** Resolve, validate, and detach the advisory model catalog. */ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] { const seen = new Set() @@ -98,20 +120,35 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee }) } -export function apply(ctx: Context, config: Config): void { +/** + * The one explicit resolve step from raw config to validated connection + * facts. Programmatic construction may bypass Schemastery normalization, so + * every default and bound is re-judged here — for the composition entry at + * load (fail loud) and for each settings snapshot at its first use. + * @param config - raw plugin config or resolved settings snapshot. + * @returns validated connection facts plus the credential reference. + */ +export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { if (config.thinking === 'disabled' && config.reasoningEffort !== undefined && config.reasoningEffort !== 'off') { throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled') } - const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY - if (apiKey === undefined || apiKey.length === 0) { - throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)') + if (config.defaultContextWindow !== undefined + && (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) { + throw new Error('llm-deepseek: defaultContextWindow must be a positive integer') } - const baseURL = config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL - ctx.llm.registerAdapter(['deepseek'], new DeepSeekAdapter({ - apiKey, - baseURL, + const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS + if (!Number.isFinite(streamIdleTimeoutMs) + || streamIdleTimeoutMs <= 0 + || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } + return { + apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), + baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, defaults: { thinking: config.thinking, reasoningEffort: config.reasoningEffort, @@ -120,7 +157,92 @@ export function apply(ctx: Context, config: Config): void { ? {} : { defaultContextWindow: config.defaultContextWindow }, models: resolveModels(config.models), - streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS, - ...config.retryPolicy === undefined ? {} : { retryPolicy: config.retryPolicy }, - })) + streamIdleTimeoutMs, + retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'), + } +} + +export function apply(ctx: Context, config: Config): void { + let current: () => Config = () => config + let lastRaw: Config | undefined + let lastGood: ResolvedDeepSeekOptions | undefined + const options = (): ResolvedDeepSeekOptions => { + const raw = current() + if (raw === lastRaw && lastGood !== undefined) return lastGood + try { + const next = resolveAdapterOptions(raw) + lastRaw = raw + lastGood = next + return next + } catch (error) { + // Static composition resolves before anything registers, so this branch + // only sees a live settings snapshot failing a beyond-schema bound: + // keep serving the last good facts and say so once per bad snapshot. + if (lastGood === undefined) throw error + lastRaw = raw + ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section') + ctx.logger.error(error) + return lastGood + } + } + options() + + const resolveApiKey = async (): Promise => { + const raw = current() + if (raw.apiKey !== undefined && raw.apiKey.length > 0) return raw.apiKey + const ref = options().apiKeyEnv + const credentials = ctx.get('credentials') + if (credentials !== undefined) { + const hit = await credentials.resolve(ref) + if (hit !== undefined) return hit.value + } else { + // Without the seam, keep the historical ambient fallback so a plain + // cordis.yml composition works from the environment alone. + const ambient = process.env[ref] + if (ambient !== undefined && ambient.length > 0) return ambient + } + throw new LlmError( + 'llm-deepseek: no API key for provider route "deepseek"; set the llm-deepseek "apiKey" setting,' + + ` store ${ref} with the credentials service, or export ${ref}`, + 'MISSING_CREDENTIAL', + ) + } + + const adapter = new DeepSeekAdapter({ options, resolveApiKey }) + // Route effects bind to this apply fiber via the stable `ctx` reference, + // even when a swap runs inside the scoped settings callback below. + let disposeRoute = ctx.llm.registerAdapter(['deepseek'], adapter) + let registeredPolicy = options().retryPolicy + const ensureRegistrationFacts = (): void => { + const policy = options().retryPolicy + if (deepEqualJson(policy, registeredPolicy)) return + // The registry captures the retry policy at registration, so it is the one + // fact per-request resolution cannot refresh: swap the registration in one + // synchronous section (same adapter instance, no NO_ADAPTER window). + disposeRoute() + disposeRoute = ctx.llm.registerAdapter(['deepseek'], adapter) + registeredPolicy = policy + } + + void resolveApiKey().then(() => undefined, () => { + // Expected on a first boot with dynamic sources: the route stays + // registered (the catalog is browsable) and each request fails with the + // actionable MISSING_CREDENTIAL message until a key arrives. + ctx.logger.warn('llm-deepseek: no API key resolved yet for route "deepseek"; requests will fail until one is configured') + }) + + ctx.inject(['settings'], (sctx) => { + const scope = sctx.settings.register(NS, Config, { base: config }) + current = () => scope.get() + sctx.effect(() => () => { + // Settings detached (provider disposed or reloading): fall back to the + // composition entry so the plugin keeps working exactly as configured. + current = () => config + ensureRegistrationFacts() + }) + ensureRegistrationFacts() + scope.watch(() => { + ensureRegistrationFacts() + }) + }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 1b64c57982..935235d825 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,5 +1,3 @@ -import { createServer } from 'node:http' -import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, @@ -14,90 +12,18 @@ import LlmService, { createUserMessage, import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { SessionId } from '@deepseek-ai/dsh-session' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import { httpErrorCode } from '../src/adapter.ts' import { assemble } from './assemble.ts' - -/** One scripted behavior for the next request the mock server receives. */ -type Behavior = - | { kind: 'sse'; events: string[]; delayMs?: number } - | { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record } - | { kind: 'close-early'; events: string[] } - -interface MockServer { - url: string - /** Bodies of received requests, in order. */ - requests: unknown[] - /** Header bags of received requests, in order (parallel to `requests`). */ - headers: IncomingMessage['headers'][] - script: Behavior[] - close(): Promise -} - -const servers: Server[] = [] +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' +import type { Behavior } from './mock-server.ts' afterEach(async () => { - await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + await closeMockServers() vi.unstubAllEnvs() vi.useRealTimers() }) -/** Local chat-completions stand-in: replays scripted behaviors per request. */ -async function mockServer(script: Behavior[]): Promise { - const requests: unknown[] = [] - const headers: IncomingMessage['headers'][] = [] - const server = createServer((request: IncomingMessage, response: ServerResponse) => { - let body = '' - request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) - request.on('end', () => { - requests.push(JSON.parse(body)) - headers.push(request.headers) - const behavior = script.shift() - if (!behavior) { - response.writeHead(500).end('mock script exhausted') - return - } - if (behavior.kind === 'http-error') { - response.writeHead(behavior.status, { - 'content-type': behavior.contentType ?? 'application/json', - ...behavior.headers, - }) - response.end(behavior.body) - return - } - response.writeHead(200, { 'content-type': 'text/event-stream' }) - const write = (index: number): void => { - if (index >= behavior.events.length) { - if (behavior.kind === 'sse') response.end() - else response.destroy() // close-early: drop the socket mid-stream - return - } - response.write(`data: ${behavior.events[index]}\n\n`) - setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5) - } - write(0) - }) - }) - servers.push(server) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('no port') - return { - url: `http://127.0.0.1:${address.port}`, - requests, - headers, - script, - close: () => new Promise(resolve => server.close(() => { resolve() })), - } -} - -const textEvents = [ - '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', - '{"choices":[{"delta":{"content":"hello"}}]}', - '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - '[DONE]', -] - async function harness(baseURL: string, config: object = {}) { const ctx = new Context() await ctx.plugin(LlmService) @@ -105,6 +31,15 @@ async function harness(baseURL: string, config: object = {}) { return ctx } +/** Direct adapter over the plugin's real resolve step, with a static key. */ +function adapterOf(config: Partial & { apiKey?: string } = {}): DeepSeekAdapter { + const { apiKey, ...rest } = config + return new DeepSeekAdapter({ + options: () => resolveAdapterOptions(rest), + resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), + }) +} + describe('DeepSeekAdapter against a mock server', () => { it('streams a text generation end to end through the assembler', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) @@ -275,11 +210,7 @@ describe('DeepSeekAdapter against a mock server', () => { 'rejects direct adapter effort %s before I/O when thinking is disabled', async (effort) => { const server = await mockServer([]) - const adapter = new DeepSeekAdapter({ - apiKey: 'test-key', - baseURL: server.url, - defaults: { thinking: 'disabled' }, - }) + const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' }) const stream = adapter.stream({ provider: 'deepseek', @@ -483,7 +414,7 @@ describe('DeepSeekAdapter against a mock server', () => { }) it('throws EMPTY_RESPONSE when the response has no body', async () => { - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const adapter = adapterOf({ baseURL: 'http://127.0.0.1:1' }) const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(null, { status: 200 }), ) @@ -538,7 +469,7 @@ describe('DeepSeekAdapter against a mock server', () => { it('maps connection failures to TRANSPORT without losing the cause', async () => { const cause = new TypeError('connection refused') const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause) - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' }) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) try { const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } @@ -555,7 +486,7 @@ describe('DeepSeekAdapter against a mock server', () => { failed.reject('offline') return failed.promise }) - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' }) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) try { const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } @@ -585,11 +516,7 @@ describe('DeepSeekAdapter against a mock server', () => { }) return Promise.resolve(new Response(body, { status: 200 })) }) - const adapter = new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'https://example.invalid', - streamIdleTimeoutMs: 100, - }) + const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 }) try { const drain = (async () => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } @@ -733,22 +660,15 @@ describe('plugin registration and config', () => { ) it.each(['high', 'max'] as const)( - 'rejects disabled-thinking effort %s at the direct constructor boundary', + 'rejects disabled-thinking effort %s at the resolver boundary', (reasoningEffort) => { - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - defaults: { thinking: 'disabled', reasoningEffort }, - })).toThrow(/only reasoningEffort "off"/) + expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort })) + .toThrow(/only reasoningEffort "off"/) }, ) - it('accepts disabled thinking with off at the direct constructor boundary', async () => { - const adapter = new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - defaults: { thinking: 'disabled', reasoningEffort: 'off' }, - }) + it('accepts disabled thinking with off at the resolver boundary', async () => { + const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' }) await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({ reasoning: { efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], @@ -863,11 +783,8 @@ describe('plugin registration and config', () => { it.each([0, 1.5])( 'rejects invalid adapter-wide default context capacity %s', async (defaultContextWindow) => { - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - defaultContextWindow, - })).toThrow(/defaultContextWindow must be a positive integer/) + expect(() => resolveAdapterOptions({ defaultContextWindow })) + .toThrow(/defaultContextWindow must be a positive integer/) const ctx = new Context() await ctx.plugin(LlmService) @@ -889,13 +806,19 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) }) - it('throws a clear error when no API key is available', async () => { + it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmDeepSeek, {})) - .rejects.toThrow(/an API key is required/) - expect(ctx.llm.listProviders()).toEqual([]) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + // First-boot onboarding: the route registers so models stay discoverable; + // only the request itself needs a key. + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) + await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toThrow(/store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY/) }) it('prefers explicit config over env for key and base URL', async () => { @@ -927,23 +850,32 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) }) - it('adapter is constructible directly for embedding', async () => { - const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + it('adapter is constructible directly for embedding over the shared resolver', async () => { + const adapter = adapterOf() expect(adapter).toBeInstanceOf(DeepSeekAdapter) - await expect(adapter.listModels('deepseek')).resolves.toEqual([]) + // Direct embedding shares the plugin's one resolve step, so it advertises + // the same default catalog instead of a divergent empty one. + await expect(adapter.listModels('deepseek')).resolves.toHaveLength(2) + }) + + it('resolves connection facts and the credential exactly once per stream call', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url })) + const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) + const adapter = new DeepSeekAdapter({ options, resolveApiKey }) + + for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } + + expect(options).toHaveBeenCalledTimes(1) + expect(resolveApiKey).toHaveBeenCalledTimes(1) + expect(server.headers[0]?.authorization).toBe('Bearer per-request-key') }) it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => { - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: Number.POSITIVE_INFINITY, - })).toThrow(/streamIdleTimeoutMs.*positive finite/) - expect(() => new DeepSeekAdapter({ - apiKey: 'k', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, - })).toThrow(/streamIdleTimeoutMs.*no greater/) + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY })) + .toThrow(/streamIdleTimeoutMs.*positive finite/) + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .toThrow(/streamIdleTimeoutMs.*no greater/) const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts new file mode 100644 index 0000000000..79a8afb671 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import LlmService from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '@deepseek-ai/dsh-settings-local' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +const NS = settingsNamespace('llm-deepseek') +const KEY_REF = credentialRef('DEEPSEEK_API_KEY') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + await closeMockServers() + vi.unstubAllEnvs() +}) + +async function home(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-llm-dynamic-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +interface Harness { + ctx: Context + settingsFiber: { dispose(): Promise } +} + +/** + * Real dynamic composition: llm + settings-local + credentials-local + + * llm-deepseek over one temp harness home. `watch: false` keeps every change + * flowing through the in-process write path, which is deterministic; external + * file watching is the providers' own covered concern. + */ +async function boot(dir: string, config: object): Promise { + const ctx = new Context() + cleanups.push(async () => { + await ctx.fiber.dispose() + }) + await ctx.plugin(LlmService) + const settingsFiber = ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) + await settingsFiber + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(LlmDeepSeek, config) + return { ctx, settingsFiber } +} + +function prompt(ctx: Context) { + return assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) +} + +describe('request-level dynamic configuration', () => { + it('routes the next request with the freshly resolved base URL and credential', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=first-key\n') + const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) + const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { baseURL: serverA.url }) + + await prompt(ctx) + expect(serverA.headers[0]?.authorization).toBe('Bearer first-key') + + await ctx.settings.update(NS, { baseURL: serverB.url }) + await ctx.credentials.set(KEY_REF, 'second-key') + + await prompt(ctx) + // No restart, no re-registration: the next request resolved both facts. + expect(serverA.requests).toHaveLength(1) + expect(serverB.headers[0]?.authorization).toBe('Bearer second-key') + }) + + it('prefers a literal settings apiKey over the credential layers', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=file-key\n') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { baseURL: server.url }) + + await ctx.settings.update(NS, { apiKey: 'literal-key' }) + await prompt(ctx) + expect(server.headers[0]?.authorization).toBe('Bearer literal-key') + }) + + it('starts keyless and serves the next request once the key arrives', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { baseURL: server.url }) + + await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + await ctx.credentials.set(KEY_REF, 'sk-arrived') + await prompt(ctx) + expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') + }) + + it('advertises a live settings catalog without re-registration', async () => { + const dir = await home() + const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + + await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) + await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) + await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ + { provider: 'deepseek', id: 'settings-model', name: 'From Settings' }, + ]) + }) + + it('re-registers the route in place when the captured retry policy changes', async () => { + const dir = await home() + const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + + await ctx.settings.update(NS, { + retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, + }) + expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) + }) + + it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => { + const dir = await home() + const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + + // Schema-valid but resolver-invalid: duplicate catalog ids pass the array + // schema and fail the explicit resolve step. + await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] }) + await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) + await ctx.settings.update(NS, { models: [{ id: 'recovered' }] }) + await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ + { provider: 'deepseek', id: 'recovered', name: 'recovered' }, + ]) + }) + + it('falls back to the composition entry when settings detach', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=steady-key\n') + const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) + const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url }) + + await ctx.settings.update(NS, { baseURL: serverB.url }) + await prompt(ctx) + expect(serverB.requests).toHaveLength(1) + + await settingsFiber.dispose() + await prompt(ctx) + expect(serverA.requests).toHaveLength(1) + expect(serverA.headers[0]?.authorization).toBe('Bearer steady-key') + }) +}) diff --git a/packages/llm/llm-deepseek/tests/mock-server.ts b/packages/llm/llm-deepseek/tests/mock-server.ts new file mode 100644 index 0000000000..cdb499e143 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/mock-server.ts @@ -0,0 +1,82 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' + +/** One scripted behavior for the next request the mock server receives. */ +export type Behavior = + | { kind: 'sse'; events: string[]; delayMs?: number } + | { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record } + | { kind: 'close-early'; events: string[] } + +export interface MockServer { + url: string + /** Bodies of received requests, in order. */ + requests: unknown[] + /** Header bags of received requests, in order (parallel to `requests`). */ + headers: IncomingMessage['headers'][] + script: Behavior[] + close(): Promise +} + +const servers: Server[] = [] + +/** Close every server opened since the last call; run from each spec's afterEach. */ +export async function closeMockServers(): Promise { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) +} + +/** A minimal complete text generation, reused by request-shape assertions. */ +export const textEvents = [ + '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', + '{"choices":[{"delta":{"content":"hello"}}]}', + '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + '[DONE]', +] + +/** Local chat-completions stand-in: replays scripted behaviors per request. */ +export async function mockServer(script: Behavior[]): Promise { + const requests: unknown[] = [] + const headers: IncomingMessage['headers'][] = [] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + let body = '' + request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + request.on('end', () => { + requests.push(JSON.parse(body)) + headers.push(request.headers) + const behavior = script.shift() + if (!behavior) { + response.writeHead(500).end('mock script exhausted') + return + } + if (behavior.kind === 'http-error') { + response.writeHead(behavior.status, { + 'content-type': behavior.contentType ?? 'application/json', + ...behavior.headers, + }) + response.end(behavior.body) + return + } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + const write = (index: number): void => { + if (index >= behavior.events.length) { + if (behavior.kind === 'sse') response.end() + else response.destroy() // close-early: drop the socket mid-stream + return + } + response.write(`data: ${behavior.events[index]}\n\n`) + setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5) + } + write(0) + }) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { + url: `http://127.0.0.1:${address.port}`, + requests, + headers, + script, + close: () => new Promise(resolve => server.close(() => { resolve() })), + } +} diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 45c2af21a5..ee8a81e73b 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -20,6 +20,12 @@ { "path": "../../llm/llm" }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52e7b070b7..c7f3ae3f7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2949,12 +2949,18 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout From c0426142c5fc0a95e67e8ec7adadd8fbeb00e3db Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:35:38 +0800 Subject: [PATCH 05/37] feat(llm-pi-ai): route-keyed profiles with per-request resolution and in-place route swaps providers becomes a dict keyed by provider route, so the composition base and the llm-pi-ai settings section merge per provider and the route set is structural; the pre-release array shape and per-profile provider field fail loud with migration directions. The adapter reads a profiles thunk once per operation and resolves the credential per stream call (literal apiKey, then apiKeyEnv through ctx.credentials with an ambient env fallback, then pi-ai's provider-native discovery), so key, endpoint, and knob changes reach the next request without restarts. Route-set or captured-retry-policy changes re-register the same adapter instance in one synchronous section; an invalid settings snapshot keeps the last good profiles. --- packages/llm/llm-pi-ai/package.json | 4 + packages/llm/llm-pi-ai/src/adapter.ts | 41 ++-- packages/llm/llm-pi-ai/src/config.ts | 85 +++++--- packages/llm/llm-pi-ai/src/index.ts | 104 +++++++-- packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 13 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 203 +++++++----------- .../llm-pi-ai/tests/dynamic-config.spec.ts | 116 ++++++++++ packages/llm/llm-pi-ai/tests/mock-server.ts | 82 +++++++ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 5 +- .../llm/llm-pi-ai/tests/sdk-options.spec.ts | 6 +- packages/llm/llm-pi-ai/tsconfig.json | 6 + pnpm-lock.yaml | 6 + 12 files changed, 470 insertions(+), 201 deletions(-) create mode 100644 packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts create mode 100644 packages/llm/llm-pi-ai/tests/mock-server.ts diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index e2b639624b..43b97a14f0 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -27,8 +27,10 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -37,9 +39,11 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 0cc6dda739..fd40c79c73 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -30,15 +30,20 @@ import type { StreamChunk, } from '@deepseek-ai/dsh-llm' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { resolveProfiles } from './config.ts' -import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' +import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' -/** Constructor options for {@link PiAiAdapter}. */ +/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */ export interface PiAiAdapterOptions { - /** Validated provider profiles this adapter instance owns. */ - profiles: readonly PiAiProviderProfile[] + /** Current validated profiles by provider route; called once per operation. */ + profiles: () => ReadonlyMap + /** + * Resolve the credential for one already-resolved profile; called once per + * stream call and frozen for that call. `undefined` defers to pi-ai's + * provider-native ambient discovery. + */ + resolveApiKey: (profile: ResolvedPiAiProviderProfile) => Promise } /** @@ -46,7 +51,7 @@ export interface PiAiAdapterOptions { * override, preserving the catalog's API/capability/compatibility metadata. */ function resolvePiModel( - profile: Omit, + profile: ResolvedPiAiProviderProfile, modelId: string, ): Model { const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model | undefined @@ -58,12 +63,13 @@ function resolvePiModel( /** Copy profile stream knobs into pi-ai's common option vocabulary. */ function profileOptions( - profile: Omit, + profile: ResolvedPiAiProviderProfile, reasoning: ModelThinkingLevel | undefined, + apiKey: string | undefined, ): SimpleStreamOptions { const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning return { - ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, + ...apiKey === undefined ? {} : { apiKey }, ...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning }, ...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets }, ...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention }, @@ -104,19 +110,16 @@ function requestHeaders(headers: Readonly> | undefined): * request, so models need not be registered during the Cordis lifecycle. */ export class PiAiAdapter extends LlmAdapter { - private readonly profiles: ReadonlyMap - - constructor(options: PiAiAdapterOptions) { + constructor(private readonly config: PiAiAdapterOptions) { super() - this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) } override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { - return this.profiles.get(provider)?.retryPolicy + return this.config.profiles().get(provider)?.retryPolicy } override listModels(provider: string): Promise { - const profile = this.profiles.get(provider) + const profile = this.config.profiles().get(provider) if (profile === undefined) { return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')) } @@ -132,7 +135,7 @@ export class PiAiAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const profile = this.profiles.get(provider) + const profile = this.config.profiles().get(provider) if (profile === undefined) { return Promise.reject(new LlmError( `pi-ai adapter does not own provider "${provider}"`, @@ -165,7 +168,10 @@ export class PiAiAdapter extends LlmAdapter { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') } - const profile = this.profiles.get(options.provider) + // One resolution per stream call: the profile snapshot and the credential + // freeze here and hold for this whole request, so an in-flight stream + // never observes a configuration change and the next call re-resolves. + const profile = this.config.profiles().get(options.provider) if (profile === undefined) { throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') } @@ -174,6 +180,7 @@ export class PiAiAdapter extends LlmAdapter { model, options.reasoningEffort ?? profile.reasoning, ) + const apiKey = await this.config.resolveApiKey(profile) const consumer = new AbortController() const upstream = options.signal === undefined @@ -184,7 +191,7 @@ export class PiAiAdapter extends LlmAdapter { try { const events = streamSimple(model, toPiContext(options), { - ...profileOptions(profile, reasoning), + ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 8c7da2badd..b644527097 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -1,5 +1,7 @@ /** * Configuration schema and provider-profile validation for the pi-ai adapter. + * Profiles are a dict keyed by provider route, so the composition base and a + * user-settings layer merge per provider and the route set is structural. * * @module dsh-llm-pi-ai/config */ @@ -7,6 +9,8 @@ import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' @@ -14,12 +18,12 @@ import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-ll /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 -/** Configuration for one pi-ai provider route. */ +/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** pi-ai provider catalog name and Harness route key. */ - provider: string - /** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */ + /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ + apiKeyEnv?: string /** Override the selected catalog model's endpoint without changing its protocol metadata. */ baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ @@ -42,18 +46,22 @@ export interface PiAiProviderProfile { retryPolicy?: RetryPolicyConfig } -/** Validated profile with every adapter-owned default resolved. */ -export interface ResolvedPiAiProviderProfile extends Omit { +/** Validated profile with its route stamped and every adapter-owned default resolved. */ +export interface ResolvedPiAiProviderProfile extends Omit { + /** pi-ai provider catalog name and Harness route key (the configuration dict key). */ + provider: string + /** Validated credential reference, when one is configured. */ + apiKeyEnv?: CredentialRef /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy } -/** Plugin configuration: the non-empty provider profiles this instance owns. */ +/** Plugin configuration: the non-empty provider routes this instance owns. */ export interface Config { - /** Non-empty set of pi-ai provider routes this adapter instance owns. */ - providers: PiAiProviderProfile[] + /** Non-empty dict of pi-ai provider routes, keyed by provider. */ + providers: Record } const thinkingBudgets = z.object({ @@ -64,8 +72,8 @@ const thinkingBudgets = z.object({ }) const profile = z.object({ - provider: z.string().required(), - apiKey: z.string(), + apiKey: z.string().role('secret'), + apiKeyEnv: z.string(), baseURL: z.string(), headers: z.dict(z.string()), reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), @@ -80,54 +88,61 @@ const profile = z.object({ /** Runtime schema for {@link Config}. */ export const Config: z = z.object({ - providers: z.array(profile).required(), + providers: z.dict(profile).required(), }) /** * Validate profiles against the installed pi-ai catalog and return a detached - * shallow copy suitable for adapter construction. - * @param profiles - configured provider profiles. + * route-keyed map suitable for per-request reads. + * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ -export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] { - if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') +export function resolveProfiles(providers: Readonly>): Map { + if (Array.isArray(providers)) { + throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles') + } + const entries = Object.entries(providers) + if (entries.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') const supported = new Set(getBuiltinProviders()) - const seen = new Set() - return profiles.map((source) => { + const resolved = new Map() + for (const [provider, source] of entries) { const legacy = source as PiAiProviderProfile & { + provider?: unknown maxRetries?: unknown maxRetryDelayMs?: unknown } + if ('provider' in legacy) { + throw new Error('llm-pi-ai: the profile "provider" field moved to the providers dict key') + } if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) { throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry') } - if (source.provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') - if (!supported.has(source.provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${source.provider}"`) - if (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`) + if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') + if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`) if (source.apiKey !== undefined && source.apiKey.trim().length === 0) { - throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty apiKey; omit it to use ambient authentication`) + throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`) } if (source.baseURL !== undefined && source.baseURL.length === 0) { - throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty baseURL`) + throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) } const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { throw new Error( - `llm-pi-ai: provider "${source.provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + `llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } - seen.add(source.provider) - return { - ...source, + const { apiKeyEnv, retryPolicy, ...rest } = source + resolved.set(provider, { + ...rest, + provider, + ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, streamIdleTimeoutMs, - retryPolicy: resolveRetryPolicy( - source.retryPolicy, - `llm-pi-ai: provider "${source.provider}" retryPolicy`, - ), - ...source.headers === undefined ? {} : { headers: { ...source.headers } }, - ...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } }, - } - }) + retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), + ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, + ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, + }) + } + return resolved } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index da104cb22d..4856dbe7e5 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -1,22 +1,27 @@ /** - * Generic pi-ai-backed LLM adapter plugin. One plugin instance registers an - * explicit set of provider profiles; requests select a profile by provider and - * resolve the model dynamically from pi-ai's installed catalog. + * Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of + * provider routes; requests select a profile by provider and resolve the + * model dynamically from pi-ai's installed catalog. Profile facts resolve per + * request over the optional `llm-pi-ai` user-settings section and the + * optional credential seam, so a changed key, endpoint, or knob reaches the + * next request without a restart; a changed *route set* (or a route's + * registration-captured retry policy) re-registers the same adapter instance + * in place. * * ```yaml * - id: llm * name: '@deepseek-ai/dsh-llm-pi-ai' * config: * providers: - * - provider: openai - * apiKey: !!js process.env.OPENAI_API_KEY + * openai: + * apiKeyEnv: OPENAI_API_KEY * retryPolicy: * mode: normal * maxRetries: 2 - * - provider: anthropic - * apiKey: !!js process.env.ANTHROPIC_API_KEY - * - provider: openrouter - * apiKey: !!js process.env.OPENROUTER_API_KEY + * anthropic: + * apiKeyEnv: ANTHROPIC_API_KEY + * openrouter: + * apiKeyEnv: OPENROUTER_API_KEY * baseURL: https://proxy.example.com/v1 * ``` * @@ -25,20 +30,93 @@ import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-llm' +import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { Config, resolveProfiles } from './config.ts' +import type { ResolvedPiAiProviderProfile } from './config.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' -export type { PiAiProviderProfile } from './config.ts' +export type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] +const NS = settingsNamespace('llm-pi-ai') + +/** The registry captures these per route; a change here must re-register. */ +function registrationFacts(profiles: ReadonlyMap): unknown { + return [...profiles.entries()].map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) +} + /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { - const profiles = resolveProfiles(config.providers) - const adapter = new PiAiAdapter({ profiles: config.providers }) - ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) + let current: () => Config = () => config + let lastRaw: Config | undefined + let lastGood: ReadonlyMap | undefined + const profiles = (): ReadonlyMap => { + const raw = current() + if (raw === lastRaw && lastGood !== undefined) return lastGood + try { + const next = resolveProfiles(raw.providers) + lastRaw = raw + lastGood = next + return next + } catch (error) { + // Static composition resolves before anything registers, so this branch + // only sees a live settings snapshot failing catalog or bound checks: + // keep serving the last good profiles and say so once per bad snapshot. + if (lastGood === undefined) throw error + lastRaw = raw + ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section') + ctx.logger.error(error) + return lastGood + } + } + profiles() + + const resolveApiKey = async (profile: ResolvedPiAiProviderProfile): Promise => { + if (profile.apiKey !== undefined) return profile.apiKey + const ref = profile.apiKeyEnv + if (ref === undefined) return undefined + const credentials = ctx.get('credentials') + if (credentials !== undefined) return (await credentials.resolve(ref))?.value + // Without the seam, keep an ambient fallback so a plain cordis.yml + // composition works from the environment alone; an empty variable defers + // to pi-ai's own provider-native discovery like an absent one. + const ambient = process.env[ref] + return ambient !== undefined && ambient.length > 0 ? ambient : undefined + } + + const adapter = new PiAiAdapter({ profiles, resolveApiKey }) + // Route effects bind to this apply fiber via the stable `ctx` reference, + // even when a swap runs inside the scoped settings callback below. + let disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter) + let registeredFacts = registrationFacts(profiles()) + const ensureRegistrationFacts = (): void => { + const facts = registrationFacts(profiles()) + if (deepEqualJson(facts, registeredFacts)) return + // The registry captures the route set and each route's retry policy at + // registration: swap the registration in one synchronous section (same + // adapter instance, no NO_ADAPTER window). + disposeRoutes() + disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter) + registeredFacts = facts + } + + ctx.inject(['settings'], (sctx) => { + const scope = sctx.settings.register(NS, Config, { base: config }) + current = () => scope.get() + sctx.effect(() => () => { + // Settings detached (provider disposed or reloading): fall back to the + // composition entry so the plugin keeps working exactly as configured. + current = () => config + ensureRegistrationFacts() + }) + ensureRegistrationFacts() + scope.watch(() => { + ensureRegistrationFacts() + }) + }) } diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 352f2067d8..a3a949fea2 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -23,12 +23,13 @@ async function harness(_model: string, config: Partial = {} contexts.push(ctx) await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'deepseek', - ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, - ...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL }, - ...config, - }], + providers: { + deepseek: { + ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, + ...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL }, + ...config, + }, + }, }) return ctx } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index cb70b6d3b8..02d7af5b2d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,5 +1,3 @@ -import { createServer } from 'node:http' -import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm' @@ -9,94 +7,30 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { resolveProfiles } from '../src/config.ts' import { assemble } from './assemble.ts' - -interface MockServer { - url: string - paths: string[] - requests: unknown[] - headers: IncomingMessage['headers'][] - readonly closedResponses: number - responseClosed: Promise -} - -const servers: Server[] = [] +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' afterEach(async () => { vi.unstubAllEnvs() - await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + await closeMockServers() }) -async function mockServer(script: { - status?: number - events?: string[] - body?: string - delayMs?: number - headers?: Record -}[]): Promise { - const paths: string[] = [] - const requests: unknown[] = [] - const headers: IncomingMessage['headers'][] = [] - let closedResponses = 0 - const responseClosed = Promise.withResolvers() - const server = createServer((request: IncomingMessage, response: ServerResponse) => { - response.on('close', () => { - closedResponses += 1 - responseClosed.resolve(undefined) - }) - let body = '' - request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) - request.on('end', () => { - paths.push(request.url ?? '') - requests.push(body.length === 0 ? undefined : JSON.parse(body)) - headers.push(request.headers) - const behavior = script.shift() ?? { status: 500, body: 'script exhausted' } - if (behavior.status !== undefined && behavior.status !== 200) { - response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers }) - response.end(behavior.body ?? '{}') - return - } - response.writeHead(200, { 'content-type': 'text/event-stream' }) - let index = 0 - const writeNext = (): void => { - const event = behavior.events?.[index++] - if (event === undefined) { response.end(); return } - response.write(`data: ${event}\n\n`) - if (behavior.delayMs === undefined) writeNext() - else setTimeout(writeNext, behavior.delayMs) - } - writeNext() - }) - }) - servers.push(server) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('no port') - return { - url: `http://127.0.0.1:${address.port}`, - paths, - requests, - headers, - responseClosed: responseClosed.promise, - get closedResponses() { return closedResponses }, - } -} - -const textEvents = [ - '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - '{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}', - '{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - '[DONE]', -] - async function harness(baseURL: string, overrides: Record = {}): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', apiKey: 'test-key', baseURL, ...overrides }], + providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } }, }) return ctx } +/** Direct adapter over the real profile resolver, with literal-key resolution. */ +function adapterOf(providers: Record): PiAiAdapter { + return new PiAiAdapter({ + profiles: () => resolveProfiles(providers), + resolveApiKey: profile => Promise.resolve(profile.apiKey), + }) +} + describe('PiAiAdapter provider routing', () => { it('resolves a catalog model dynamically and uses a private endpoint', async () => { const server = await mockServer([{ events: textEvents }]) @@ -182,8 +116,8 @@ describe('PiAiAdapter provider routing', () => { const server = await mockServer([{ events: textEvents }]) const ctx = new Context() await ctx.plugin(LlmService) - ctx.llm.registerAdapter(['deepseek'], new PiAiAdapter({ - profiles: [{ provider: 'deepseek', apiKey: 'test-key', baseURL: server.url }], + ctx.llm.registerAdapter(['deepseek'], adapterOf({ + deepseek: { apiKey: 'test-key', baseURL: server.url }, })) const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) @@ -212,7 +146,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(result.finish.kind).toBe('error') @@ -232,7 +166,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) @@ -246,12 +180,13 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'openai', - apiKey: 'test-key', - baseURL: `${server.url}/api/projects/openai/openai/v1`, - headers: { 'api-key': 'test-key', Authorization: '' }, - }], + providers: { + openai: { + apiKey: 'test-key', + baseURL: `${server.url}/api/projects/openai/openai/v1`, + headers: { 'api-key': 'test-key', Authorization: '' }, + }, + }, }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] }) expect(result.finish.kind).toBe('error') @@ -333,16 +268,15 @@ describe('provider profile lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmService) const fiber = await ctx.plugin(LlmPiAi, { - providers: [ - { - provider: 'openai', + providers: { + openai: { retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, }, }, - { provider: 'anthropic' }, - ], + anthropic: {}, + }, }) expect(ctx.llm.listProviders()).toEqual([ { id: 'openai', name: 'openai' }, @@ -365,7 +299,7 @@ describe('provider profile lifecycle', () => { it('exposes the installed pi-ai model catalog through provider-neutral metadata', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai' }] }) + await ctx.plugin(LlmPiAi, { providers: { openai: {} } }) const models = await ctx.llm.listModels('openai') expect(models.find(model => model.id === 'gpt-4.1')).toEqual({ provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', @@ -379,7 +313,7 @@ describe('provider profile lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek' }, { provider: 'openai' }], + providers: { deepseek: {}, openai: {} }, }) await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) @@ -413,7 +347,7 @@ describe('provider profile lifecycle', () => { const supported = new Context() await supported.plugin(LlmService) await supported.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'max' }], + providers: { deepseek: { reasoning: 'max' } }, }) await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } }) @@ -421,7 +355,7 @@ describe('provider profile lifecycle', () => { const unsupported = new Context() await unsupported.plugin(LlmService) await unsupported.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'medium' }], + providers: { deepseek: { reasoning: 'medium' } }, }) await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) @@ -429,7 +363,7 @@ describe('provider profile lifecycle', () => { const disabled = new Context() await disabled.plugin(LlmService) await disabled.plugin(LlmPiAi, { - providers: [{ provider: 'deepseek', reasoning: 'off' }], + providers: { deepseek: { reasoning: 'off' } }, }) await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } }) @@ -443,24 +377,45 @@ describe('provider profile lifecycle', () => { expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') }) - it('validates empty, duplicate, unknown, and explicitly blank profiles', () => { - expect(() => resolveProfiles([])).toThrow(/at least one/) - expect(() => resolveProfiles([{ provider: '' }])).toThrow(/non-empty/) - expect(() => resolveProfiles([{ provider: 'not-real' }])).toThrow(/unknown/) - expect(() => resolveProfiles([{ provider: 'openai' }, { provider: 'openai' }])).toThrow(/duplicate/) - expect(() => resolveProfiles([{ provider: 'openai', apiKey: '' }])).toThrow(/empty apiKey/) - expect(() => resolveProfiles([{ provider: 'openai', apiKey: ' ' }])).toThrow(/empty apiKey/) - expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/) + it('falls back to the ambient environment for apiKeyEnv without the credentials seam', async () => { + vi.stubEnv('PI_CUSTOM_REF_KEY', 'custom-ref-key') + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer custom-ref-key') + }) + + it('treats an empty apiKeyEnv variable as absent and defers to SDK ambient discovery', async () => { + vi.stubEnv('PI_CUSTOM_REF_KEY', '') + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') + }) + + it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => { + expect(() => resolveProfiles({})).toThrow(/at least one/) + expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/) + expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/) + // The pre-release array shape and its per-profile provider field fail + // loud with migration directions instead of half-working. + expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/) + expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/) + expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/) + expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/) + expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/) + expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/) }) it.each(['maxRetries', 'maxRetryDelayMs'] as const)( 'rejects removed profile field %s instead of silently restoring hidden SDK retries', async (field) => { - const legacy = { provider: 'openai', [field]: 2 } - expect(() => resolveProfiles([legacy as never])).toThrow(/removed.*agent recovery/i) + const legacy = { [field]: 2 } + expect(() => resolveProfiles({ openai: legacy })).toThrow(/removed.*agent recovery/i) const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmPiAi, { providers: [legacy as never] })) + await expect(ctx.plugin(LlmPiAi, { providers: { openai: legacy } })) .rejects.toThrow(/removed.*agent recovery/i) }, ) @@ -476,30 +431,26 @@ describe('provider profile lifecycle', () => { for (const entry of invalid) { const ctx = new Context() await ctx.plugin(LlmService) - await expect(ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai', ...entry }] })) + await expect(ctx.plugin(LlmPiAi, { providers: { openai: { ...entry } } })) .rejects.toThrow() } }) it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => { - expect(() => resolveProfiles([{ - provider: 'openai', - retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } }, - }])).toThrow(/retryPolicy\.backoff\.jitterRatio/) + expect(() => resolveProfiles({ + openai: { retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } } }, + })).toThrow(/retryPolicy\.backoff\.jitterRatio/) const ctx = new Context() await ctx.plugin(LlmService) await expect(ctx.plugin(LlmPiAi, { - providers: [{ - provider: 'openai', - retryPolicy: { mode: 'normal', maxRetries: -1 }, - }], + providers: { openai: { retryPolicy: { mode: 'normal', maxRetries: -1 } } }, })).rejects.toThrow(/retryPolicy/) expect(ctx.llm.listProviders()).toEqual([]) }) it('constructs the adapter directly and rejects routes it does not own', async () => { - const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) + const adapter = adapterOf({ openai: {} }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) await expect(adapter.resolveModel('anthropic', 'claude-sonnet-4')) .rejects.toMatchObject({ code: 'NO_ADAPTER' }) @@ -511,12 +462,12 @@ describe('provider profile lifecycle', () => { expect(new LlmError('x', 'X')).toBeInstanceOf(Error) }) - it('validates direct-constructor profiles at the embedding boundary', () => { - expect(() => new PiAiAdapter({ - profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }], + it('validates profiles at the shared resolver boundary', () => { + expect(() => resolveProfiles({ + openai: { streamIdleTimeoutMs: 0 }, })).toThrow(/streamIdleTimeoutMs.*positive finite/) - expect(() => new PiAiAdapter({ - profiles: [{ provider: 'openai', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }], + expect(() => resolveProfiles({ + openai: { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, })).toThrow(/streamIdleTimeoutMs.*no greater/) }) }) @@ -527,7 +478,7 @@ describe('abort wiring', () => { const message = Object.defineProperty({}, 'role', { get() { throw original }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -548,7 +499,7 @@ describe('abort wiring', () => { throw original }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', @@ -562,7 +513,7 @@ describe('abort wiring', () => { }) it('resolves catalog endpoints without an override before honoring pre-abort', async () => { - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } }) const controller = new AbortController() controller.abort('already stopped') const chunks = [] diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts new file mode 100644 index 0000000000..5b7c9e5e3a --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import LlmService from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SettingsLocal } from '@deepseek-ai/dsh-settings-local' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +const NS = settingsNamespace('llm-pi-ai') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() + await closeMockServers() + vi.unstubAllEnvs() +}) + +async function home(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-dynamic-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +/** Real dynamic composition mirroring the deepseek twin's harness. */ +async function boot(dir: string, config: LlmPiAi.Config): Promise { + const ctx = new Context() + cleanups.push(async () => { + await ctx.fiber.dispose() + }) + await ctx.plugin(LlmService) + await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(LlmPiAi, config) + return ctx +} + +describe('request-level dynamic profiles', () => { + it('adds a provider route from settings and drops it when the user layer resets', async () => { + const dir = await home() + const server = await mockServer([{ events: textEvents }]) + const ctx = await boot(dir, { + providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } }, + }) + + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + await ctx.settings.update(NS, { + providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } }, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek']) + + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(server.headers[0]?.authorization).toBe('Bearer live-key') + + // Reset the user layer: the settings-born route unregisters, the + // composition route stays. + await ctx.settings.replace(NS, {}) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'NO_ADAPTER' }) + }) + + it('rotates the per-request credential referenced by apiKeyEnv', async () => { + vi.stubEnv('PI_DYNAMIC_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n') + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = await boot(dir, { + providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, + }) + + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer pk-one') + + await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two') + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[1]?.authorization).toBe('Bearer pk-two') + }) + + it('re-registers routes in place when a captured retry policy changes', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: {} } }) + + await ctx.settings.update(NS, { + providers: { + openai: { + retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, + }, + }, + }) + expect(ctx.llm.providerRetryPolicy('openai')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + }) + + it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: {} } }) + + // Schema-valid but catalog-invalid: the resolver rejects it and the + // last good route set keeps serving. + await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/mock-server.ts b/packages/llm/llm-pi-ai/tests/mock-server.ts new file mode 100644 index 0000000000..573c61a9a2 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/mock-server.ts @@ -0,0 +1,82 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' + +export interface MockServer { + url: string + paths: string[] + requests: unknown[] + headers: IncomingMessage['headers'][] + readonly closedResponses: number + responseClosed: Promise +} + +const servers: Server[] = [] + +/** Close every server opened since the last call; run from each spec's afterEach. */ +export async function closeMockServers(): Promise { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) +} + +/** A minimal complete text generation in pi-ai's chat-completions shape. */ +export const textEvents = [ + '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + '{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}', + '{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + '[DONE]', +] + +/** Local provider stand-in: replays scripted behaviors per request. */ +export async function mockServer(script: { + status?: number + events?: string[] + body?: string + delayMs?: number + headers?: Record +}[]): Promise { + const paths: string[] = [] + const requests: unknown[] = [] + const headers: IncomingMessage['headers'][] = [] + let closedResponses = 0 + const responseClosed = Promise.withResolvers() + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + response.on('close', () => { + closedResponses += 1 + responseClosed.resolve(undefined) + }) + let body = '' + request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + request.on('end', () => { + paths.push(request.url ?? '') + requests.push(body.length === 0 ? undefined : JSON.parse(body)) + headers.push(request.headers) + const behavior = script.shift() ?? { status: 500, body: 'script exhausted' } + if (behavior.status !== undefined && behavior.status !== 200) { + response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers }) + response.end(behavior.body ?? '{}') + return + } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + let index = 0 + const writeNext = (): void => { + const event = behavior.events?.[index++] + if (event === undefined) { response.end(); return } + response.write(`data: ${event}\n\n`) + if (behavior.delayMs === undefined) writeNext() + else setTimeout(writeNext, behavior.delayMs) + } + writeNext() + }) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { + url: `http://127.0.0.1:${address.port}`, + paths, + requests, + headers, + responseClosed: responseClosed.promise, + get closedResponses() { return closedResponses }, + } +} diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 0d08e9b93d..f59859bfad 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -43,12 +43,11 @@ async function harness(): Promise { contexts.push(ctx) await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: providerCases.map(profile => ({ - provider: profile.provider, + providers: Object.fromEntries(providerCases.map(profile => [profile.provider, { ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, ...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL }, ...profile.headers === undefined ? {} : { headers: profile.headers }, - })), + }])), }) return ctx } diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index d96297c242..3f12ef4460 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -10,6 +10,7 @@ vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => { }) import { PiAiAdapter } from '../src/adapter.ts' +import { resolveProfiles } from '../src/config.ts' afterEach(() => { streamSimple.mockReset() }) @@ -21,7 +22,10 @@ describe('pi-ai SDK retry boundary', () => { throw failure }, }) - const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai', apiKey: 'test-key' }] }) + const adapter = new PiAiAdapter({ + profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }), + resolveApiKey: () => Promise.resolve('test-key'), + }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'openai', diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 45c2af21a5..ee8a81e73b 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -20,6 +20,12 @@ { "path": "../../llm/llm" }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7f3ae3f7d..1f28171d9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2977,6 +2977,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2986,6 +2989,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../llm-deepseek + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout From d77db29f01e798dcef9dcab7edf1fff95bfddbdb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 13:44:24 +0800 Subject: [PATCH 06/37] test: real-Loader dynamic composition, keyless onboarding snapshot, and .env-only e2e llm-deepseek gains a Loader+Include composition spec proving external settings.yaml/.env edits reach the very next request, and a real-API e2e where only a credentials-local document holds the key. The headless example pins the first-run missing-credential UX as a keyless stream-json snapshot (new credentials.cordis.snapshot.yml scenario); runLoaderSmoke learns expectedExitCode so a designed failure surface can be pinned instead of masked. --- .../credentials.cordis.snapshot.yml | 27 ++++ .../headless-agent/tests/headless.snapshot.ts | 33 ++++ .../stream-json.expected.jsonl | 8 + examples/package.json | 8 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 34 ++++- .../tests/loader-composition.spec.ts | 142 ++++++++++++++++++ packages/support/loader-smoke/src/index.ts | 12 +- .../loader-smoke/tests/loader-smoke.spec.ts | 27 +++- pnpm-lock.yaml | 6 + 9 files changed, 290 insertions(+), 7 deletions(-) create mode 100644 examples/headless-agent/credentials.cordis.snapshot.yml create mode 100644 examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl create mode 100644 packages/llm/llm-deepseek/tests/loader-composition.spec.ts diff --git a/examples/headless-agent/credentials.cordis.snapshot.yml b/examples/headless-agent/credentials.cordis.snapshot.yml new file mode 100644 index 0000000000..7e85b90df7 --- /dev/null +++ b/examples/headless-agent/credentials.cordis.snapshot.yml @@ -0,0 +1,27 @@ +# Keyless dynamic-configuration composition: the settings and credentials +# providers live under the run cwd, no API key exists anywhere, and the +# deepseek route still registers — so the prompt fails with the actionable +# MISSING_CREDENTIAL guidance this snapshot pins as first-run UX. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: settings + name: '@deepseek-ai/dsh-settings-local' + config: + dshHome: ./.dsh + debounceMs: 10 + - id: credentials + name: '@deepseek-ai/dsh-credentials-local' + config: + dshHome: ./.dsh + # The endpoint is never dialed: credential resolution fails first. + - id: llm-deepseek-keyless + name: '@deepseek-ai/dsh-llm-deepseek' + config: + baseURL: 'http://127.0.0.1:9' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 2581d8a042..fba7bf7338 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -27,6 +27,8 @@ const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) const retryScenarioDir = join(snapshotsDir, 'provider-retry') const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) +const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') +const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) @@ -168,6 +170,37 @@ describe('headless stream-json snapshots', () => { expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('surfaces actionable missing-credential guidance through the one-shot app', async () => { + const streamExpected = join(credentialsScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'missing-credential headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-missing-credential-', + binScript, + configPath: credentialsConfigPath, + binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'], + tsconfigPath, + env: { + // First-run posture: no key in the environment, none under ./.dsh. + DEEPSEEK_API_KEY: '', + DEEPSEEK_BASE_URL: '', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + // The designed failure surface: the one-shot app reports the failed turn. + expectedExitCode: 1, + prepare: (cwd) => { runCwd = cwd }, + }) + + expect(result.stderr).toBe( + 'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek";' + + ' set the llm-deepseek "apiKey" setting, store DEEPSEEK_API_KEY with the credentials service,' + + ' or export DEEPSEEK_API_KEY\n', + ) + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs the model default and a dynamic next-step reasoning effort', async () => { const result = await runLoaderSmoke({ label: 'reasoning effort headless stream-json snapshot', diff --git a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl new file mode 100644 index 0000000000..d7d72f6a86 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -0,0 +1,8 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"say pong","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}}}} +{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}} diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..35d9a74e3e 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", @@ -16,6 +16,7 @@ "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", + "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", @@ -32,7 +33,6 @@ "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", - "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", @@ -44,13 +44,15 @@ "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", + "@deepseek-ai/dsh-settings-local": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", - "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", + "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index f6d97031c4..e59af1185d 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,7 +1,11 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' import { assemble, type AssembledResult } from './assemble.ts' @@ -53,6 +57,34 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { + it('serves a real request with the key held only by a credentials-local document', async () => { + const key = process.env.DEEPSEEK_API_KEY + if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-')) + try { + await writeFile(join(dir, '.env'), `DEEPSEEK_API_KEY=${key}\n`, { mode: 0o600 }) + // Scrub the ambient variable so only the credential seam can supply the + // key: this request proves the per-request resolution path end to end. + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmService) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(LlmDeepSeek, {}) + + const result = await assemble(ctx, { + model: FLASH, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 50, + }) + expect(result.finish.kind).toBe('stop') + expect(textOf(result).toLowerCase()).toContain('pong') + } finally { + vi.unstubAllEnvs() + await rm(dir, { recursive: true, force: true }) + } + }) + it('flash dynamically switches from off to high', async () => { const ctx = await harness(FLASH, { reasoningEffort: 'off' }) const withoutThinking = await assemble(ctx,{ diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..9ca8c87367 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -0,0 +1,142 @@ +/** + * Real-composition guard for the dynamic-configuration chain: LlmService, + * settings-local, credentials-local, and llm-deepseek boot from a test-only + * cordis.yml through the actual Loader + Include path, external edits of + * settings.yaml and .env hot-publish through their providers, and the very + * next request carries the fresh base URL and credential. The same adapter + * composition without settings or credentials entries keeps entry-config + * behavior — the documented optional-inject fallback. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import SettingsLocal from '@deepseek-ai/dsh-settings-local' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +const NS = settingsNamespace('llm-deepseek') +const KEY_REF = credentialRef('DEEPSEEK_API_KEY') + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined + await closeMockServers() + vi.unstubAllEnvs() +}) + +async function loadComposition( + options: { withDynamic: boolean; baseURL: string }, +): Promise<{ ctx: Context; settingsPath: string; envPath: string }> { + root = await mkdtemp(join(tmpdir(), 'dsh-llm-composition-')) + const settingsPath = join(root, 'settings.yaml') + const envPath = join(root, '.env') + if (options.withDynamic) { + await writeFile(settingsPath, '# personal settings\n') + await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n') + } + + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + '- id: llm', + " name: 'test-llm-service'", + ...options.withDynamic + ? [ + '- id: settings', + " name: '@deepseek-ai/dsh-settings-local'", + ' config:', + ` path: ${JSON.stringify(settingsPath)}`, + ' debounceMs: 10', + '- id: credentials', + " name: '@deepseek-ai/dsh-credentials-local'", + ' config:', + ` path: ${JSON.stringify(envPath)}`, + ' debounceMs: 10', + ] + : [], + '- id: llm-deepseek', + " name: '@deepseek-ai/dsh-llm-deepseek'", + ' config:', + ` baseURL: ${JSON.stringify(options.baseURL)}`, + ...options.withDynamic ? [] : [' apiKey: entry-key'], + '', + ].join('\n')) + + const ctx = new Context() + context = ctx + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['test-llm-service', LlmService], + ['@deepseek-ai/dsh-settings-local', SettingsLocal], + ['@deepseek-ai/dsh-credentials-local', CredentialsLocal], + ['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + return { ctx, settingsPath, envPath } +} + +describe('llm-deepseek real dynamic composition', () => { + it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) + const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx, settingsPath, envPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url }) + + expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS]) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key') + + // External edits, exactly as a user or the web UI would leave them on disk. + await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`) + await vi.waitFor(() => { + expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) + }, { timeout: 5000 }) + await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n') + await vi.waitFor(async () => { + expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' }) + }, { timeout: 5000 }) + + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(serverA.requests).toHaveLength(1) + expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key') + }) + + it('boots the same adapter without settings or credentials entries on entry config alone', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url }) + + expect(ctx.get('settings')).toBeUndefined() + expect(ctx.get('credentials')).toBeUndefined() + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer entry-key') + }) +}) diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index e573684a4e..4ee5cfec7d 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -141,6 +141,13 @@ export interface LoaderSmokeOptions { readonly prepare?: (cwd: string) => Promise | void /** Optional world-state assertion run in the isolated cwd before cleanup. */ readonly inspect?: (cwd: string) => Promise | void + /** + * Exact process exit code this smoke expects; defaults to `0`. Scenarios + * pinning a designed failure surface (a one-shot turn ending in an error + * result) declare its nonzero exit here, and a run that exits any other + * way — including succeeding — still fails the smoke. + */ + readonly expectedExitCode?: number } /** Captured output from a Loader smoke that exited successfully. */ @@ -187,8 +194,9 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { libBinScript: fixture('fail'), configPath, tsconfigPath, - })).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed') + })).rejects.toThrow('failure fixture exited 7 (expected 0). stdout:\n\nstderr:\nfixture failed') + }) + + it('accepts a declared expected failure exit and rejects any other outcome', async () => { + // A scenario pinning a designed failure surface declares its exit code… + const declared = await runLoaderSmoke({ + label: 'declared failure fixture', + tempDirPrefix: 'loader-smoke-declared-fail-', + binScript: fixture('fail'), + libBinScript: fixture('fail'), + configPath, + tsconfigPath, + expectedExitCode: 7, + }) + expect(declared.stderr).toBe('fixture failed\n') + + // …and a run that succeeds instead still fails the smoke. + await expect(runLoaderSmoke({ + label: 'unexpectedly clean fixture', + tempDirPrefix: 'loader-smoke-clean-', + binScript: fixture('success'), + libBinScript: fixture('success'), + configPath, + tsconfigPath, + expectedExitCode: 7, + })).rejects.toThrow(/exited 0 \(expected 7\)/) }) it('kills a process at its deadline and reports captured output', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f28171d9e..de87482bb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,6 +448,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:* version: link:../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:* + version: link:../packages/credentials/credentials-local '@deepseek-ai/dsh-fs-local': specifier: workspace:* version: link:../packages/fs/fs-local @@ -529,6 +532,9 @@ importers: '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm + '@deepseek-ai/dsh-settings-local': + specifier: workspace:* + version: link:../packages/settings/settings-local '@deepseek-ai/dsh-spill-local': specifier: workspace:* version: link:../packages/spill/spill-local From b0a2011d95277d4cbacfe2a05082a4f51c5db9f0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:20:06 +0800 Subject: [PATCH 07/37] docs: bilingual credentials/settings-consumer documentation, catalogs, and gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New credentials data-structure page (type-equiv manifested), group README, rewritten llm-deepseek/llm-pi-ai READMEs (dynamic configuration, dict profiles, credential chain), capability-seams/service-role registration, Agent Note (bilingual), demo compositions mounting settings-local + credentials-local with no inline key plumbing, installSettingsSection consumer helper on the settings seam (deduplicating both adapters' wiring), jscpd symmetry markers for the provider twins, runtime-closure additions for python/sdk-runtime, and doc-budget ceilings AGENTS.md 1750→1755 / packages/README.md 850→865 for the structural one-line group rows. --- ...est-level-llm-config-credentials.i18n.yaml | 6 +++ ...29-request-level-llm-config-credentials.md | 29 ++++++++++ ...request-level-llm-config-credentials.zh.md | 29 ++++++++++ AGENTS.md | 1 + docs/capability-seams.md | 12 ++++- docs/config-catalog.md | 53 +++++++++++++------ docs/cordis-catalog/events.md | 21 ++++++++ docs/cordis-catalog/services.md | 46 ++++++++++++++++ docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 1 + docs/core-data-structures/core.zh.md | 1 + .../credentials.i18n.yaml | 6 +++ docs/core-data-structures/credentials.md | 50 +++++++++++++++++ docs/core-data-structures/credentials.zh.md | 50 +++++++++++++++++ docs/event-producer-consumer.md | 1 + examples/headless-agent/composition.md | 6 +++ examples/headless-agent/cordis.yml | 25 ++++++--- .../credentials.cordis.snapshot.yml | 15 ++---- examples/package.json | 2 +- examples/tui-agent/composition.md | 6 +++ examples/tui-agent/cordis.yml | 14 ++++- packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 41 ++++++++++++++ packages/credentials/README.i18n.yaml | 6 +++ packages/credentials/README.md | 14 +++++ packages/credentials/README.zh.md | 14 +++++ .../credentials-local/README.i18n.yaml | 6 +++ .../credentials/credentials-local/README.md | 2 +- .../credentials-local/README.zh.md | 16 +++--- .../credentials-local/src/index.ts | 8 +++ .../credentials/credentials/README.i18n.yaml | 6 +++ packages/credentials/credentials/README.md | 5 +- packages/credentials/credentials/README.zh.md | 29 +++++----- .../credentials/credentials/tests/memory.ts | 2 - packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 18 +++++-- packages/llm/llm-deepseek/README.zh.md | 18 +++++-- packages/llm/llm-deepseek/src/index.ts | 20 +++---- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 30 +++++++---- packages/llm/llm-pi-ai/README.zh.md | 30 +++++++---- packages/llm/llm-pi-ai/src/index.ts | 20 +++---- packages/settings/settings/src/index.ts | 50 +++++++++++++++++ .../settings/settings/tests/settings.spec.ts | 45 +++++++++++++++- packages/util/README.i18n.yaml | 6 +-- packages/util/README.md | 1 + packages/util/README.zh.md | 1 + packages/util/atomic-write/README.i18n.yaml | 6 +++ packages/util/atomic-write/README.md | 6 +++ packages/util/atomic-write/README.zh.md | 18 ++++--- pnpm-lock.yaml | 9 ++++ python/sdk-runtime/package.json | 25 +++++---- scripts/doc-budgets.manifest.json | 4 +- scripts/gen-cordis-catalog.ts | 3 ++ scripts/gen-doc-graphs.ts | 13 ++++- scripts/project-doc-site.spec.ts | 2 +- scripts/type-equiv.manifest.json | 15 ++++++ .../verify-package-readme-model-experience.ts | 3 ++ website/docs.ts | 1 + 61 files changed, 732 insertions(+), 153 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md create mode 100644 .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md create mode 100644 docs/core-data-structures/credentials.i18n.yaml create mode 100644 docs/core-data-structures/credentials.md create mode 100644 docs/core-data-structures/credentials.zh.md create mode 100644 packages/credentials/README.i18n.yaml create mode 100644 packages/credentials/README.md create mode 100644 packages/credentials/README.zh.md create mode 100644 packages/credentials/credentials-local/README.i18n.yaml create mode 100644 packages/credentials/credentials/README.i18n.yaml create mode 100644 packages/util/atomic-write/README.i18n.yaml diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml new file mode 100644 index 0000000000..f54a4bdbe6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-07-29-request-level-llm-config-credentials.md +2026-07-29-request-level-llm-config-credentials.md: 13fefff9fe2646a9ef8e7200bd908d8764e006ea +2026-07-29-request-level-llm-config-credentials.zh.md: 5e29946ade3d8b2b8ae51075c29c14867fe29f71 diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md new file mode 100644 index 0000000000..13fefff9fe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -0,0 +1,29 @@ +# Agent Note: request-level LLM configuration and the credential seam + +Status: implemented + +English | [中文](2026-07-29-request-level-llm-config-credentials.zh.md) + +> Scope: the first production consumers of `ctx.settings` (the two LLM adapter plugins), the new `packages/credentials/` capability family, and the `packages/util/atomic-write` extraction. The follow-up wire surface (`settings.*`/`credentials.*` RPC, secret-role masking, the web settings form) is a separate PR and not part of this note's shipped scope. + +## Problem + +The [settings seam](2026-07-28-user-settings-seam.md) shipped without a production consumer, and the LLM adapters were the motivating one: both froze `apiKey`/`baseURL`/catalog into adapter instances at plugin load, so a changed key or endpoint needed a process restart, and a missing key failed plugin load — the worst possible first-run posture for a personal config page ("store a key, then restart"). Secrets were also headed the wrong way: the natural move (put `apiKey` in the settings document) would have forced masking, server-side backfill on `replace`, and dotfiles-sync warnings, a mitigation stack for a problem peer products simply do not have — Codex (`env_key` + auth.json), Reasonix (`api_key_env` + home `.env`), OpenCode/Pi (`auth.json`), Claude Code (`apiKeyHelper`) all keep secrets out of configuration files. + +## Decision + +**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes. + +**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. + +**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions. + +## Alternatives considered + +- **A bridge plugin (`dsh-llm-models`) owning one unified `models` dict** — with per-plugin namespaces there is nothing left to bridge, and the adapter-mapping rules it needed were pure invented indirection. +- **Secrets in settings.yaml under `role('secret')` masking** — deleting the problem (references) beats mitigating it (mask + backfill + sync warnings); the coding-agent cohort is unanimous. +- **Registry-level live retry policy** — making `providerRetryPolicy` re-read per call would silently change the `ctx.llm` capture contract every registration relies on; re-registering the route in place keeps that contract and stays observable. + +## Consequences + +Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md new file mode 100644 index 0000000000..5e29946ade --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -0,0 +1,29 @@ +# Agent Note:请求级 LLM 配置与凭据 seam + +Status: implemented + +[English](2026-07-29-request-level-llm-config-credentials.md) | 中文 + +> 范围:`ctx.settings` 的第一批生产消费方(两个 LLM 适配器插件)、新增的 `packages/credentials/` 能力族,以及 `packages/util/atomic-write` 的抽取。后续的 wire 面(`settings.*`/`credentials.*` RPC、secret 角色脱敏、web 设置表单)是单独的 PR,不在本 note 已交付范围内。 + +## 问题 + +[settings seam](2026-07-28-user-settings-seam.md) 落地时没有生产消费方,而 LLM 适配器正是当初驱动该 seam 的那个消费方:两个适配器都在插件加载时把 `apiKey`/`baseURL`/catalog 冻结进适配器实例,改密钥或端点就要重启进程,密钥缺失则直接使插件加载失败——对个人配置页而言,这是最糟糕的首次运行姿态(「先存密钥,再重启」)。机密的走向也不对:顺理成章的做法(把 `apiKey` 放进设置文档)会被迫引入脱敏、`replace` 时的服务端回填与 dotfiles 同步告警,为一个同类产品根本没有的问题堆起一整摞缓解措施——Codex(`env_key` + auth.json)、Reasonix(`api_key_env` + 家目录 `.env`)、OpenCode/Pi(`auth.json`)、Claude Code(`apiKeyHelper`)全都把机密挡在配置文件之外。 + +## 决策 + +**按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。 + +**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 + +**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引。 + +## 曾考虑的替代方案 + +- **由桥接插件(`dsh-llm-models`)持有统一的 `models` 字典**——有了按插件划分的 namespace,就没有什么可桥接的了;它所需的适配器映射规则纯属凭空发明的间接层。 +- **把机密放进 settings.yaml 并靠 `role('secret')` 脱敏**——删除问题本身(引用)胜过缓解问题(脱敏 + 回填 + 同步告警);编码 agent 同类产品在这一点上口径一致。 +- **注册表级的实时重试策略**——让 `providerRetryPolicy` 每次调用都重读,会静默改变所有注册都依赖的 `ctx.llm` 捕获契约;原地重新注册路由既保住该契约,又保持可观察。 + +## 后果 + +上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏;settings 层的数组仍整体替换(deepseek 的 `models` 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。 diff --git a/AGENTS.md b/AGENTS.md index 4fb65caa11..959013e110 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// hooks/ Claude Code/Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends settings/ user-settings seam + file-backed provider + credentials/ credential-reference seam + env-over-.env provider acp/ automation-only Agent Client Protocol server ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6d324f222a..8dbd7b964f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -38,6 +38,9 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_local["settings-local"] + pkg_credentials["credentials"] + svc_credentials["ctx.credentials
Credential seam"] + pkg_credentials_local["credentials-local"] pkg_session_telemetry["session-telemetry"] svc_telemetry["ctx.telemetry
Session telemetry seam"] pkg_session_telemetry_otel["session-telemetry-otel"] @@ -167,6 +170,8 @@ flowchart LR pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_compact_tool_result_prune --> svc_toolResultPrune + pkg_credentials --> svc_credentials + pkg_credentials_local --> svc_credentials pkg_fs --> svc_fs pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs @@ -247,6 +252,8 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_commands --> pkg_tui svc_compact --> pkg_compact_basic + svc_credentials --> pkg_llm_deepseek + svc_credentials --> pkg_llm_pi_ai svc_fs --> pkg_tool_fs svc_httpServer --> pkg_connection svc_httpServer --> pkg_hmr @@ -284,6 +291,8 @@ flowchart LR svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_inprocess + svc_settings --> pkg_llm_deepseek + svc_settings --> pkg_llm_pi_ai svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain @@ -332,7 +341,8 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | - | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet. | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section. | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3dfe2c95f6..d3844268ac 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -379,6 +379,24 @@ export interface ToolResultPruneConfig { Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../packages/compact/compact-tool-result-prune/src/types.ts) +## `@deepseek-ai/dsh-credentials-local` + +```ts config-catalog +/** Plugin config: file location and hot-reload behavior. */ +export interface Config { + /** Credentials document path; defaults to `.env` under the harness home. */ + path?: string + /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Watch the document and hot-publish external edits; defaults to true. */ + watch?: boolean + /** Watcher write-settle window in milliseconds; defaults to 100. */ + debounceMs?: number +} +``` + +Source: [`packages/credentials/credentials-local/src/index.ts:24`](../packages/credentials/credentials-local/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` ```ts config-catalog @@ -562,15 +580,18 @@ Requires: `llm` ```ts config-catalog /** - * Plugin config, validated by the same-named schemastery schema. Every field - * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), omitted thinking - * mode uses the provider default, and omitted reasoning effort resolves to - * `high`. + * Plugin config, validated by the same-named schemastery schema and doubling + * as the `llm-deepseek` settings-section shape. Every field is optional in + * yml: a missing API key resolves through {@link Config.apiKeyEnv} at each + * request (a request without any key fails with `MISSING_CREDENTIAL`, not at + * plugin load), omitted thinking mode uses the provider default, and omitted + * reasoning effort resolves to `high`. */ export interface Config { - /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ + /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ + apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ @@ -602,25 +623,25 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:49`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` Requires: `llm` ```ts config-catalog -/** Plugin configuration: the non-empty provider profiles this instance owns. */ +/** Plugin configuration: the non-empty provider routes this instance owns. */ export interface Config { - /** Non-empty set of pi-ai provider routes this adapter instance owns. */ - providers: PiAiProviderProfile[] + /** Non-empty dict of pi-ai provider routes, keyed by provider. */ + providers: Record } -/** Configuration for one pi-ai provider route. */ +/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** pi-ai provider catalog name and Harness route key. */ - provider: string - /** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */ + /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ apiKey?: string + /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ + apiKeyEnv?: string /** Override the selected catalog model's endpoint without changing its protocol metadata. */ baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ @@ -646,7 +667,7 @@ export interface PiAiProviderProfile { Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:54`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:62`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -2232,6 +2253,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) +- `@deepseek-ai/dsh-credentials` — abstract `Credentials` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) @@ -2250,6 +2272,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts)) - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) +- `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c7587e3932..e38a806254 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -422,6 +422,27 @@ A command was registered or unregistered. This is an unfiltered registry notific Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src/index.ts) +## `credentials/*` + +### `credentials/updated` — emit + +Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. + +```ts cordis-catalog +/** + * Committed change to a provider-managed credential source: a `set`, an + * `unset`, or an external edit observed in storage. Ambient + * process-environment changes are not observable and never emit. + * @param ref - the reference whose stored value changed. + * @mode emit + */ +'credentials/updated'(ref: CredentialRef): void +``` + +Types: [CredentialRef](../core-data-structures/credentials.md) + +Source: [`packages/credentials/credentials/src/index.ts:62`](../../packages/credentials/credentials/src/index.ts) + ## `domain/*` ### `domain/changed` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 35459c471b..d4b9c54c4b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -488,6 +488,52 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts) +## `ctx.credentials` — `Credentials` (abstract seam) + +Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. + +```ts cordis-catalog +/** + * Resolve one reference to its current value. Resolution is per call: + * consumers re-resolve at each operation and must not cache across + * operations — that per-operation read is what makes a changed credential + * reach the next operation without a restart. + * @param ref - the reference to resolve. + * @returns the value and its source, or `undefined` while unconfigured. + */ +abstract resolve(ref: CredentialRef): Promise + +/** + * Describe one reference for configuration surfaces without exposing the + * value. + * @param ref - the reference to describe. + * @returns configured state, supplying source, and writability. + */ +abstract describe(ref: CredentialRef): Promise + +/** + * Durably store one value in the provider-managed writable source. Rejects + * while a read-only source shadows the reference — the write would appear + * to succeed while resolution keeps returning the shadowing value — and + * rejects an empty value (use {@link unset}). + * @param ref - the reference to store. + * @param value - the non-empty secret value. + */ +abstract set(ref: CredentialRef, value: string): Promise + +/** + * Remove one reference from the provider-managed writable source; removing + * an absent reference is a no-op. Rejects while a read-only source shadows + * the reference, like {@link set}. + * @param ref - the reference to remove. + */ +abstract unset(ref: CredentialRef): Promise +``` + +Types: [CredentialInfo](../core-data-structures/credentials.md) · [CredentialRef](../core-data-structures/credentials.md) · [ResolvedCredential](../core-data-structures/credentials.md) + +Source: [`packages/credentials/credentials/src/index.ts:72`](../../packages/credentials/credentials/src/index.ts) + ## `ctx.fs` — `FileSystem` (abstract seam) Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index b583b53f2a..0663e38f9e 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: ca178edc941903f0432ed9b340db963f1a95f223 -core.zh.md: d325cfefca60f5247492b64d5ceecdb552e57efb +core.md: e2ba74e5922f55c71ebc9f08691659603ef1fa6a +core.zh.md: 3ce9212f35e9c8367f462d6ab0cac695f745c3b0 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ca178edc94..e2ba74e592 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -25,6 +25,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | +| [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | | [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d325cfefca..3ce9212f35 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -25,6 +25,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | | [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | +| [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | diff --git a/docs/core-data-structures/credentials.i18n.yaml b/docs/core-data-structures/credentials.i18n.yaml new file mode 100644 index 0000000000..23bb940afe --- /dev/null +++ b/docs/core-data-structures/credentials.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/core-data-structures/credentials.md +credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951 +credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca diff --git a/docs/core-data-structures/credentials.md b/docs/core-data-structures/credentials.md new file mode 100644 index 0000000000..3f6fcd127d --- /dev/null +++ b/docs/core-data-structures/credentials.md @@ -0,0 +1,50 @@ +# User Credentials + +English | [中文](credentials.zh.md) + +The credential seam of [dsh-credentials](../../packages/credentials/credentials) keeps secrets out of configuration: settings sections and `cordis.yml` entries carry *references* (environment-variable names), providers such as [dsh-credentials-local](../../packages/credentials/credentials-local) own the values, and consumers resolve a reference once per operation — the LLM adapters resolve once per model request, so a rotated credential reaches the very next request without any restart. One seam-wide rule binds every provider: an empty stored value is absent everywhere. + +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) + +## Identity + +A reference names one credential as a POSIX-style environment-variable name. The brand keeps references from mixing with other cross-boundary strings; construction validates the shell-identifier shape. + +```ts type-equiv +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +type CredentialRef = Branded<'CredentialRef'> +``` + +## Resolution + +`resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism. + +```ts type-equiv +/** One resolved credential value and the source layer that supplied it. */ +interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} +``` + +## Description + +`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider reports a reference supplied by the live process environment as `writable: false` — a write would appear to succeed while resolution kept returning the shadowing value, so the seam rejects it and the UI can render the reference read-only up front. + +```ts type-equiv +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} +``` + +## Change commits + +`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge. diff --git a/docs/core-data-structures/credentials.zh.md b/docs/core-data-structures/credentials.zh.md new file mode 100644 index 0000000000..b5d2d9e164 --- /dev/null +++ b/docs/core-data-structures/credentials.zh.md @@ -0,0 +1,50 @@ +# 用户凭据 + +[English](credentials.md) | 中文 + +[dsh-credentials](../../packages/credentials/credentials) 的凭据 seam 把机密挡在配置之外:settings 分节与 `cordis.yml` 条目携带的是*引用*(环境变量名),值归 [dsh-credentials-local](../../packages/credentials/credentials-local) 这类 provider 所有,消费方每个操作解析一次引用——LLM 适配器每次模型请求解析一次,因此轮换后的凭据无需任何重启即可作用于紧随其后的下一次请求。一条 seam 级规则约束每个 provider:空的存储值在任何地方都视为不存在。 + +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) + +## 标识 + +引用以 POSIX 风格环境变量名命名一条凭据。brand 使引用不与其他跨边界字符串混用;构造时校验 shell 标识符形态。 + +```ts type-equiv +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +type CredentialRef = Branded<'CredentialRef'> +``` + +## 解析 + +`resolve(ref)` 返回值,连同供出该值、由 provider 定义的来源层;未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这次按操作进行的读取正是热更新机制。 + +```ts type-equiv +/** One resolved credential value and the source layer that supplied it. */ +interface ResolvedCredential { + /** The non-empty secret value. */ + value: string + /** Provider-defined source layer id (the local provider uses `env` and `file`). */ + source: string +} +``` + +## 描述 + +`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地 provider 把由活跃进程环境供值的引用报告为 `writable: false`——那样的写入会表面成功而解析持续返回遮蔽值,因此 seam 直接拒绝,界面也得以提前把该引用渲染为只读。 + +```ts type-equiv +/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ +interface CredentialInfo { + /** Whether {@link Credentials.resolve} would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether {@link Credentials.set} would currently succeed for this reference. */ + writable: boolean +} +``` + +## 变更提交 + +`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 90f830408a..e1120093e6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,6 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | +| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:62`](../packages/credentials/credentials/src/index.ts) | [`credentials-local`](../packages/credentials/credentials-local) (`emit`) | [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 53a01260e1..38774195e1 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -8,6 +8,10 @@ The headless demo combines the real DeepSeek adapter and coding capabilities wit ```mermaid flowchart LR cfg["examples/headless-agent
cordis.yml"] + plugin_headless_settings["settings
@deepseek-ai/dsh-settings-local"] + cfg --> plugin_headless_settings + plugin_headless_credentials["credentials
@deepseek-ai/dsh-credentials-local"] + cfg --> plugin_headless_credentials plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek plugin_headless_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] @@ -55,6 +59,8 @@ flowchart LR | Plugin id | Package / module | | --- | --- | +| `settings` | `@deepseek-ai/dsh-settings-local` | +| `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 896c73469b..3fc363ea0f 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -1,16 +1,27 @@ # One-shot coding agent with format-pure stdout. The app bin loads the -# gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional -# `DEEPSEEK_BASE_URL` through `!!js`. +# gitignored root `.env` into the process environment; entry configs here are +# the composition base, while user-plane values resolve per request through +# the two providers below. + +# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a +# `llm-deepseek:` section there overrides the adapter entry below without a +# restart. +- id: settings + name: '@deepseek-ai/dsh-settings-local' + +# Credential store: the live process environment over `$DSH_HOME/.env` +# (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` +# through it at each request, so no key is inlined in this file. +- id: credentials + name: '@deepseek-ai/dsh-credentials-local' # The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed -# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort). -# Shipped default: full thinking at max effort on every request (wire-only -# defaults; they never enter the request header). +# twin (a `providers` dict keyed by route; `reasoning: high` replaces +# thinking/reasoningEffort). Shipped default: full thinking at max effort on +# every request (wire-only defaults; they never enter the request header). - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max models: diff --git a/examples/headless-agent/credentials.cordis.snapshot.yml b/examples/headless-agent/credentials.cordis.snapshot.yml index 7e85b90df7..10bc2591c8 100644 --- a/examples/headless-agent/credentials.cordis.snapshot.yml +++ b/examples/headless-agent/credentials.cordis.snapshot.yml @@ -1,6 +1,6 @@ -# Keyless dynamic-configuration composition: the settings and credentials -# providers live under the run cwd, no API key exists anywhere, and the -# deepseek route still registers — so the prompt fails with the actionable +# Keyless dynamic-configuration composition: the base settings and credentials +# providers see only the isolated run home, no API key exists anywhere, and +# the deepseek route still registers — so the prompt fails with the actionable # MISSING_CREDENTIAL guidance this snapshot pins as first-run UX. - id: base name: '@cordisjs/plugin-include' @@ -11,15 +11,6 @@ name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - insert: - - id: settings - name: '@deepseek-ai/dsh-settings-local' - config: - dshHome: ./.dsh - debounceMs: 10 - - id: credentials - name: '@deepseek-ai/dsh-credentials-local' - config: - dshHome: ./.dsh # The endpoint is never dialed: credential resolution fails first. - id: llm-deepseek-keyless name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/examples/package.json b/examples/package.json index 35d9a74e3e..48885a1c35 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index c6fc223113..69cd9704a3 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -10,6 +10,10 @@ flowchart LR cfg["examples/tui-agent
cordis.yml"] plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] cfg --> plugin_tui_hmr + plugin_tui_settings["settings
@deepseek-ai/dsh-settings-local"] + cfg --> plugin_tui_settings + plugin_tui_credentials["credentials
@deepseek-ai/dsh-credentials-local"] + cfg --> plugin_tui_credentials plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] @@ -70,6 +74,8 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | +| `settings` | `@deepseek-ai/dsh-settings-local` | +| `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 7c8b03db05..23f13a0218 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -10,13 +10,23 @@ config: root: ['.'] +# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a +# `llm-deepseek:` section there overrides the adapter entry below without a +# restart. +- id: settings + name: '@deepseek-ai/dsh-settings-local' + +# Credential store: the live process environment over `$DSH_HOME/.env` +# (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` +# through it at each request, so no key is inlined in this file. +- id: credentials + name: '@deepseek-ai/dsh-credentials-local' + # The native DeepSeek adapter. Shipped default: full thinking at max effort on # every request (wire-only defaults; they never enter the request header). - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL thinking: enabled reasoningEffort: max diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index e720be54b1..d7bb88710c 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: 205fd060de43b33b1e9ddbd72a2a8fd2b526a889 -README.zh.md: 57d15ad3d78e941393059001eafffd7468633696 +README.md: 48fa3272f7e024a295e7beaa68b9365aac379319 +README.zh.md: 686a9123f5f89ac244f8ef880e78609a131eb8b9 diff --git a/packages/README.md b/packages/README.md index 205fd060de..48fa3272f7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -39,6 +39,7 @@ Packages live at `packages///`; groups are containers, while names r | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface | | [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface | +| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface | | [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 57d15ad3d7..686a9123f5 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -39,6 +39,7 @@ | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 | | [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 | +| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` provider | 产品:稳定表面 | | [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 | | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 | | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f90451084a..2cdfa4449a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -264,6 +264,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'credentials', + summary: 'Abstract credential service.', + methods: [ + { + signature: 'abstract resolve(ref: CredentialRef): Promise', + jsDoc: '/**\n * Resolve one reference to its current value. Resolution is per call:\n * consumers re-resolve at each operation and must not cache across\n * operations — that per-operation read is what makes a changed credential\n * reach the next operation without a restart.\n * @param ref - the reference to resolve.\n * @returns the value and its source, or `undefined` while unconfigured.\n */', + }, + { + signature: 'abstract describe(ref: CredentialRef): Promise', + jsDoc: '/**\n * Describe one reference for configuration surfaces without exposing the\n * value.\n * @param ref - the reference to describe.\n * @returns configured state, supplying source, and writability.\n */', + }, + { + signature: 'abstract set(ref: CredentialRef, value: string): Promise', + jsDoc: '/**\n * Durably store one value in the provider-managed writable source. Rejects\n * while a read-only source shadows the reference — the write would appear\n * to succeed while resolution keeps returning the shadowing value — and\n * rejects an empty value (use {@link unset}).\n * @param ref - the reference to store.\n * @param value - the non-empty secret value.\n */', + }, + { + signature: 'abstract unset(ref: CredentialRef): Promise', + jsDoc: '/**\n * Remove one reference from the provider-managed writable source; removing\n * an absent reference is a no-op. Rejects while a read-only source shadows\n * the reference, like {@link set}.\n * @param ref - the reference to remove.\n */', + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -1208,6 +1230,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', summary: 'A command was registered or unregistered.', }, + { + name: 'credentials/updated', + mode: 'emit', + signature: '\'credentials/updated\'(ref: CredentialRef): void', + jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */', + summary: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage.', + }, { name: 'domain/changed', mode: 'emit', @@ -1674,6 +1703,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}', }, + { + name: 'CredentialInfo', + declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n writable: boolean;\n}', + }, + { + name: 'CredentialRef', + declaration: 'export type CredentialRef = Branded<\'CredentialRef\'>;', + }, { name: 'DiffCallView', declaration: 'export interface DiffCallView {\n card: \'diff\';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n}', @@ -2058,6 +2095,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResolvedAlwaysRetryPolicy', declaration: 'export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'always\';\n}', }, + { + name: 'ResolvedCredential', + declaration: 'export interface ResolvedCredential {\n value: string;\n source: string;\n}', + }, { name: 'ResolvedNormalRetryPolicy', declaration: 'export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'normal\';\n readonly maxRetries: number;\n readonly retryableCodes: readonly string[];\n}', diff --git a/packages/credentials/README.i18n.yaml b/packages/credentials/README.i18n.yaml new file mode 100644 index 0000000000..e8b35ba48e --- /dev/null +++ b/packages/credentials/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/credentials/README.md +README.md: 1d450cbeef84750fa57ca0151563c496aed0ce12 +README.zh.md: 843230c3cebf35f234d3ad812165b16ea734678b diff --git a/packages/credentials/README.md b/packages/credentials/README.md new file mode 100644 index 0000000000..1d450cbeef --- /dev/null +++ b/packages/credentials/README.md @@ -0,0 +1,14 @@ +# credentials/ + +English | [中文](README.zh.md) + +The credential capability seam, as three-package shape dictates (interface / implementation / consumers): + +| Package | Role | +|---|---| +| [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references, per-operation `resolve`, UI-safe `describe`, fail-loud `set`/`unset`, the `credentials/updated` commit event | +| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.env` (writable, byte-preserving line edits, hot-reloaded) | + +Configuration files carry *references* to secrets (`apiKeyEnv: DEEPSEEK_API_KEY`), never the secrets: the settings document stays safe to sync and render, and rotating a value touches no configuration. The LLM adapters are the first consumers — they resolve their reference once per model request, which is what makes a key stored moments ago reach the very next request without restarting anything. + +The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers. diff --git a/packages/credentials/README.zh.md b/packages/credentials/README.zh.md new file mode 100644 index 0000000000..843230c3ce --- /dev/null +++ b/packages/credentials/README.zh.md @@ -0,0 +1,14 @@ +# credentials/ + +[English](README.md) | 中文 + +凭据能力 seam,按三包形态的要求组织(接口/实现/消费方): + +| 包 | 角色 | +|---|---| +| [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用、按操作 `resolve`、对 UI 安全的 `describe`、响亮失败的 `set`/`unset`,以及 `credentials/updated` 提交事件 | +| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.env`(可写、保字节行级编辑、热重载)之上 | + +配置文件携带的是对机密的*引用*(`apiKeyEnv: DEEPSEEK_API_KEY`),绝不携带机密本身:设置文档可以放心同步与渲染,轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。 + +seam 形状为 keyring、辅助命令与 KMS 后端的 provider 留有余地。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml new file mode 100644 index 0000000000..23cf5bb09b --- /dev/null +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/credentials/credentials-local/README.md +README.md: 277c7db02836819e34a5c2db8aaf542c8eeec162 +README.zh.md: af1b840142d214b8cd8cf59690cb5773fae2e867 diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 55b73c2ac6..277c7db028 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -32,7 +32,7 @@ External edits publish `credentials/updated` per changed reference after the sna ## Model Experience -Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface. +Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface. #### KV Cache effect diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 2563c973a7..af1b840142 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -2,14 +2,14 @@ [English](README.md) | 中文 -文件型[凭据](../credentials/README.zh.md) provider:两层来源,一条诚实的优先级。 +文件型[凭据](../credentials/README.md) provider:两层来源,一条诚实的优先级。 | 层 | 来源 id | 可写 | 优先 | |---|---|---|---| | 活跃进程环境 | `env` | 否 | 恒定优先 | | `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | -环境优先,因为启动时注入(`DEEPSEEK_API_KEY=… dsh`、CI secrets、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须**可见地**只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 +环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 ## 配置 @@ -18,25 +18,25 @@ | `path` | `/.env` | 凭据文档位置。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 | | `watch` | `true` | 热发布外部编辑。 | -| `debounceMs` | `100` | watcher 写入沉降窗口。 | +| `debounceMs` | `100` | watcher 写入稳定窗口。 | ## 文档本身 -dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释与无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.zh.md),权限 `0600`。 +dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释与无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.md),权限 `0600`。 -值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值、以及已经跨越多个物理行的条目,响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 +值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 ## 热重载 -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后一份好快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 +外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 ## Model Experience -Indirectly: resolved values authorize LLM adapter requests; the consuming adapter owns every model-visible surface. +经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 #### KV Cache effect -No direct invalidation; credentials never enter a request prefix. +无直接失效;凭据绝不进入请求前缀。 ## Known Limitations and Deferred Work diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 853a1ce015..c1fcd37f16 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -118,6 +118,9 @@ function upsertLine(text: string | undefined, ref: CredentialRef, line: string | /** File-backed credentials provider (`$DSH_HOME/.env`). */ export class CredentialsLocal extends Credentials { + /* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with + settings-local (prefer symmetry for parallel values); extracting the shared + shape would couple the two providers' teardown semantics across packages. */ static Config: z = z.object({ path: z.string(), dshHome: z.string(), @@ -145,6 +148,7 @@ export class CredentialsLocal extends Credentials { private isClosed(): boolean { return this.closed } + /* jscpd:ignore-end */ constructor(ctx: Context, public config: Config) { super(ctx) @@ -162,6 +166,9 @@ export class CredentialsLocal extends Credentials { } await this.loadInitial() if (!this.spec.watch) return + /* jscpd:ignore-start -- same watcher discipline as settings-local by design: + the serialized-refresh and quiesce-on-dispose shape is the reviewed + lifecycle contract, not accidental repetition. */ const watcher = chokidarWatch(this.spec.filename, { ignoreInitial: true, awaitWriteFinish: { @@ -183,6 +190,7 @@ export class CredentialsLocal extends Credentials { this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) this.ctx.logger.warn(error) }) + /* jscpd:ignore-end */ yield async () => { // Quiesce: stop accepting events, close the watcher, then wait out any // queued or in-flight refresh so nothing publishes after disposal. diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml new file mode 100644 index 0000000000..10fe5f0ffe --- /dev/null +++ b/packages/credentials/credentials/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/credentials/credentials/README.md +README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc +README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index 48b7b0f952..1c18c47623 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -13,8 +13,11 @@ Abstract credential seam (`ctx.credentials`). One doctrine, three consequences: ## Surface ```ts +import type { Context } from 'cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' +declare const ctx: Context + const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value @@ -32,7 +35,7 @@ The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only so ## Model Experience -Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. +Indirectly, through the consuming LLM adapters: a resolved value authorizes their provider requests, and the adapter owns every model-visible surface. #### KV Cache effect diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index ef9e32ebad..751fb7c1e8 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -4,42 +4,45 @@ 抽象凭据 seam(`ctx.credentials`)。一条准则,三个推论: -**配置只携带对秘密的引用,绝不携带秘密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答"配置了吗、来自哪层、能否写入";轮换秘密不触碰任何配置文件。 +**配置只携带对机密的引用,绝不携带机密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答「配置了吗、来自哪层、能否写入」;轮换机密不触碰任何配置文件。 -**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM adapter 每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 +**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM 适配器每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 -**空的存储值等于不存在。** 处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的秘密。 +**空的存储值等于不存在。**处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的机密。 ## 接口面 ```ts +import type { Context } from 'cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' -const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell 标识符,品牌类型 +declare const ctx: Context + +const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined -const info = await ctx.credentials.describe(ref) // { configured, source?, writable } —— 绝不含值 -await ctx.credentials.set(ref, 'sk-…') // 被只读来源遮蔽时拒绝 -await ctx.credentials.unset(ref) // 不存在时为 no-op;同样的遮蔽规则 +const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value +await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref +await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule ``` -`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新"已配置"徽标。 +`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 -`set`/`unset` 的遮蔽规则是刻意的 fail-loud:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 +`set`/`unset` 的遮蔽规则是刻意的响亮失败:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 ## Providers -[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带秘密。 +[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。 ## Model Experience -Indirectly: a resolved value authorizes provider requests; the consuming adapter owns every model-visible surface. +经由消费它的 LLM 适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 #### KV Cache effect -No direct invalidation; credentials never enter a request prefix. +无直接失效;凭据绝不进入请求前缀。 ## Known Limitations and Deferred Work -- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费者。 +- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费方。 - **引用限定为环境变量形状**——在有 provider 需要更丰富寻址前,保持单一扁平的 POSIX 标识符命名空间。 - **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`。 diff --git a/packages/credentials/credentials/tests/memory.ts b/packages/credentials/credentials/tests/memory.ts index c562d8ab0a..dc1ed77a06 100644 --- a/packages/credentials/credentials/tests/memory.ts +++ b/packages/credentials/credentials/tests/memory.ts @@ -47,5 +47,3 @@ export class MemoryCredentials extends Credentials { return Promise.resolve() } } - -export default MemoryCredentials diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 7f7619b003..48dac1d70f 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 7a2314ea2ca0fb6606310a4961fcbc658240a7b8 -README.zh.md: 523bbbfd29b4598c024a1fff4a7121a7cb88bf41 +README.md: 88f4fd7c017a5dbb070bdaf8ee47bb5610b23303 +README.zh.md: 5331a4d44c08e2fc4a5f8486128079d9b01e8454 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7a2314ea2c..88f4fd7c01 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -14,8 +14,9 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback - baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com + apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment + # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file + baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default @@ -44,6 +45,15 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und `streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. +## Dynamic configuration (settings + credentials) + +Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: + +- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. +- **`ctx.credentials`** — the API key resolves per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. + +The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek')` always reports the current policy. + ## App attribution Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compact-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests. @@ -62,7 +72,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` ## Testing -Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. +Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, retry-policy re-registration), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. ## Model Experience @@ -96,6 +106,8 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work +- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. +- **`Config.apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). - **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 523bbbfd29..5331a4d44c 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -14,8 +14,9 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback - baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com + apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment + # apiKey: … # literal escape hatch; prefer the reference so no secret enters this file + baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; off | high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default @@ -44,6 +45,15 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE `streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在 chunk 间花费的时间。一个稳定 abort 信号会在整个调用中达到请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用精确发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久 agent 步骤边界单独执行该策略。 +## 动态配置(settings + credentials) + +连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: + +- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 + +唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek')` 始终报告当前策略。 + ## 应用归因 每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器契约下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。 @@ -62,7 +72,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE ## 测试 -单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,由 key 调节):V4 Flash + V4 Pro,覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传。 +单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,由 key 调节):V4 Flash + V4 Pro,覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传,以及密钥仅存在于 credentials-local 文档中的请求。 ## 模型体验 @@ -96,6 +106,8 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 +- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 +- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 跨越协议。 diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 054f1984b0..bb2ccdaa11 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -17,7 +17,7 @@ import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/ds import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' -import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' @@ -231,18 +231,10 @@ export function apply(ctx: Context, config: Config): void { ctx.logger.warn('llm-deepseek: no API key resolved yet for route "deepseek"; requests will fail until one is configured') }) - ctx.inject(['settings'], (sctx) => { - const scope = sctx.settings.register(NS, Config, { base: config }) - current = () => scope.get() - sctx.effect(() => () => { - // Settings detached (provider disposed or reloading): fall back to the - // composition entry so the plugin keeps working exactly as configured. - current = () => config - ensureRegistrationFacts() - }) - ensureRegistrationFacts() - scope.watch(() => { - ensureRegistrationFacts() - }) + installSettingsSection(ctx, NS, Config, config, { + setSource: (source) => { + current = source + }, + onChange: ensureRegistrationFacts, }) } diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 38b9c093f6..98e7579f80 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: ac47cf6a21285fc887948a5a7798a9f1cb9157b0 -README.zh.md: a3d864ed9068d8bdaff4c5b73a4b7b339802ae05 +README.md: 21e1f6f11777d9de230f26c00046248e111cf0b0 +README.zh.md: 4f8423bd9a5b812f97a3360218ed1a35a9e58dae diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index ac47cf6a21..21e1f6f117 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -2,21 +2,21 @@ English | [中文](README.zh.md) -Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns an explicit list of provider profiles; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. +Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns a dict of provider profiles keyed by route; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal. ## Config -Configure credentials and deployment-specific transport settings per provider. Omitting `apiKey` delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. +Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file; omitting both delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY + openai: + apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high retryPolicy: @@ -26,22 +26,28 @@ Configure credentials and deployment-specific transport settings per provider. O initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - - provider: openrouter - apiKey: !!js process.env.OPENROUTER_API_KEY + openrouter: + apiKeyEnv: OPENROUTER_API_KEY headers: X-Deployment: production ``` -Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. +Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. + +## Dynamic configuration (settings + credentials) + +The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. + +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; the raw environment variable without a mounted seam), then pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin re-registers the same adapter instance in one synchronous section, so `ctx.llm.listProviders()` and `providerRetryPolicy()` always reflect the current configuration. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -71,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata ## Testing -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. Real-API coverage remains key-gated under `pnpm run test:e2e`. +Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. Real-API coverage remains key-gated under `pnpm run test:e2e`. ## Model Experience @@ -105,6 +111,8 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work +- **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. +- **`apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index a3d864ed90..4f8423bd9a 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -2,21 +2,21 @@ [English](README.md) | 中文 -基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM seam 通用多提供方适配器。一个插件实例拥有显式提供方 profile 列表;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 +基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 包根目录公开 Cordis 插件契约与 `PiAiAdapter`;profile 解析、模型构造、回放转换和流转换保留在包内部。 ## 配置 -按提供方配置凭证与部署特定传输设置。省略 `apiKey` 会将认证委托给 pi-ai 的提供方原生环境发现。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 +按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件;两者都省略则把认证委托给 pi-ai 的提供方原生环境发现。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY + openai: + apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high retryPolicy: @@ -26,22 +26,28 @@ initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - - provider: openrouter - apiKey: !!js process.env.OPENROUTER_API_KEY + openrouter: + apiKeyEnv: OPENROUTER_API_KEY headers: X-Deployment: production ``` -每个提供方名称必须存在于 pi-ai 已安装 catalog 中,且在此插件实例中最多出现一次。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 +每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 + +## 动态配置(settings + credentials) + +适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 + +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时为原始环境变量),最后是 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会在一个同步区段内重新注册同一适配器实例,因此 `ctx.llm.listProviders()` 与 `providerRetryPolicy()` 始终反映当前配置。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 `reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型协议拼写仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `provider`、`apiKey`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的 normal 默认值。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。 +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的 normal 默认值。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent 级重试预算。Idle 过期会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -71,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK ## 测试 -单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用一次协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。真实 API 覆盖仍位于由 key 调节的 `pnpm run test:e2e` 下。 +单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用一次协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。真实 API 覆盖仍位于由 key 调节的 `pnpm run test:e2e` 下。 ## 模型体验 @@ -105,6 +111,8 @@ pi-ai 事件会变为 harness reasoning、文本、工具调用、usage 与 fini ## 已知限制与暂缓事项 +- **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 +- **`apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 - **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 4856dbe7e5..dd79113da1 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -30,7 +30,7 @@ import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-llm' -import { deepEqualJson, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' @@ -105,18 +105,10 @@ export function apply(ctx: Context, config: Config): void { registeredFacts = facts } - ctx.inject(['settings'], (sctx) => { - const scope = sctx.settings.register(NS, Config, { base: config }) - current = () => scope.get() - sctx.effect(() => () => { - // Settings detached (provider disposed or reloading): fall back to the - // composition entry so the plugin keeps working exactly as configured. - current = () => config - ensureRegistrationFacts() - }) - ensureRegistrationFacts() - scope.watch(() => { - ensureRegistrationFacts() - }) + installSettingsSection(ctx, NS, Config, config, { + setSource: (source) => { + current = source + }, + onChange: ensureRegistrationFacts, }) } diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index a7e9366048..2df73528e8 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -442,4 +442,54 @@ export abstract class Settings extends Service { } } +/** Hooks a consumer hands to {@link installSettingsSection}. */ +export interface SettingsSectionHooks { + /** + * Receive the active configuration source: the resolved settings scope + * while one is attached, the composition entry otherwise. Called before + * the matching `onChange` at attach and at detach. + * @param current - thunk returning the currently authoritative value. + */ + setSource(current: () => T): void + /** + * Re-judge anything derived from the source — registration-level facts, + * memoized resolutions — after an attach, a detach, or a committed change. + */ + onChange(): void +} + +/** + * Install the canonical optional-settings consumer wiring: while a settings + * service exists, register `ns` with the consumer's composition entry as the + * `base` layer and point the source thunk at the resolved scope; when the + * service goes away (disposal, provider reload), fall back to the entry so + * the consumer keeps working exactly as composed. The registration rides the + * scoped fiber, so no settings service ever mounted means none of this runs. + * @param ctx - consumer plugin context owning the wiring. + * @param ns - the consumer-owned settings namespace. + * @param schema - schema resolving the namespace (typically the plugin Config). + * @param entry - the consumer's composition entry config, used as `base`. + * @param hooks - source sink and change notification. + */ +export function installSettingsSection( + ctx: Context, + ns: SettingsNamespace, + schema: z, + entry: T, + hooks: SettingsSectionHooks, +): void { + ctx.inject(['settings'], (sctx) => { + const scope = sctx.settings.register(ns, schema, { base: entry }) + hooks.setSource(() => scope.get()) + sctx.effect(() => () => { + hooks.setSource(() => entry) + hooks.onChange() + }) + hooks.onChange() + scope.watch(() => { + hooks.onChange() + }) + }) +} + export default Settings diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index a989d9a5cc..6ad290779f 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import z from 'schemastery' -import { Settings, deepEqualJson, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' +import { Settings, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' import { MemorySettings } from './memory.ts' /** A provider implementing only the three primitives: the seam owns init. */ @@ -558,3 +558,46 @@ describe('watch', () => { expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 }) }) }) + +describe('installSettingsSection', () => { + const HelperSchema: z<{ theme: string }> = z.object({ + theme: z.string().default('default'), + }) + + it('drives the source through attach, live commits, and detach', async () => { + const ctx = new Context() + const entry = { theme: 'entry' } + let current: () => { theme: string } = () => entry + let changes = 0 + installSettingsSection(ctx, settingsNamespace('helper-ns'), HelperSchema, entry, { + setSource: (source) => { + current = source + }, + onChange: () => { + changes += 1 + }, + }) + // No settings service mounted: nothing ran, the entry stays authoritative. + expect(current()).toEqual({ theme: 'entry' }) + expect(changes).toBe(0) + + const fiber = ctx.plugin(MemorySettings, { doc: { 'helper-ns': { theme: 'user' } } }) + await fiber + await vi.waitFor(() => { + expect(current()).toEqual({ theme: 'user' }) + }) + expect(changes).toBe(1) + + await ctx.settings.update(settingsNamespace('helper-ns'), { theme: 'live' }) + await vi.waitFor(() => { + expect(changes).toBe(2) + }) + expect(current()).toEqual({ theme: 'live' }) + + await fiber.dispose() + await vi.waitFor(() => { + expect(changes).toBe(3) + }) + expect(current()).toEqual({ theme: 'entry' }) + }) +}) diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 1fc811bc8b..1662cd7149 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 140df90571d84320fb4eb888508c67e60aa29a22 -README.zh.md: 4c16df2a56476c0a7c965a037389fa5ba231e273 +# pnpm run verify-translation-pairing --write packages/util/README.md +README.md: 3c7fd29e40c07cb25dc6ad040f86c4e31cd41931 +README.zh.md: 3b4626c7bac8c294dcbdf52ef3b0da46d39a03c8 diff --git a/packages/util/README.md b/packages/util/README.md index 140df90571..3c7fd29e40 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -10,6 +10,7 @@ Zero-dependency primitives shared across the other groups. A package lands here | `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) | | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | | `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | +| `atomic-write/` | Atomic file replacement — `writeFileAtomic` (exclusive-create temp + rename carrying the caller-stated mode); shared by the settings and credentials stores | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 4c16df2a56..3b4626c7ba 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -10,6 +10,7 @@ | `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) | | `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 | | `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 | +| `atomic-write/` | 原子文件替换:`writeFileAtomic`(独占创建临时文件 + 携带调用方所声明 mode 的 rename);由设置与凭据存储共用 | `dsh-brand` 是规范示例:它只负责 `Branded` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。 diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml new file mode 100644 index 0000000000..e1f2ea37f5 --- /dev/null +++ b/packages/util/atomic-write/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/util/atomic-write/README.md +README.md: 2cd57a0fa42601e393a41de68af3f9b1e2f033b5 +README.zh.md: e8f18a8ec6ef6077f15cebed0062fabc0638ee0e diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index 42c65c820a..2cd57a0fa4 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -9,6 +9,8 @@ Zero-dependency atomic file replacement shared by file-backed stores that must n ```ts import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +declare const text: string + await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) ``` @@ -24,6 +26,10 @@ One export. The contract, in the order failures would exploit it: None, as this is a pure filesystem primitive; nothing here reaches a model request. +#### KV Cache effect + +None; nothing here enters a request prefix. + ## Known Limitations and Deferred Work - **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index 4a59eaea9d..e8f18a8ec6 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -2,29 +2,35 @@ [English](README.md) | 中文 -零依赖的原子文件替换,供绝不允许在磁盘上留下半截内容、被符号链接劫持或权限过宽内容的文件型存储共用——用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。 +零依赖的原子文件替换,供绝不允许在磁盘上留下不完整、被符号链接劫持或权限过宽内容的文件型存储共用:用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。 ## 接口面 ```ts import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +declare const text: string + await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) ``` -仅一个导出。契约按攻击面利用顺序列出: +仅一个导出。契约按故障利用它的先后顺序列出: - **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。 - **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。 - **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。 - **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。 -- 自动创建父目录;任何失败都会清理临时文件并重新抛出;读者只会看到旧内容或完整的新内容。 +- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。 ## Model Experience -None, as this is a pure filesystem primitive; nothing here reaches a model request. +无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。 + +#### KV Cache effect + +无;此处没有任何内容会进入请求前缀。 ## Known Limitations and Deferred Work -- **原子但不保证落盘持久**——不对文件或目录做 `fsync`,崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,持久化策略留给调用方。 -- **仅支持字符串内容**——在出现真实消费者之前不提供 `Buffer` 或流式形态。 +- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。 +- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de87482bb1..8a6c4b7544 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5472,6 +5472,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../packages/credentials/credentials '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -5574,6 +5577,9 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../packages/settings/settings '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill @@ -5688,6 +5694,9 @@ importers: cordis: specifier: workspace:^ version: link:../../vendor/cordis + schemastery: + specifier: workspace:^ + version: link:../../vendor/schemastery vendor/cordis: dependencies: diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a1d8728d4c..976ea1146d 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -10,8 +10,8 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", @@ -22,6 +22,8 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", @@ -32,34 +34,31 @@ "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jsonrpc": "workspace:^", - "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", @@ -67,11 +66,14 @@ "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", @@ -91,9 +93,10 @@ "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", "@deepseek-ai/dsh-web-search-exa": "workspace:^", "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "cordis": "workspace:^" + "@deepseek-ai/dsh-workspace-context": "workspace:^", + "cordis": "workspace:^", + "schemastery": "workspace:^" } } diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 48a570a20d..d569811b56 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1750, + "AGENTS.md": 1755, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 850 + "packages/README.md": 865 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0654cd2277..59a39eae1a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -194,6 +194,9 @@ export const LINK_MAP: Record = { SettingsScope: 'settings.md', SettingsDescriptor: 'settings.md', SettingsUpdateSource: 'settings.md', + CredentialRef: 'credentials.md', + CredentialInfo: 'credentials.md', + ResolvedCredential: 'credentials.md', AskUserQuestionAnswer: 'user-interaction.md', AskUserQuestionRequest: 'user-interaction.md', UserInteractionProvider: 'user-interaction.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index aa599370e1..54467c3710 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -143,8 +143,17 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'User-settings seam', mode: 'seam', implementations: ['settings-local'], - consumers: [], - note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet.', + consumers: ['llm-deepseek', 'llm-pi-ai'], + note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section.', + }, + { + key: 'credentials', + pkg: 'credentials', + title: 'Credential seam', + mode: 'seam', + implementations: ['credentials-local'], + consumers: ['llm-deepseek', 'llm-pi-ai'], + note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request.', }, { key: 'telemetry', diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 28f854fe9b..3892a0e5f3 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -197,7 +197,7 @@ describe('docsPages locale routes', () => { const translated = rootPages.filter(page => page.contentLocale === 'zh-CN') const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US') - expect(translated).toHaveLength(19) + expect(translated).toHaveLength(20) expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true) expect(fallbacks.map(page => page.source).sort()).toEqual([ 'docs/core-data-structures/commands.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5b35d7427e..7ecdcea55c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1328,6 +1328,21 @@ "doc": "docs/core-data-structures/settings.md", "symbol": "SettingsUpdateSource", "source": "packages/settings/settings/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "CredentialRef", + "source": "packages/credentials/credentials/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "ResolvedCredential", + "source": "packages/credentials/credentials/src/index.ts" + }, + { + "doc": "docs/core-data-structures/credentials.md", + "symbol": "CredentialInfo", + "source": "packages/credentials/credentials/src/index.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index c3c9cba8a1..f558046e51 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -97,6 +97,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' }, 'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' }, + 'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model surface a value authorizes.' }, + 'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model surface.' }, + 'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/website/docs.ts b/website/docs.ts index cbc8d9e627..a640d01e18 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -257,6 +257,7 @@ const coreDataReference = pairedPages(([ ['web.md', 'Web 访问', 'Web access', 19], ['persistence.md', '会话持久化', 'Session persistence', 20], ['settings.md', '用户设置', 'User settings', 21], + ['credentials.md', '用户凭据', 'User credentials', 22], ] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({ source: `docs/core-data-structures/${file}`, route: `reference/core-data-structures/${file}`, From 2a53abd491862e85eae9db53bf2449dfac21b8c8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:26:00 +0800 Subject: [PATCH 08/37] fix(examples): drop the dev HMR row the native source launch cannot load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendored @cordisjs/plugin-hmr uses decorators — non-erasable syntax the --experimental-transform-types demo:tui launch refuses — so its row made the shipped TUI composition unbootable from source while the tsx-launched snapshot harness masked it. The stale !!js head comment goes with it; restoring dev HMR is tracked separately. --- examples/tui-agent/cordis.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 23f13a0218..583cf9f3cc 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -1,14 +1,11 @@ # Full-screen TUI coding agent with swappable DeepSeek and local-bash backends. # `dsh-tui-demo` supplies the agent spine, workspace instructions, generic # task controls, JSONL persistence, the pi-tui front door, and `main`. -# HMR remains a leaf because it depends on Loader internals. The app bin loads -# the gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional -# `DEEPSEEK_BASE_URL` through `!!js`. - -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] +# The app bin loads the gitignored root `.env` into the process environment; +# entry configs here are the composition base, while user-plane values resolve +# per request through the settings and credentials providers below. +# No dev HMR row: vendored @cordisjs/plugin-hmr uses decorators, which the +# native --experimental-transform-types source launch cannot load. # User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a # `llm-deepseek:` section there overrides the adapter entry below without a From 4bb101e002c765df1b8da25617b6c31c27ccd240 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:29:12 +0800 Subject: [PATCH 09/37] Revert "fix(examples): drop the dev HMR row the native source launch cannot load" This reverts commit 8ca499e33dd2b6e1f4e80189a1a3ab9e6873ddf7. --- examples/tui-agent/cordis.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 583cf9f3cc..23f13a0218 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -1,11 +1,14 @@ # Full-screen TUI coding agent with swappable DeepSeek and local-bash backends. # `dsh-tui-demo` supplies the agent spine, workspace instructions, generic # task controls, JSONL persistence, the pi-tui front door, and `main`. -# The app bin loads the gitignored root `.env` into the process environment; -# entry configs here are the composition base, while user-plane values resolve -# per request through the settings and credentials providers below. -# No dev HMR row: vendored @cordisjs/plugin-hmr uses decorators, which the -# native --experimental-transform-types source launch cannot load. +# HMR remains a leaf because it depends on Loader internals. The app bin loads +# the gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional +# `DEEPSEEK_BASE_URL` through `!!js`. + +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] # User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a # `llm-deepseek:` section there overrides the adapter entry below without a From 9336fed1e9e0bb7ddc22ab67f9ab8d0a199f8634 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:45:01 +0800 Subject: [PATCH 10/37] feat(examples): mount the pi-ai adapter in the TUI demo composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openai + anthropic routes register keyless beside the direct deepseek adapter — the catalog stays browsable and requests fail actionably until a key arrives — with per-request apiKeyEnv resolution, so a user's settings section (proxy baseURL, extra routes) and .env keys are the only steps to turn them on. --- examples/tui-agent/cordis.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 23f13a0218..2c35d84e41 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -30,6 +30,20 @@ thinking: enabled reasoningEffort: max +# The pi-ai multi-provider twin beside the direct adapter: openai + anthropic +# routes register keyless (the catalog stays browsable; a request needs a +# key). Keys resolve per request through the apiKeyEnv references, and a +# `llm-pi-ai:` settings section overrides per provider — proxy baseURL, added +# routes — without a restart. +- id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + config: + providers: + openai: + apiKeyEnv: OPENAI_API_KEY + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY + # Local executor for the app bundle's bash tool. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). - id: subprocess From 4e9916b3e54e6727b8ecb08c12de6667a45abf08 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:56:09 +0800 Subject: [PATCH 11/37] =?UTF-8?q?feat(llm-pi-ai):=20dormant=20bare=20mount?= =?UTF-8?q?=20=E2=80=94=20routes=20live=20entirely=20in=20the=20settings?= =?UTF-8?q?=20plane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty or omitted providers dict is now the valid dormant posture: the adapter mounts with zero routes and no catalog entries, registers routes the moment the llm-pi-ai settings section supplies profiles, and drops them when it empties. The TUI demo mounts the adapter bare, so adding an openai/anthropic provider is purely a settings.yaml (or, next PR, web form) operation with per-request apiKeyEnv credential resolution. --- ...est-level-llm-config-credentials.i18n.yaml | 4 ++-- ...29-request-level-llm-config-credentials.md | 2 +- ...request-level-llm-config-credentials.zh.md | 2 +- docs/config-catalog.md | 10 +++++--- examples/tui-agent/composition.md | 3 +++ examples/tui-agent/cordis.yml | 17 +++++-------- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/config.ts | 23 +++++++++++------- packages/llm/llm-pi-ai/src/index.ts | 15 ++++++++---- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +++- .../llm-pi-ai/tests/dynamic-config.spec.ts | 24 +++++++++++++++++++ 13 files changed, 76 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index f54a4bdbe6..c8cde9db07 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.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-07-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: 13fefff9fe2646a9ef8e7200bd908d8764e006ea -2026-07-29-request-level-llm-config-credentials.zh.md: 5e29946ade3d8b2b8ae51075c29c14867fe29f71 +2026-07-29-request-level-llm-config-credentials.md: 67baec4b70d0c754f22573d87fb4492de5ca16a4 +2026-07-29-request-level-llm-config-credentials.zh.md: 36182b77f4494c99b0fb08107f865f85322ece6c diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md index 13fefff9fe..67baec4b70 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -16,7 +16,7 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti **Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. -**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions. +**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 5e29946ade..36182b77f4 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -16,7 +16,7 @@ Status: implemented **机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 -**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引。 +**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。 ## 曾考虑的替代方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d3844268ac..b032b5a91b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -630,10 +630,14 @@ Source: [`packages/llm/llm-deepseek/src/index.ts:49`](../packages/llm/llm-deepse Requires: `llm` ```ts config-catalog -/** Plugin configuration: the non-empty provider routes this instance owns. */ +/** Plugin configuration: the provider routes this instance owns. */ export interface Config { - /** Non-empty dict of pi-ai provider routes, keyed by provider. */ - providers: Record + /** + * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is + * the dormant settings-driven posture: the adapter mounts with no routes + * and registers them the moment a settings section supplies profiles. + */ + providers?: Record } /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 69cd9704a3..2091bfc75e 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -16,6 +16,8 @@ flowchart LR cfg --> plugin_tui_credentials plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek + plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] + cfg --> plugin_tui_llm_pi_ai plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] cfg --> plugin_tui_subprocess plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] @@ -77,6 +79,7 @@ flowchart LR | `settings` | `@deepseek-ai/dsh-settings-local` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 2c35d84e41..2fc3728e8a 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -30,19 +30,14 @@ thinking: enabled reasoningEffort: max -# The pi-ai multi-provider twin beside the direct adapter: openai + anthropic -# routes register keyless (the catalog stays browsable; a request needs a -# key). Keys resolve per request through the apiKeyEnv references, and a -# `llm-pi-ai:` settings section overrides per provider — proxy baseURL, added -# routes — without a restart. +# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra +# models in the picker) until a `llm-pi-ai:` settings section supplies +# provider profiles — then those routes register live, keys resolving per +# request through their apiKeyEnv references, and drop again when the +# section empties. Which adapters exist is composition; which providers run +# is the user's settings document. - id: llm-pi-ai name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - openai: - apiKeyEnv: OPENAI_API_KEY - anthropic: - apiKeyEnv: ANTHROPIC_API_KEY # Local executor for the app bundle's bash tool. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 98e7579f80..325fb0bf50 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 21e1f6f11777d9de230f26c00046248e111cf0b0 -README.zh.md: 4f8423bd9a5b812f97a3360218ed1a35a9e58dae +README.md: fb8145d58a7c74c70498468044282c740460a947 +README.zh.md: e49243d81d204ea0567a6930ec99e4fa97f78df4 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 21e1f6f117..fb8145d58a 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -35,7 +35,7 @@ Configure credentials and deployment-specific transport settings per provider, k X-Deployment: production ``` -Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. +Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. ## Dynamic configuration (settings + credentials) diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 4f8423bd9a..e49243d81d 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -35,7 +35,7 @@ X-Deployment: production ``` -每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 +每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 ## 动态配置(settings + credentials) diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index b644527097..053d6d56e6 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -58,10 +58,14 @@ export interface ResolvedPiAiProviderProfile extends Omit + /** + * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is + * the dormant settings-driven posture: the adapter mounts with no routes + * and registers them the moment a settings section supplies profiles. + */ + providers?: Record } const thinkingBudgets = z.object({ @@ -88,21 +92,24 @@ const profile = z.object({ /** Runtime schema for {@link Config}. */ export const Config: z = z.object({ - providers: z.dict(profile).required(), + providers: z.dict(profile).default({}), }) /** * Validate profiles against the installed pi-ai catalog and return a detached - * route-keyed map suitable for per-request reads. + * route-keyed map suitable for per-request reads. This is the one explicit + * resolve step, so an omitted dict resolves to the empty (dormant) route set + * here rather than through a hidden fallback. * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ -export function resolveProfiles(providers: Readonly>): Map { +export function resolveProfiles( + providers: Readonly> | undefined, +): Map { if (Array.isArray(providers)) { throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles') } - const entries = Object.entries(providers) - if (entries.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') + const entries = Object.entries(providers ?? {}) const supported = new Set(getBuiltinProviders()) const resolved = new Map() for (const [provider, source] of entries) { diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index dd79113da1..6140b2456d 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -91,19 +91,24 @@ export function apply(ctx: Context, config: Config): void { const adapter = new PiAiAdapter({ profiles, resolveApiKey }) // Route effects bind to this apply fiber via the stable `ctx` reference, - // even when a swap runs inside the scoped settings callback below. - let disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter) - let registeredFacts = registrationFacts(profiles()) + // even when a swap runs inside the scoped settings callback below. A bare + // mount (zero routes) is the dormant posture: nothing registers until a + // settings section supplies profiles, and routes drop when it empties. + let disposeRoutes: (() => void) | undefined + let registeredFacts: unknown const ensureRegistrationFacts = (): void => { const facts = registrationFacts(profiles()) if (deepEqualJson(facts, registeredFacts)) return // The registry captures the route set and each route's retry policy at // registration: swap the registration in one synchronous section (same // adapter instance, no NO_ADAPTER window). - disposeRoutes() - disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter) + disposeRoutes?.() + disposeRoutes = undefined + const routes = [...profiles().keys()] + if (routes.length > 0) disposeRoutes = ctx.llm.registerAdapter(routes, adapter) registeredFacts = facts } + ensureRegistrationFacts() installSettingsSection(ctx, NS, Config, config, { setSource: (source) => { diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 02d7af5b2d..4b97e7b2a7 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -395,7 +395,9 @@ describe('provider profile lifecycle', () => { }) it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => { - expect(() => resolveProfiles({})).toThrow(/at least one/) + // Empty and omitted dicts are the dormant zero-route posture, not errors. + expect(resolveProfiles({}).size).toBe(0) + expect(resolveProfiles(undefined).size).toBe(0) expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/) expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/) // The pre-release array shape and its per-profile provider field fail diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 5b7c9e5e3a..4bf4d6425a 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -42,6 +42,30 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise { } describe('request-level dynamic profiles', () => { + it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => { + vi.stubEnv('PI_DYNAMIC_KEY', '') + const dir = await home() + await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n') + const server = await mockServer([{ events: textEvents }]) + // The exact product posture: `- id: llm-pi-ai` with no config at all. + const ctx = await boot(dir, {}) + + expect(ctx.llm.listProviders()).toEqual([]) + await ctx.settings.update(NS, { + providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0) + + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(server.headers[0]?.authorization).toBe('Bearer pk-from-settings') + + // Emptying the user layer returns the adapter to its dormant state. + await ctx.settings.replace(NS, {}) + expect(ctx.llm.listProviders()).toEqual([]) + }) + it('adds a provider route from settings and drops it when the user layer resets', async () => { const dir = await home() const server = await mockServer([{ events: textEvents }]) From 7f1496c996760ebea79de4fc0457111307510b4c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 15:16:18 +0800 Subject: [PATCH 12/37] docs: regenerate the module graph for the credentials family and atomic-write --- docs/module-graph.md | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index b0f1111826..010fc596c1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -8,6 +8,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri ```mermaid flowchart TD subgraph group_util["packages/util"] + pkg_atomic_write["atomic-write"] pkg_brand["brand"] pkg_paths["paths"] pkg_retention["retention"] @@ -173,6 +174,10 @@ flowchart TD pkg_time_context["time-context"] pkg_workspace_context["workspace-context"] end + subgraph group_credentials["packages/credentials"] + pkg_credentials["credentials"] + pkg_credentials_local["credentials-local"] + end subgraph group_examples["packages/examples"] pkg_acp_demo["acp-demo"] pkg_agent_spine_demo["agent-spine-demo"] @@ -216,6 +221,10 @@ flowchart TD pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] end + subgraph group_settings["packages/settings"] + pkg_settings["settings"] + pkg_settings_local["settings-local"] + end subgraph group_storage["packages/storage"] pkg_storage["storage"] pkg_storage_domain["storage-domain"] @@ -244,6 +253,7 @@ flowchart TD subgraph group_workspace["packages/workspace"] pkg_workspace["workspace"] end + pkg_atomic_write --> pkg_invariants pkg_brand --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants @@ -301,12 +311,16 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots pkg_client_ui_workspace --> pkg_invariants + pkg_credentials --> pkg_brand + pkg_credentials --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_helper --> pkg_subprocess pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths + pkg_settings --> pkg_brand + pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_invariants @@ -315,11 +329,15 @@ flowchart TD pkg_storage_sqlite --> pkg_storage pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess + pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_credentials pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings pkg_llm_pi_ai --> pkg_timeout pkg_session --> pkg_brand pkg_session --> pkg_invariants @@ -355,11 +373,19 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants + pkg_credentials_local --> pkg_atomic_write + pkg_credentials_local --> pkg_credentials + pkg_credentials_local --> pkg_invariants + pkg_credentials_local --> pkg_paths pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm + pkg_settings_local --> pkg_atomic_write + pkg_settings_local --> pkg_invariants + pkg_settings_local --> pkg_paths + pkg_settings_local --> pkg_settings pkg_token_meter --> pkg_invariants pkg_token_meter --> pkg_llm pkg_token_meter --> pkg_session @@ -943,6 +969,7 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | +| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | @@ -976,14 +1003,16 @@ flowchart TD | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -992,8 +1021,10 @@ flowchart TD | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | From 90c3118302fdf717a237e9f6de3b1443325ecaf7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 15:40:09 +0800 Subject: [PATCH 13/37] fix(credentials-local): one operation chain, read-modify-write under the shared writer lock, and a quote-aware line editor Review round three, credentials half. dsh-atomic-write grows the cross-process writer-lock primitive (withFileLock: wx sentinel, bounded backoff, stale takeover via onStaleBreak, deadline failure) plus a dirMode option, and settings-local migrates its private copy to it; both providers now create harness-home directories 0700. credentials-local reuses the reviewed settings-local shape: watcher reloads and line edits share one settled operation chain; every write re-reads the document under the lock and publishes unobserved external entries before editing, so an edit inside the debounce window (or another process's write) can never be overwritten; the watcher's ready signal queues one reconcile closing the startup gap. The line editor is now physical-line aware: continuation lines of a quoted multi-line value are never mistaken for assignments, untouched lines keep their exact bytes (CRLF included), an edited line keeps its own terminator, and appends use the document's dominant ending. A multi-line entry reports writable: false, matching what set() would do. The Credentials base class owns a contained notifyUpdated fan-out: providers publish only after the commit, every listener runs, sync throws and async rejections are logged without failing the committed write, and INVARIANT-coded failures rethrow after the fan-out. --- .../credentials-local/src/index.ts | 257 +++++++++++++----- .../credentials-local/tests/drain.spec.ts | 10 +- .../tests/review-fixes.spec.ts | 202 ++++++++++++++ .../credentials-local/tests/watcher.spec.ts | 16 ++ packages/credentials/credentials/src/index.ts | 50 +++- packages/settings/settings-local/src/index.ts | 82 +----- packages/util/atomic-write/src/index.ts | 117 +++++++- 7 files changed, 579 insertions(+), 155 deletions(-) create mode 100644 packages/credentials/credentials-local/tests/review-fixes.spec.ts diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index c1fcd37f16..576b8241f0 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -3,19 +3,21 @@ * a `$DSH_HOME/.env` document. The environment is authoritative and read-only * (a launch-time override must win, and must be visibly read-only rather than * silently shadow writes); the file is the provider-managed writable source: - * `set`/`unset` rewrite only their own line and preserve every other byte, - * external edits hot-publish through the seam, and each reload replaces the - * snapshot wholesale so a deleted entry never lingers in memory. + * every write re-reads the document under a cross-process writer lock before + * rewriting only its own line — preserving every other byte, physical line + * endings and quoted multi-line values included — external edits hot-publish + * through the seam, and each reload replaces the snapshot wholesale so a + * deleted entry never lingers in memory. * @module @deepseek-ai/dsh-credentials-local */ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' +import { mkdir, readFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' import { parse } from 'dotenv' -import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' @@ -58,11 +60,6 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -/** Match the physical line(s) assigning one reference (ref chars need no escaping). */ -function refLinePattern(ref: CredentialRef): RegExp { - return new RegExp(`^\\s*(?:export\\s+)?${ref}\\s*=`) -} - /** Values that survive a dotenv round-trip without quoting. */ const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/ @@ -90,30 +87,98 @@ function renderLine(ref: CredentialRef, value: string): string { throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`) } +/** Split text into physical lines with their terminators attached. */ +function physicalLines(text: string): string[] { + return text.length === 0 ? [] : text.split(/(?<=\n)/) +} + +/** One physical line's content without its terminator. */ +function lineContent(line: string): string { + if (line.endsWith('\r\n')) return line.slice(0, -2) + if (line.endsWith('\n')) return line.slice(0, -1) + return line +} + +/** One physical line's terminator (empty on a final unterminated line). */ +function lineTerminator(line: string): string { + return line.slice(lineContent(line).length) +} + +/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */ +const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/ + +/** Quote characters dotenv reads across physical lines. */ +const MULTILINE_QUOTES = ['\'', '"', '`'] + /** - * Replace, insert, or delete one reference's assignment while preserving every - * other byte. The first matching line is rewritten in place; further matches - * are dropped (dotenv reads the last one, so duplicates are dead weight that - * would otherwise override the edit). + * The quote character an assignment's value part opens without closing on its + * own line — the following physical lines are that value's continuation, not + * assignments — or `undefined` for a single-line value. */ -function upsertLine(text: string | undefined, ref: CredentialRef, line: string | undefined): string { - const lines = text === undefined || text.length === 0 ? [] : text.split('\n') - if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() - const matcher = refLinePattern(ref) +function opensMultiline(valuePart: string): string | undefined { + const trimmed = valuePart.trimStart() + const quote = trimmed[0] + if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined + const rest = trimmed.slice(1) + const body = quote === '"' ? rest.replaceAll('\\"', '') : rest + return body.includes(quote) ? undefined : quote +} + +/** Whether a continuation line closes the given quote. */ +function closesQuote(content: string, quote: string): boolean { + const body = quote === '"' ? content.replaceAll('\\"', '') : content + return body.includes(quote) +} + +/** + * Replace, insert, or delete one reference's assignment while preserving + * every other byte: untouched lines keep their exact content and terminators + * (CRLF included), and the physical lines inside another key's quoted + * multi-line value are never mistaken for assignments. The first matching + * assignment is rewritten in place with its own line ending; later duplicates + * drop (dotenv reads the last one, so a surviving duplicate would override + * the edit); an insert appends in the document's dominant ending style. + */ +function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string { + const lines = physicalLines(text ?? '') + const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n' const out: string[] = [] let placed = false - for (const current of lines) { - if (matcher.test(current)) { - if (line !== undefined && !placed) { - out.push(line) - placed = true - } + let pendingQuote: string | undefined + for (const line of lines) { + const content = lineContent(line) + if (pendingQuote !== undefined) { + // Inside a quoted multi-line value: never an assignment, always kept. + if (closesQuote(content, pendingQuote)) pendingQuote = undefined + out.push(line) continue } - out.push(current) + const match = ASSIGNMENT.exec(content) + if (match === null) { + out.push(line) + continue + } + const [, key, valuePart] = match + if (key !== ref) { + pendingQuote = opensMultiline(valuePart ?? '') + out.push(line) + continue + } + // The write path refuses multi-line targets before rendering, so the + // matched assignment is single-line and drops or rewrites wholesale. + if (rendered !== undefined && !placed) { + out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`) + placed = true + } } - if (line !== undefined && !placed) out.push(line) - return out.length === 0 ? '' : `${out.join('\n')}\n` + if (rendered !== undefined && !placed) { + const last = out[out.length - 1] + if (last !== undefined && lineTerminator(last) === '') { + out[out.length - 1] = `${last}${dominant}` + } + out.push(`${rendered}${dominant}`) + } + return out.join('') } /** File-backed credentials provider (`$DSH_HOME/.env`). */ @@ -137,10 +202,12 @@ export class CredentialsLocal extends Credentials { private text: string | undefined /** Parsed document snapshot; replaced wholesale on every reload. */ private values = new Map() - /** Serializes watcher-triggered reloads so reads never interleave. */ - private refreshTask: Promise = Promise.resolve() - /** Serializes writes to the one document; settled tail. */ - private writeChain: Promise = Promise.resolve() + /** + * Single exclusive operation chain: watcher reloads and line edits run one + * at a time in queue order (settled tail), so an edit can never render from + * text a concurrent reload is busy replacing. + */ + private operations: Promise = Promise.resolve() /** Set at dispose: refuse new writes and let in-flight work no-op. */ private closed = false @@ -159,10 +226,10 @@ export class CredentialsLocal extends Credentials { async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { yield async () => { - // Drain: refuse new writes, then settle the queued ones so disposal + // Drain: refuse new operations, then settle the queued ones so disposal // completes only once storage is quiescent. this.closed = true - await this.writeChain + await this.operations } await this.loadInitial() if (!this.spec.watch) return @@ -178,26 +245,27 @@ export class CredentialsLocal extends Credentials { }) watcher.on('all', () => { if (this.closed) return - this.refreshTask = this.refreshTask.then(() => this.refresh()).catch((error: unknown) => { - // Only an invariant violation escaping the update fan-out can reject a - // refresh; keep the reload queue alive and surface it as an error so - // one poisoned commit cannot silently end hot reloading forever. - this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename) - this.ctx.logger.error(error) - }) + this.queueRefresh() + }) + watcher.on('ready', () => { + // The initial load raced the watcher's own setup: a change written + // between that read and the watcher becoming active never fires an + // event. One reconcile at ready closes the gap. + if (this.closed) return + this.queueRefresh() }) watcher.on('error', (error) => { this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) this.ctx.logger.warn(error) }) - /* jscpd:ignore-end */ yield async () => { // Quiesce: stop accepting events, close the watcher, then wait out any - // queued or in-flight refresh so nothing publishes after disposal. + // queued or in-flight operation so nothing publishes after disposal. this.closed = true await watcher.close() - await this.refreshTask + await this.operations } + /* jscpd:ignore-end */ } override resolve(ref: CredentialRef): Promise { @@ -215,7 +283,9 @@ export class CredentialsLocal extends Credentials { } const stored = this.values.get(ref) if (stored !== undefined && stored.length > 0) { - return Promise.resolve({ configured: true, source: 'file', writable: true }) + // A quoted multi-line value resolves fine but the line editor refuses to + // rewrite it, so writability must say what set() would actually do. + return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') }) } return Promise.resolve({ configured: false, writable: true }) } @@ -231,6 +301,24 @@ export class CredentialsLocal extends Credentials { await this.write(ref, undefined) } + /** Queue one exclusive document operation behind every earlier one. */ + private enqueue(operation: () => Promise): Promise { + const task = this.operations.then(operation) + this.operations = task.then(() => undefined, () => undefined) + return task + } + + /** Queue a reload; only an invariant violation escaping the fan-out can reject it. */ + private queueRefresh(): void { + void this.enqueue(() => this.refresh()).catch((error: unknown) => { + // Only an invariant violation escaping the update fan-out can reject a + // refresh; keep the operation queue alive and surface it as an error so + // one poisoned commit cannot silently end hot reloading forever. + this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename) + this.ctx.logger.error(error) + }) + } + /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */ private async write(ref: CredentialRef, value: string | undefined): Promise { const verb = value === undefined ? 'unset' : 'set' @@ -238,32 +326,43 @@ export class CredentialsLocal extends Credentials { throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`) } this.assertUnshadowed(ref, verb) - // The stored tail is settled on both outcomes, so chaining needs no catch - // and one rejected write can never poison the queue for later callers. - const previous = this.writeChain - const run = previous.then(async () => { + return this.enqueue(async () => { if (this.isClosed()) { throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`) } // Re-judged at run time: the environment may have changed while queued. this.assertUnshadowed(ref, verb) - const existing = this.values.get(ref) - if (value === undefined && existing === undefined) return - if (existing !== undefined && existing.includes('\n')) { - throw new Error( - `credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`, - ) - } - const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value)) - // 0600: a document holding secrets is never world-readable. - await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600 }) - this.text = nextText - if (value === undefined) this.values.delete(ref) - else this.values.set(ref, value) - this.ctx.emit('credentials/updated', ref) + // The writer lock's exclusive create needs the parent to exist; 0700 + // because the harness home holds user-private data. + await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) + await withFileLock(this.spec.filename, async () => { + // Read-modify-write: fold in any on-disk state this process has not + // observed yet — an external edit still inside the watcher debounce + // window, a change the watcher missed, or another process's write — + // so the line edit below can never resurrect a stale document. + await this.reconcileFromDisk() + const existing = this.values.get(ref) + if (value === undefined && existing === undefined) return + if (existing !== undefined && existing.includes('\n')) { + throw new Error( + `credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`, + ) + } + const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value)) + // 0600: a document holding secrets is never world-readable. + await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 }) + this.text = nextText + if (value === undefined) this.values.delete(ref) + else this.values.set(ref, value) + // After the commit: a broken observer must never make the durable + // write look failed (an INVARIANT failure still rethrows). + this.notifyUpdated(ref) + }, { + onStaleBreak: (lockPath) => { + this.ctx.logger.warn('credentials-local: breaking a stale writer lock at %s', lockPath) + }, + }) }) - this.writeChain = run.then(() => undefined, () => undefined) - return run } /** Reject a write the live environment would shadow into apparent no-effect. */ @@ -294,19 +393,33 @@ export class CredentialsLocal extends Credentials { * Re-read the document after a watcher event. Unchanged content (including * this provider's own writes) is a no-op; an unreadable document keeps the * last good snapshot and warns — a live hot-reload must never take the - * process down. dotenv parsing is lenient by design and cannot fail. + * process down. An invariant violation escaping the fan-out is not a reload + * failure and propagates to the queue's error surface. */ private async refresh(): Promise { if (this.closed) return + try { + await this.reconcileFromDisk() + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error + this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename) + this.ctx.logger.warn(error) + } + } + + /** + * Compare the on-disk text against the cache and publish any difference + * into the seam. Absence publishes the empty store; an unreadable file + * throws, so each caller picks its policy — a reload warns and keeps the + * last good snapshot, a write fails loud. dotenv parsing is lenient by + * design and cannot fail. + */ + private async reconcileFromDisk(): Promise { let text: string | undefined try { text = await readFile(this.spec.filename, 'utf8') } catch (error) { - if (!isENOENT(error)) { - this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename) - this.ctx.logger.warn(error) - return - } + if (!isENOENT(error)) throw error text = undefined } if (text === this.text || this.isClosed()) return @@ -314,7 +427,7 @@ export class CredentialsLocal extends Credentials { const changed = this.changedRefs(this.values, next) this.text = text this.values = next - for (const ref of changed) this.ctx.emit('credentials/updated', ref) + for (const ref of changed) this.notifyUpdated(ref) } /** Seam-addressable entries whose effective (non-empty) value changed. */ diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts index 6c05759b54..baefbd52c5 100644 --- a/packages/credentials/credentials-local/tests/drain.spec.ts +++ b/packages/credentials/credentials-local/tests/drain.spec.ts @@ -6,11 +6,15 @@ import { join } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '../src/index.ts' -// The atomic write is the only asynchronous hold point inside a queued write; -// gating it makes the dispose-versus-queued-write race fully deterministic. -vi.mock('@deepseek-ai/dsh-atomic-write', () => { +// The atomic write is the gated asynchronous hold point inside a queued +// write; gating it makes the dispose-versus-queued-write race fully +// deterministic. The lock helper passes through so the gated operation still +// runs inside its real acquire/release cycle. +vi.mock('@deepseek-ai/dsh-atomic-write', async (importOriginal) => { + const actual = await importOriginal() let gate: Promise = Promise.resolve() return { + ...actual, writeFileAtomic: vi.fn(() => gate), __setGate: (next: Promise) => { gate = next diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts new file mode 100644 index 0000000000..7583cf0813 --- /dev/null +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -0,0 +1,202 @@ +// Third-review behaviors: read-modify-write under the writer lock (external +// edits survive an API write), the contained credentials/updated fan-out (a +// broken observer never fails a committed write), and the physical-line +// editor's multi-line and CRLF discipline. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { CredentialsLocal } from '../src/index.ts' + +const ALPHA = credentialRef('DSH_REVIEW_ALPHA') +const BETA = credentialRef('DSH_REVIEW_BETA') +const INNER = credentialRef('DSH_REVIEW_INNER') + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!() +}) + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-review-')) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + return dir +} + +async function boot(config: ConstructorParameters[1]): Promise { + const ctx = new Context() + const fiber = ctx.plugin(CredentialsLocal, config) + cleanups.push(async () => { await fiber.dispose() }) + await fiber + return ctx +} + +describe('read-modify-write', () => { + it('folds an unobserved external edit into a write instead of overwriting it', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + const seen: string[] = [] + ctx.on('credentials/updated', (ref) => { seen.push(ref) }) + await ctx.credentials.set(ALPHA, 'one') + // The external edit has landed on disk but no watcher reported it (watch + // is off — the same blind spot as a debounce window or a missed event). + await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`) + await ctx.credentials.set(ALPHA, 'two') + const text = await readFile(path, 'utf8') + expect(text).toContain(`${BETA}=external`) + expect(text).toContain(`${ALPHA}=two`) + // The fold published the unobserved entry before the write's own commit. + expect(seen).toEqual([ALPHA, BETA, ALPHA]) + expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' }) + }) + + it('keeps both refs when two providers write the same document concurrently', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const first = await boot({ path, watch: false }) + const second = await boot({ path, watch: false }) + await Promise.all([ + (async () => { for (const value of ['1', '2', '3'] as const) await first.credentials.set(ALPHA, value) })(), + (async () => { for (const value of ['1', '2', '3'] as const) await second.credentials.set(BETA, value) })(), + ]) + const third = await boot({ path, watch: false }) + expect(await third.credentials.resolve(ALPHA)).toEqual({ value: '3', source: 'file' }) + expect(await third.credentials.resolve(BETA)).toEqual({ value: '3', source: 'file' }) + }) + + it('breaks a stale writer lock with a warning and writes through', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + await writeFile(`${path}.lock`, 'crashed-holder\n') + const past = (Date.now() - 60_000) / 1000 + await utimes(`${path}.lock`, past, past) + await ctx.credentials.set(ALPHA, 'nine') + expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=nine`) + }) + + it('creates the credentials directory owner-only', async () => { + const dir = await tempDir() + const home = join(dir, 'home') + const ctx = await boot({ path: join(home, '.env'), watch: false }) + await ctx.credentials.set(ALPHA, 'one') + expect((await stat(home)).mode & 0o777).toBe(0o700) + }) +}) + +describe('contained update fan-out', () => { + it('does not fail a committed set when a listener throws, and later listeners still run', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, '.env'), watch: false }) + ctx.on('credentials/updated', () => { + throw new Error('observer boom') + }) + const second = vi.fn() + ctx.on('credentials/updated', second) + await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined() + expect(second).toHaveBeenCalledWith(ALPHA) + expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' }) + }) + + it('contains an async listener rejection', async () => { + const dir = await tempDir() + const ctx = await boot({ path: join(dir, '.env'), watch: false }) + // An unknown-returning function keeps the typed surface legal while the + // runtime value is still the rejected promise the containment must handle. + const boom = (): unknown => Promise.reject(new Error('async observer boom')) + ctx.on('credentials/updated', boom) + await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined() + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const ctx = await boot({ path, watch: false }) + ctx.on('credentials/updated', () => { + throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) + }) + const second = vi.fn() + ctx.on('credentials/updated', second) + await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/) + // Harness-fatal by design — but the write itself committed first. + expect(second).toHaveBeenCalledWith(ALPHA) + expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`) + expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' }) + }) +}) + +describe('physical-line editor', () => { + it('never mistakes a quoted multi-line continuation for an assignment', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n` + await writeFile(path, wrapped) + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(ALPHA, 'b') + // The wrapped value survives byte-for-byte; only ALPHA's line changed. + const afterAlpha = await readFile(path, 'utf8') + expect(afterAlpha).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n`) + // Setting the inner-looking ref appends a real assignment; the + // continuation line inside the quoted value stays untouched. + await ctx.credentials.set(INNER, 'real') + const afterInner = await readFile(path, 'utf8') + expect(afterInner).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n${INNER}=real\n`) + expect(await ctx.credentials.resolve(INNER)).toEqual({ value: 'real', source: 'file' }) + }) + + it('preserves CRLF line endings on untouched and edited lines', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`) + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(ALPHA, 'b') + expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`) + await ctx.credentials.set(INNER, 'new') + expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`) + }) + + it('terminates a final unterminated line before appending', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `${ALPHA}=a`) + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(BETA, 'b') + expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`) + }) + + it('rewrites a final unterminated assignment in the dominant ending style', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `${ALPHA}=a`) + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(ALPHA, 'b') + expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`) + }) + + it('tracks a single-quoted multi-line value through its continuation', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`) + const ctx = await boot({ path, watch: false }) + await ctx.credentials.set(ALPHA, 'x') + expect(await readFile(path, 'utf8')) + .toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`) + }) + + it('reports a multi-line entry as unwritable and refuses to edit it', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `${ALPHA}="line1\nline2"\n`) + const ctx = await boot({ path, watch: false }) + expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false }) + await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/) + await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/) + // Resolution still serves the multi-line value. + expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' }) + }) +}) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index 798cdc8a88..6ff53252cf 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -151,6 +151,7 @@ describe('watcher pipeline', () => { await fiber.dispose() disposed = true instance!.watcher.emit('all', 'change', path) + instance!.watcher.emit('ready') await new Promise(resolve => setTimeout(resolve, 100)) expect(postDisposeCommits).toBe(0) }) @@ -204,4 +205,19 @@ describe('watcher pipeline', () => { await new Promise(resolve => setTimeout(resolve, 50)) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() }) + + it('reconciles at watcher ready so a change during setup is not missed', async () => { + const dir = await tempDir() + const path = join(dir, '.env') + await writeFile(path, `${KEY}=a\n`) + const ctx = await boot({ path, debounceMs: 5 }) + // Written after the initial load but before the watcher became active: + // no 'all' event will ever fire for it. + await writeFile(path, `${KEY}=written-before-ready\n`) + const [instance] = await fakeInstances() + instance!.watcher.emit('ready') + await vi.waitFor(async () => { + expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'written-before-ready', source: 'file' }) + }) + }) }) diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index 2df89132ac..b640b42881 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -55,7 +55,12 @@ declare module 'cordis' { /** * Committed change to a provider-managed credential source: a `set`, an * `unset`, or an external edit observed in storage. Ambient - * process-environment changes are not observable and never emit. + * process-environment changes are not observable and never emit. Listener + * failures are contained and logged — a sync throw and an async rejection + * alike — without changing the committed operation's outcome, except + * `INVARIANT`-coded failures, which rethrow after every listener ran; + * that rethrow reaches the emitter only from synchronous listeners, so + * invariant checks on this event must not be async functions. * @param ref - the reference whose stored value changed. * @mode emit */ @@ -109,6 +114,49 @@ export abstract class Credentials extends Service { * @param ref - the reference to remove. */ abstract unset(ref: CredentialRef): Promise + + /* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit + fan-out: the contained-dispatch shape is the reviewed listener-lifecycle + contract, and extracting it would couple the two seams' event semantics. */ + /** + * Fan `credentials/updated` out with contained listener failures: every + * listener runs, and a sync throw or async rejection is logged without + * changing the committed operation's outcome — except `INVARIANT`-coded + * failures, which rethrow after every listener ran (the rethrow reaches the + * caller only from synchronous listeners, so invariant checks on this event + * must not be async functions). Providers call this only after the write or + * reload actually committed, so a broken observer can never make a durable + * change look failed. + * @param ref - the reference whose stored value changed. + */ + protected notifyUpdated(ref: CredentialRef): void { + let invariantFailure: unknown + const args = ['credentials/updated', ref] + for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { + try { + const returned = listener(ref) + if (returned != null && typeof (returned as PromiseLike).then === 'function') { + void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { + this.warnListenerFailure(ref, error) + }) + } + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { + invariantFailure ??= error + continue + } + this.warnListenerFailure(ref, error) + } + } + if (invariantFailure !== undefined) throw invariantFailure as Error + } + /* jscpd:ignore-end */ + + /** Contained-listener diagnostic shared by the sync and async failure paths. */ + private warnListenerFailure(ref: CredentialRef, error: unknown): void { + this.ctx.logger.warn('credentials: a credentials/updated listener for "%s" failed', ref) + this.ctx.logger.warn(error) + } } export default Credentials diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index c129285ea6..8043e6db45 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -10,10 +10,10 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, readFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' -import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings' @@ -96,23 +96,6 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -/** Whether an exclusive create failed because the path already exists. */ -function isEEXIST(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' -} - -/** - * Writer-lock protocol constants. These are robustness invariants of the - * cross-process write protocol, not deployment tunables: a holder rewrites one - * small document in milliseconds, so contention resolves well inside the - * retry deadline, and a lock older than the stale age can only belong to a - * crashed holder. - */ -const LOCK_RETRY_INITIAL_MS = 20 -const LOCK_RETRY_MAX_MS = 200 -const LOCK_TIMEOUT_MS = 2_000 -const LOCK_STALE_MS = 5_000 - /** File-backed settings provider (`settings.yaml`/`.json`). */ export class SettingsLocal extends Settings { static Config: z = z.object({ @@ -199,8 +182,9 @@ export class SettingsLocal extends Settings { private async persistSection(ns: SettingsNamespace, section: Record): Promise { // The writer lock's exclusive create needs the parent to exist before // writeFileAtomic gets its own chance to create it. - await mkdir(dirname(this.spec.filename), { recursive: true }) - await this.withWriterLock(async () => { + // 0700: the harness home holds user-private documents. + await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) + await withFileLock(this.spec.filename, async () => { // Read-modify-write: fold in any on-disk state this process has not // observed yet — an external edit still inside the watcher debounce // window, a change the watcher missed, or another process's write — so @@ -212,59 +196,13 @@ export class SettingsLocal extends Settings { ? this.renderYaml(ns, section) : this.renderJson(ns, section) // 0600: a document that may hold personal values is never world-readable. - await writeFileAtomic(this.spec.filename, output, { mode: 0o600 }) + await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 }) this.text = output - }) - } - - /** - * Hold the cross-process writer lock around one read-render-rename cycle. - * The lock is a `wx`-created sibling (`.lock`); the rename-based - * commit keeps readers lock-free, so only writers contend. A lock older - * than {@link LOCK_STALE_MS} is a crashed holder and is broken with a - * warning; a live holder past {@link LOCK_TIMEOUT_MS} fails the write. - */ - private async withWriterLock(operation: () => Promise): Promise { - const lockPath = `${this.spec.filename}.lock` - const deadline = Date.now() + LOCK_TIMEOUT_MS - let delay = LOCK_RETRY_INITIAL_MS - for (;;) { - try { - await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) - break - } catch (error) { - if (!isEEXIST(error)) throw error - } - const ageMs = await this.lockAgeMs(lockPath) - // The holder released between the failed create and the stat: the lock - // is free right now, so retry without burning backoff or deadline. - if (ageMs === undefined) continue - if (ageMs > LOCK_STALE_MS) { + }, { + onStaleBreak: (lockPath) => { this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath) - await rm(lockPath, { force: true }) - continue - } - if (Date.now() >= deadline) { - throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`) - } - await new Promise(resolve => setTimeout(resolve, delay)) - delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS) - } - try { - return await operation() - } finally { - await rm(lockPath, { force: true }) - } - } - - /** Age of the writer lock, or `undefined` when it vanished after a failed create. */ - private async lockAgeMs(lockPath: string): Promise { - try { - return Date.now() - (await stat(lockPath)).mtimeMs - } catch (error) { - if (!isENOENT(error)) throw error - return undefined - } + }, + }) } override async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts index f4a20c10bc..e7148f41ad 100644 --- a/packages/util/atomic-write/src/index.ts +++ b/packages/util/atomic-write/src/index.ts @@ -1,14 +1,17 @@ /** - * Zero-dependency atomic file replacement. `writeFileAtomic` writes a - * random-suffix sibling with exclusive create and the caller's permission - * bits, then renames it over the target, so readers observe either the old or - * the new complete content and a replaced file ends up with exactly the - * stated mode. + * Zero-dependency atomic file replacement and writer coordination. + * `writeFileAtomic` writes a random-suffix sibling with exclusive create and + * the caller's permission bits, then renames it over the target, so readers + * observe either the old or the new complete content and a replaced file ends + * up with exactly the stated mode. `withFileLock` serializes cross-process + * writers of one file through a `wx`-created `.lock` sibling, so a + * read-modify-write cycle can never resurrect a state another writer just + * replaced; readers stay lock-free because the rename commit is atomic. * @module @deepseek-ai/dsh-atomic-write */ import { randomBytes } from 'node:crypto' -import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' /** @@ -21,6 +24,12 @@ export interface WriteFileAtomicOptions { * rename (subject to the process umask, like every fresh inode). */ mode: number + /** + * Permission bits for parent directories this call creates (subject to the + * umask; existing directories keep their mode). Omission uses the mkdir + * default — pass `0o700` when the tree holds user-private data. + */ + dirMode?: number } /** @@ -38,7 +47,10 @@ export interface WriteFileAtomicOptions { * @param options - permission bits for the replacement inode. */ export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise { - await mkdir(dirname(filename), { recursive: true }) + await mkdir(dirname(filename), { + recursive: true, + ...options.dirMode === undefined ? {} : { mode: options.dirMode }, + }) const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp` try { await writeFile(temp, content, { mode: options.mode, flag: 'wx' }) @@ -48,3 +60,94 @@ export async function writeFileAtomic(filename: string, content: string, options throw error } } + +/** Whether an exclusive create failed because the path already exists. */ +function isEEXIST(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + +/** Whether a filesystem error means absence. */ +function isENOENT(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} + +/** + * Writer-lock protocol constants. These are robustness invariants of the + * cross-process write protocol, not deployment tunables: a holder rewrites one + * small file in milliseconds, so contention resolves well inside the retry + * deadline, and a lock older than the stale age can only belong to a crashed + * holder. + */ +const LOCK_RETRY_INITIAL_MS = 20 +const LOCK_RETRY_MAX_MS = 200 +const LOCK_TIMEOUT_MS = 2_000 +const LOCK_STALE_MS = 5_000 + +/** Options for {@link withFileLock}. */ +export interface WithFileLockOptions { + /** + * Called once each time a stale (crashed-holder) lock is broken, so the + * caller can log the takeover in its own voice. + */ + onStaleBreak?: (lockPath: string) => void +} + +/** Age of the lock file, or `undefined` when it vanished after a failed create. */ +async function lockAgeMs(lockPath: string): Promise { + try { + return Date.now() - (await stat(lockPath)).mtimeMs + } catch (error) { + if (!isENOENT(error)) throw error + return undefined + } +} + +/** + * Hold the cross-process writer lock for `filename` around one operation. The + * lock is a `wx`-created sibling (`.lock`); paired with the + * rename-based commit of {@link writeFileAtomic}, readers stay lock-free and + * only writers contend. Contention backs off exponentially; a lock older than + * the stale age is a crashed holder and is broken (see + * {@link WithFileLockOptions.onStaleBreak}); a live holder past the deadline + * fails the operation with a timed-out error. The parent directory must exist. + * @param filename - the file whose writers this lock serializes. + * @param operation - the read-render-commit cycle to run while holding the lock. + * @param options - stale-takeover notification hook. + * @returns the operation's result; the lock releases on both outcomes. + */ +export async function withFileLock( + filename: string, + operation: () => Promise, + options?: WithFileLockOptions, +): Promise { + const lockPath = `${filename}.lock` + const deadline = Date.now() + LOCK_TIMEOUT_MS + let delay = LOCK_RETRY_INITIAL_MS + for (;;) { + try { + await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) + break + } catch (error) { + if (!isEEXIST(error)) throw error + } + const ageMs = await lockAgeMs(lockPath) + // The holder released between the failed create and the stat: the lock is + // free right now, so retry without burning backoff or deadline. + if (ageMs === undefined) continue + if (ageMs > LOCK_STALE_MS) { + options?.onStaleBreak?.(lockPath) + await rm(lockPath, { force: true }) + continue + } + if (Date.now() >= deadline) { + throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`) + } + await new Promise(resolve => setTimeout(resolve, delay)) + delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS) + } + try { + return await operation() + } finally { + await rm(lockPath, { force: true }) + } +} From 8f045bfdbd9c0cf48dde296b705fe18adfb437c5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 15:44:32 +0800 Subject: [PATCH 14/37] fix(cli)!: stop hoisting $DSH_HOME/.env into process.env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped surfaces loaded the harness home's .env into the process environment before cordis booted. credentials-local then saw every stored key as an ambient launch override: describe reported source 'env' with writable false, and set/unset rejected as shadowed — so a key the web page or TUI stored was unrotatable and undeletable from the next run onward, and the adapter kept using the value captured at launch. The home's .env is now the credential provider's own store, read by that provider alone and hot-reloaded by it. The genuine launch environment and the invoking directory's .env (loaded by the bin) remain the read-only ambient layer, so a plain composition without the provider still resolves keys exactly as before. Proven by a real restart in the loader composition: store a key through the seam, dispose the tree, re-boot over the same harness home, and the entry is still file-sourced and writable — rotating it lands on the very next request. --- apps/cli/README.md | 2 +- apps/cli/src/app-cli-entry.ts | 17 +++------ apps/cli/src/tui.ts | 17 +++++---- .../tests/loader-composition.spec.ts | 38 +++++++++++++++++-- packages/ui/app-boot/README.md | 4 +- 5 files changed, 53 insertions(+), 25 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index 93c36d18ab..1decf018f5 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -12,7 +12,7 @@ The TUI surface: - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; -- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. +- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 668d8f7f02..aa488ab045 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,10 +1,11 @@ /** * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share * (`dsh web` and `dsh -p` boot the one composition; TUI migrates later). - * Everything here is what must exist before the Loader runs: layered env, - * the patch composition over the shipped cordis.yml (profile json + CLI - * flags + the resolved frontend dist), and the fail-loud triple after the - * tree settles. + * Everything here is what must exist before the Loader runs: the patch + * composition over the shipped cordis.yml (profile json + CLI flags + the + * resolved frontend dist) and the fail-loud triple after the tree settles. + * The environment is what the bin already loaded (ambient plus the invoking + * directory's `.env`); `$DSH_HOME/.env` belongs to the credential provider. */ import { readFileSync } from 'node:fs' @@ -17,7 +18,7 @@ import type { FiberState } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot' +import { assertEntriesLoaded, installFailLoud } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -147,7 +148,6 @@ export class AppCLIEntry { * @returns the settled root context and the listening port. */ async run(): Promise<{ ctx: Context; port: number }> { - this.loadEnvLayers() this.composePatches() await this.bootTree() this.assertBoot() @@ -157,11 +157,6 @@ export class AppCLIEntry { return { ctx: this.ctx, port } } - /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */ - private loadEnvLayers(): void { - loadEnv('dsh', resolveDshHome()) - } - /** * Compose the patch set from the non-yml config sources: computed * engineering defaults (the global session root), profile json (user diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 0d402c1c51..ffb241631b 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -1,9 +1,10 @@ /** * `dsh` default surface — the interactive TUI coding agent. Boots the shipped * tui-agent config (or the `--config` override) with the personal overlay - * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: - * ambient environment, then the invoking directory's `.env`, then the personal one) - * and its `config.yaml` patches the booted tree. The workspace is the invoking + * from the Harness home (`~/.dsh`): its `config.yaml` patches the booted tree. + * The environment layers are the ambient one and the invoking directory's + * `.env`; `$DSH_HOME/.env` stays the credential provider's own store and is + * never hoisted into `process.env`. The workspace is the invoking * directory: sessions, relative paths, and workspace instructions resolve from * the cwd, so `dsh` acts on whatever project it is launched in. After boot, the * agent's system prompt is told the path to this harness checkout so it can find @@ -17,12 +18,10 @@ import { addHarnessSourceSection, boot, installFailLoud, - loadEnv, loadPersonalPatches, RESUME_SESSION_ID_KEY, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' import type { Context } from 'cordis' import { TUI_GOODBYE_MESSAGE_KEY, @@ -63,9 +62,11 @@ export async function runTui(config: string | undefined, resumeSessionId: string process.exit(1) } installFailLoud(NAME) - // The bin already loaded the invoking directory's .env; the personal .env - // only fills what is still unset (process.loadEnvFile never overrides). - loadEnv(NAME, resolveDshHome()) + // The bin already loaded the invoking directory's .env as the ambient + // layer. `$DSH_HOME/.env` is deliberately NOT loaded here: it is the + // credential provider's own writable store, and hoisting it into + // process.env would make every stored key look like a read-only launch + // override on the next run, blocking rotation from the TUI and the web page. process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills') // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume` // flag, so the resumed process rehydrates through this same intake. The host diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 9ca8c87367..402f94441d 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -41,12 +41,15 @@ afterEach(async () => { }) async function loadComposition( - options: { withDynamic: boolean; baseURL: string }, + options: { withDynamic: boolean; baseURL: string; reuseRoot?: string }, ): Promise<{ ctx: Context; settingsPath: string; envPath: string }> { - root = await mkdtemp(join(tmpdir(), 'dsh-llm-composition-')) + // A reused root is the restart case: the same harness home, its documents + // exactly as the previous process left them. + const fresh = options.reuseRoot === undefined + root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-')) const settingsPath = join(root, 'settings.yaml') const envPath = join(root, '.env') - if (options.withDynamic) { + if (options.withDynamic && fresh) { await writeFile(settingsPath, '# personal settings\n') await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n') } @@ -129,6 +132,35 @@ describe('llm-deepseek real dynamic composition', () => { expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key') }) + it('keeps a stored key writable and rotatable across a real restart', async () => { + // No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist + // $DSH_HOME/.env into process.env, so a stored key must stay file-sourced. + vi.stubEnv('DEEPSEEK_API_KEY', '') + const first = await mockServer([{ kind: 'sse', events: textEvents }]) + const second = await mockServer([{ kind: 'sse', events: textEvents }]) + const boot = await loadComposition({ withDynamic: true, baseURL: first.url }) + const home = root! + await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui') + expect(await boot.ctx.get('credentials')!.describe(KEY_REF)) + .toEqual({ configured: true, source: 'file', writable: true }) + await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui') + await boot.ctx.fiber.dispose() + context = undefined + + // Restart over the same harness home. + const restarted = await loadComposition({ withDynamic: true, baseURL: second.url, reuseRoot: home }) + const credentials = restarted.ctx.get('credentials')! + // The stored key is still the provider's own writable file entry — not a + // read-only launch override, which is what hoisting it would have made it. + expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' }) + expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true }) + // Rotation still works after the restart, and the next request uses it. + await credentials.set(KEY_REF, 'rotated-after-restart') + await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart') + }) + it('boots the same adapter without settings or credentials entries on entry config alone', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const server = await mockServer([{ kind: 'sse', events: textEvents }]) diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 0282d3e955..47c5de35e6 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -26,8 +26,8 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: -- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`. -- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. +- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. +- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures. From 54f95d7669af550c7c8f986bca223b908a7f1ef6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 15:51:35 +0800 Subject: [PATCH 15/37] fix(llm): atomic route replacement, whole-snapshot requests, and loud credential misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings across the seam and both adapters. registerAdapter now returns a handle carrying replace(providers): the candidate route set is validated in full before anything moves, so a route another adapter owns leaves the previous registration intact, and the swap itself is one synchronous section with no observable gap. pi-ai uses it instead of dispose-then-register — the old shape dropped every route when the new set conflicted, and its facts cache could then equal the registry's, so reverting to a working configuration never re-applied. Its registration facts are also sorted by provider, so a settings document that merely reorders keys no longer triggers a swap. DeepSeek's per-request snapshot now carries the credential facts, and resolveApiKey receives it instead of re-reading the raw config: a settings generation the resolver rejects can no longer contribute its literal key to a request the previous generation's endpoint serves. pi-ai only defers to the SDK's provider-native discovery when a profile names no credential at all; a configured apiKeyEnv that misses now fails with MISSING_CREDENTIAL naming the route and the reference, instead of handing pi-ai undefined and letting it authenticate with an unrelated ambient key. The eager boot-time credential probe is gone: it could run before the credentials service mounted and reported every failure as a missing key. The route stays registered and browsable; the first request gives the accurate error, whose guidance now leads with the credential store and mentions a literal apiKey last. --- packages/llm/llm-deepseek/src/adapter.ts | 22 +++- packages/llm/llm-deepseek/src/index.ts | 41 ++++--- .../llm/llm-deepseek/tests/adapter.spec.ts | 4 +- .../llm-deepseek/tests/dynamic-config.spec.ts | 23 ++++ packages/llm/llm-pi-ai/src/adapter.ts | 8 +- packages/llm/llm-pi-ai/src/index.ts | 64 ++++++++--- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 14 ++- .../llm-pi-ai/tests/dynamic-config.spec.ts | 51 ++++++++- packages/llm/llm/src/index.ts | 101 +++++++++++++----- 9 files changed, 252 insertions(+), 76 deletions(-) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 0163dd3cab..a4b02a3e39 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -17,6 +17,7 @@ import type { ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' @@ -45,6 +46,14 @@ export interface DeepSeekCatalogModel { export interface DeepSeekConnectionOptions { /** Endpoint base; `/chat/completions` is appended. */ baseURL: string + /** + * Literal API key of this same resolution, when the configuration carried + * one. Travelling with the endpoint is the point: a request can never pair + * one generation's URL with another generation's secret. + */ + apiKey?: string + /** Credential reference of this same resolution, resolved per request when no literal key exists. */ + apiKeyEnv: CredentialRef /** Request defaults applied to every call (thinking mode, effort). */ defaults: RequestDefaults /** Positive context capacity used when the selected model has no exact value. */ @@ -62,11 +71,12 @@ export interface DeepSeekAdapterOptions { /** Current validated connection facts; called once per operation. */ options: () => DeepSeekConnectionOptions /** - * Resolve the bearer token for one request; called once per stream call and - * frozen for that call. Throws `LlmError` `MISSING_CREDENTIAL` when no key - * is available anywhere. + * Resolve the bearer token for the connection facts of one request. The + * snapshot is passed in — never re-read — so the key can only ever come + * from the same resolution as the endpoint it is sent to. Throws `LlmError` + * `MISSING_CREDENTIAL` when no key is available anywhere. */ - resolveApiKey: () => Promise + resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -189,8 +199,10 @@ export class DeepSeekAdapter extends LlmAdapter { // One resolution per stream call: connection facts and the credential // freeze here and hold for this whole request, so an in-flight stream // never observes a configuration change and the next call re-resolves. + // The key resolves *from this snapshot*, so an endpoint and the secret + // sent to it can never come from different configuration generations. const connection = this.config.options() - const apiKey = await this.config.resolveApiKey() + const apiKey = await this.config.resolveApiKey(connection) const consumer = new AbortController() const upstream = options.signal === undefined ? consumer.signal diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index bb2ccdaa11..6623351774 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -16,7 +16,6 @@ import z from 'schemastery' import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' -import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' @@ -32,6 +31,8 @@ export const inject = ['llm'] const NS = settingsNamespace('llm-deepseek') const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' +/** The single provider route this plugin owns. */ +const PROVIDER = 'deepseek' const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 }, @@ -89,11 +90,13 @@ export const Config: z = z.object({ /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ export const PUBLIC_BASE_URL = 'https://api.deepseek.com' -/** Connection facts plus the plugin-consumed credential reference. */ -export interface ResolvedDeepSeekOptions extends DeepSeekConnectionOptions { - /** Reference resolved per request when no literal key is configured. */ - apiKeyEnv: CredentialRef -} +/** + * One resolution's complete request facts. Connection and credential facts + * are one value on purpose: a snapshot the resolver rejects keeps the whole + * previous generation, so a request can never pair a stale endpoint with a + * newer key. + */ +export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions /** Resolve, validate, and detach the advisory model catalog. */ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] { @@ -147,6 +150,7 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { ) } return { + ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {}, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, defaults: { @@ -187,10 +191,11 @@ export function apply(ctx: Context, config: Config): void { } options() - const resolveApiKey = async (): Promise => { - const raw = current() - if (raw.apiKey !== undefined && raw.apiKey.length > 0) return raw.apiKey - const ref = options().apiKeyEnv + const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { + // Every credential fact comes from the caller's snapshot, so a rejected + // settings generation cannot leak its key onto the previous endpoint. + if (connection.apiKey !== undefined) return connection.apiKey + const ref = connection.apiKeyEnv const credentials = ctx.get('credentials') if (credentials !== undefined) { const hit = await credentials.resolve(ref) @@ -202,8 +207,9 @@ export function apply(ctx: Context, config: Config): void { if (ambient !== undefined && ambient.length > 0) return ambient } throw new LlmError( - 'llm-deepseek: no API key for provider route "deepseek"; set the llm-deepseek "apiKey" setting,' - + ` store ${ref} with the credentials service, or export ${ref}`, + `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` + + ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a` + + ' last resort — set a literal "apiKey" in the llm-deepseek settings section', 'MISSING_CREDENTIAL', ) } @@ -211,7 +217,7 @@ export function apply(ctx: Context, config: Config): void { const adapter = new DeepSeekAdapter({ options, resolveApiKey }) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. - let disposeRoute = ctx.llm.registerAdapter(['deepseek'], adapter) + let disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter) let registeredPolicy = options().retryPolicy const ensureRegistrationFacts = (): void => { const policy = options().retryPolicy @@ -220,17 +226,10 @@ export function apply(ctx: Context, config: Config): void { // fact per-request resolution cannot refresh: swap the registration in one // synchronous section (same adapter instance, no NO_ADAPTER window). disposeRoute() - disposeRoute = ctx.llm.registerAdapter(['deepseek'], adapter) + disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter) registeredPolicy = policy } - void resolveApiKey().then(() => undefined, () => { - // Expected on a first boot with dynamic sources: the route stays - // registered (the catalog is browsable) and each request fails with the - // actionable MISSING_CREDENTIAL message until a key arrives. - ctx.logger.warn('llm-deepseek: no API key resolved yet for route "deepseek"; requests will fail until one is configured') - }) - installSettingsSection(ctx, NS, Config, config, { setSource: (source) => { current = source diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 935235d825..b5a4bc9ff4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -817,8 +817,10 @@ describe('plugin registration and config', () => { await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + // The guidance leads with the credential store — the path that keeps the + // secret out of configuration files — and mentions a literal key last. await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY/) + .rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s) }) it('prefers explicit config over env for key and base URL', async () => { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 79a8afb671..3cd430ec14 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -143,6 +143,29 @@ describe('request-level dynamic configuration', () => { ]) }) + it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + const good = await mockServer([{ kind: 'sse', events: textEvents }]) + const rejected = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url }) + + // One snapshot moves the endpoint AND the literal key, and fails the + // resolve step beyond the schema (duplicate catalog ids). + await ctx.settings.update(NS, { + apiKey: 'rejected-key', + baseURL: rejected.url, + models: [{ id: 'dup' }, { id: 'dup' }], + }) + + await prompt(ctx) + // The rejected generation contributes nothing: not its endpoint, and — the + // regression this pins — not its key either. + expect(rejected.requests).toHaveLength(0) + expect(good.requests).toHaveLength(1) + expect(good.headers[0]?.authorization).toBe('Bearer good-key') + }) + it('falls back to the composition entry when settings detach', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index fd40c79c73..030592f74c 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -41,9 +41,11 @@ export interface PiAiAdapterOptions { /** * Resolve the credential for one already-resolved profile; called once per * stream call and frozen for that call. `undefined` defers to pi-ai's - * provider-native ambient discovery. + * provider-native ambient discovery, which the plugin allows only for a + * profile naming no credential at all; a named reference that misses throws + * `LlmError` `MISSING_CREDENTIAL` rather than falling back. */ - resolveApiKey: (profile: ResolvedPiAiProviderProfile) => Promise + resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise } /** @@ -180,7 +182,7 @@ export class PiAiAdapter extends LlmAdapter { model, options.reasoningEffort ?? profile.reasoning, ) - const apiKey = await this.config.resolveApiKey(profile) + const apiKey = await this.config.resolveApiKey(options.provider, profile) const consumer = new AbortController() const upstream = options.signal === undefined diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 6140b2456d..7ff3825bf2 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -29,7 +29,8 @@ */ import type { Context } from 'cordis' -import type {} from '@deepseek-ai/dsh-llm' +import { LlmError } from '@deepseek-ai/dsh-llm' +import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { Config, resolveProfiles } from './config.ts' @@ -45,9 +46,15 @@ export const inject = ['llm'] const NS = settingsNamespace('llm-pi-ai') -/** The registry captures these per route; a change here must re-register. */ +/** + * The registry captures these per route; a change here must re-register. + * Sorted by provider so a settings document that merely reorders its keys is + * not mistaken for a route change. + */ function registrationFacts(profiles: ReadonlyMap): unknown { - return [...profiles.entries()].map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) + return [...profiles.entries()] + .map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) + .sort((left, right) => left.provider < right.provider ? -1 : left.provider > right.provider ? 1 : 0) } /** Register one generic pi-ai adapter for all configured provider routes. */ @@ -76,17 +83,31 @@ export function apply(ctx: Context, config: Config): void { } profiles() - const resolveApiKey = async (profile: ResolvedPiAiProviderProfile): Promise => { + const resolveApiKey = async ( + provider: string, + profile: ResolvedPiAiProviderProfile, + ): Promise => { if (profile.apiKey !== undefined) return profile.apiKey const ref = profile.apiKeyEnv + // Only a profile that names no credential at all defers to pi-ai's + // provider-native discovery. Once one is named, a miss must fail loud: + // handing pi-ai `undefined` would let it pick up an unrelated ambient key + // (OPENAI_API_KEY and friends), billing another tenant for a request the + // deployment meant to authenticate differently. if (ref === undefined) return undefined const credentials = ctx.get('credentials') - if (credentials !== undefined) return (await credentials.resolve(ref))?.value - // Without the seam, keep an ambient fallback so a plain cordis.yml - // composition works from the environment alone; an empty variable defers - // to pi-ai's own provider-native discovery like an absent one. - const ambient = process.env[ref] - return ambient !== undefined && ambient.length > 0 ? ambient : undefined + const hit = credentials !== undefined + ? (await credentials.resolve(ref))?.value + // Without the seam, read exactly the named variable so a plain + // cordis.yml composition works from the environment alone. + : process.env[ref] + if (hit !== undefined && hit.length > 0) return hit + throw new LlmError( + `llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not` + + ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,` + + ' and remove apiKeyEnv only if this provider should authenticate from pi-ai\'s own environment discovery', + 'MISSING_CREDENTIAL', + ) } const adapter = new PiAiAdapter({ profiles, resolveApiKey }) @@ -94,18 +115,29 @@ export function apply(ctx: Context, config: Config): void { // even when a swap runs inside the scoped settings callback below. A bare // mount (zero routes) is the dormant posture: nothing registers until a // settings section supplies profiles, and routes drop when it empties. - let disposeRoutes: (() => void) | undefined + let registration: AdapterRegistrationHandle | undefined let registeredFacts: unknown const ensureRegistrationFacts = (): void => { const facts = registrationFacts(profiles()) if (deepEqualJson(facts, registeredFacts)) return // The registry captures the route set and each route's retry policy at - // registration: swap the registration in one synchronous section (same - // adapter instance, no NO_ADAPTER window). - disposeRoutes?.() - disposeRoutes = undefined + // registration, so a change to either must re-register. The swap is + // atomic (same adapter instance, validated before anything moves): a + // conflicting route leaves the previous routes serving requests, and + // `registeredFacts` only advances once the registry actually holds the + // new set — so returning to a working configuration always re-applies. const routes = [...profiles().keys()] - if (routes.length > 0) disposeRoutes = ctx.llm.registerAdapter(routes, adapter) + if (registration === undefined) { + // Dormant bare mount: nothing is registered until a section supplies + // profiles, and an empty section keeps it that way. + if (routes.length === 0) { + registeredFacts = facts + return + } + registration = ctx.llm.registerAdapter(routes, adapter) + } else { + registration.replace(routes) + } registeredFacts = facts } ensureRegistrationFacts() diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 4b97e7b2a7..a0826b3571 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -27,7 +27,7 @@ async function harness(baseURL: string, overrides: Record = {}) function adapterOf(providers: Record): PiAiAdapter { return new PiAiAdapter({ profiles: () => resolveProfiles(providers), - resolveApiKey: profile => Promise.resolve(profile.apiKey), + resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey), }) } @@ -385,13 +385,19 @@ describe('provider profile lifecycle', () => { expect(server.headers[0]?.authorization).toBe('Bearer custom-ref-key') }) - it('treats an empty apiKeyEnv variable as absent and defers to SDK ambient discovery', async () => { + it('fails a named-but-missing apiKeyEnv instead of using another ambient key', async () => { + // The exact confusion this guards: the named reference is empty while an + // unrelated provider key sits in the environment. Deferring to pi-ai's own + // discovery here would authenticate as another tenant. vi.stubEnv('PI_CUSTOM_REF_KEY', '') vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toThrow(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s) + expect(server.requests).toHaveLength(0) }) it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => { diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 4bf4d6425a..598d2aa2a9 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import LlmService from '@deepseek-ai/dsh-llm' +import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -14,6 +14,14 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const NS = settingsNamespace('llm-pi-ai') +/** Minimal foreign adapter: only needs to own a route the pi-ai plugin then wants. */ +class StubAdapter extends LlmAdapter { + + override async * stream(): AsyncIterable { + throw new Error('stub adapter must never stream') + } +} + const cleanups: Array<() => Promise> = [] afterEach(async () => { @@ -137,4 +145,45 @@ describe('request-level dynamic profiles', () => { await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } }) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) }) + + it('keeps serving its routes when a settings-born route collides with another adapter', async () => { + const dir = await home() + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } }) + // Another adapter owns `anthropic`; the registry must refuse to hand it over. + ctx.llm.registerAdapter(['anthropic'], new StubAdapter()) + + await ctx.settings.update(NS, { + providers: { + openai: { apiKey: 'pk', baseURL: `${server.url}/v1` }, + anthropic: { apiKey: 'other' }, + }, + }) + + // The conflicting swap was refused whole: the previous route set still + // owns openai (an eager dispose would have dropped it), and anthropic + // still belongs to its original adapter. + expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai']) + const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) + expect(result.finish.kind).toBe('error') + expect(server.paths).toEqual(['/v1/responses']) + + // Reverting to the working configuration re-applies, even though its + // facts equal the ones the registry already holds. + await ctx.settings.replace(NS, {}) + expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai']) + await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) + expect(server.paths).toEqual(['/v1/responses', '/v1/responses']) + }) + + it('ignores a settings document that merely reorders its provider keys', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: {}, anthropic: {} } }) + const before = ctx.llm.listProviders().map(provider => provider.id) + + // Same routes, different YAML key order: nothing about the registration + // changed, so no swap should happen at all. + await ctx.settings.update(NS, { providers: { anthropic: {}, openai: {} } }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(before) + }) }) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 1fb4443af0..73ac6ed900 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -184,6 +184,26 @@ export abstract class LlmAdapter { abstract stream(options: GenerateOptions): AsyncIterable } +/** + * What {@link LlmService.registerAdapter} returns: the disposer, plus an + * atomic route replacement for the same adapter instance. + */ +export interface AdapterRegistrationHandle { + /** Release every route this registration currently holds. */ + (): void + /** + * Replace this registration's routes with `providers`, keeping the same + * adapter instance. The candidate set is validated in full first — a + * conflict with another adapter, an invalid name, or bad provider metadata + * throws and leaves the current routes untouched — and the swap itself is + * one synchronous section, so no request can observe a gap. An empty array + * is legal here (a settings section that emptied holds zero routes while + * staying registered), unlike an empty initial registration. + * @param providers - the complete next route set for this registration. + */ + replace(providers: string[]): void +} + /** * The abstract `llm` service: an adapter registry plus a streaming model-call * surface, interceptable via the `llm/stream` waterfall. @@ -201,39 +221,70 @@ export class LlmService extends Service { * Disposed with the fiber. * @param providers - every provider route this adapter should serve. * @param adapter - the adapter that streams calls for those providers. - * @returns the disposer that unregisters all of them. + * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}. */ - registerAdapter(providers: string[], adapter: LlmAdapter): () => void { + registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle { + // The routes this registration currently holds; `replace` rewrites it, and + // the disposer releases whatever it holds at disposal time. + const owned = new Set() const dispose = this.ctx.effect(function* (this: LlmService) { if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER') - const unique = new Set() - const registrations: AdapterRegistration[] = [] - for (const provider of providers) { - if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER') - if (unique.has(provider) || this.adapters.has(provider)) { - throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER') - } - const info = adapter.providerInfo(provider) - if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) { - throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER') - } - unique.add(provider) - const retryPolicy = adapter.providerRetryPolicy(provider) - ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) - registrations.push({ - adapter, - provider: { id: info.id, name: info.name }, - retryPolicy, - }) - } - for (const registration of registrations) this.adapters.set(registration.provider.id, registration) + this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned)) yield () => { - for (const provider of providers) this.adapters.delete(provider) + for (const provider of owned) this.adapters.delete(provider) + owned.clear() } }.bind(this), 'llm.registerAdapter()') // ctx.effect's disposer returns Promise; our disposer API is // synchronous fire-and-forget — discard the (always-resolved) promise. - return () => void dispose() + const handle = (() => void dispose()) as AdapterRegistrationHandle + handle.replace = (next: string[]): void => { + this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned)) + } + return handle + } + + /** + * Validate one candidate route set for `adapter`, treating routes this + * registration already holds as available. Nothing is mutated: a rejected + * candidate leaves the registry exactly as it was. + */ + private prepareRoutes(providers: string[], adapter: LlmAdapter, owned: ReadonlySet): AdapterRegistration[] { + const unique = new Set() + const registrations: AdapterRegistration[] = [] + for (const provider of providers) { + if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER') + if (unique.has(provider) || (this.adapters.has(provider) && !owned.has(provider))) { + throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER') + } + const info = adapter.providerInfo(provider) + if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) { + throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER') + } + unique.add(provider) + const retryPolicy = adapter.providerRetryPolicy(provider) + ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) + registrations.push({ + adapter, + provider: { id: info.id, name: info.name }, + retryPolicy, + }) + } + return registrations + } + + /** + * Swap this registration's routes for the prepared ones in one synchronous + * section, so no observer can see the registry between the release and the + * re-registration. + */ + private commitRoutes(owned: Set, registrations: readonly AdapterRegistration[]): void { + for (const provider of owned) this.adapters.delete(provider) + owned.clear() + for (const registration of registrations) { + this.adapters.set(registration.provider.id, registration) + owned.add(registration.provider.id) + } } /** From d91f0227e6bc98ebcbea1554dc0bbf74ba85e7f4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 15:52:51 +0800 Subject: [PATCH 16/37] fix(settings): keep installSettingsSection quiet when its consumer unloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper's cleanup ran the same fallback for two different events. A settings provider detaching leaves the consumer running, so falling back to the composition entry and re-judging derived facts is right. The consumer's own unload ran it too — re-registering routes and touching resources the teardown was releasing. The disposer now checks the consumer fiber's own state and returns when it is unloading or disposed. --- packages/settings/settings/src/index.ts | 21 +++++++++++++ .../settings/settings/tests/settings.spec.ts | 30 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 233a6734cb..95d43e756f 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -538,6 +538,20 @@ export abstract class Settings extends Service { } } +/** + * Value mirror of the `FiberState` members {@link isUnloading} compares + * against: a const enum has no runtime object to import, and the value is + * needed at runtime (same rationale as the CLI boot driver's mirror). + */ +const FIBER_DISPOSED = 4 +const FIBER_UNLOADING = 5 + +/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */ +function isUnloading(ctx: Context): boolean { + const state: number = ctx.fiber.state + return state === FIBER_UNLOADING || state === FIBER_DISPOSED +} + /** Hooks a consumer hands to {@link installSettingsSection}. */ export interface SettingsSectionHooks { /** @@ -578,6 +592,13 @@ export function installSettingsSection( const scope = sctx.settings.register(ns, schema, { base: entry }) hooks.setSource(() => scope.get()) sctx.effect(() => () => { + // This disposer runs for two different reasons. A settings provider + // detaching leaves the consumer running, so it must fall back to its + // composition entry and re-judge what it derived. The consumer's own + // unload runs it too — and there `onChange` would re-register routes + // and touch resources the teardown is releasing, so the fallback is + // pointless and the notification actively harmful. + if (isUnloading(ctx)) return hooks.setSource(() => entry) hooks.onChange() }) diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 76d40b77d6..5f9ac7dae7 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -694,4 +694,34 @@ describe('installSettingsSection', () => { }) expect(current()).toEqual({ theme: 'entry' }) }) + + it('stays silent when the consumer itself unloads', async () => { + const { ctx } = await boot({ doc: { 'helper-ns': { theme: 'user' } } }) + const entry = { theme: 'entry' } + let current: () => { theme: string } = () => entry + const changes: string[] = [] + const consumer = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, { + setSource: (source) => { + current = source + }, + onChange: () => { + changes.push(current().theme) + }, + }) + }, + }) + await consumer + await vi.waitFor(() => { + expect(changes).toEqual(['user']) + }) + + // The consumer's own teardown must not re-derive anything: an onChange + // here would re-register routes and touch resources being released. + await consumer.dispose() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(changes).toEqual(['user']) + }) }) From 7606a9981332249bc3a0a43d280df1fd0c56df30 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 16:02:13 +0800 Subject: [PATCH 17/37] feat(sandbox): deny confined executions read access to the credential document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential store is 0600 under a 0700 directory, which stops other OS users but not the model: tool processes run as the same user, so under the shipped danger-full-access default they read it like any other file. SandboxExecutionPolicy grows readDenyPaths, and sandbox-policy defaults it to $DSH_HOME/.env — the exact file rather than the harness home, so the model keeps its documented access to its own session log. Seatbelt appends a trailing deny (last matching rule wins) and bwrap maps /dev/null over each path after any workspace bind; Landlock grants are a pure allow-list that cannot subtract from its own / read grant, so confine() reports partial enforcement there instead of claiming a boundary the process does not have. A real-kernel Seatbelt e2e proves the shape: the same read succeeds unconfined and fails under the denial, while a sibling file in the same directory stays readable. Both READMEs state the residual boundary plainly — no confining mode means no boundary — and record the OS keychain provider as the real answer. --- .../credentials/credentials-local/README.md | 15 +++++++-- packages/sandbox/sandbox-local/src/index.ts | 7 +++- .../sandbox/sandbox-local/src/profiles.ts | 23 ++++++++++++- .../sandbox/sandbox-local/tests/local.spec.ts | 21 ++++++++++++ .../sandbox-local/tests/seatbelt.e2e.ts | 33 ++++++++++++++++++- packages/sandbox/sandbox-policy/README.md | 6 ++++ packages/sandbox/sandbox-policy/package.json | 2 ++ packages/sandbox/sandbox-policy/src/index.ts | 23 ++++++++++++- .../sandbox-policy/tests/policy.spec.ts | 29 +++++++++++++++- packages/sandbox/sandbox-policy/tsconfig.json | 3 ++ packages/sandbox/sandbox/src/index.ts | 12 +++++++ 11 files changed, 167 insertions(+), 7 deletions(-) diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 277c7db028..2288d6d713 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -22,7 +22,7 @@ The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, ## The document -dotenv format, parsed with `dotenv` and edited by a line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, comments and unrelated lines survive verbatim. Writes go through [`dsh-atomic-write`](../../util/atomic-write/README.md) with mode `0600`. +dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule. @@ -30,6 +30,15 @@ Values are rendered in the narrowest style dotenv reads back verbatim — bare, External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address. +## Security boundary + +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns. Two things narrow that: + +- A **confining sandbox mode** denies the credential document specifically: [`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) defaults `readDenyPaths` to `$DSH_HOME/.env`, and the Seatbelt and bwrap backends enforce it (Landlock cannot subtract from its own `/` read grant and reports `partial`). The denial names the file, not the home, so the model keeps its documented access to its own session log. +- The harness never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)). + +Neither makes an unconfined agent safe. A deployment that must keep provider keys away from its own agent should run a confining mode; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. + ## Model Experience Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface. @@ -40,7 +49,9 @@ No direct invalidation; credentials never enter a request prefix. ## Known Limitations and Deferred Work -- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; edit the file directly. +- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly. +- **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check. +- **A same-UID process can read the document** — see [Security boundary](#security-boundary): only a confining sandbox mode denies it, and an OS-keychain provider is deferred. - **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format. - **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there. - **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot. diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 98dc86d23e..827f20d696 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -228,7 +228,12 @@ export class LocalSandboxProvider extends SandboxProvider { const selected = this.selectRunner(policy.mode) return { argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv], - enforcement: selected.enforcement, + // Landlock grants are a pure allow-list, so it cannot subtract a read + // denial from its own `/` read grant: promising `full` there would + // misreport a boundary the process does not have. + enforcement: selected.runner === 'landlock' && (policy.readDenyPaths?.length ?? 0) > 0 + ? 'partial' + : selected.enforcement, denialSignatures: DENIAL_SIGNATURES[selected.runner], runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner], } diff --git a/packages/sandbox/sandbox-local/src/profiles.ts b/packages/sandbox/sandbox-local/src/profiles.ts index cee0f00852..27ca150ef4 100644 --- a/packages/sandbox/sandbox-local/src/profiles.ts +++ b/packages/sandbox/sandbox-local/src/profiles.ts @@ -5,9 +5,14 @@ */ import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run' -import { writableRoots } from '@deepseek-ai/dsh-sandbox' +import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +/** This policy's read denials, canonical and deduplicated like the writable roots. */ +function denyPaths(policy: SandboxPolicy): string[] { + return [...new Set((policy.readDenyPaths ?? []).map(path => canonicalPath(path)))] +} + /** * Build the bwrap profile arguments for one file-effect policy. * @param policy - file-effect policy to express as bwrap mounts. @@ -19,6 +24,10 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] { args.push('--tmpfs', '/tmp') args.push('--bind', policy.workspaceRoot, policy.workspaceRoot) } + // Read denials come last so a workspace bind can never re-expose one. + // `/dev/null` over the path reads as empty; the `-try` form tolerates a + // path that does not exist yet (no credential stored so far). + for (const path of denyPaths(policy)) args.push('--ro-bind-try', '/dev/null', path) return args } @@ -28,6 +37,10 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] { * @returns launcher grant arguments before the trailing separator and command argv. */ export function landlockProfileArgs(policy: SandboxPolicy): string[] { + // Landlock grants are a pure allow-list: a read grant on `/` cannot be + // subtracted from, so a requested read denial is unenforceable here. The + // provider reports `partial` enforcement for exactly this case rather than + // pretending the boundary exists. const readWrite = ['/dev/null'] if (policy.mode === 'workspace-write') { readWrite.push('/tmp', policy.workspaceRoot) @@ -54,5 +67,13 @@ export function seatbeltProfileArgs(policy: SandboxPolicy): string[] { if (roots.length > 0) { forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`) } + // SBPL applies the last matching rule, so the read denial is appended after + // every allow above and governs both reads and writes of those paths. Both + // filters are emitted so a denial may name a file or a directory. + const denied = denyPaths(policy) + if (denied.length > 0) { + const filters = denied.map(path => `(literal ${sbplString(path)}) (subpath ${sbplString(path)})`).join(' ') + forms.push(`(deny file-read* file-write* ${filters})`) + } return ['-p', forms.join(' ')] } diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index f7cc952498..ab99c5cc99 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -62,6 +62,27 @@ describe('profile dialects', () => { ]) }) + it('bwrap read denial: /dev/null over each denied path, after any workspace bind', () => { + expect(bwrapProfileArgs({ ...WW, readDenyPaths: ['/ws/secret.env'] })).toEqual([ + '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', + '--tmpfs', '/tmp', '--bind', '/ws', '/ws', + // The workspace bind above would otherwise re-expose the file. + '--ro-bind-try', '/dev/null', '/ws/secret.env', + ]) + }) + + it('landlock ignores read denials: a `/` read grant cannot subtract from itself', () => { + expect(landlockProfileArgs({ ...RO, readDenyPaths: ['/ws/secret.env'] })) + .toEqual(landlockProfileArgs(RO)) + }) + + it('seatbelt read denial: a trailing deny naming the path as both a file and a directory', () => { + expect(seatbeltProfileArgs({ ...RO, readDenyPaths: ['/ws/secret.env'] })).toEqual([ + '-p', + `${SEATBELT_RO_PROFILE} (deny file-read* file-write* (literal "/ws/secret.env") (subpath "/ws/secret.env"))`, + ]) + }) + it('landlock read-only: readable tree plus a writable /dev/null, nothing else', () => { // /dev/null specifically, NOT /dev: a whole-/dev grant would let confined // commands write real host paths beneath it (/dev/shm) under read-only. diff --git a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts index 6d645b1a3b..a01e3a25a2 100644 --- a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -70,6 +70,37 @@ describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement throu expect(result.stdout).toBe('dev-ok\n') }) + it('denies reading a credential document the mode would otherwise allow', async () => { + // The harness's own secret store: readable to the user, and the model's + // bash runs as that user — only the confinement can take it away. + const workdir = await tempDir(tmpdir()) + const secret = join(workdir, '.env') + await writeFile(secret, 'DEEPSEEK_API_KEY=sk-must-not-leak\n', { mode: 0o600 }) + const sandbox = await provider() + + const allowed = runConfined(sandbox, `cat ${secret}`, { mode: 'read-only', workspaceRoot: workdir }) + expect(allowed.result.stdout).toContain('sk-must-not-leak') + + const denied = runConfined(sandbox, `cat ${secret}`, { + mode: 'read-only', + workspaceRoot: workdir, + readDenyPaths: [secret], + }) + expect(denied.result.stdout).not.toContain('sk-must-not-leak') + expect(denied.result.status).not.toBe(0) + expect(denied.confined.enforcement).toBe('full') + // Everything else under the same directory stays readable: the denial is + // the credential document, not the harness home. + const sibling = join(workdir, 'notes.txt') + await writeFile(sibling, 'ordinary\n') + const neighbour = runConfined(sandbox, `cat ${sibling}`, { + mode: 'read-only', + workspaceRoot: workdir, + readDenyPaths: [secret], + }) + expect(neighbour.result.stdout).toBe('ordinary\n') + }) + it('read-only grants no temp area: a write under the user temp dir is denied too', async () => { // The per-user darwin temp dir is a workspace-write grant, not a // read-only one — under read-only the only write-shaped path is /dev/null. diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index dca54330bc..297dd7d521 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -13,6 +13,12 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de - `mode` — the deployment default `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`), validated at load. Default `read-only` (fail-safe). - `workspaceRoot` — the fallback directory `workspace-write` may write under for agentless calls or sessions without a cwd. Default `process.cwd()`, resolved to its absolute filesystem identity either way. A normal agent call uses its session header's immutable `cwd` instead. +## Read denials + +`readDenyPaths` names absolute paths a **confined** execution must not read, whatever its mode otherwise permits. Omitted (or empty) denies the harness credential document `$DSH_HOME/.env`; a non-empty list replaces that default. Denials name exact paths rather than roots on purpose: denying the whole harness home would also take away the model's documented access to its own session log. + +Enforcement is backend-shaped. Seatbelt appends a trailing `deny file-read* file-write*` (last matching rule wins) and bwrap maps `/dev/null` over each path after any workspace bind; Landlock grants are a pure allow-list, so a read grant on `/` cannot be subtracted from and `confine()` reports `partial` enforcement rather than pretending the boundary exists. `danger-full-access` confines nothing at all, so no denial applies there — the credential document is then protected only by its file mode, which does not stop a same-UID tool process. + ## Surface - `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index d5f9270ed1..bb48bb0b35 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -28,6 +28,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -37,6 +38,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 1f5ba0bb00..74c05f76a1 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -14,10 +14,11 @@ * @module @deepseek-ai/dsh-sandbox-policy */ -import { resolve as resolvePath } from 'node:path' +import { join, resolve as resolvePath } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' import type { Session } from '@deepseek-ai/dsh-session' import { effectiveSandboxMode } from './session-mode.ts' @@ -49,6 +50,16 @@ export interface Config { * `process.cwd()`). Normal agent calls use their session cwd instead. */ workspaceRoot?: string + /** + * Absolute paths confined executions must not read, whatever their mode + * otherwise permits. Omitted (or empty) denies the harness home's + * credential document (`$DSH_HOME/.env`) — exactly that file, so the model + * keeps the documented access to its own session log under the same home; + * a non-empty list replaces it. Backends that cannot express a read denial + * report `partial` enforcement instead of pretending, and + * `danger-full-access` confines nothing, so no denial applies there at all. + */ + readDenyPaths?: string[] } /** Inputs that select the sandbox policy for one capability call. */ @@ -72,12 +83,15 @@ export class SandboxPolicyService extends Service { // No schema default: process.cwd() is resolved in the constructor so the // stored root is always absolute regardless of how it was supplied. workspaceRoot: z.string(), + readDenyPaths: z.array(z.string()), }) /** The deployment default mode — the fallback beneath a session override. */ readonly defaultMode: SandboxMode /** The absolute `workspace-write` fallback root for calls without a session cwd. */ readonly workspaceRoot: string + /** Absolute paths every confined execution is denied read access to. */ + readonly readDenyPaths: readonly string[] constructor(ctx: Context, config: Config) { super(ctx, 'sandboxPolicy') @@ -86,6 +100,12 @@ export class SandboxPolicyService extends Service { // the process cwd is real branching, resolved absolute either way. this.defaultMode = config.mode as SandboxMode this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd()) + // The credential document is the default denial; a configured list + // replaces it. Schemastery fills an omitted array with `[]`, so empty and + // omitted are the same request: protect the default document. + const denyPaths = config.readDenyPaths ?? [] + this.readDenyPaths = (denyPaths.length > 0 ? denyPaths : [join(resolveDshHome(), '.env')]) + .map(resolveWorkspaceRoot) } /** @@ -102,6 +122,7 @@ export class SandboxPolicyService extends Service { return { mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), + readDenyPaths: this.readDenyPaths, } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 63ca0cd3d5..34e2f1b5cb 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -10,9 +10,14 @@ import { join, resolve, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' -async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) { +async function mounted(config: { + mode?: 'read-only' | 'workspace-write' | 'danger-full-access' + workspaceRoot?: string + readDenyPaths?: string[] +} = {}) { const ctx = new Context() await ctx.plugin(SandboxPolicyService, config) return ctx @@ -41,11 +46,28 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub')) }) + it('denies reading the harness credential document by default', async () => { + const ctx = await mounted() + // The exact file, not the whole home: the model keeps the documented + // access to its own session log under the same directory. + expect(ctx.sandboxPolicy.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) + expect(ctx.sandboxPolicy.resolve().readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) + }) + + it('replaces the default with a configured denial list', async () => { + const configured = await mounted({ readDenyPaths: ['/vault/../vault/./keys.env'] }) + expect(configured.sandboxPolicy.readDenyPaths).toEqual([resolve('/vault/keys.env')]) + // Schemastery fills an omitted array with `[]`, so empty reads as omitted. + const empty = await mounted({ readDenyPaths: [] }) + expect(empty.sandboxPolicy.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) + }) + it('resolves the deployment policy for an agentless call', async () => { const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' }) expect(ctx.sandboxPolicy.resolve()).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/fallback'), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) }) @@ -58,16 +80,19 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/projects/first'), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({ mode: 'read-only', workspaceRoot: resolve('/projects/second'), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined() expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only') expect(ctx.sandboxPolicy.resolve()).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/fallback'), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) }) @@ -87,6 +112,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({ mode: 'workspace-write', workspaceRoot: realpathSync.native(physical), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) } finally { rmSync(root, { recursive: true, force: true }) @@ -100,6 +126,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({ mode: 'danger-full-access', workspaceRoot: resolve('/projects/approved'), + readDenyPaths: [resolve(resolveDshHome(), '.env')], }) }) diff --git a/packages/sandbox/sandbox-policy/tsconfig.json b/packages/sandbox/sandbox-policy/tsconfig.json index cb6fc623d0..65c906d6c3 100644 --- a/packages/sandbox/sandbox-policy/tsconfig.json +++ b/packages/sandbox/sandbox-policy/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../sandbox" }, + { + "path": "../../util/paths" + }, { "path": "../../core/session" }, diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 781227f411..11e690704e 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -40,6 +40,18 @@ export interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Absolute paths a confined execution must not READ, whatever the mode + * otherwise permits — the harness's own credential document is the + * motivating case, which is why these are exact paths rather than roots: + * denying the whole harness home would also take away the model's + * documented access to its own session log. Not every backend can express + * a read denial (a Landlock allow-list granting `/` cannot subtract from + * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a + * denial is requested and the selected backend cannot apply it. Never a + * boundary under `danger-full-access`, which confines nothing at all. + */ + readDenyPaths?: readonly string[] } /** From 9626c15c6bfb651a7759939024a34c6cad5181e6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 16:11:21 +0800 Subject: [PATCH 18/37] test(sandbox): carry the resolved read denials through consumer policy assertions The policy home's resolve() now stamps readDenyPaths, so every consumer that pins the resolved shape (bash-sandbox hand-off, tool-fs stamps) carries it, and three uncovered branches gained real tests: landlock reporting partial enforcement for a denial it cannot express, the policy's default under programmatic construction, and both ambient credential paths in llm-deepseek without a mounted seam. --- ...29-request-level-llm-config-credentials.md | 2 +- ...tial-boundaries-and-atomic-registration.md | 37 ++++++ .../bash/bash-sandbox/tests/sandbox.spec.ts | 10 +- .../credentials-local/README.zh.md | 15 ++- .../credentials-local/src/index.ts | 1 + packages/fs/tool-fs/tests/tools.spec.ts | 10 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 21 ++++ packages/llm/llm-pi-ai/README.md | 6 +- packages/llm/llm-pi-ai/README.zh.md | 6 +- packages/llm/llm-pi-ai/src/index.ts | 2 +- .../tests/loader-composition.spec.ts | 116 ++++++++++++++++++ packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- .../sandbox/sandbox-local/tests/local.spec.ts | 9 ++ packages/sandbox/sandbox-policy/README.zh.md | 6 + .../sandbox-policy/tests/policy.spec.ts | 7 ++ pnpm-lock.yaml | 3 + 19 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md create mode 100644 packages/llm/llm-pi-ai/tests/loader-composition.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md index 67baec4b70..f12a2496a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -26,4 +26,4 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti ## Consequences -Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). +Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). Review of this seam later reworked where the store lives and who may read it, made one request resolve one configuration generation, and made route replacement atomic ([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md)). diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md new file mode 100644 index 0000000000..837aa3e7b8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -0,0 +1,37 @@ +# Agent Note: credential boundaries, whole-snapshot requests, and atomic route registration + +Status: implemented + +English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.md) + +> Scope: the third review round over the [request-level LLM configuration seam](2026-07-29-request-level-llm-config-credentials.md) — where a stored credential lives and who can read it, how one request's facts stay one generation, and how a route set changes without a window. Companion to the [settings write-path note](2026-07-30-settings-write-path-integrity.md), whose provider fixes this round applies to `credentials-local` and whose writer lock it promotes into `dsh-atomic-write`. + +## Problem + +Review found the credential path leaking across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-local defects that same review round fixed (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user. + +Two request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied. + +## Decision + +**`$DSH_HOME/.env` belongs to the credential provider alone.** No surface loads it into `process.env`. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. + +**The confining sandbox is the only real read boundary, and it names the file.** `SandboxExecutionPolicy` grows `readDenyPaths`, defaulted by `sandbox-policy` to `$DSH_HOME/.env`. Seatbelt appends a trailing `deny file-read* file-write*` (SBPL's last matching rule wins) and bwrap maps `/dev/null` over each path after any workspace bind; Landlock grants are a pure allow-list that cannot subtract from its own `/` read grant, so `confine()` reports `partial` enforcement instead of claiming a boundary the process lacks. Denials name exact paths, not roots: denying the whole harness home would also take away the model's documented access to its own session log. Both READMEs state the residue plainly — under the shipped `danger-full-access` default nothing is confined and the file is protected only by the OS user — and record an OS-keychain provider as the real answer. + +**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. + +**Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change. + +**Contained publication for committed credential writes.** `Credentials.notifyUpdated` fans `credentials/updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown. + +## Alternatives considered + +- **Denying the whole harness home** — one root would have covered the credential document and any future secret file, but it also covers `sessions/`, and `DSH_SESSION_JSONL` is a documented model-visible capability. Exact paths keep the denial to what is actually secret. +- **Removing `DSH_HOME` from the model's bash environment** — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. The sandbox denial is the boundary; hiding the pointer is not. +- **Shipping the OS-keychain provider in this round** — it is the only design where the model's processes genuinely cannot read the secret, and it is a sibling package with three platform backends. Sizing it against the rest of this review round would have delayed every other fix; it is recorded as the deferred answer, not as a maybe. +- **Treating `readDenyPaths: []` as an opt-out** — schemastery fills an omitted array with `[]`, so empty and omitted are indistinguishable at the constructor. Empty therefore means "protect the default document"; a deployment that stores credentials elsewhere names its own paths, and a denial on a path nothing reads costs nothing. +- **A `replaceRegistration(previous, next)` service method** — the review's shape, but it makes the caller carry the previous handle and lets it pass a mismatched one. Hanging `replace` on the registration handle makes ownership structural: only the registration that holds routes can replace them. + +## Consequences + +`update()`-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and `describe()` reports `writable: false` for multi-line entries it will not rewrite. A confined execution loses read access to `$DSH_HOME/.env` — deployments that deliberately let an agent read its own credential file must configure `readDenyPaths` themselves. `LlmAdapter` registrants keep working unchanged (the handle is still callable as the disposer), and `DeepSeekConnectionOptions` gained credential fields, so a programmatic constructor of the adapter must supply `apiKeyEnv`. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution). diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 90a67999c8..df4916b6b6 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -11,6 +11,7 @@ import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' @@ -74,8 +75,11 @@ function runResult(exitCode: number | null, stderr: string): BashRunResult { return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) } } +/** The policy home's default read denial: the harness credential document. */ +const DEFAULT_DENY = [resolve(resolveDshHome(), '.env')] + function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy { - return { mode, workspaceRoot } + return { mode, workspaceRoot, readDenyPaths: DEFAULT_DENY } } describe('the provider hand-off', () => { @@ -86,7 +90,7 @@ describe('the provider hand-off', () => { expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) expect(calls).toEqual([{ argv: ['bash', '-c', 'echo \'a b\' "c\'d"'], - policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) }, + policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()), readDenyPaths: DEFAULT_DENY }, }]) }) @@ -103,7 +107,7 @@ describe('the provider hand-off', () => { const { bash, calls } = await setup({ mode: 'workspace-write' }) const result = await bash.run(bash.resolve({ command: 'true' })) expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) - expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) }) + expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()), readDenyPaths: DEFAULT_DENY }) }) it('an explicit workspaceRoot on the policy wins', async () => { diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index af1b840142..1b2b002e60 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -22,7 +22,7 @@ ## 文档本身 -dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释与无关行逐字保留。落盘经 [`dsh-atomic-write`](../../util/atomic-write/README.md),权限 `0600`。 +dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。 值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 @@ -30,6 +30,15 @@ dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不 外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 +## 安全边界 + +文档位于 `0700` 目录下、权限 `0600`,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致。有两件事收窄了这一点: + +- **约束型沙箱模式**会专门拒绝凭据文档:[`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) 把 `readDenyPaths` 默认为 `$DSH_HOME/.env`,Seatbelt 与 bwrap 后端会执行它(Landlock 无法从自己的 `/` 读授权中扣除,只能报 `partial`)。这条拒绝点名的是该文件而非整个 home,因此模型对自己会话日志的既定访问不受影响。 +- harness 绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))。 + +这两者都不能让未受约束的 agent 变得安全。必须让提供方密钥远离自身 agent 的部署应当运行约束型模式;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 + ## Model Experience 经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 @@ -40,7 +49,9 @@ dotenv 格式,用 `dotenv` 解析;写回用行级编辑器,保留一切不 ## Known Limitations and Deferred Work -- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;请直接编辑文件。 +- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 +- **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 +- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有约束型沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。 - **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 - **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 - **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 576b8241f0..44678b5837 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -160,6 +160,7 @@ function upsertLine(text: string | undefined, ref: CredentialRef, rendered: stri } const [, key, valuePart] = match if (key !== ref) { + /* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */ pendingQuote = opensMultiline(valuePart ?? '') out.push(line) continue diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index de844dcaf4..a3b658e6bf 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' @@ -112,6 +113,9 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } +/** The policy home's default read denial: the harness credential document. */ +const DEFAULT_DENY = [resolve(resolveDshHome(), '.env')] + describe('session cwd resolution', () => { const execution = (cwd?: string) => cwd === undefined ? {} @@ -763,13 +767,13 @@ describe('sandbox escalation surface (write/edit)', () => { it('a plain write stamps the default mode with the calling session root', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent()) - expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }]) + expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) }) it('a standing session override folds onto the stamp', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }])) - expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }]) + expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) }) it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => { @@ -802,7 +806,7 @@ describe('sandbox escalation surface (write/edit)', () => { agent: escalationAgent() as never, signal: new AbortController().signal, }) - expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }]) + expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) }) it('a rejected escalation fails closed with its own text and never mutates', async () => { diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 88f4fd7c01..ab44b61e30 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -50,7 +50,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. -- **`ctx.credentials`** — the API key resolves per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek')` always reports the current policy. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 386c695766..4ecaf361fd 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -50,7 +50,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 -- **`ctx.credentials`**——API 密钥按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek')` 始终报告当前策略。 diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index b5a4bc9ff4..aec9229e25 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -823,6 +823,27 @@ describe('plugin registration and config', () => { .rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s) }) + it('reads the ambient variable when no credentials seam is mounted', async () => { + // The plain cordis.yml composition: no credential provider, the key in + // the launching environment. + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmDeepSeek, { baseURL: server.url }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') + }) + + it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + }) + it('prefers explicit config over env for key and base URL', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1') diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index fb8145d58a..0099c9acd3 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -8,7 +8,7 @@ The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile r ## Config -Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file; omitting both delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. +Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what delegates authentication to pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. ```yaml - id: llm @@ -41,7 +41,7 @@ Each dict key must exist in pi-ai's installed catalog; the dict shape makes dupl The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. -Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; the raw environment variable without a mounted seam), then pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin re-registers the same adapter instance in one synchronous section, so `ctx.llm.listProviders()` and `providerRetryPolicy()` always reflect the current configuration. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. @@ -77,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata ## Testing -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. Real-API coverage remains key-gated under `pnpm run test:e2e`. +Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. `tests/loader-composition.spec.ts` boots the dormant posture from a test-only `cordis.yml` through the actual Loader and registers its route from an on-disk `settings.yaml` edit. Real-API coverage remains key-gated under `pnpm run test:e2e`. ## Model Experience diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 0d0e2152d2..7cb4f5fcbc 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -8,7 +8,7 @@ ## 配置 -按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件;两者都省略则把认证委托给 pi-ai 的提供方原生环境发现。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 +按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会把认证委托给 pi-ai 的提供方原生环境发现;已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 ```yaml - id: llm @@ -41,7 +41,7 @@ 适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时为原始环境变量),最后是 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会在一个同步区段内重新注册同一适配器实例,因此 `ctx.llm.listProviders()` 与 `providerRetryPolicy()` 始终反映当前配置。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 @@ -77,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK ## 测试 -单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 +单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起休眠姿态,并从磁盘上的一次 `settings.yaml` 编辑注册出它的路由。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 ## 模型体验 diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 7ff3825bf2..4c610cae21 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -54,7 +54,7 @@ const NS = settingsNamespace('llm-pi-ai') function registrationFacts(profiles: ReadonlyMap): unknown { return [...profiles.entries()] .map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) - .sort((left, right) => left.provider < right.provider ? -1 : left.provider > right.provider ? 1 : 0) + .sort((left, right) => left.provider.localeCompare(right.provider)) } /** Register one generic pi-ai adapter for all configured provider routes. */ diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..460e78b7c2 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -0,0 +1,116 @@ +/** + * Real-composition guard for the dormant pi-ai posture: LlmService, + * settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a + * test-only cordis.yml through the actual Loader + Include path, an external + * edit of settings.yaml registers the route live, and the next request + * carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot + * catch Loader export-shape failures, which is why the twin adapter has the + * same guard. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' +import SettingsLocal from '@deepseek-ai/dsh-settings-local' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined + await closeMockServers() + vi.unstubAllEnvs() +}) + +/** Boot the dormant composition: a bare `llm-pi-ai` row with no config at all. */ +async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }> { + root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-')) + const settingsPath = join(root, 'settings.yaml') + await writeFile(settingsPath, '# personal settings\n') + await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n') + + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + '- id: llm', + " name: 'test-llm-service'", + '- id: settings', + " name: '@deepseek-ai/dsh-settings-local'", + ' config:', + ` path: ${JSON.stringify(settingsPath)}`, + ' debounceMs: 10', + '- id: credentials', + " name: '@deepseek-ai/dsh-credentials-local'", + ' config:', + ` path: ${JSON.stringify(join(root, '.env'))}`, + ' debounceMs: 10', + '- id: llm-pi-ai', + " name: '@deepseek-ai/dsh-llm-pi-ai'", + '', + ].join('\n')) + + const ctx = new Context() + context = ctx + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['test-llm-service', LlmService], + ['@deepseek-ai/dsh-settings-local', SettingsLocal], + ['@deepseek-ai/dsh-credentials-local', CredentialsLocal], + ['@deepseek-ai/dsh-llm-pi-ai', LlmPiAi], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + return { ctx, settingsPath } +} + +describe('llm-pi-ai real dormant composition', () => { + it('boots with zero routes and registers one the moment settings supply a profile', async () => { + vi.stubEnv('PI_COMPOSITION_KEY', '') + const server = await mockServer([{ events: textEvents }]) + const { ctx, settingsPath } = await loadComposition() + + // The shipped posture: the adapter exists, no route does. + expect(ctx.llm.listProviders()).toEqual([]) + + // Exactly what the web Models page leaves on disk. + await writeFile(settingsPath, [ + 'llm-pi-ai:', + ' providers:', + ' deepseek:', + ' apiKeyEnv: PI_COMPOSITION_KEY', + ` baseURL: ${server.url}`, + '', + ].join('\n')) + await vi.waitFor(() => { + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + }, { timeout: 5000 }) + + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(server.headers[0]?.authorization).toBe('Bearer key-from-store') + }) +}) diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d343449d15..5b0c1b2dca 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -10,7 +10,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa ### Public API -- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. +- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 4dc4a0ca06..5f5c8142ec 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -10,7 +10,7 @@ ### 公开 API -- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。 +- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。 - `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。 - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index ab99c5cc99..ceadaba184 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -329,6 +329,15 @@ describe('the default landlock probe (launcher CLI contract)', () => { expect(sandbox.confine(['true'], RO).enforcement).toBe('partial') }) + it('reports partial enforcement when a read denial is requested it cannot express', async () => { + const launcher = fakeLauncher() + const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher }) + // Fully enforced for the write policy, yet the read denial is + // unexpressible in an allow-list that already grants `/` for reads. + expect(sandbox.confine(['true'], RO).enforcement).toBe('full') + expect(sandbox.confine(['true'], { ...RO, readDenyPaths: ['/ws/secret.env'] }).enforcement).toBe('partial') + }) + it('reads a failing launcher as unusable: the chain ends and fails closed', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) const launcher = join(dir, 'landlock-run') diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index a201d48c81..1de92eb814 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -13,6 +13,12 @@ - `mode`:部署默认 `SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`),加载时验证。默认为 `read-only`(故障安全)。 - `workspaceRoot`:无 agent(智能体)的调用或没有 cwd 的会话在 `workspace-write` 下可写入的回退目录。默认为 `process.cwd()`;无论显式配置还是采用默认值,都会解析为其绝对文件系统标识。普通 agent 调用改用其会话头中不可变的 `cwd`。 +## 读取拒绝 + +`readDenyPaths` 列出**受约束**执行绝不可读取的绝对路径,无论其模式在其他方面允许什么。省略(或为空)时拒绝 harness 凭据文档 `$DSH_HOME/.env`;非空列表则替换该默认值。拒绝项有意点名确切路径而非根目录:拒绝整个 harness home 会连带拿走模型对自己会话日志的既定访问。 + +强制执行的形态由后端决定。Seatbelt 追加一条尾部 `deny file-read* file-write*`(最后匹配的规则胜出),bwrap 在任何工作区绑定之后把 `/dev/null` 映射到每个路径上;Landlock 的授权是纯粹的允许列表,`/` 上的读授权无法被扣除,因此 `confine()` 把强制执行报为 `partial`,而不是假装该边界存在。`danger-full-access` 根本不做任何约束,那里也就没有任何拒绝适用——凭据文档届时只受自身文件权限模式保护,而这挡不住同 UID 的工具进程。 + ## 接口 - `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 34e2f1b5cb..7740abc058 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -54,6 +54,13 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve().readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) }) + it('defaults the denial list under programmatic construction too', () => { + // Constructing the service directly bypasses Schemastery, so the field + // arrives undefined rather than as the empty array the schema fills. + const service = new SandboxPolicyService(new Context(), {}) + expect(service.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) + }) + it('replaces the default with a configured denial list', async () => { const configured = await mounted({ readDenyPaths: ['/vault/../vault/./keys.env'] }) expect(configured.sandboxPolicy.readDenyPaths).toEqual([resolve('/vault/keys.env')]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37f7e6cf29..367b2f4299 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3618,6 +3618,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../sandbox From e7894f4152cbe4f3b60d81f1cb52c1a4c24717ad Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 16:37:28 +0800 Subject: [PATCH 19/37] docs(credentials): record the third-review contracts across READMEs, catalogs, and a new Agent Note Both provider READMEs state what actually holds: credentials-local now documents the physical-line editor, the read-modify-write under the writer lock, and a Security boundary section saying plainly that the file mode stops other OS users and not the model. sandbox-policy documents readDenyPaths and its per-backend enforcement. The llm READMEs carry the registration handle, pi-ai's credential-miss semantics, and DeepSeek's same-generation snapshot; app-boot and the CLI README stop describing $DSH_HOME/.env as an environment layer. A new Agent Note records the round (and the prior seam note cross-links it); the sandbox and core catalog pages gain readDenyPaths and AdapterRegistrationHandle with their manifest entries. The headless missing-credential snapshot re-records for the reworded guidance, pi-ai gains the Loader-composition guard its twin already had, and the deliberate provider symmetry is marked for the clone detector. --- ...est-level-llm-config-credentials.i18n.yaml | 4 +- ...request-level-llm-config-credentials.zh.md | 2 +- ...undaries-and-atomic-registration.i18n.yaml | 6 +++ ...l-boundaries-and-atomic-registration.zh.md | 41 +++++++++++++++++++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.zh.md | 2 +- docs/config-catalog.md | 16 ++++++-- docs/cordis-catalog/events.md | 11 +++-- docs/cordis-catalog/services.md | 14 +++---- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 24 +++++++++++ docs/core-data-structures/core.zh.md | 24 +++++++++++ docs/core-data-structures/sandbox.i18n.yaml | 6 +-- docs/core-data-structures/sandbox.md | 14 ++++++- docs/core-data-structures/sandbox.zh.md | 14 ++++++- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 3 +- .../headless-agent/tests/headless.snapshot.ts | 7 +++- .../stream-json.expected.jsonl | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 ++++-- .../credentials-local/README.i18n.yaml | 4 +- .../credentials-local/README.zh.md | 8 ++-- .../credentials-local/src/index.ts | 10 +++++ packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm/README.i18n.yaml | 4 +- .../sandbox/sandbox-policy/README.i18n.yaml | 4 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.zh.md | 4 +- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 +++ 31 files changed, 212 insertions(+), 54 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index c8cde9db07..c7861321a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.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-07-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: 67baec4b70d0c754f22573d87fb4492de5ca16a4 -2026-07-29-request-level-llm-config-credentials.zh.md: 36182b77f4494c99b0fb08107f865f85322ece6c +2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992 +2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 36182b77f4..99fd90013a 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -26,4 +26,4 @@ Status: implemented ## 后果 -上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏;settings 层的数组仍整体替换(deepseek 的 `models` 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。 +上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏;settings 层的数组仍整体替换(deepseek 的 `models` 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。对该 seam 的评审随后改造了存储的所在位置与谁可以读取它,让一个请求解析出一个配置世代,并使路由替换成为原子操作([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md))。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml new file mode 100644 index 0000000000..e7c7b51af6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-07-30-credential-boundaries-and-atomic-registration.md +2026-07-30-credential-boundaries-and-atomic-registration.md: 837aa3e7b8ed30c66aad880ab2d76eee376e1854 +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: a00c3d93d2dc0451ed29613c4a804b0e518264ed diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md new file mode 100644 index 0000000000..a00c3d93d2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 凭据边界、按整份快照发起的请求与原子路由注册 + +Status: implemented + +[English](2026-07-30-credential-boundaries-and-atomic-registration.md) | 中文 + +> 范围:对[请求级 LLM(大语言模型)配置 seam](2026-07-29-request-level-llm-config-credentials.md)的第三轮评审——存下来的凭据落在哪里、谁能读到它,一次请求的事实如何保持为同一代,以及一组路由如何在不留空窗的前提下更换。本 note 与 [settings 写路径 note](2026-07-30-settings-write-path-integrity.md) 配套:本轮把那篇 note 的提供方修复套用到 `credentials-local`,并把其中的写锁提升进 `dsh-atomic-write`。 + +## 问题 + +评审发现,凭据路径正在越过它自己划下的边界泄漏。已交付的各个面在 Cordis 启动之前就把 `$DSH_HOME/.env` 提升进了 `process.env`,于是下一次运行时,`credentials-local` 会把它自己存下的每个键都判成来自环境的只读启动覆盖:`describe()` 报告 `source: 'env'` 且 `writable: false`,`set`/`unset` 以被遮蔽为由拒绝,从 web 页面或 TUI 存入的密钥既无法轮换也无法删除,而适配器还在继续使用启动时捕获的那个值。 + +存储自身的写路径重演了同一轮评审在 settings-local 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 `writable: true` 而 `set` 总是抛错,`credentials/updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。 + +在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。 + +与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。 + +## 决策 + +**`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 + +**受限沙箱才是唯一真正的读取边界,而且它点名到具体文件。**`SandboxExecutionPolicy` 新增 `readDenyPaths`,由 `sandbox-policy` 默认设为 `$DSH_HOME/.env`。Seatbelt 在末尾追加一条 `deny file-read* file-write*`(SBPL 中最后一条匹配规则胜出),bwrap 则在所有工作区 bind 之后把 `/dev/null` 映射到每条路径上;Landlock 的授权是纯粹的允许列表,无法从它自己对 `/` 的读取授权中减去任何东西,因此 `confine()` 报告 `partial` 强制执行,而不是声称一条该进程其实并不具备的边界。拒绝点名的是确切路径,而不是根目录:把整个 harness home 都拒掉,会连带夺走模型对自身会话日志的成文访问权。两个 README 都直白写明残留风险——在已交付的 `danger-full-access` 默认值下没有任何东西受限,这个文件只靠 OS 用户身份保护——并记下 OS 钥匙串(keychain)提供方才是真正的答案。 + +**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 + +**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。 + +**已提交的凭据写入采用收容式发布。**`Credentials.notifyUpdated` 逐个监听器扇出 `credentials/updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。 + +## 曾考虑的替代方案 + +- **拒掉整个 harness home**——一个根目录本可覆盖凭据文档以及将来任何机密文件,但它同时也覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。用确切路径可以把拒绝范围限定在真正属于机密的东西上。 +- **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。沙箱拒绝才是边界,藏起指针不是。 +- **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密,而它是一个带三种平台后端的兄弟包(package)。把它与本轮评审的其余工作放在一起评估体量,会拖慢其他每一项修复;它被记录为那个延后的答案,而不是一个「也许」。 +- **把 `readDenyPaths: []` 当作 opt-out**——schemastery 会把省略的数组填成 `[]`,因此在构造函数处空数组与省略无从分辨。于是空数组的含义就是「保护默认文档」;把凭据存在别处的部署自行点名它自己的路径,而在没人读取的路径上设一条拒绝并不产生任何代价。 +- **做成 `replaceRegistration(previous, next)` 服务方法**——这是评审给出的形状,但它要求调用方自行携带上一个句柄,也允许它传入一个不匹配的句柄。把 `replace` 挂在注册句柄上,让归属关系变成结构性的:只有持有路由的那一项注册才能替换它们。 + +## 后果 + +`update()` 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,`describe()` 对它不会改写的多行条目报告 `writable: false`。受限执行会失去对 `$DSH_HOME/.env` 的读取权限——刻意让 agent 读取自身凭据文件的部署,必须自行配置 `readDenyPaths`。`LlmAdapter` 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),`DeepSeekConnectionOptions` 则新增了凭据字段,因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项:OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index bb5f370009..d6193c6645 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 93c36d18abd06bbd7a80c918f520b92489180395 -README.zh.md: 85f4624a592eaf2ae44dc31fb4e18fb5657e62fd +README.md: 1decf018f53e55e6dde73d8b65963ab96e20122b +README.zh.md: eecf60ca6a1a543b6c8f71297eab036ca6b42815 diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 85f4624a59..eecf60ca6a 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -12,7 +12,7 @@ TUI 界面: - 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume ` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id(不使用环境变量),已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话; - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析; - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; -- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 +- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。 Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 513df9b0d2..4e8f02536b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -416,7 +416,7 @@ export interface Config { } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:24`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-fs-local` @@ -656,7 +656,7 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:49`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:50`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -1027,12 +1027,22 @@ export interface Config { * `process.cwd()`). Normal agent calls use their session cwd instead. */ workspaceRoot?: string + /** + * Absolute paths confined executions must not read, whatever their mode + * otherwise permits. Omitted (or empty) denies the harness home's + * credential document (`$DSH_HOME/.env`) — exactly that file, so the model + * keeps the documented access to its own session log under the same home; + * a non-empty list replaces it. Backends that cannot express a read denial + * report `partial` enforcement instead of pretending, and + * `danger-full-access` confines nothing, so no denial applies there at all. + */ + readDenyPaths?: string[] } ``` Depends on: [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:44`](../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:45`](../packages/sandbox/sandbox-policy/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 36dc85e4ea..f56552afff 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -443,13 +443,18 @@ Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src ### `credentials/updated` — emit -Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. +Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions. ```ts cordis-catalog /** * Committed change to a provider-managed credential source: a `set`, an * `unset`, or an external edit observed in storage. Ambient - * process-environment changes are not observable and never emit. + * process-environment changes are not observable and never emit. Listener + * failures are contained and logged — a sync throw and an async rejection + * alike — without changing the committed operation's outcome, except + * `INVARIANT`-coded failures, which rethrow after every listener ran; + * that rethrow reaches the emitter only from synchronous listeners, so + * invariant checks on this event must not be async functions. * @param ref - the reference whose stored value changed. * @mode emit */ @@ -458,7 +463,7 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o Types: [CredentialRef](../core-data-structures/credentials.md) -Source: [`packages/credentials/credentials/src/index.ts:62`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts:67`](../../packages/credentials/credentials/src/index.ts) ## `domain/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d157fffe31..016dff7b15 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -532,7 +532,7 @@ abstract unset(ref: CredentialRef): Promise Types: [CredentialInfo](../core-data-structures/credentials.md) · [CredentialRef](../core-data-structures/credentials.md) · [ResolvedCredential](../core-data-structures/credentials.md) -Source: [`packages/credentials/credentials/src/index.ts:72`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts:77`](../../packages/credentials/credentials/src/index.ts) ## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam) @@ -790,9 +790,9 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surf * Disposed with the fiber. * @param providers - every provider route this adapter should serve. * @param adapter - the adapter that streams calls for those providers. - * @returns the disposer that unregisters all of them. + * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}. */ -registerAdapter(providers: string[], adapter: LlmAdapter): () => void +registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle /** * Describe provider routes with a registered adapter. @@ -864,9 +864,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:191`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:211`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1059,7 +1059,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:131`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:143`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` @@ -1087,7 +1087,7 @@ overrideOf(session: Session): SandboxMode | undefined Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxMode](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) · [Session](../core-data-structures/session.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:79`](../../packages/sandbox/sandbox-policy/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 948002edff..c8cb8302d1 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 5e1049a131cfdbf2368350dbc199aebceebf71ba -core.zh.md: fbb95c1dfa40cc05d9e1f3a4c6ef32c11cab0ec7 +core.md: 5c79f454f50a059d72a592df45d504ee78835e0b +core.zh.md: 258517c625822bdbd64138baf3df186b075bb5c6 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 5e1049a131..5c79f454f5 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -183,6 +183,30 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) Provider and model discovery uses small provider-neutral descriptors. A model catalog is advisory: routing still keys on a registered provider, and an adapter may accept unlisted model ids. +Registering an adapter returns a handle: the disposer, plus the atomic route replacement a plugin whose route set is user-configurable needs. + +```ts type-equiv +/** + * What {@link LlmService.registerAdapter} returns: the disposer, plus an + * atomic route replacement for the same adapter instance. + */ +interface AdapterRegistrationHandle { + /** Release every route this registration currently holds. */ + (): void + /** + * Replace this registration's routes with `providers`, keeping the same + * adapter instance. The candidate set is validated in full first — a + * conflict with another adapter, an invalid name, or bad provider metadata + * throws and leaves the current routes untouched — and the swap itself is + * one synchronous section, so no request can observe a gap. An empty array + * is legal here (a settings section that emptied holds zero routes while + * staying registered), unlike an empty initial registration. + * @param providers - the complete next route set for this registration. + */ + replace(providers: string[]): void +} +``` + ```ts type-equiv /** Display metadata for one registered provider route. */ interface LlmProviderInfo { diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index fbb95c1dfa..258517c625 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -189,6 +189,30 @@ interface MessageSourceMap { 提供方与模型发现使用小型、提供方无关的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。 +注册适配器会返回一个句柄:既是释放器,也带有原子的路由替换——路由集合由用户配置决定的插件正需要它。 + +```ts type-equiv +/** + * What {@link LlmService.registerAdapter} returns: the disposer, plus an + * atomic route replacement for the same adapter instance. + */ +interface AdapterRegistrationHandle { + /** Release every route this registration currently holds. */ + (): void + /** + * Replace this registration's routes with `providers`, keeping the same + * adapter instance. The candidate set is validated in full first — a + * conflict with another adapter, an invalid name, or bad provider metadata + * throws and leaves the current routes untouched — and the swap itself is + * one synchronous section, so no request can observe a gap. An empty array + * is legal here (a settings section that emptied holds zero routes while + * staying registered), unlike an empty initial registration. + * @param providers - the complete next route set for this registration. + */ + replace(providers: string[]): void +} +``` + ```ts type-equiv /** Display metadata for one registered provider route. */ interface LlmProviderInfo { diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index f8189f4e15..c369d8066f 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -sandbox.md: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec -sandbox.zh.md: 9a52f126758fe0e7988715c7824e963bd6e6ea84 +# pnpm run verify-translation-pairing --write docs/core-data-structures/sandbox.md +sandbox.md: 566ac0edc0ba0600e2a1b5ecf18cc34e05e910ec +sandbox.zh.md: 24d8fbfc6c952246278192b5bed7cdc09223e7db diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 9bc05fa06f..566ac0edc0 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## Per-call policy -The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The root is canonicalized with filesystem semantics before lexical normalization, so a cwd containing `symlink/..` identifies the directory where a spawned process actually runs. +The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The root is canonicalized with filesystem semantics before lexical normalization, so a cwd containing `symlink/..` identifies the directory where a spawned process actually runs. `readDenyPaths` names paths a confined execution must not read whatever its mode permits — the harness credential document by default — and backends that cannot express such a denial report `partial` enforcement rather than claiming a boundary the process lacks. ```ts type-equiv /** @@ -53,6 +53,18 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Absolute paths a confined execution must not READ, whatever the mode + * otherwise permits — the harness's own credential document is the + * motivating case, which is why these are exact paths rather than roots: + * denying the whole harness home would also take away the model's + * documented access to its own session log. Not every backend can express + * a read denial (a Landlock allow-list granting `/` cannot subtract from + * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a + * denial is requested and the selected backend cannot apply it. Never a + * boundary under `danger-full-access`, which confines nothing at all. + */ + readDenyPaths?: readonly string[] } ``` diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 9a52f12675..24d8fbfc6c 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## 逐调用策略 -完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent(智能体)时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。 +完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent(智能体)时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。`readDenyPaths` 点名受限执行无论其模式允许什么都不得读取的路径——默认是 harness 凭据文档——无法表达此类拒绝的后端会把强制执行报为 `partial`,而不是声称一条该进程其实并不具备的边界。 ```ts type-equiv /** @@ -53,6 +53,18 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Absolute paths a confined execution must not READ, whatever the mode + * otherwise permits — the harness's own credential document is the + * motivating case, which is why these are exact paths rather than roots: + * denying the whole harness home would also take away the model's + * documented access to its own session log. Not every backend can express + * a read denial (a Landlock allow-list granting `/` cannot subtract from + * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a + * denial is requested and the selected backend cannot apply it. Never a + * boundary under `danger-full-access`, which confines nothing at all. + */ + readDenyPaths?: readonly string[] } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 14e8a00960..a481e67e54 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -26,7 +26,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | -| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:62`](../packages/credentials/credentials/src/index.ts) | [`credentials-local`](../packages/credentials/credentials-local) (`emit`) | [`credentials`](../packages/credentials/credentials) | +| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 81dd199dbb..25d5ee1399 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -481,6 +481,7 @@ flowchart TD pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_policy --> pkg_invariants + pkg_sandbox_policy --> pkg_paths pkg_sandbox_policy --> pkg_sandbox pkg_sandbox_policy --> pkg_session pkg_session_projection --> pkg_invariants @@ -1098,7 +1099,7 @@ flowchart TD | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index fba7bf7338..a09e0281cc 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -191,10 +191,13 @@ describe('headless stream-json snapshots', () => { prepare: (cwd) => { runCwd = cwd }, }) + // The guidance leads with the credential store — the path that keeps the + // secret out of configuration files — and offers a literal key last. expect(result.stderr).toBe( 'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek";' - + ' set the llm-deepseek "apiKey" setting, store DEEPSEEK_API_KEY with the credentials service,' - + ' or export DEEPSEEK_API_KEY\n', + + ' store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),' + + ' export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal' + + ' "apiKey" in the llm-deepseek settings section\n', ) const normalized = normalizeHeadlessStream(result.stdout, runCwd) if (refreshing) await writeFile(streamExpected, normalized) diff --git a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl index d7d72f6a86..c48a42f62e 100644 --- a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -4,5 +4,5 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}}}} -{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; set the llm-deepseek \"apiKey\" setting, store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY","code":"MISSING_CREDENTIAL"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}} +{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9f3d78c9bf..553e321d7a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -405,8 +405,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.', methods: [ { - signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): () => void', - jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer that unregisters all of them.\n */', + signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle', + jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.\n */', }, { signature: 'listProviders(): LlmProviderInfo[]', @@ -1297,7 +1297,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'credentials/updated', mode: 'emit', signature: '\'credentials/updated\'(ref: CredentialRef): void', - jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */', + jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit. Listener\n * failures are contained and logged — a sync throw and an async rejection\n * alike — without changing the committed operation\'s outcome, except\n * `INVARIANT`-coded failures, which rethrow after every listener ran;\n * that rethrow reaches the emitter only from synchronous listeners, so\n * invariant checks on this event must not be async functions.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */', summary: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage.', }, { @@ -1521,6 +1521,10 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ + { + name: 'AdapterRegistrationHandle', + declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', + }, { name: 'Agent', declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', @@ -2207,7 +2211,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SandboxExecutionPolicy', - declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n readDenyPaths?: readonly string[];\n}', }, { name: 'SandboxMode', diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 23cf5bb09b..55d8f24b34 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/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/credentials/credentials-local/README.md -README.md: 277c7db02836819e34a5c2db8aaf542c8eeec162 -README.zh.md: af1b840142d214b8cd8cf59690cb5773fae2e867 +README.md: 2288d6d7133a7f356823e3e4f28746cfd28b2597 +README.zh.md: 959322c9ec670ed76b89f1f3a19246191b3ec02c diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 1b2b002e60..959322c9ec 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -32,12 +32,12 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 ## 安全边界 -文档位于 `0700` 目录下、权限 `0600`,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致。有两件事收窄了这一点: +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致。有两件事收窄了这一点: -- **约束型沙箱模式**会专门拒绝凭据文档:[`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) 把 `readDenyPaths` 默认为 `$DSH_HOME/.env`,Seatbelt 与 bwrap 后端会执行它(Landlock 无法从自己的 `/` 读授权中扣除,只能报 `partial`)。这条拒绝点名的是该文件而非整个 home,因此模型对自己会话日志的既定访问不受影响。 +- **受限沙箱模式**会专门拒绝凭据文档:[`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) 把 `readDenyPaths` 默认为 `$DSH_HOME/.env`,Seatbelt 与 bwrap 后端会执行它(Landlock 无法从自己的 `/` 读授权中扣除,只能报 `partial`)。这条拒绝点名的是该文件而非整个 home,因此模型对自己会话日志的既定访问不受影响。 - harness 绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))。 -这两者都不能让未受约束的 agent 变得安全。必须让提供方密钥远离自身 agent 的部署应当运行约束型模式;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 +这两者都不能让未受限的 agent 变得安全。必须让提供方密钥远离自身 agent 的部署应当运行受限模式;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 ## Model Experience @@ -51,7 +51,7 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 - **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 - **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 -- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有约束型沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。 +- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有受限沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。 - **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 - **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 - **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index 44678b5837..c62d4411fa 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -302,6 +302,11 @@ export class CredentialsLocal extends Credentials { await this.write(ref, undefined) } + /* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same + reviewed contract as settings-local, deliberately mirrored (prefer symmetry + for parallel values); the two providers own different documents and + failure policies, so extracting the shape would couple their teardown + semantics across packages for a handful of lines. */ /** Queue one exclusive document operation behind every earlier one. */ private enqueue(operation: () => Promise): Promise { const task = this.operations.then(operation) @@ -319,6 +324,7 @@ export class CredentialsLocal extends Credentials { this.ctx.logger.error(error) }) } + /* jscpd:ignore-end */ /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */ private async write(ref: CredentialRef, value: string | undefined): Promise { @@ -390,6 +396,9 @@ export class CredentialsLocal extends Credentials { this.values = new Map(Object.entries(parse(text))) } + /* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and + reconcile policy: warn-and-keep on a reload, throw on a write, invariant + failures propagate. */ /** * Re-read the document after a watcher event. Unchanged content (including * this provider's own writes) is a no-op; an unreadable document keeps the @@ -430,6 +439,7 @@ export class CredentialsLocal extends Credentials { this.values = next for (const ref of changed) this.notifyUpdated(ref) } + /* jscpd:ignore-end */ /** Seam-addressable entries whose effective (non-empty) value changed. */ private changedRefs(prev: Map, next: Map): CredentialRef[] { diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 41ecd175d9..e02f994fef 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 88f4fd7c017a5dbb070bdaf8ee47bb5610b23303 -README.zh.md: 386c695766a68c9054472bd5c9b9deb6746c52e6 +README.md: ab44b61e300ca65cc4dd3507ad7262cd08edcfce +README.zh.md: 4ecaf361fdb396f9f8079476240b5e9353a73f5e diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 6584157850..25e825eead 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: fb8145d58a7c74c70498468044282c740460a947 -README.zh.md: 0d0e2152d27447705cdf19f5b36069314ff05b4d +README.md: 0099c9acd39cd2d471936505726d68423f351c76 +README.zh.md: 7cb4f5fcbc1c7a67b77d690031cc7d553569433f diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 49ff6d7c48..d7740dcebf 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: d343449d1530bf70a3a8c57f883894e29c42d18f -README.zh.md: 4dc4a0ca06378116d05fdb4b9b048738930511fd +README.md: 5b0c1b2dcafeefaad25f1714e4a1783430370118 +README.zh.md: 5f5c8142ec829e8ca8cfd40e6caa341ae0a33c7d diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index b21c8d885a..78ec04a6a0 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/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/sandbox/sandbox-policy/README.md -README.md: dca54330bc888af9ecac21aa92019d8a2b0140bd -README.zh.md: a201d48c81f563fc3d85495e964bb67432517a3c +README.md: 297dd7d5210bb30963a162c6a55a598c6d522aaf +README.zh.md: 1de92eb81409a7fabb25de94eb5372f0f16afb6f diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 08a274fd25..17ce617b70 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad -README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06 +README.md: 47c5de35e6151b82f8d99c06618c42dfabe59f5e +README.zh.md: 9878567464b46865f9320582359f7baa0c97f30c diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b7121bbd28..9878567464 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -26,8 +26,8 @@ 开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: -- **`.env`**:在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`。 -- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 +- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 +- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e2fed96e45..b9dbb501ec 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -33,6 +33,7 @@ export const LINK_MAP: Readonly> = { MessageId: 'core.md', HookContext: 'core.md', SettleReason: 'core.md', + AdapterRegistrationHandle: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmModelReasoningInfo: 'core.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 6687d4127d..8e6f4c62ce 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -31,6 +31,11 @@ "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "AdapterRegistrationHandle", + "source": "packages/llm/llm/src/index.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmProviderInfo", From ba0757223d6cc27bb4dc718d9d210ebc2141185a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 17:04:09 +0800 Subject: [PATCH 20/37] feat(web): add a web render-intent card for web_search and web_fetch results web_search and web_fetch returned only model-facing text, whose markdown source list is lossy (title-or-hostname label, snippet and date concatenated), so a client could not recover the structured sources. Add a card:'web' result view with a kind discriminant ('search' carrying structured sources + answer + truncated, 'fetch' carrying url + statusCode + truncated), projected through each tool's output.presentationMeta and read back in presentResult. A UI without the web card falls back to content; the TUI is unchanged. The web consumer is a follow-up. --- .../2026-07-30-web-result-card.i18n.yaml | 6 + .../feature/2026-07-30-web-result-card.md | 46 +++++ .../feature/2026-07-30-web-result-card.zh.md | 45 +++++ packages/core/tools/src/index.ts | 4 + packages/core/tools/src/presentation.ts | 84 ++++++++- packages/web/tool-web/src/fetch.ts | 76 +++++++- packages/web/tool-web/src/index.ts | 6 +- packages/web/tool-web/src/search.ts | 104 ++++++++++- packages/web/tool-web/tests/tool-web.spec.ts | 166 ++++++++++++++++++ 9 files changed, 532 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml new file mode 100644 index 0000000000..498f6557f8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-07-30-web-result-card.md +2026-07-30-web-result-card.md: 675c93ebfda0d74b2809e5d12fb55df85020646e +2026-07-30-web-result-card.zh.md: be02cbbfa272590c43b088d194dda0dbfab7adc0 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md new file mode 100644 index 0000000000..675c93ebfd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md @@ -0,0 +1,46 @@ +# Agent Note: Web result card — a structured render intent for web_search and web_fetch + +Status: implemented + +English | [中文](2026-07-30-web-result-card.zh.md) + +## Problem + +The `web_search` and `web_fetch` tools each declared a generic pending card (`presentCall`, `kind: 'search'`/`'fetch'`) but no `presentResult`, so a completed web call reached a UI only as the model-facing render text. For a web frontend that wants to render a citation list or a fetch summary, that text is lossy: `web_search`'s render collapses each source's `title`, `snippet`, and `publishedAt` into one free-text markdown line labelled by title OR hostname (`formatSearchOutput` in `packages/web/tool-web/src/search.ts`), so reparsing the render cannot recover the per-source fields; and `web_fetch`'s render carries `url` and `statusCode` only in a header line. The render-intent contract ([tagged union](../architecture/2026-07-02-tool-render-intent-union.md)) had no arm a web tool could declare to carry a structured result. + +## Decision + +Add one `card: 'web'` result arm to `ToolResultView` (`packages/core/tools/src/presentation.ts`), a union `WebResultView = WebSearchResultView | WebFetchResultView` discriminated by a `kind: 'search' | 'fetch'` field, plus a `WebSource` shape for one citeable source. Both tools now declare `presentResult`. + +One tag with a `kind` discriminant, not two tags. Both calls are web retrieval and a web frontend renders them with one component family (a retrieval card whose body differs by kind), so a shared `card` keeps every card consumer's switch to one added arm and lets the frontend branch on `kind` inside it. Two tags would force every present and future consumer to add two arms for what is one visual family. The `kind` values match the two tools' existing generic call-view `kind`s, so a call and its result read as the same category. + +`presentationMeta` is mandatory here, not a convenience. The structured result object a tool returns from `execute` does NOT reach a client over the wire — only the model-facing `render` text and, when declared, the `output.presentationMeta` JSON projected onto the `tool/result` event's `meta` do. Because the render text is lossy for `web_search`'s sources, projecting the sources through `presentationMeta` is the only faithful route to `{url, title?, snippet?, publishedAt?}` at the consumer. This mirrors the write/edit diff template (`packages/fs/tool-fs/src/diff.ts`): a `*MetaFromValue` projector feeds `output.presentationMeta`, and a `*MetaFromResult` narrower reads `result.meta` back with a defensive fallback to the generic card. `web_fetch`'s meta carries `url`/`statusCode`/`truncated` only; its body is already markdown in the result content, so it is not duplicated into meta. + +Each result view carries an optional `content?: ContentBlock[]` set to the model-facing result content. A UI without the `web` capability — including the TUI, whose transcript renderer has no `web` arm — renders that content through its existing generic/default path (`packages/ui/tui/src/components/transcript.ts`, `renderBody`'s `view.content ?? this.result?.content`), so the new tag needs no dedicated TUI arm and the TUI keeps compiling and rendering the text. + +`presentResult` returns `undefined` (the generic card) on an error result and on absent or malformed `meta`, because presentation runs on replay of arbitrary logged results (possibly from an older schema) and must never throw. The narrowers validate every field defensively; an empty source list is valid meta, not malformed. + +## Consequences + +The web frontend consumer is a separate later PR: this PR adds the contract arm and makes the two tools emit it, with no client-side rendering. Any existing `ToolResultView` consumer that switches exhaustively must add a `web` arm; the TUI does not switch exhaustively and needs none. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change. + +A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag. + +## Alternatives considered + +**Two card tags (`web-search`, `web-fetch`).** Rejected: it doubles the arm count at every card consumer for one visual family, and the two shapes already share enough (a titled retrieval card with fallback content) that a `kind` discriminant expresses the difference without a second tag. + +**Reparse the render text in `presentResult` instead of projecting meta.** Rejected for `web_search`: the render's source list is lossy (title-or-hostname label, snippet and date concatenated into free text), so reparsing cannot faithfully recover the structured fields. `presentationMeta` is the only route that preserves them. + +**Carry the fetch body in meta too.** Rejected: the body is already the model-facing markdown in the result content, and duplicating it into meta would double the persisted payload for no gain; the view points a UI at the existing content. + +## Testing + +`packages/web/tool-web/tests/tool-web.spec.ts` covers, per-file to the 100% gate: `searchMetaFromValue`/`fetchMetaFromValue` projection including omission of absent optional fields; `searchMetaFromResult`/`fetchMetaFromResult` narrowing with a round-trip and every malformed-shape rejection (non-object, wrong field types, a malformed source entry) plus the empty-source-list accept; `presentSearchResult`/`presentFetchResult` typed views including the truncated signal, the error-result fallback, and the malformed-meta fallback; and two real-registry executions asserting the tool projects the meta onto `result.meta` and its registered `presentResult` derives the `card: 'web'` view. + +## Related + +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `web` arm. +- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent that carried the bash `terminal` render intent to the browser; the web frontend consumer of this arm is its analogue, deferred to a later PR. + + diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md new file mode 100644 index 0000000000..be02cbbfa2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md @@ -0,0 +1,45 @@ +# Agent Note: Web result card — a structured render intent for web_search and web_fetch + +Status: implemented + +[English](2026-07-30-web-result-card.md) | 中文 + +## Problem + +`web_search` 与 `web_fetch` 工具各自声明了一个 generic 待定卡片(`presentCall`,`kind: 'search'`/`'fetch'`),但没有 `presentResult`,因此一个已完成的 web 调用抵达 UI 时只剩下面向模型的 render 文本。对于想渲染引用列表或抓取摘要的 web 前端而言,该文本是有损的:`web_search` 的 render 把每个来源的 `title`、`snippet`、`publishedAt` 压进一行以 title 或 hostname 标注的自由文本 markdown(`packages/web/tool-web/src/search.ts` 中的 `formatSearchOutput`),因此重新解析 render 无法恢复各来源字段;`web_fetch` 的 render 也仅在一行 header 里携带 `url` 与 `statusCode`。渲染意图契约([标签联合类型](../architecture/2026-07-02-tool-render-intent-union.md))此前没有一个可供 web 工具声明、用以携带结构化结果的分支。 + +## Decision + +向 `ToolResultView`(`packages/core/tools/src/presentation.ts`)新增一个 `card: 'web'` 结果分支,它是以 `kind: 'search' | 'fetch'` 字段作判别的联合 `WebResultView = WebSearchResultView | WebFetchResultView`,并附一个表示单个可引用来源的 `WebSource` 形状。两个工具现在都声明 `presentResult`。 + +采用一个标签加 `kind` 判别,而非两个标签。两个调用都是 web 检索,web 前端会用同一族组件渲染它们(一个检索卡片,正文按 kind 不同),因此共用一个 `card` 让每个 card 消费者的 switch 只需新增一个分支,并让前端在其内部按 `kind` 分岔。两个标签会迫使当前及未来每个消费者为本属同一视觉族的东西添加两个分支。这两个 `kind` 取值与两个工具既有的 generic 调用视图 `kind` 一致,因此一个调用与它的结果读起来是同一类别。 + +`presentationMeta` 在这里是必需的,而非便利手段。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会。由于 render 文本对 `web_search` 的来源是有损的,经 `presentationMeta` 投影来源,是在消费端得到忠实 `{url, title?, snippet?, publishedAt?}` 的唯一途径。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的 meta 只携带 `url`/`statusCode`/`truncated`;其正文已是结果内容中的 markdown,因此不重复写入 meta。 + +每个结果视图携带一个可选的 `content?: ContentBlock[]`,设为面向模型的结果内容。不具备 `web` 能力的 UI——包括其 transcript 渲染器没有 `web` 分支的 TUI——经由既有的 generic/默认路径渲染该内容(`packages/ui/tui/src/components/transcript.ts` 中 `renderBody` 的 `view.content ?? this.result?.content`),因此新标签无需专门的 TUI 分支,TUI 继续编译并渲染文本。 + +`presentResult` 在错误结果、以及 `meta` 缺失或畸形时返回 `undefined`(即 generic 卡片),因为 presentation 会在对任意已记录结果(可能来自旧 schema)的重放中运行,绝不能抛错。收窄器防御性地校验每个字段;空来源列表是有效 meta,而非畸形。 + +## Consequences + +web 前端消费者是一个独立的后续 PR:本 PR 新增契约分支并让两个工具发出它,不含客户端渲染。任何做穷尽 switch 的现有 `ToolResultView` 消费者都必须新增一个 `web` 分支;TUI 并不穷尽 switch,无需新增。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。 + +未来想用此卡片的 web 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。 + +## Alternatives considered + +**两个 card 标签(`web-search`、`web-fetch`)。** 否决:它在每个 card 消费者处为一个视觉族翻倍分支数,而两个形状已共享得够多(一个带回退内容的带标题检索卡片),`kind` 判别无需第二个标签即可表达差异。 + +**在 `presentResult` 里重新解析 render 文本,而非投影 meta。** 对 `web_search` 否决:render 的来源列表是有损的(title 或 hostname 标签,snippet 与日期拼进自由文本),因此重新解析无法忠实恢复结构化字段。`presentationMeta` 是唯一保留它们的途径。 + +**把抓取正文也放进 meta。** 否决:正文已是结果内容中面向模型的 markdown,把它复制进 meta 会为无收益的目的翻倍持久化载荷;视图让 UI 指向既有内容。 + +## Testing + +`packages/web/tool-web/tests/tool-web.spec.ts` 覆盖以下内容,满足按文件 100% 的门禁:`searchMetaFromValue`/`fetchMetaFromValue` 投影,含对缺席可选字段的省略;`searchMetaFromResult`/`fetchMetaFromResult` 收窄,含一次往返与每种畸形形状的拒绝(非对象、字段类型错误、畸形来源条目)以及空来源列表的接受;`presentSearchResult`/`presentFetchResult` 类型化视图,含 truncated 信号、错误结果回退与畸形 meta 回退;以及两次真实注册表执行,断言工具把 meta 投影到 `result.meta` 上,其注册的 `presentResult` 推导出 `card: 'web'` 视图。 + +## Related + +- [标签化的工具调用渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md) —— 本卡片以 `web` 分支扩展的 `card` 标签词汇表。 +- [Web terminal card](2026-07-28-web-terminal-card.md) —— 把 bash `terminal` 渲染意图带到浏览器的先例;本分支的 web 前端消费者是它的对应物,推迟到后续 PR。 + diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 2caaaa8276..e825a1a0dc 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -82,6 +82,10 @@ export type { GenericResultView, TerminalResultView, DiffResultView, + WebResultView, + WebSearchResultView, + WebFetchResultView, + WebSource, } from './presentation.ts' declare module 'cordis' { diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index 17b88b822f..f73ddb06d2 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -125,7 +125,7 @@ export interface DiffCallView { * `ToolDefinition.presentResult`; omitting the method keeps the pending * title and renders the raw result content. */ -export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView +export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView /** * The default completed card: an optional replacement title and reformatted @@ -176,3 +176,85 @@ export interface DiffResultView { /** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */ diffs: FileDiff[] } + +/** + * One citeable source in a completed {@link WebSearchResultView}, the faithful + * projection of one web-search source. The render text a web tool returns is + * lossy — its markdown list collapses `title`/`snippet`/`publishedAt` into one + * free-text line and labels a source by title OR hostname — so a UI cannot + * reliably recover these fields by reparsing that text. A tool therefore + * projects this structured shape through `output.presentationMeta`, and its + * `presentResult` reads it back. + */ +export interface WebSource { + /** The source URL. */ + url: string + /** The source title, when the provider returned one. */ + title?: string + /** A short excerpt or summary, when the provider returned one. */ + snippet?: string + /** Publication/crawl timestamp as a provider-supplied ISO-8601 string, when present. */ + publishedAt?: string +} + +/** + * A completed web retrieval rendered as a structured card by a capable UI. Set + * by a web tool whose call retrieves from the web (`web_search`, `web_fetch`). + * One `kind`-tagged union carries both shapes because both are web retrieval and + * a UI renders them with one component family; a UI switches on `kind`. An + * incapable UI falls back to `content` (the reformatted model-facing text). This + * is the result-time analogue of the `web_search`/`web_fetch` calls' generic + * call views (`kind: 'search'`/`'fetch'`); those tools keep their generic + * pending card and add only this completed card. + */ +export type WebResultView = WebSearchResultView | WebFetchResultView + +/** + * The completed state of a `web_search` call: the structured sources the model + * cited, an optional provider answer, and whether the source list was cut to the + * result cap. A capable UI renders the sources as a citation list; an incapable + * UI renders `content`. + */ +export interface WebSearchResultView { + card: 'web' + kind: 'search' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** The faithful, structured sources — the field render text cannot losslessly carry. */ + sources: WebSource[] + /** The provider-generated answer text, when any. */ + answer?: string + /** True when the tool cut the source list to its result cap. */ + truncated: boolean + /** + * UI-facing fallback content (harness {@link ContentBlock}s), reformatted from + * the model-facing result. A UI without the `web` capability renders this. + * Omit to let the UI render the raw result content. + */ + content?: ContentBlock[] +} + +/** + * The completed state of a `web_fetch` call: the fetched URL, its HTTP status, + * and whether the content was cut. The body itself is already markdown in the + * result content, so this card carries the retrieval summary and leaves the body + * to `content`. + */ +export interface WebFetchResultView { + card: 'web' + kind: 'fetch' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** The final URL after allowed redirects. */ + url: string + /** HTTP status code of the fetched response. */ + statusCode: number + /** True when the provider or the output cap cut the content. */ + truncated: boolean + /** + * UI-facing fallback content (harness {@link ContentBlock}s): the already-markdown + * body. A UI without the `web` capability renders this. Omit to let the UI + * render the raw result content. + */ + content?: ContentBlock[] +} diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 75108f663c..246505adb5 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -9,7 +9,7 @@ import type { Context } from 'cordis' import TurndownService from 'turndown' import { gfm } from '@joplin/turndown-plugin-gfm' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, JsonValue, ToolResult, WebFetchResultView } from '@deepseek-ai/dsh-tools' import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web' import { assertNever } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -278,6 +278,78 @@ export function presentFetchCall(args: { url: string }): GenericCallView { return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url } } +/** + * The `web_fetch` tool's private `tool/result` `meta` payload: the fetch summary + * a UI cannot recover from the model-facing render text without reparsing its + * header line. Attached opaquely (as `JsonValue`) on the tool result and + * persisted with the session log, so `presentResult` reproduces the fetch card + * on replay. The body itself is already markdown in the result content, so it is + * not duplicated here. + */ +export interface WebFetchMeta { + /** The final URL after allowed redirects. */ + url: string + /** HTTP status code of the fetched response. */ + statusCode: number + /** True when the provider or the output cap cut the content. */ + truncated: boolean +} + +/** The `web_fetch` canonical output value projected into presentation meta. */ +type WebFetchValue = { + url: string + statusCode: number + truncated: boolean +} + +/** + * Project a validated `web_fetch` output value into its replayable presentation + * meta ({@link WebFetchMeta} as opaque JSON). + * + * @param value - the canonical `web_fetch` output value. + * @returns the URL, status code, and truncation flag. + */ +export function fetchMetaFromValue(value: WebFetchValue): JsonValue { + return { url: value.url, statusCode: value.statusCode, truncated: value.truncated } +} + +/** + * Narrow opaque live or replayed result metadata to a {@link WebFetchMeta}. + * Malformed metadata returns `undefined` so presentation can fall back to the + * generic card instead of throwing during replay. + * + * @param meta - result metadata. + * @returns the validated fetch meta, or `undefined` for absent or malformed data. + */ +export function fetchMetaFromResult(meta: unknown): WebFetchMeta | undefined { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined + const { url, statusCode, truncated } = meta as Record + if (typeof url !== 'string' || typeof statusCode !== 'number' || typeof truncated !== 'boolean') return undefined + return { url, statusCode, truncated } +} + +/** + * Completed-call presentation: a `web` fetch card carrying the retrieval summary + * from `meta` alongside the already-markdown body as fallback content. + * + * @param result - the final model-facing tool result; `meta` carries the summary. + * @returns the fetch result view, or `undefined` (generic card) on failure or + * malformed meta. + */ +export function presentFetchResult(result: ToolResult): WebFetchResultView | undefined { + if (result.isError) return undefined + const meta = fetchMetaFromResult(result.meta) + if (meta === undefined) return undefined + return { + card: 'web', + kind: 'fetch', + url: meta.url, + statusCode: meta.statusCode, + truncated: meta.truncated, + content: result.content, + } +} + /** * Register the `web_fetch` tool and its system-prompt guidance. * @@ -333,6 +405,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar }, }, render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }], + presentationMeta: (_args, value) => fetchMetaFromValue(value), }, timeoutMs, // Provider reads do not mutate parent-agent state. @@ -351,5 +424,6 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar } }, presentCall: presentFetchCall, + presentResult: (_args, result) => presentFetchResult(result), })) } diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 74585b3cab..397e2bf7bb 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -12,8 +12,10 @@ import type {} from '@deepseek-ai/dsh-web' import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts' import { applyWebFetchTool } from './fetch.ts' -export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts' -export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall } from './fetch.ts' +export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult } from './search.ts' +export type { WebSearchMeta } from './search.ts' +export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts' +export type { WebFetchMeta } from './fetch.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-web' diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 816650e4b0..792adcc5e3 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -7,7 +7,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, JsonValue, ToolResult, WebSearchResultView, WebSource } from '@deepseek-ai/dsh-tools' import type { WebSearchResult } from '@deepseek-ai/dsh-web' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -84,6 +84,106 @@ export function presentSearchCall(args: { query: string }): GenericCallView { return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query } } +/** + * The `web_search` tool's private `tool/result` `meta` payload: the structured + * sources, the optional provider answer, and the truncation flag. Attached + * opaquely (as `JsonValue`) on the tool result and persisted with the session + * log, so `presentResult` reproduces the search card on replay. The render text + * is lossy — its markdown source list collapses each source's title, snippet, + * and date into one free-text line labelled by title OR hostname — so reparsing + * that text cannot recover the per-source fields; this projection is the only + * faithful route to them. + */ +export interface WebSearchMeta { + /** The faithful structured sources, in result order. */ + sources: WebSource[] + /** True when the tool cut the source list to its result cap. */ + truncated: boolean + /** The provider-generated answer text, when any. */ + answer?: string +} + +/** The `web_search` canonical output value projected into presentation meta. */ +type WebSearchValue = { + content?: string + sources: readonly WebSource[] + truncated: boolean +} + +/** + * Project a validated `web_search` output value into its replayable + * presentation meta ({@link WebSearchMeta} as opaque JSON). + * + * @param value - the canonical `web_search` output value. + * @returns the structured sources, the truncation flag, and the answer when present. + */ +export function searchMetaFromValue(value: WebSearchValue): JsonValue { + return { + sources: value.sources.map(source => ({ + url: source.url, + ...source.title !== undefined ? { title: source.title } : {}, + ...source.snippet !== undefined ? { snippet: source.snippet } : {}, + ...source.publishedAt !== undefined ? { publishedAt: source.publishedAt } : {}, + })), + truncated: value.truncated, + ...value.content !== undefined ? { answer: value.content } : {}, + } +} + +/** Whether `value` is a valid {@link WebSource} (defensive narrowing from opaque `meta`). */ +function isWebSource(value: unknown): value is WebSource { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { url, title, snippet, publishedAt } = value as Record + return typeof url === 'string' + && (title === undefined || typeof title === 'string') + && (snippet === undefined || typeof snippet === 'string') + && (publishedAt === undefined || typeof publishedAt === 'string') +} + +/** + * Narrow opaque live or replayed result metadata to a {@link WebSearchMeta}. + * Malformed metadata returns `undefined` so presentation can fall back to the + * generic card instead of throwing during replay. + * + * @param meta - result metadata. + * @returns the validated search meta, or `undefined` for absent or malformed data. + */ +export function searchMetaFromResult(meta: unknown): WebSearchMeta | undefined { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined + const { sources, truncated, answer } = meta as Record + if (!Array.isArray(sources) || !sources.every(isWebSource)) return undefined + if (typeof truncated !== 'boolean') return undefined + if (answer !== undefined && typeof answer !== 'string') return undefined + return { + sources, + truncated, + ...answer !== undefined ? { answer } : {}, + } +} + +/** + * Completed-call presentation: a `web` search card carrying the faithful + * structured sources from `meta` alongside the model-facing text as fallback + * content. + * + * @param result - the final model-facing tool result; `meta` carries the sources. + * @returns the search result view, or `undefined` (generic card) on failure or + * malformed meta. + */ +export function presentSearchResult(result: ToolResult): WebSearchResultView | undefined { + if (result.isError) return undefined + const meta = searchMetaFromResult(result.meta) + if (meta === undefined) return undefined + return { + card: 'web', + kind: 'search', + sources: meta.sources, + truncated: meta.truncated, + ...meta.answer !== undefined ? { answer: meta.answer } : {}, + content: result.content, + } +} + /** * Register the `web_search` tool and its system-prompt guidance. * @@ -131,6 +231,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: }, }, render: (_args, value) => [{ type: 'text', text: formatSearchOutput(value) }], + presentationMeta: (_args, value) => searchMetaFromValue(value), }, timeoutMs, // Provider reads do not mutate parent-agent state. @@ -153,5 +254,6 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: } }, presentCall: presentSearchCall, + presentResult: (_args, result) => presentSearchResult(result), })) } diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 2324922046..9fc90396a8 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -14,8 +14,16 @@ import { parseFetchArgs, presentSearchCall, presentFetchCall, + presentSearchResult, + presentFetchResult, + searchMetaFromValue, + searchMetaFromResult, + fetchMetaFromValue, + fetchMetaFromResult, WEB_SEARCH_MAX_RESULTS, } from '@deepseek-ai/dsh-tool-web' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ToolResult } from '@deepseek-ai/dsh-tools' const testToolSignal = new AbortController().signal @@ -91,6 +99,96 @@ describe('search formatting', () => { }) }) +/** Build a completed non-error tool result with the given meta and text content. */ +function toolResult(meta: unknown, text = 'body', isError = false): ToolResult { + const content: ContentBlock[] = [{ type: 'text', text }] + return { content, isError, ...meta !== undefined ? { meta: meta as never } : {} } +} + +describe('web_search presentation meta and result view', () => { + it('projects sources, answer, and truncation into meta, omitting absent optional fields', () => { + const meta = searchMetaFromValue({ + content: 'an answer', truncated: true, + sources: [ + { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' }, + { url: 'https://b.test/y' }, + ], + }) + expect(meta).toEqual({ + answer: 'an answer', + truncated: true, + sources: [ + { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' }, + { url: 'https://b.test/y' }, + ], + }) + }) + + it('omits answer from meta when the provider returned none', () => { + const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] }) + expect(meta).toEqual({ truncated: false, sources: [{ url: 'https://a.test' }] }) + }) + + it('round-trips projected meta back to a typed search meta', () => { + const value = { + content: 'ans', truncated: false, + sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }], + } + expect(searchMetaFromResult(searchMetaFromValue(value))).toEqual({ + answer: 'ans', truncated: false, + sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }], + }) + }) + + it('presents a completed search as a web/search card carrying the structured sources and fallback content', () => { + const meta = searchMetaFromValue({ + content: 'an answer', truncated: true, + sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], + }) + expect(presentSearchResult(toolResult(meta, 'rendered'))).toEqual({ + card: 'web', + kind: 'search', + answer: 'an answer', + truncated: true, + sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], + content: [{ type: 'text', text: 'rendered' }], + }) + }) + + it('omits the answer from the view when meta carries none', () => { + const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] }) + const view = presentSearchResult(toolResult(meta)) + expect(view).toBeDefined() + expect(view && 'answer' in view).toBe(false) + }) + + it('falls back to the generic card on an error result', () => { + const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] }) + expect(presentSearchResult(toolResult(meta, 'body', true))).toBeUndefined() + }) + + it('falls back to the generic card on absent or malformed meta', () => { + expect(presentSearchResult(toolResult(undefined))).toBeUndefined() + expect(searchMetaFromResult(undefined)).toBeUndefined() + expect(searchMetaFromResult(null)).toBeUndefined() + expect(searchMetaFromResult('nope')).toBeUndefined() + expect(searchMetaFromResult([])).toBeUndefined() + expect(searchMetaFromResult({})).toBeUndefined() + expect(searchMetaFromResult({ sources: 'x', truncated: false })).toBeUndefined() + expect(searchMetaFromResult({ sources: [], truncated: 'no' })).toBeUndefined() + expect(searchMetaFromResult({ sources: [], truncated: false, answer: 1 })).toBeUndefined() + expect(searchMetaFromResult({ sources: [null], truncated: false })).toBeUndefined() + expect(searchMetaFromResult({ sources: [{ url: 1 }], truncated: false })).toBeUndefined() + expect(searchMetaFromResult({ sources: [{ url: 'u', title: 2 }], truncated: false })).toBeUndefined() + expect(searchMetaFromResult({ sources: [{ url: 'u', snippet: 2 }], truncated: false })).toBeUndefined() + expect(searchMetaFromResult({ sources: [{ url: 'u', publishedAt: 2 }], truncated: false })).toBeUndefined() + }) + + it('accepts an empty source list as valid meta', () => { + expect(searchMetaFromResult({ sources: [], truncated: false })).toEqual({ sources: [], truncated: false }) + }) +}) + describe('fetch formatting', () => { const NO_CAP = 1_000_000 const HEADER = 'Fetched https://a.test (HTTP 200)\n\n' @@ -259,6 +357,42 @@ describe('fetch formatting', () => { }) }) +describe('web_fetch presentation meta and result view', () => { + it('projects url, status, and truncation into meta', () => { + expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true })) + .toEqual({ url: 'https://a.test', statusCode: 404, truncated: true }) + }) + + it('presents a completed fetch as a web/fetch card carrying the summary and the markdown body as fallback content', () => { + const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false }) + expect(presentFetchResult(toolResult(meta, '# Title'))).toEqual({ + card: 'web', + kind: 'fetch', + url: 'https://a.test', + statusCode: 200, + truncated: false, + content: [{ type: 'text', text: '# Title' }], + }) + }) + + it('falls back to the generic card on an error result', () => { + const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false }) + expect(presentFetchResult(toolResult(meta, 'body', true))).toBeUndefined() + }) + + it('falls back to the generic card on absent or malformed meta', () => { + expect(presentFetchResult(toolResult(undefined))).toBeUndefined() + expect(fetchMetaFromResult(undefined)).toBeUndefined() + expect(fetchMetaFromResult(null)).toBeUndefined() + expect(fetchMetaFromResult('nope')).toBeUndefined() + expect(fetchMetaFromResult([])).toBeUndefined() + expect(fetchMetaFromResult({})).toBeUndefined() + expect(fetchMetaFromResult({ url: 1, statusCode: 200, truncated: false })).toBeUndefined() + expect(fetchMetaFromResult({ url: 'u', statusCode: 'x', truncated: false })).toBeUndefined() + expect(fetchMetaFromResult({ url: 'u', statusCode: 200, truncated: 'no' })).toBeUndefined() + }) +}) + describe('tool-web registration', () => { it('registers both tools by default', async () => { const { fiber, ctx } = await mountTools() @@ -323,6 +457,38 @@ describe('tool-web execution through the real registry', () => { await fiber.dispose() }) + it('projects the search sources into the tool result meta and derives its web/search view', async () => { + const result: WebSearchResult = { + content: 'answer', truncated: true, + sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], + } + const { ctx, fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) }) + const out = await call('web_search', { query: 'q' }) + expect(out.meta).toEqual({ + answer: 'answer', truncated: true, + sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], + }) + const view = ctx.tools.get('web_search')?.presentResult?.({ query: 'q' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} }) + expect(view).toMatchObject({ card: 'web', kind: 'search', truncated: true, answer: 'answer' }) + await fiber.dispose() + }) + + it('projects the fetch summary into the tool result meta and derives its web/fetch view', async () => { + const fetchProvider = { + id: 'stub-fetch', + available: () => available, + fetch: (request: { url: string }) => Promise.resolve({ + url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: true, + }), + } + const { ctx, fiber, call } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) + const out = await call('web_fetch', { url: 'https://a.test' }) + expect(out.meta).toEqual({ url: 'https://a.test', statusCode: 200, truncated: true }) + const view = ctx.tools.get('web_fetch')?.presentResult?.({ url: 'https://a.test' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} }) + expect(view).toMatchObject({ card: 'web', kind: 'fetch', url: 'https://a.test', statusCode: 200, truncated: true }) + await fiber.dispose() + }) + it('surfaces a structured WebError when no provider is available', async () => { const { fiber, call } = await mountTools() const out = await call('web_search', { query: 'q' }) From 52ae5789825e5931d1166149e28cb3ad8b49a430 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 17:09:29 +0800 Subject: [PATCH 21/37] test(tui): pin the personal overlay to the environment layers the CLI loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The personal-config smoke asserted that `$DSH_HOME/.env` feeds a `!!js` expression in the personal `config.yaml` — the hoist this branch removed so `credentials-local` can own that document and keep stored keys rotatable. Seed both layers instead and let one expression separate them: the welcome prefers the personal variable, so it can only render the invoking directory's value while the harness home's `.env` stays out of `process.env`. The negative that made the removal worth doing is now asserted in the assembled application, not just in the provider's unit tests. --- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index cd5f83e4e3..094d8549c0 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -355,18 +355,23 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('applies the personal overlay: config.yaml patches the tree and .env feeds its !!js', async () => { - // The whole personal-config chain in one boot: the personal .env supplies - // the variable, config.yaml patches the tui-agent entry with a `!!js` - // reference to it, and the banner renders the patched welcome verbatim. + it('applies the personal overlay: config.yaml patches the tree, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => { + // The whole personal-config chain in one boot, plus the environment layer + // it deliberately excludes. The single `!!js` expression prefers the + // personal variable, so the patched welcome can only render the project + // value when the harness home's .env — the credential store of + // `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting it + // would make every stored key read as a read-only launch override on the + // next run and hand it to every subprocess the agent starts. const output = await smoke({ label: 'dsh personal overlay', tempDirPrefix: 'dsh-personal-overlay-', binScript: dshBinScript, configArgs: [], prepare: seedWorkspace({ + workspace: { '.env': 'DSH_PROJECT_WELCOME=PROJECT OVERLAY READY.\n' }, personal: { - '.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n', + '.env': 'DSH_PERSONAL_WELCOME=HOME ENV LEAKED.\n', 'config.yaml': [ '- id: tui-agent', " name: '@deepseek-ai/dsh-tui-demo'", @@ -374,14 +379,15 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { ' provider: deepseek', ' model: deepseek-v4-flash', ' workspaceContext: false', - ' welcome: !!js process.env.DSH_PERSONAL_WELCOME', + ' welcome: !!js process.env.DSH_PERSONAL_WELCOME ?? process.env.DSH_PROJECT_WELCOME', '', ].join('\n'), }, }), - actions: [{ waitFor: 'PERSONAL OVERLAY READY.', send: '/exit\r' }], + actions: [{ waitFor: 'PROJECT OVERLAY READY.', send: '/exit\r' }], }) - expect(output).toContain('PERSONAL OVERLAY READY.') + expect(output).toContain('PROJECT OVERLAY READY.') + expect(output).not.toContain('HOME ENV LEAKED.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) From a90ccc4453221da577fce51f75812ceb228568ae Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 17:09:42 +0800 Subject: [PATCH 22/37] revert(sandbox): withdraw the credential-document read denial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `readDenyPaths` policy field shipped in the previous commit broke Linux confinement outright. bwrap has to create the `/dev/null` bind's mount point inside a tree its own profile has already made read-only, so it refused the entire confinement whenever the parent directory was absent — every host that has not stored a credential yet, including a fresh install: bwrap: Can't mkdir parents for /home/runner/.dsh/.env: Read-only file system which the executor correctly classifies as SANDBOX_UNAVAILABLE, so every confined bash call failed closed. Landlock cannot subtract from its own `/` read grant, so it reported `partial` enforcement on every confined call for a file it never hid, with no way to switch the denial off (schemastery fills an omitted array with `[]`, so empty and omitted were indistinguishable). A protection that breaks confinement where it works and misreports it where it does not is worse than a documented absence. Revert the field, both expressible backends, the enforcement downgrade, and the policy default; state the residue plainly in the credentials-local READMEs — file mode stops other OS users, not the model — and keep the OS-keychain provider recorded as the real answer. The narrower discipline stands: no surface hoists the credential document into `process.env`, and the model is never handed a resolved path to it. --- ...undaries-and-atomic-registration.i18n.yaml | 4 +-- ...tial-boundaries-and-atomic-registration.md | 9 +++-- ...l-boundaries-and-atomic-registration.zh.md | 9 +++-- docs/config-catalog.md | 12 +------ docs/cordis-catalog/services.md | 4 +-- docs/core-data-structures/sandbox.i18n.yaml | 6 ++-- docs/core-data-structures/sandbox.md | 14 +------- docs/core-data-structures/sandbox.zh.md | 14 +------- .../bash/bash-sandbox/tests/sandbox.spec.ts | 10 ++---- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../credentials-local/README.i18n.yaml | 4 +-- .../credentials/credentials-local/README.md | 7 ++-- .../credentials-local/README.zh.md | 7 ++-- packages/fs/tool-fs/tests/tools.spec.ts | 10 ++---- packages/sandbox/sandbox-local/src/index.ts | 7 +--- .../sandbox/sandbox-local/src/profiles.ts | 23 +----------- .../sandbox/sandbox-local/tests/local.spec.ts | 30 ---------------- .../sandbox-local/tests/seatbelt.e2e.ts | 33 +---------------- .../sandbox/sandbox-policy/README.i18n.yaml | 4 +-- packages/sandbox/sandbox-policy/README.md | 6 ---- packages/sandbox/sandbox-policy/README.zh.md | 6 ---- packages/sandbox/sandbox-policy/package.json | 2 -- packages/sandbox/sandbox-policy/src/index.ts | 23 +----------- .../sandbox-policy/tests/policy.spec.ts | 36 +------------------ packages/sandbox/sandbox-policy/tsconfig.json | 3 -- packages/sandbox/sandbox/src/index.ts | 12 ------- pnpm-lock.yaml | 3 -- 27 files changed, 38 insertions(+), 262 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index e7c7b51af6..98f2b0cb0d 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.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-07-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 837aa3e7b8ed30c66aad880ab2d76eee376e1854 -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: a00c3d93d2dc0451ed29613c4a804b0e518264ed +2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4 +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 3eb3b022064124aad2a389abba3063af4e2110fa diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md index 837aa3e7b8..6fe5f554ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -16,7 +16,7 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept **`$DSH_HOME/.env` belongs to the credential provider alone.** No surface loads it into `process.env`. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. -**The confining sandbox is the only real read boundary, and it names the file.** `SandboxExecutionPolicy` grows `readDenyPaths`, defaulted by `sandbox-policy` to `$DSH_HOME/.env`. Seatbelt appends a trailing `deny file-read* file-write*` (SBPL's last matching rule wins) and bwrap maps `/dev/null` over each path after any workspace bind; Landlock grants are a pure allow-list that cannot subtract from its own `/` read grant, so `confine()` reports `partial` enforcement instead of claiming a boundary the process lacks. Denials name exact paths, not roots: denying the whole harness home would also take away the model's documented access to its own session log. Both READMEs state the residue plainly — under the shipped `danger-full-access` default nothing is confined and the file is protected only by the OS user — and record an OS-keychain provider as the real answer. +**The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one. **One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. @@ -26,12 +26,11 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept ## Alternatives considered -- **Denying the whole harness home** — one root would have covered the credential document and any future secret file, but it also covers `sessions/`, and `DSH_SESSION_JSONL` is a documented model-visible capability. Exact paths keep the denial to what is actually secret. -- **Removing `DSH_HOME` from the model's bash environment** — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. The sandbox denial is the boundary; hiding the pointer is not. +- **A sandbox read-denial naming `$DSH_HOME/.env`** — implemented as a `readDenyPaths` policy field (a trailing SBPL `deny file-read* file-write*`, a `/dev/null` bwrap bind) and withdrawn on its own evidence. bwrap must create that bind's mount point inside a tree its profile has already made read-only, so it refuses the entire confinement whenever the parent directory is absent — every host that has not stored a credential yet, including a fresh install; Landlock cannot subtract from its own `/` read grant, so every confined call would report `partial` for a file it never hid. A protection that breaks confinement where it works and misreports it where it does not is worse than a documented absence. Denying the whole harness home was rejected earlier for a separate reason: it also covers `sessions/`, and `DSH_SESSION_JSONL` is a documented model-visible capability. +- **Removing `DSH_HOME` from the model's bash environment** — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. There is no boundary here for it to complement; hiding the pointer would only make the absence harder to see. - **Shipping the OS-keychain provider in this round** — it is the only design where the model's processes genuinely cannot read the secret, and it is a sibling package with three platform backends. Sizing it against the rest of this review round would have delayed every other fix; it is recorded as the deferred answer, not as a maybe. -- **Treating `readDenyPaths: []` as an opt-out** — schemastery fills an omitted array with `[]`, so empty and omitted are indistinguishable at the constructor. Empty therefore means "protect the default document"; a deployment that stores credentials elsewhere names its own paths, and a denial on a path nothing reads costs nothing. - **A `replaceRegistration(previous, next)` service method** — the review's shape, but it makes the caller carry the previous handle and lets it pass a mismatched one. Hanging `replace` on the registration handle makes ownership structural: only the registration that holds routes can replace them. ## Consequences -`update()`-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and `describe()` reports `writable: false` for multi-line entries it will not rewrite. A confined execution loses read access to `$DSH_HOME/.env` — deployments that deliberately let an agent read its own credential file must configure `readDenyPaths` themselves. `LlmAdapter` registrants keep working unchanged (the handle is still callable as the disposer), and `DeepSeekConnectionOptions` gained credential fields, so a programmatic constructor of the adapter must supply `apiKeyEnv`. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution). +`update()`-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and `describe()` reports `writable: false` for multi-line entries it will not rewrite. `LlmAdapter` registrants keep working unchanged (the handle is still callable as the disposer), and `DeepSeekConnectionOptions` gained credential fields, so a programmatic constructor of the adapter must supply `apiKeyEnv`. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution). diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index a00c3d93d2..3eb3b02206 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -20,7 +20,7 @@ Status: implemented **`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 -**受限沙箱才是唯一真正的读取边界,而且它点名到具体文件。**`SandboxExecutionPolicy` 新增 `readDenyPaths`,由 `sandbox-policy` 默认设为 `$DSH_HOME/.env`。Seatbelt 在末尾追加一条 `deny file-read* file-write*`(SBPL 中最后一条匹配规则胜出),bwrap 则在所有工作区 bind 之后把 `/dev/null` 映射到每条路径上;Landlock 的授权是纯粹的允许列表,无法从它自己对 `/` 的读取授权中减去任何东西,因此 `confine()` 报告 `partial` 强制执行,而不是声称一条该进程其实并不具备的边界。拒绝点名的是确切路径,而不是根目录:把整个 harness home 都拒掉,会连带夺走模型对自身会话日志的成文访问权。两个 README 都直白写明残留风险——在已交付的 `danger-full-access` 默认值下没有任何东西受限,这个文件只靠 OS 用户身份保护——并记下 OS 钥匙串(keychain)提供方才是真正的答案。 +**存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。 **一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 @@ -30,12 +30,11 @@ Status: implemented ## 曾考虑的替代方案 -- **拒掉整个 harness home**——一个根目录本可覆盖凭据文档以及将来任何机密文件,但它同时也覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。用确切路径可以把拒绝范围限定在真正属于机密的东西上。 -- **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。沙箱拒绝才是边界,藏起指针不是。 +- **用沙箱点名拒读 `$DSH_HOME/.env`**——已按 `readDenyPaths` 策略字段实现过(末尾一条 SBPL `deny file-read* file-write*`、一条 `/dev/null` 的 bwrap bind),又被它自己的证据推翻。bwrap 必须在自己 profile 已经置为只读的目录树内部创建该 bind 的挂载点,因此只要父目录不存在,它就会拒绝整次约束——那是每一台还没有存过凭据的主机,包括全新安装;Landlock 无法从它自己对 `/` 的读取授权中减去任何东西,于是每一次受限调用都会为一个它其实从未藏起的文件报 `partial`。一项在生效之处破坏约束、在不生效之处误报的保护,比一条写明的「没有保护」更糟。至于拒掉整个 harness home,早先另有理由被否:它同时覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。 +- **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。这里并不存在一条需要它来补强的边界,藏起指针只会让这份缺席更难被看见。 - **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密,而它是一个带三种平台后端的兄弟包(package)。把它与本轮评审的其余工作放在一起评估体量,会拖慢其他每一项修复;它被记录为那个延后的答案,而不是一个「也许」。 -- **把 `readDenyPaths: []` 当作 opt-out**——schemastery 会把省略的数组填成 `[]`,因此在构造函数处空数组与省略无从分辨。于是空数组的含义就是「保护默认文档」;把凭据存在别处的部署自行点名它自己的路径,而在没人读取的路径上设一条拒绝并不产生任何代价。 - **做成 `replaceRegistration(previous, next)` 服务方法**——这是评审给出的形状,但它要求调用方自行携带上一个句柄,也允许它传入一个不匹配的句柄。把 `replace` 挂在注册句柄上,让归属关系变成结构性的:只有持有路由的那一项注册才能替换它们。 ## 后果 -`update()` 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,`describe()` 对它不会改写的多行条目报告 `writable: false`。受限执行会失去对 `$DSH_HOME/.env` 的读取权限——刻意让 agent 读取自身凭据文件的部署,必须自行配置 `readDenyPaths`。`LlmAdapter` 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),`DeepSeekConnectionOptions` 则新增了凭据字段,因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项:OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。 +`update()` 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,`describe()` 对它不会改写的多行条目报告 `writable: false`。`LlmAdapter` 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),`DeepSeekConnectionOptions` 则新增了凭据字段,因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项:OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4e8f02536b..dae7128fed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1027,22 +1027,12 @@ export interface Config { * `process.cwd()`). Normal agent calls use their session cwd instead. */ workspaceRoot?: string - /** - * Absolute paths confined executions must not read, whatever their mode - * otherwise permits. Omitted (or empty) denies the harness home's - * credential document (`$DSH_HOME/.env`) — exactly that file, so the model - * keeps the documented access to its own session log under the same home; - * a non-empty list replaces it. Backends that cannot express a read denial - * report `partial` enforcement instead of pretending, and - * `danger-full-access` confines nothing, so no denial applies there at all. - */ - readDenyPaths?: string[] } ``` Depends on: [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:45`](../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:44`](../packages/sandbox/sandbox-policy/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 016dff7b15..8cdc8b9065 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1059,7 +1059,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:143`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:131`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` @@ -1087,7 +1087,7 @@ overrideOf(session: Session): SandboxMode | undefined Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxMode](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) · [Session](../core-data-structures/session.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:79`](../../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../../packages/sandbox/sandbox-policy/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index c369d8066f..f8189f4e15 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/core-data-structures/sandbox.md -sandbox.md: 566ac0edc0ba0600e2a1b5ecf18cc34e05e910ec -sandbox.zh.md: 24d8fbfc6c952246278192b5bed7cdc09223e7db +# pnpm run verify-translation-pairing --write +sandbox.md: 9bc05fa06f22fdc9ac9e8aacd482c1e7c2f2edec +sandbox.zh.md: 9a52f126758fe0e7988715c7824e963bd6e6ea84 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 566ac0edc0..9bc05fa06f 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## Per-call policy -The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The root is canonicalized with filesystem semantics before lexical normalization, so a cwd containing `symlink/..` identifies the directory where a spawned process actually runs. `readDenyPaths` names paths a confined execution must not read whatever its mode permits — the harness credential document by default — and backends that cannot express such a denial report `partial` enforcement rather than claiming a boundary the process lacks. +The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration is the agentless fallback. The root is canonicalized with filesystem semantics before lexical normalization, so a cwd containing `symlink/..` identifies the directory where a spawned process actually runs. ```ts type-equiv /** @@ -53,18 +53,6 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string - /** - * Absolute paths a confined execution must not READ, whatever the mode - * otherwise permits — the harness's own credential document is the - * motivating case, which is why these are exact paths rather than roots: - * denying the whole harness home would also take away the model's - * documented access to its own session log. Not every backend can express - * a read denial (a Landlock allow-list granting `/` cannot subtract from - * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a - * denial is requested and the selected backend cannot apply it. Never a - * boundary under `danger-full-access`, which confines nothing at all. - */ - readDenyPaths?: readonly string[] } ``` diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 24d8fbfc6c..9a52f12675 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial' ## 逐调用策略 -完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent(智能体)时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。`readDenyPaths` 点名受限执行无论其模式允许什么都不得读取的路径——默认是 harness 凭据文档——无法表达此类拒绝的后端会把强制执行报为 `partial`,而不是声称一条该进程其实并不具备的边界。 +完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置是没有 agent(智能体)时的回退值。root 会先按文件系统语义规范化,再做词法规范化,因此包含 `symlink/..` 的 cwd 会标识所生成进程实际运行的目录。 ```ts type-equiv /** @@ -53,18 +53,6 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string - /** - * Absolute paths a confined execution must not READ, whatever the mode - * otherwise permits — the harness's own credential document is the - * motivating case, which is why these are exact paths rather than roots: - * denying the whole harness home would also take away the model's - * documented access to its own session log. Not every backend can express - * a read denial (a Landlock allow-list granting `/` cannot subtract from - * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a - * denial is requested and the selected backend cannot apply it. Never a - * boundary under `danger-full-access`, which confines nothing at all. - */ - readDenyPaths?: readonly string[] } ``` diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index df4916b6b6..90a67999c8 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -11,7 +11,6 @@ import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' @@ -75,11 +74,8 @@ function runResult(exitCode: number | null, stderr: string): BashRunResult { return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) } } -/** The policy home's default read denial: the harness credential document. */ -const DEFAULT_DENY = [resolve(resolveDshHome(), '.env')] - function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy { - return { mode, workspaceRoot, readDenyPaths: DEFAULT_DENY } + return { mode, workspaceRoot } } describe('the provider hand-off', () => { @@ -90,7 +86,7 @@ describe('the provider hand-off', () => { expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) expect(calls).toEqual([{ argv: ['bash', '-c', 'echo \'a b\' "c\'d"'], - policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()), readDenyPaths: DEFAULT_DENY }, + policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) }, }]) }) @@ -107,7 +103,7 @@ describe('the provider hand-off', () => { const { bash, calls } = await setup({ mode: 'workspace-write' }) const result = await bash.run(bash.resolve({ command: 'true' })) expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) - expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()), readDenyPaths: DEFAULT_DENY }) + expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) }) }) it('an explicit workspaceRoot on the policy wins', async () => { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 553e321d7a..10de2583c2 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2211,7 +2211,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SandboxExecutionPolicy', - declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n readDenyPaths?: readonly string[];\n}', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}', }, { name: 'SandboxMode', diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 55d8f24b34..89a8576683 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/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/credentials/credentials-local/README.md -README.md: 2288d6d7133a7f356823e3e4f28746cfd28b2597 -README.zh.md: 959322c9ec670ed76b89f1f3a19246191b3ec02c +README.md: 126140b10719dc6f7bc458a118ba1feb1f440270 +README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 2288d6d713..126140b107 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -32,12 +32,9 @@ External edits publish `credentials/updated` per changed reference after the sna ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns. Two things narrow that: +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns, and no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given. -- A **confining sandbox mode** denies the credential document specifically: [`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) defaults `readDenyPaths` to `$DSH_HOME/.env`, and the Seatbelt and bwrap backends enforce it (Landlock cannot subtract from its own `/` read grant and reports `partial`). The denial names the file, not the home, so the model keeps its documented access to its own session log. -- The harness never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)). - -Neither makes an unconfined agent safe. A deployment that must keep provider keys away from its own agent should run a confining mode; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. +That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. ## Model Experience diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index 959322c9ec..c22575115a 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -32,12 +32,9 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致。有两件事收窄了这一点: +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致,也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 -- **受限沙箱模式**会专门拒绝凭据文档:[`dsh-sandbox-policy`](../../sandbox/sandbox-policy/README.md) 把 `readDenyPaths` 默认为 `$DSH_HOME/.env`,Seatbelt 与 bwrap 后端会执行它(Landlock 无法从自己的 `/` 读授权中扣除,只能报 `partial`)。这条拒绝点名的是该文件而非整个 home,因此模型对自己会话日志的既定访问不受影响。 -- harness 绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))。 - -这两者都不能让未受限的 agent 变得安全。必须让提供方密钥远离自身 agent 的部署应当运行受限模式;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 +这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 ## Model Experience diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index a3b658e6bf..de844dcaf4 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -5,7 +5,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' @@ -113,9 +112,6 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } -/** The policy home's default read denial: the harness credential document. */ -const DEFAULT_DENY = [resolve(resolveDshHome(), '.env')] - describe('session cwd resolution', () => { const execution = (cwd?: string) => cwd === undefined ? {} @@ -767,13 +763,13 @@ describe('sandbox escalation surface (write/edit)', () => { it('a plain write stamps the default mode with the calling session root', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent()) - expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) + expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }]) }) it('a standing session override folds onto the stamp', async () => { const { ctx, fs } = await setupConfining() await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }])) - expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) + expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }]) }) it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => { @@ -806,7 +802,7 @@ describe('sandbox escalation surface (write/edit)', () => { agent: escalationAgent() as never, signal: new AbortController().signal, }) - expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project'), readDenyPaths: DEFAULT_DENY }]) + expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }]) }) it('a rejected escalation fails closed with its own text and never mutates', async () => { diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 827f20d696..98dc86d23e 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -228,12 +228,7 @@ export class LocalSandboxProvider extends SandboxProvider { const selected = this.selectRunner(policy.mode) return { argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv], - // Landlock grants are a pure allow-list, so it cannot subtract a read - // denial from its own `/` read grant: promising `full` there would - // misreport a boundary the process does not have. - enforcement: selected.runner === 'landlock' && (policy.readDenyPaths?.length ?? 0) > 0 - ? 'partial' - : selected.enforcement, + enforcement: selected.enforcement, denialSignatures: DENIAL_SIGNATURES[selected.runner], runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner], } diff --git a/packages/sandbox/sandbox-local/src/profiles.ts b/packages/sandbox/sandbox-local/src/profiles.ts index 27ca150ef4..cee0f00852 100644 --- a/packages/sandbox/sandbox-local/src/profiles.ts +++ b/packages/sandbox/sandbox-local/src/profiles.ts @@ -5,14 +5,9 @@ */ import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run' -import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' +import { writableRoots } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -/** This policy's read denials, canonical and deduplicated like the writable roots. */ -function denyPaths(policy: SandboxPolicy): string[] { - return [...new Set((policy.readDenyPaths ?? []).map(path => canonicalPath(path)))] -} - /** * Build the bwrap profile arguments for one file-effect policy. * @param policy - file-effect policy to express as bwrap mounts. @@ -24,10 +19,6 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] { args.push('--tmpfs', '/tmp') args.push('--bind', policy.workspaceRoot, policy.workspaceRoot) } - // Read denials come last so a workspace bind can never re-expose one. - // `/dev/null` over the path reads as empty; the `-try` form tolerates a - // path that does not exist yet (no credential stored so far). - for (const path of denyPaths(policy)) args.push('--ro-bind-try', '/dev/null', path) return args } @@ -37,10 +28,6 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] { * @returns launcher grant arguments before the trailing separator and command argv. */ export function landlockProfileArgs(policy: SandboxPolicy): string[] { - // Landlock grants are a pure allow-list: a read grant on `/` cannot be - // subtracted from, so a requested read denial is unenforceable here. The - // provider reports `partial` enforcement for exactly this case rather than - // pretending the boundary exists. const readWrite = ['/dev/null'] if (policy.mode === 'workspace-write') { readWrite.push('/tmp', policy.workspaceRoot) @@ -67,13 +54,5 @@ export function seatbeltProfileArgs(policy: SandboxPolicy): string[] { if (roots.length > 0) { forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`) } - // SBPL applies the last matching rule, so the read denial is appended after - // every allow above and governs both reads and writes of those paths. Both - // filters are emitted so a denial may name a file or a directory. - const denied = denyPaths(policy) - if (denied.length > 0) { - const filters = denied.map(path => `(literal ${sbplString(path)}) (subpath ${sbplString(path)})`).join(' ') - forms.push(`(deny file-read* file-write* ${filters})`) - } return ['-p', forms.join(' ')] } diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index ceadaba184..f7cc952498 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -62,27 +62,6 @@ describe('profile dialects', () => { ]) }) - it('bwrap read denial: /dev/null over each denied path, after any workspace bind', () => { - expect(bwrapProfileArgs({ ...WW, readDenyPaths: ['/ws/secret.env'] })).toEqual([ - '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', - '--tmpfs', '/tmp', '--bind', '/ws', '/ws', - // The workspace bind above would otherwise re-expose the file. - '--ro-bind-try', '/dev/null', '/ws/secret.env', - ]) - }) - - it('landlock ignores read denials: a `/` read grant cannot subtract from itself', () => { - expect(landlockProfileArgs({ ...RO, readDenyPaths: ['/ws/secret.env'] })) - .toEqual(landlockProfileArgs(RO)) - }) - - it('seatbelt read denial: a trailing deny naming the path as both a file and a directory', () => { - expect(seatbeltProfileArgs({ ...RO, readDenyPaths: ['/ws/secret.env'] })).toEqual([ - '-p', - `${SEATBELT_RO_PROFILE} (deny file-read* file-write* (literal "/ws/secret.env") (subpath "/ws/secret.env"))`, - ]) - }) - it('landlock read-only: readable tree plus a writable /dev/null, nothing else', () => { // /dev/null specifically, NOT /dev: a whole-/dev grant would let confined // commands write real host paths beneath it (/dev/shm) under read-only. @@ -329,15 +308,6 @@ describe('the default landlock probe (launcher CLI contract)', () => { expect(sandbox.confine(['true'], RO).enforcement).toBe('partial') }) - it('reports partial enforcement when a read denial is requested it cannot express', async () => { - const launcher = fakeLauncher() - const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher }) - // Fully enforced for the write policy, yet the read denial is - // unexpressible in an allow-list that already grants `/` for reads. - expect(sandbox.confine(['true'], RO).enforcement).toBe('full') - expect(sandbox.confine(['true'], { ...RO, readDenyPaths: ['/ws/secret.env'] }).enforcement).toBe('partial') - }) - it('reads a failing launcher as unusable: the chain ends and fails closed', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) const launcher = join(dir, 'landlock-run') diff --git a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts index a01e3a25a2..6d645b1a3b 100644 --- a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -70,37 +70,6 @@ describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement throu expect(result.stdout).toBe('dev-ok\n') }) - it('denies reading a credential document the mode would otherwise allow', async () => { - // The harness's own secret store: readable to the user, and the model's - // bash runs as that user — only the confinement can take it away. - const workdir = await tempDir(tmpdir()) - const secret = join(workdir, '.env') - await writeFile(secret, 'DEEPSEEK_API_KEY=sk-must-not-leak\n', { mode: 0o600 }) - const sandbox = await provider() - - const allowed = runConfined(sandbox, `cat ${secret}`, { mode: 'read-only', workspaceRoot: workdir }) - expect(allowed.result.stdout).toContain('sk-must-not-leak') - - const denied = runConfined(sandbox, `cat ${secret}`, { - mode: 'read-only', - workspaceRoot: workdir, - readDenyPaths: [secret], - }) - expect(denied.result.stdout).not.toContain('sk-must-not-leak') - expect(denied.result.status).not.toBe(0) - expect(denied.confined.enforcement).toBe('full') - // Everything else under the same directory stays readable: the denial is - // the credential document, not the harness home. - const sibling = join(workdir, 'notes.txt') - await writeFile(sibling, 'ordinary\n') - const neighbour = runConfined(sandbox, `cat ${sibling}`, { - mode: 'read-only', - workspaceRoot: workdir, - readDenyPaths: [secret], - }) - expect(neighbour.result.stdout).toBe('ordinary\n') - }) - it('read-only grants no temp area: a write under the user temp dir is denied too', async () => { // The per-user darwin temp dir is a workspace-write grant, not a // read-only one — under read-only the only write-shaped path is /dev/null. diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index 78ec04a6a0..b21c8d885a 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/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/sandbox/sandbox-policy/README.md -README.md: 297dd7d5210bb30963a162c6a55a598c6d522aaf -README.zh.md: 1de92eb81409a7fabb25de94eb5372f0f16afb6f +README.md: dca54330bc888af9ecac21aa92019d8a2b0140bd +README.zh.md: a201d48c81f563fc3d85495e964bb67432517a3c diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index 297dd7d521..dca54330bc 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -13,12 +13,6 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de - `mode` — the deployment default `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`), validated at load. Default `read-only` (fail-safe). - `workspaceRoot` — the fallback directory `workspace-write` may write under for agentless calls or sessions without a cwd. Default `process.cwd()`, resolved to its absolute filesystem identity either way. A normal agent call uses its session header's immutable `cwd` instead. -## Read denials - -`readDenyPaths` names absolute paths a **confined** execution must not read, whatever its mode otherwise permits. Omitted (or empty) denies the harness credential document `$DSH_HOME/.env`; a non-empty list replaces that default. Denials name exact paths rather than roots on purpose: denying the whole harness home would also take away the model's documented access to its own session log. - -Enforcement is backend-shaped. Seatbelt appends a trailing `deny file-read* file-write*` (last matching rule wins) and bwrap maps `/dev/null` over each path after any workspace bind; Landlock grants are a pure allow-list, so a read grant on `/` cannot be subtracted from and `confine()` reports `partial` enforcement rather than pretending the boundary exists. `danger-full-access` confines nothing at all, so no denial applies there — the credential document is then protected only by its file mode, which does not stop a same-UID tool process. - ## Surface - `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 1de92eb814..a201d48c81 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -13,12 +13,6 @@ - `mode`:部署默认 `SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`),加载时验证。默认为 `read-only`(故障安全)。 - `workspaceRoot`:无 agent(智能体)的调用或没有 cwd 的会话在 `workspace-write` 下可写入的回退目录。默认为 `process.cwd()`;无论显式配置还是采用默认值,都会解析为其绝对文件系统标识。普通 agent 调用改用其会话头中不可变的 `cwd`。 -## 读取拒绝 - -`readDenyPaths` 列出**受约束**执行绝不可读取的绝对路径,无论其模式在其他方面允许什么。省略(或为空)时拒绝 harness 凭据文档 `$DSH_HOME/.env`;非空列表则替换该默认值。拒绝项有意点名确切路径而非根目录:拒绝整个 harness home 会连带拿走模型对自己会话日志的既定访问。 - -强制执行的形态由后端决定。Seatbelt 追加一条尾部 `deny file-read* file-write*`(最后匹配的规则胜出),bwrap 在任何工作区绑定之后把 `/dev/null` 映射到每个路径上;Landlock 的授权是纯粹的允许列表,`/` 上的读授权无法被扣除,因此 `confine()` 把强制执行报为 `partial`,而不是假装该边界存在。`danger-full-access` 根本不做任何约束,那里也就没有任何拒绝适用——凭据文档届时只受自身文件权限模式保护,而这挡不住同 UID 的工具进程。 - ## 接口 - `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index bb48bb0b35..d5f9270ed1 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -28,7 +28,6 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -38,7 +37,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 74c05f76a1..1f5ba0bb00 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -14,11 +14,10 @@ * @module @deepseek-ai/dsh-sandbox-policy */ -import { join, resolve as resolvePath } from 'node:path' +import { resolve as resolvePath } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' import type { Session } from '@deepseek-ai/dsh-session' import { effectiveSandboxMode } from './session-mode.ts' @@ -50,16 +49,6 @@ export interface Config { * `process.cwd()`). Normal agent calls use their session cwd instead. */ workspaceRoot?: string - /** - * Absolute paths confined executions must not read, whatever their mode - * otherwise permits. Omitted (or empty) denies the harness home's - * credential document (`$DSH_HOME/.env`) — exactly that file, so the model - * keeps the documented access to its own session log under the same home; - * a non-empty list replaces it. Backends that cannot express a read denial - * report `partial` enforcement instead of pretending, and - * `danger-full-access` confines nothing, so no denial applies there at all. - */ - readDenyPaths?: string[] } /** Inputs that select the sandbox policy for one capability call. */ @@ -83,15 +72,12 @@ export class SandboxPolicyService extends Service { // No schema default: process.cwd() is resolved in the constructor so the // stored root is always absolute regardless of how it was supplied. workspaceRoot: z.string(), - readDenyPaths: z.array(z.string()), }) /** The deployment default mode — the fallback beneath a session override. */ readonly defaultMode: SandboxMode /** The absolute `workspace-write` fallback root for calls without a session cwd. */ readonly workspaceRoot: string - /** Absolute paths every confined execution is denied read access to. */ - readonly readDenyPaths: readonly string[] constructor(ctx: Context, config: Config) { super(ctx, 'sandboxPolicy') @@ -100,12 +86,6 @@ export class SandboxPolicyService extends Service { // the process cwd is real branching, resolved absolute either way. this.defaultMode = config.mode as SandboxMode this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd()) - // The credential document is the default denial; a configured list - // replaces it. Schemastery fills an omitted array with `[]`, so empty and - // omitted are the same request: protect the default document. - const denyPaths = config.readDenyPaths ?? [] - this.readDenyPaths = (denyPaths.length > 0 ? denyPaths : [join(resolveDshHome(), '.env')]) - .map(resolveWorkspaceRoot) } /** @@ -122,7 +102,6 @@ export class SandboxPolicyService extends Service { return { mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), - readDenyPaths: this.readDenyPaths, } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 7740abc058..63ca0cd3d5 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -10,14 +10,9 @@ import { join, resolve, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' -async function mounted(config: { - mode?: 'read-only' | 'workspace-write' | 'danger-full-access' - workspaceRoot?: string - readDenyPaths?: string[] -} = {}) { +async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) { const ctx = new Context() await ctx.plugin(SandboxPolicyService, config) return ctx @@ -46,35 +41,11 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub')) }) - it('denies reading the harness credential document by default', async () => { - const ctx = await mounted() - // The exact file, not the whole home: the model keeps the documented - // access to its own session log under the same directory. - expect(ctx.sandboxPolicy.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) - expect(ctx.sandboxPolicy.resolve().readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) - }) - - it('defaults the denial list under programmatic construction too', () => { - // Constructing the service directly bypasses Schemastery, so the field - // arrives undefined rather than as the empty array the schema fills. - const service = new SandboxPolicyService(new Context(), {}) - expect(service.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) - }) - - it('replaces the default with a configured denial list', async () => { - const configured = await mounted({ readDenyPaths: ['/vault/../vault/./keys.env'] }) - expect(configured.sandboxPolicy.readDenyPaths).toEqual([resolve('/vault/keys.env')]) - // Schemastery fills an omitted array with `[]`, so empty reads as omitted. - const empty = await mounted({ readDenyPaths: [] }) - expect(empty.sandboxPolicy.readDenyPaths).toEqual([resolve(resolveDshHome(), '.env')]) - }) - it('resolves the deployment policy for an agentless call', async () => { const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' }) expect(ctx.sandboxPolicy.resolve()).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/fallback'), - readDenyPaths: [resolve(resolveDshHome(), '.env')], }) }) @@ -87,19 +58,16 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/projects/first'), - readDenyPaths: [resolve(resolveDshHome(), '.env')], }) expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({ mode: 'read-only', workspaceRoot: resolve('/projects/second'), - readDenyPaths: [resolve(resolveDshHome(), '.env')], }) expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined() expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only') expect(ctx.sandboxPolicy.resolve()).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/fallback'), - readDenyPaths: [resolve(resolveDshHome(), '.env')], }) }) @@ -119,7 +87,6 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({ mode: 'workspace-write', workspaceRoot: realpathSync.native(physical), - readDenyPaths: [resolve(resolveDshHome(), '.env')], }) } finally { rmSync(root, { recursive: true, force: true }) @@ -133,7 +100,6 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({ mode: 'danger-full-access', workspaceRoot: resolve('/projects/approved'), - readDenyPaths: [resolve(resolveDshHome(), '.env')], }) }) diff --git a/packages/sandbox/sandbox-policy/tsconfig.json b/packages/sandbox/sandbox-policy/tsconfig.json index 65c906d6c3..cb6fc623d0 100644 --- a/packages/sandbox/sandbox-policy/tsconfig.json +++ b/packages/sandbox/sandbox-policy/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../sandbox" }, - { - "path": "../../util/paths" - }, { "path": "../../core/session" }, diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 11e690704e..781227f411 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -40,18 +40,6 @@ export interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string - /** - * Absolute paths a confined execution must not READ, whatever the mode - * otherwise permits — the harness's own credential document is the - * motivating case, which is why these are exact paths rather than roots: - * denying the whole harness home would also take away the model's - * documented access to its own session log. Not every backend can express - * a read denial (a Landlock allow-list granting `/` cannot subtract from - * itself), so {@link ConfinedArgv.enforcement} drops to `partial` when a - * denial is requested and the selected backend cannot apply it. Never a - * boundary under `danger-full-access`, which confines nothing at all. - */ - readDenyPaths?: readonly string[] } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 367b2f4299..37f7e6cf29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3618,9 +3618,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/dsh-paths': - specifier: workspace:^ - version: link:../../util/paths '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../sandbox From c0679f42b5728270ea953585e693942bb87bdeff Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 17:50:04 +0800 Subject: [PATCH 23/37] fix(web): cap the approval takeover at the composer's text height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval panel replaces the InputBar while a sandbox escalation waits, and its justification and command are unbounded model text. With no height cap, a long command grew the card until the refuse/allow row went under the fold: at 900x700 the action row's bottom landed at y=749, so the user could read the request and not answer it. Justification and command now scroll in one region capped at the same height as the composer's draft area, with the amber strip and the action row outside it. The cap is one value with two consumers — declared as --dsh-composer-text-max-height on ConversationRoot's .composerSeat, the composer chain's only shared ancestor — so the seat cannot cap its two states differently. The card rebinds the l2 scrollbar pair like every other scrolling surface on an elevated background. Covered by a new web e2e scenario that drives the real composition (read-only session, denied write, the model's escalation retry, answer clicked through the panel) and measures the live panel at two viewport heights against the composer's own cap, read off the textarea rather than hardcoded. --- ...07-30-approval-panel-command-cap.i18n.yaml | 6 + .../2026-07-30-approval-panel-command-cap.md | 48 +++++ ...026-07-30-approval-panel-command-cap.zh.md | 48 +++++ apps/web/tests/approval-composer.e2e.ts | 178 ++++++++++++++++++ .../approval-composer/answered.expected.md | 57 ++++++ .../snapshots/approval-composer/session.jsonl | 64 +++++++ .../approval-composer/ui.expected.md | 3 + apps/web/tsconfig.json | 1 + .../client/skeleton/ApprovalPanel.module.css | 24 ++- .../src/client/skeleton/ApprovalPanel.tsx | 24 ++- .../skeleton/ConversationRoot.module.css | 8 + .../src/client/skeleton/InputBar.module.css | 4 +- tsconfig.host.json | 1 + 13 files changed, 453 insertions(+), 13 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md create mode 100644 apps/web/tests/approval-composer.e2e.ts create mode 100644 apps/web/tests/snapshots/approval-composer/answered.expected.md create mode 100644 apps/web/tests/snapshots/approval-composer/session.jsonl create mode 100644 apps/web/tests/snapshots/approval-composer/ui.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml new file mode 100644 index 0000000000..908f03860e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/bug-fix/2026-07-30-approval-panel-command-cap.md +2026-07-30-approval-panel-command-cap.md: a9282f132e655833cfe687409c287c5afd538d50 +2026-07-30-approval-panel-command-cap.zh.md: 7eb40942e10134d43478e53e06c584bb97d3bb8f diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md new file mode 100644 index 0000000000..a9282f132e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md @@ -0,0 +1,48 @@ +# Agent Note: The approval takeover shares the composer's text cap + +Status: implemented + +English | [中文](2026-07-30-approval-panel-command-cap.zh.md) + +## Problem + +The approval panel is a composer takeover: while a sandbox escalation waits, it replaces the InputBar in the composer seat with the model's justification, the paired command, and a refuse/allow row. Both texts are unbounded model output, and the card had no height cap. A long command — the realistic shape, since escalation happens on the command the sandbox just denied, and a denied command is often a long inline write — grew the card until the action row left the viewport. The user could read the request and not answer it: the buttons existed, off screen, in a sticky footer that had already used the whole column. + +The InputBar the panel replaces has always been capped (14 lines, then the textarea scrolls), so the takeover was also the one composer state that could grow without limit — the seat's height jumped on election and jumped back on answer. + +## Decision + +The panel's justification and command move into one scroll region (`data-approval-scroll`) capped at the same height as the composer's draft area; the amber strip and the action row sit outside it, so both buttons are in the card at every content length. + +The cap is one value with two consumers, declared as `--dsh-composer-text-max-height: 336px` on `ConversationRoot`'s `.composerSeat` — the composer chain's only shared ancestor, since the fallback InputBar and an elected takeover render as siblings. `InputBar`'s mirror and the panel's scroll region both read it, so the seat cannot cap its two states differently: what the designer asked for ("unify it with the input box's max height") is now a fact of the stylesheet rather than a number repeated in two files. The region is `box-sizing: border-box` so the cap is its outer height, the same box the composer's draft area occupies. + +The panel's card rebinds `--dsh-scrollbar-thumb{,-hover}` to the l2 pair, as every scrolling surface on an elevated background must ([scrollbar contract](../../../../packages/client/ui-theme/src/styles/scrollbar.css)). + +## Alternatives considered + +**Cap the whole card instead of the text region.** One declaration, no restructuring, and it reads as the literal "same max height as the input box". Rejected because the card holds the strip and the action row: at 336px total the justification and command would get ~250px, less room than the draft they replace, and the numbers would only agree by coincidence of the strip's height. Capping the text region makes both seats top out at the same text height, which is the property that keeps the footer from jumping. + +**Cap against the viewport like the question composer (`min(60vh, 520px)`).** The sibling takeover already does this, so it is the local precedent. Rejected because the designer's request was parity with the InputBar, and the two takeovers are not the same shape: the question composer's scroll content is a list of options the user must compare, which wants as much viewport as it can get, while the approval panel's is one command the user skims before deciding. A viewport-relative cap would also make the seat's height jump on election again, in the other direction. + +**Ellipsize or truncate the command.** No scroll region, no cap, and the buttons stay put. Rejected because the command is the thing being approved: hiding its tail asks the user to consent to text they cannot read. Truncation is also unrecoverable here — the panel is the whole approval UI, so there is no "show more" surface to fall back to. + +**Leave the action row inside the scroll region and cap the region.** Fewer moving parts than pinning the row. Rejected because it reproduces the defect inside the card: the buttons scroll out of the region, and the user has to discover a scrollbar to reach them. + +## Consequences + +- A long command scrolls inside the card and the refuse/allow buttons stay on screen. Measured on the built client at 900x1000 and 900x700: the region reports `scrollHeight` past `clientHeight`, and both buttons stay inside the card and inside the viewport. +- Electing the takeover no longer changes how tall the composer seat can get, so the transcript above it does not reflow by hundreds of pixels when an approval arrives or resolves. +- The InputBar's 14-line cap now resolves through a custom property inherited from `.composerSeat`. Rendering the bar outside that seat would drop the declaration (an unresolved `var()` with no fallback), so a future composer host has to carry the property — which is why it is declared on the shared seat rather than the app root. +- The scenario's recorded command is a 200-token blob, far longer than a round trip needs. That cost is deliberate: the cap is unfalsifiable without content that passes it, and the model compresses any regular payload (the first recording turned "alpha 400 times" into `printf 'alpha %.0s' {1..400}`, a one-line command that proves nothing). + +## Verification + +`apps/web/tests/approval-composer.e2e.ts` drives the real composition: a read-only session, a denied write, the model's escalation retry, and the answer clicked through the panel. The geometry assertion runs on the live panel at two viewport heights and is guarded against holding vacuously — the region must actually be scrolling, and the measured cap must equal the composer's own, which the test reads off the live textarea before sending rather than hardcoding the px value. + +Confirmed both directions against the built client. With the cap reverted, the region reports `scrolls: false` and grows to the command's full height (1798px for the recorded blob at 900x1000, against 336px capped); at 900x700 the card is 680px tall against a 700px viewport and the action row's bottom lands at y=749 — below the fold, the designer's report exactly. With the cap restored the scenario passes in replay. + +Reproducing the off-screen buttons needs a card taller than the scrollport, not merely a tall card. The composer seat is `position: sticky; bottom: 0`, so while the card still fits it stays pinned to the viewport bottom and the buttons remain visible — at 900x1000 the uncapped card ate the whole transcript yet kept its action row on screen. Only once the card outgrows the scrollport does sticky stop being able to hold the bottom edge, and the row goes under. + +The geometry block and the goldens are replay-only, so record mode reaches the fixture write instead of aborting on layout. + +The panel ships as a client-module bundle: `pnpm run build:web` alone does not pick up a change to `ApprovalPanel.module.css` or a new `data-` hook in `ApprovalPanel.tsx` — the package build must run first, or the browser lane asserts against an older client than the tree. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md new file mode 100644 index 0000000000..7eb40942e1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md @@ -0,0 +1,48 @@ +# Agent Note: 审批接管面板与输入框共用同一文本高度上限 + +Status: implemented + +[English](2026-07-30-approval-panel-command-cap.md) | 中文 + +## 问题 + +审批面板是一次 composer 接管:当一次沙箱越权申请处于等待状态时,它在 composer 容器中取代 InputBar,展示模型给出的理由、与之配对的命令,以及一行拒绝/允许按钮。这两段文本都是长度不受限的模型输出,而卡片当时没有任何高度上限。命令一长——而这正是现实中的常见形态,因为越权申请针对的就是沙箱刚刚拒绝的那条命令,而被拒绝的命令往往是一次很长的内联写入——卡片就会一直变高,直到操作按钮行离开视口。用户能读到这次申请,却无法回应它:按钮存在,只是在屏幕之外,位于一个已经占满整列的吸底容器里。 + +被它取代的 InputBar 一直是有上限的(14 行,之后由 textarea 自行滚动),因此这次接管也是 composer 唯一一个可以无限增高的状态——被选中时容器高度骤增,回应之后又骤降。 + +## 决策 + +面板的理由与命令移入同一个滚动区域(`data-approval-scroll`),其高度上限与 composer 的草稿区完全相同;琥珀色状态条与操作按钮行位于该区域之外,因此无论内容多长,两个按钮都留在卡片内。 + +这个上限是一个值、两个消费者,以 `--dsh-composer-text-max-height: 336px` 声明在 `ConversationRoot` 的 `.composerSeat` 上——它是 composer 链唯一的共同祖先,因为兜底的 InputBar 与被选中的接管面板是兄弟节点。`InputBar` 的 mirror 与面板的滚动区域都读取它,于是同一个容器不可能给它的两种状态设出不同上限:设计同学要求的"可以跟输入框最大高度统一",如今是样式表中的一个事实,而不是抄在两个文件里的一个数字。该区域取 `box-sizing: border-box`,因此上限指的是它的外框高度,与 composer 草稿区占据的是同一个盒子。 + +面板卡片把 `--dsh-scrollbar-thumb{,-hover}` 重新绑定到 l2 那一对,这是每一个位于高层表面上的滚动区域都必须做的([滚动条约定](../../../../packages/client/ui-theme/src/styles/scrollbar.css))。 + +## 曾考虑的替代方案 + +**给整张卡片设上限,而不是给文本区域设。** 一条声明,不需要重构结构,而且它读起来就是字面意义上的"与输入框相同的最大高度"。之所以否决:卡片还装着状态条和操作按钮行——总高 336px 时,理由与命令只能分到约 250px,比它们所取代的草稿区更窄,而且两边数字能对上纯属状态条高度的巧合。给文本区域设上限,才能让两种状态在同一文本高度处收住,而这正是让底部不再跳动的那条性质。 + +**像提问 composer 那样按视口设上限(`min(60vh, 520px)`)。** 同为接管面板的兄弟组件已经这么做了,因此这是本地既有先例。之所以否决:设计同学的要求是与 InputBar 对齐,而两个接管面板形态并不相同——提问 composer 的滚动内容是一组需要用户互相比较的选项,能占多少视口就该占多少;审批面板的滚动内容则是一条命令,用户在决定之前扫读即可。按视口设上限还会让容器高度在被选中时再次跳动,只是方向相反。 + +**对命令做省略号或截断处理。** 不需要滚动区域,不需要上限,按钮也不会移位。之所以否决:命令正是被审批的对象,隐去它的尾部等于要求用户为自己读不到的文本背书。在这里截断还是不可恢复的——面板就是审批的全部界面,没有"展开更多"的落脚处。 + +**把操作按钮行留在滚动区域内,只给该区域设上限。** 比把按钮行固定住少动几处。之所以否决:这会把缺陷搬进卡片内部——按钮滚出该区域,用户得先发现有滚动条才能碰到它们。 + +## 后果 + +- 长命令在卡片内滚动,拒绝/允许按钮留在屏幕内。在构建产物客户端上于 900x1000 与 900x700 实测:该区域报告的 `scrollHeight` 超过 `clientHeight`,两个按钮都留在卡片内、也都留在视口内。 +- 选中接管面板不再改变 composer 容器能达到的高度,因此审批到来或解决时,上方的会话流不会有数百像素的重排。 +- InputBar 的 14 行上限现在通过一个自 `.composerSeat` 继承而来的自定义属性解析。把输入栏渲染到该容器之外会丢掉这条声明(一个没有兜底值的未解析 `var()`),因此未来的 composer 宿主必须带上这个属性——这也正是它声明在共享容器上、而不是应用根节点上的原因。 +- 该场景录制的命令是一段 200 个 token 的字符块,远超一次往返所需。这个代价是有意付出的:没有能越过上限的内容,这个上限无法被证伪,而模型会把任何规整的载荷压缩掉(第一次录制时,模型把"alpha 重复 400 次"写成了 `printf 'alpha %.0s' {1..400}`,一条什么也证明不了的单行命令)。 + +## 验证 + +`apps/web/tests/approval-composer.e2e.ts` 驱动的是真实组合:一个只读会话、一次被拒绝的写入、模型的越权重试,以及在面板上点击完成的回应。几何断言在两个视口高度上针对活动面板执行,并有守卫防止它空洞地成立——该区域必须确实处在滚动状态,且实测上限必须等于 composer 自身的上限,后者由测试在发送之前从活动 textarea 上读出,而不是把该像素值写死。 + +在构建产物客户端上双向确认过。撤销上限后,该区域报告 `scrolls: false`,并长到命令的完整高度(900x1000 下,录制的字符块为 1798px,而设上限后为 336px);在 900x700 下卡片高 680px、视口高 700px,操作按钮行底边落在 y=749——正在折叠之下,与设计同学的反馈完全一致。恢复上限后,该场景在回放模式下通过。 + +要复现按钮跑到屏幕外,需要的是比滚动视口更高的卡片,而不只是一张很高的卡片。composer 容器为 `position: sticky; bottom: 0`,因此在卡片尚能容纳时它会一直吸附在视口底部,按钮仍然可见——在 900x1000 下,未设上限的卡片吃掉了整个会话流,却仍把操作按钮行留在屏幕内。只有当卡片长过滚动视口,sticky 才再也无法守住底边,按钮行随之沉入折叠之下。 + +几何断言块与 golden 仅在回放模式下执行,这样录制模式才能走到写入 fixture 那一步,而不是在布局检查处中断。 + +该面板以客户端模组包的形式发布:单跑 `pnpm run build:web` 不会带上对 `ApprovalPanel.module.css` 的改动,也不会带上 `ApprovalPanel.tsx` 中新增的 `data-` 钩子——必须先执行包构建,否则浏览器测试通道会对着一个比工作树更旧的客户端做断言。 diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts new file mode 100644 index 0000000000..c9d5c493c6 --- /dev/null +++ b/apps/web/tests/approval-composer.e2e.ts @@ -0,0 +1,178 @@ +// Web e2e scenario: the composer-takeover approval panel under a long +// command. The shipped composition confines bash through the sandbox policy +// and routes its escalation through the approval seam, so a read-only session +// asked to write a file produces a REAL pending approval — the panel renders +// in the browser, the test measures its geometry, answers through it, and the +// escalated command then runs. Replay is deterministic: the denial, the +// escalation retry and its command text arrive from replayed chunks, and the +// answer click is the test's own gesture (the same sanctioned reaction to +// model content as the question composer: the turn cannot complete without it). +// +// Geometry is the point of the scenario. The command is unbounded model text, +// and before the cap a long one grew the card until the refuse/allow buttons +// left the viewport — an approval the user could see and not answer. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +// Empty type import: carries the approval package's session-event merge, so +// the decided-outcome assertion below type-checks against the real union. +import type {} from '@deepseek-ai/dsh-user-approval' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/approval-composer', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +// Second golden: the answered transcript — the granted escalation ran and the +// turn finished, the state the waiting golden cannot see. +const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') +const MODE = webSnapshotMode() + +// Irreducible payload: the command has to be long enough to pass the card's +// height cap, which is the only shape that reproduces an action row pushed off +// screen. Unrelated tokens, not a repeated word — a repeated word is what the +// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a +// short command proves nothing here. The formula keeps the source small; the +// model receives the expanded literal it has to put in the command. +const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ') +const PROMPT = `Write a file named notes.txt in the workspace containing exactly this text on one line: ${TOKENS}. Use one bash command with the literal text inline. Then reply with the single word DONE and stop.` + +/** Draft used to measure the composer's own text cap: enough lines to pass it. */ +const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n') + +describe('web e2e: approval takeover keeps its actions reachable', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('caps the long command, answers through the panel, and runs the escalated command', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-approval')) + if (MODE !== 'record') { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + + // The composer's own text cap, measured on the live textarea before the + // takeover replaces it. The panel's scroll region must stop at the same + // height (the designer's requirement: one cap for the composer seat), and + // measuring it here keeps the assertion free of the px value itself. + await input.fill(CAP_PROBE) + const composerCap = await input.evaluate(el => el.clientHeight) + expect(composerCap).toBeGreaterThan(0) + await input.fill('') + + // Read-only: the mode whose denial the model escalates from. Switched + // through the shipped access-mode chip, not a test-only seam. + await page.locator('[aria-label^="Access mode"]').click() + await page.getByRole('menuitem', { name: 'Read Only' }).click() + await expect.poll( + () => page.locator('[aria-label="Access mode, current: Read Only"]').count(), + { timeout: 15_000 }, + ).toBe(1) + + const settled = scaffold.whenTurnSettled(MODE === 'record' ? 240_000 : 60_000) + await input.fill(PROMPT) + await input.press('Enter') + + // The panel takes over the input area while the tool blocks. Its presence + // is a STABLE waiting state (it stays until answered), so waitFor is + // race-free. + const panel = page.locator('[data-approval-key]') + await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 }) + const scroll = panel.locator('[data-approval-scroll]') + await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0) + + if (MODE !== 'record') { + // This golden owns the stable waiting surface; the answered golden below + // owns the resulting transcript. + const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + + // The regression this scenario exists for: an uncapped card grew with + // the command until the action row left the viewport. Measured at the + // lane baseline and at a short viewport, on the live panel. + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + for (const height of [1000, 700]) { + await page.setViewportSize({ width: 900, height }) + const geometry = await panel.evaluate((root) => { + const region = root.querySelector('[data-approval-scroll]') + const card = region?.parentElement ?? null + // Role/text, not the CSS-module class names: the built client hashes those. + const buttons = [...root.querySelectorAll('button')] + const rows = buttons.map(button => button.getBoundingClientRect()) + return { + buttons: buttons.length, + capped: region === null ? 0 : region.clientHeight, + // A scrolling region proves the cap is genuinely engaged; without + // it every assertion below would hold vacuously. + scrolls: region === null ? false : region.scrollHeight > region.clientHeight, + cardBottom: card === null ? Number.NaN : card.getBoundingClientRect().bottom, + actionsTop: Math.min(...rows.map(rect => rect.top)), + actionsBottom: Math.max(...rows.map(rect => rect.bottom)), + viewport: window.innerHeight, + } + }) + expect(geometry.buttons).toBe(2) + expect(geometry.scrolls).toBe(true) + // One cap for the seat: the panel's text region stops where the + // composer draft does (sub-pixel tolerance for the shared padding). + expect(Math.abs(geometry.capped - composerCap)).toBeLessThan(1) + // Both buttons stay inside the card AND inside the viewport — the + // answerable state the cap exists to guarantee. + expect(geometry.actionsTop).toBeGreaterThan(0) + expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.viewport) + expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.cardBottom) + } + await page.setViewportSize(original) + } + + await panel.getByRole('button', { name: '允许一次' }).click() + + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + return + } + // World state: the granted escalation is what let the command run, and the + // panel leaves with the regular composer restored. + expect(JSON.stringify(sessionEvents.filter(e => e.type === 'approval/decided').at(-1))) + .toContain('allowed-once') + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 20_000 }).toBeGreaterThanOrEqual(1) + expect(await page.locator('[data-approval-key]').count()).toBe(0) + await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) + const answered = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ANSWERED_EXPECTED, answered, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 300_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/approval-composer/answered.expected.md b/apps/web/tests/snapshots/approval-composer/answered.expected.md new file mode 100644 index 0000000000..ac2e9b4941 --- /dev/null +++ b/apps/web/tests/snapshots/approval-composer/answered.expected.md @@ -0,0 +1,57 @@ +- banner: + - navigation "Session hierarchy": + - button "Write a file named notes.txt" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- img +- text: "/permission read-only Permission preset: read-only. Write a file named notes.txt in the workspace containing exactly this text on one line: tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh toktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokkfz tokqjy tokwnx. Use one bash command with the literal text inline. Then reply with the single word DONE and stop. {{clock}}" +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- button "Think The user wants me to write a file named notes.txt with a specific line of text. Let me do this with a single bash command using echo.": + - img + - img + - text: Think The user wants me to write a file named notes.txt with a specific line of text. Let me do this with a single bash command using echo. +- img +- text: Bash Write notes.txt with the specified text 失败 workspace echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt 退出码 1 +- button "复制" +- text: "[stderr] bash: notes.txt: Operation not permitted [sandbox: file access denied under read-only mode] [sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]" +- button "Think The sandbox denied the file write. I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode) and provide a justification.": + - img + - img + - text: Think The sandbox denied the file write. I need to retry with sandbox_permissions set to "workspace-write" (the narrowest wider mode) and provide a justification. +- img +- text: Bash Write notes.txt with the specified text 已完成 workspace echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt +- button "复制" +- text: (no output) +- button "Think The file was written successfully. Let me verify it was created correctly.": + - img + - img + - text: Think The file was written successfully. Let me verify it was created correctly. +- img +- text: Read +- button "notes.txt" +- button "Think The file was created successfully with the exact text on one line as requested. Now I need to reply with the single word DONE.": + - img + - img + - text: Think The file was created successfully with the exact text on one line as requested. Now I need to reply with the single word DONE. +- paragraph: DONE +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} +- textbox "Message the agent" +- button "Add attachment": + - img +- 'button "Access mode, current: Read Only"': Read Only +- button "Plan mode off, press to turn on": Plan off +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] +- text: 1 turns · 4 steps Tool call {{duration}} Cache hit 96% Input 27.4K tok · Output 1.9K tok diff --git a/apps/web/tests/snapshots/approval-composer/session.jsonl b/apps/web/tests/snapshots/approval-composer/session.jsonl new file mode 100644 index 0000000000..b072da5fc4 --- /dev/null +++ b/apps/web/tests/snapshots/approval-composer/session.jsonl @@ -0,0 +1,64 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785403668101,"cwd":"{{cwd}}/workspace"} +{"type":"command/run","seq":0,"time":1785403668197,"data":{"commandId":"cmd-0ab130cc-1","name":"permission","args":" read-only","source":{"kind":"user"}}} +{"type":"permission/preset","seq":1,"time":1785403668197,"data":{"preset":"read-only"}} +{"type":"sandbox/mode","seq":2,"time":1785403668197,"data":{"mode":"read-only"}} +{"type":"approval/policy","seq":3,"time":1785403668198,"data":{"policy":"ask"}} +{"type":"command/done","seq":4,"time":1785403668198,"data":{"commandId":"cmd-0ab130cc-1","kind":"success","text":"Permission preset: read-only."}} +{"type":"turn/start","seq":5,"time":1785403668212,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":6,"time":1785403668212,"data":{"content":[{"type":"text","text":"Write a file named notes.txt in the workspace containing exactly this text on one line: tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh toktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokkfz tokqjy tokwnx. Use one bash command with the literal text inline. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"f8d50240-b224-4d14-a126-63301ca93176"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785403668213,"data":{"title":"Write a file named notes.txt","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":8,"time":1785403668214,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":9,"time":1785403668215,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":10,"time":1785403669261,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":11,"time0":1785403669262,"data":{"turn":1,"step":1,"index":0,"dt":[110,26,1,0,0,18,0,0,28,0,0,0,0,0,25,1,0,0,24,0,1,23,27,0,0,0,0,31,1],"texts":["The"," user"," wants"," me"," to"," write"," a"," file"," named"," notes",".txt"," with"," a"," specific"," line"," of"," text","."," Let"," me"," do"," this"," with"," a"," single"," bash"," command"," using"," echo","."]}} +{"type":"assistant/chunk","seq":41,"time":1785403669643,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":42,"time0":1785403669643,"data":{"turn":1,"step":1,"index":1,"dt":[32,1,0,0,0,16,1,0,0,0,0,25,0,0,0,0,0,22,0,0,25,0,0,54,1,0,0,0,0,25,0,1,0,30,1,0,0,25,1,0,0,29,0,0,0,0,22,1,0,0,14,0,0,0,53,0,0,1,0,0,0,0,14,1,0,0,0,34,0,0,12,0,0,35,0,1,0,23,1,0,0,0,26,1,0,0,0,17,0,0,0,0,0,29,0,0,0,56,1,0,2,0,0,5,0,0,0,0,29,1,0,18,1,0,30,0,0,1,687,1,0,0,107,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,2,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,79,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,17,0,0,0,56,0,1,0,0,0,0,0,0,0,12,1,0,0,0,33,0,0,0,13,0,0,24,1,0,27,1,0,0,0,24,1,0,0,0,25,0,0,0,0,30,0,0,22,1,0,57,0,0,0,0,1,0,0,19,1,0,0,42,1,0,0,0,3,0,1,0,17,0,0,31,0,0,0,0,21,0,1,0,27,1,0,22,1,0,20,1,0,0,30,0,0,1,0,19,1,0,0,0,43,0,4,0,0,22,1,0,24,0,0,26,1,0,0,0,27,0,0,0,20,0,0,0,0,26,0,0,0,0,24,0,0,0,32,0,1,0,0,14,0,0,30,0,0,1,34,1,0,0,10,0,0,22,0,0,56,1,0,0,0,0,1,0,0,20,1,0,0,20,1,0,25,0,0,48,1,0,0,17,0,0,0,35,0,0,1,0,14,0,31,0,0,1,0,18,1,0,0,26,0,1,0,25,0,0,28,0,0,0,61,1,0,0,0,0,1,0,12,0,0,29,1,0,0,17,1,0,0,0,18,0,0,25,0,0,0,128,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,6,0,1,0,60,1,0,0,0,0,0,10,0,0,1321,0,0,174,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,84,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,81,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,15],"id":"call_00_RFz12ulKTflJvhrwgkQX7978","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," '","tok","63","z"," tok","c","7","y"," tok","ib","x"," tok","of","w"," tok","uj","v"," tok","10","nu"," tok","16","rt"," tok","1","c","vs"," tok","1","iz","r"," tok","1","p","3","q"," tok","1","v","7","p"," tok","21","bo"," tok","2","a","4"," tok","8","e","3"," to","kei","2"," tok","km","1"," tok","qq","0"," tok","w","tz"," tok","12","xy"," tok","191","x"," tok","1","f","5","w"," tok","1","l","9","v"," tok","1","r","du"," tok","1","x","ht"," tok","23","ls"," tok","4","k","8"," to","ka","o","7"," tok","gs","6"," to","km","w","5"," to","kt","04"," tok","z","43"," tok","158","2"," tok","1","bc","1"," tok","1","hg","0"," tok","1","nj","z"," tok","1","t","ny"," tok","1","z","rx"," tok","qd"," tok","6","uc"," tok","cy","b"," tok","j","2","a"," tok","p","69"," tok","va","8"," tok","11","e","7"," tok","17","i","6"," tok","1","dm","5"," tok","1","j","q","4"," tok","1","pu","3"," tok","1","vy","2"," tok","222","1"," tok","30","h"," tok","94","g"," tok","f","8","f"," to","kl","ce"," tok","rg","d"," tok","x","kc"," tok","13","ob"," tok","19","sa"," tok","1","fw","9"," tok","1","m","08"," tok","1","s","47"," tok","1","y","86"," tok","24","c","5"," tok","5","al"," tok","bek"," to","kh","ij"," to","kn","mi"," to","kt","qh"," tok","zug"," tok","15","y","f"," tok","1","c","2","e"," tok","1","i","6","d"," tok","1","o","ac"," tok","1","ue","b"," tok","20","ia"," tok","1","g","q"," tok","7","kp"," tok","do","o"," tok","js","n"," tok","p","wm"," tok","w","0","l"," tok","124","k"," tok","188","j"," tok","1","e","ci"," tok","1","k","gh"," tok","1","q","kg"," tok","1","wof"," tok","22","se"," tok","3","qu"," tok","9","ut"," tok","f","ys"," to","km","2","r"," to","ks","6","q"," to","ky","ap"," tok","14","eo"," tok","1","ain"," tok","1","g","mm"," tok","1","m","ql"," tok","1","s","uk"," tok","1","yy","j"," tok","252","i"," tok","60","y"," tok","c","4","x"," to","ki","8","w"," tok","oc","v"," tok","ugu"," tok","10","kt"," tok","16","os"," tok","1","cs","r"," tok","1","iw","q"," tok","1","p","0","p"," tok","1","v","4","o"," tok","218","n"," tok","273"," tok","8","b","2"," tok","ef","1"," to","kk","j","0"," tok","qm","z"," tok","w","q","y"," tok","12","ux"," tok","18","yw"," tok","1","f","2","v"," tok","1","l","6","u"," tok","1","rat"," tok","1","xes"," tok","23","ir"," tok","4","h","7"," tok","al","6"," to","kg","p","5"," tok","mt","4"," to","ks","x","3"," tok","z","12"," tok","155","1"," tok","1","b","90"," tok","1","h","cz"," tok","1","ng","y"," tok","1","tk","x"," tok","1","z","ow"," to","kn","c"," tok","6","rb"," tok","c","va"," tok","iz","9"," tok","p","38"," tok","v","77"," tok","11","b","6"," tok","17","f","5"," tok","1","dj","4"," tok","1","jn","3"," tok","1","pr","2"," tok","1","vv","1"," tok","21","z","0"," tok","2","xg"," tok","91","f"," tok","f","5","e"," to","kl","9","d"," tok","rd","c"," tok","x","hb"," tok","13","la"," tok","19","p","9"," tok","1","ft","8"," tok","1","lx","7"," tok","1","s","16"," tok","1","y","55"," tok","249","4"," tok","57","k"," tok","bb","j"," to","kh","fi"," to","kn","jh"," tok","kt","ng"," tok","z","rf"," tok","15","ve"," tok","1","b","zd"," tok","1","i","3","c"," tok","1","o","7","b"," tok","1","uba"," tok","20","f","9"," tok","1","dp"," tok","7","ho"," tok","d","ln"," tok","j","pm"," tok","pt","l"," tok","v","xk"," tok","121","j"," tok","185","i"," tok","1","e","9","h"," tok","1","kd","g"," tok","1","qh","f"," tok","1","w","le"," tok","22","pd"," tok","3","nt"," tok","9","rs"," tok","f","vr"," to","kl","z","q"," to","ks","3","p"," to","ky","7","o"," tok","14","bn"," tok","1","af","m"," tok","1","g","jl"," tok","1","mn","k"," tok","1","sr","j"," tok","1","y","vi"," tok","24","zh"," tok","5","xx"," tok","c","1","w"," to","ki","5","v"," to","ko","9","u"," tok","ud","t"," tok","10","hs"," tok","16","lr"," tok","1","cp","q"," tok","1","it","p"," tok","1","ox","o"," tok","1","v","1","n"," tok","215","m"," tok","242"," tok","881"," tok","ec","0"," tok","f","z"," tok","q","jy"," tok","wn","x","'"," >"," notes",".txt","\"",", ","\"","description","\"",": ","\"","Write"," notes",".txt"," with"," the"," specified"," text","\"","}"]}} +{"type":"assistant/chunk","seq":843,"time":1785403674783,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a file named notes.txt with a specific line of text. Let me do this with a single bash command using echo."}}}} +{"type":"assistant/chunk","seq":844,"time":1785403674783,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_RFz12ulKTflJvhrwgkQX7978","name":"bash","arguments":"{\"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"description\": \"Write notes.txt with the specified text\"}"}}}} +{"type":"assistant/chunk","seq":845,"time":1785403674783,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":65,"outputTokens":872,"cacheReadTokens":5376,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":846,"time":1785403674783,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":847,"time":1785403674786,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a file named notes.txt with a specific line of text. Let me do this with a single bash command using echo."},{"type":"tool-call","id":"call_00_RFz12ulKTflJvhrwgkQX7978","name":"bash","arguments":"{\"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"description\": \"Write notes.txt with the specified text\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"d01c80d5-7880-4039-827c-4b35e4d40dac"},"usage":{"inputTokens":65,"outputTokens":872,"cacheReadTokens":5376,"reasoningTokens":30}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605,606,607,608,609,610,611,612,613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747,748,749,750,751,752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767,768,769,770,771,772,773,774,775,776,777,778,779,780,781,782,783,784,785,786,787,788,789,790,791,792,793,794,795,796,797,798,799,800,801,802,803,804,805,806,807,808,809,810,811,812,813,814,815,816,817,818,819,820,821,822,823,824,825,826,827,828,829,830,831,832,833,834,835,836,837,838,839,840,841,842,843,844,845,846],"surfaceOp":"append"} +{"type":"tool/call","seq":848,"time":1785403674787,"data":{"turn":1,"step":1,"callId":"call_00_RFz12ulKTflJvhrwgkQX7978","name":"bash","arguments":"{\"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"description\": \"Write notes.txt with the specified text\"}"}} +{"type":"tool/result","seq":849,"time":1785403674809,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_RFz12ulKTflJvhrwgkQX7978"},"content":[{"type":"tool-result","toolCallId":"call_00_RFz12ulKTflJvhrwgkQX7978","content":[{"type":"text","text":"[stderr]\nbash: notes.txt: Operation not permitted\n[sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]\n[exit code: 1]"}],"isError":false}],"role":"user","id":"b555391f-0a33-4110-9631-4c3d073cb73c"}},"sourceEventSeqs":[848],"surfaceOp":"append"} +{"type":"step/end","seq":850,"time":1785403674809,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":851,"time":1785403674812,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":852,"time":1785403676090,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":853,"time0":1785403676091,"data":{"turn":1,"step":2,"index":0,"dt":[140,23,0,0,0,22,0,27,0,0,0,26,0,0,0,0,0,22,0,0,28,1,0,0,0,0,20,1,21,0,1,0,0,19,1,0,0],"texts":["The"," sand","box"," denied"," the"," file"," write","."," I"," need"," to"," ret","ry"," with"," sand","box","_per","missions"," set"," to"," \"","works","pace","-w","rite","\""," (","the"," narrow","est"," wider"," mode",")"," and"," provide"," a"," justification","."]}} +{"type":"assistant/chunk","seq":891,"time":1785403676518,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":892,"time0":1785403676518,"data":{"turn":1,"step":2,"index":1,"dt":[33,0,1,0,0,19,0,0,0,0,21,0,0,0,17,0,27,1,0,0,22,0,0,0,0,24,0,0,0,23,1,0,0,0,23,0,0,0,30,0,0,0,0,21,0,0,0,55,0,0,0,0,0,0,0,0,36,0,1,0,7,0,0,0,31,1,0,0,19,0,0,0,0,25,0,0,0,0,19,0,0,27,1,0,0,26,0,0,0,0,19,0,0,0,25,0,0,0,0,32,0,0,0,0,0,0,0,1,0,33,0,0,0,0,19,0,0,23,1,0,0,0,20,0,0,15,0,0,33,0,0,0,18,0,0,0,30,0,0,0,0,19,0,0,0,0,0,23,0,0,0,19,0,0,0,26,0,0,25,1,0,0,0,22,0,0,24,0,0,0,28,0,1,22,1,0,0,23,0,0,0,56,0,0,0,0,0,0,0,0,0,0,0,24,1,0,0,0,0,24,1,0,0,0,27,0,0,0,24,0,0,0,0,18,0,0,0,15,0,0,18,0,0,27,1,0,55,0,0,0,1,0,0,0,19,0,0,0,21,0,0,0,50,0,1,0,3,0,0,0,15,1,23,1,0,0,23,1,0,0,19,0,0,22,0,0,27,1,0,0,1,23,1,0,0,0,0,26,0,0,1,0,13,0,0,0,0,42,1,0,0,16,1,0,0,0,0,22,0,0,0,25,0,0,0,0,21,1,0,0,24,0,0,0,44,1,0,5,0,0,0,0,22,0,0,25,0,0,0,29,0,0,22,0,0,0,0,17,1,0,0,43,0,1,0,4,1,0,0,0,23,0,0,0,28,1,0,0,58,0,0,0,0,0,0,14,0,0,0,1,23,1,0,0,22,1,0,0,0,23,1,0,0,0,25,1,0,0,26,1,0,0,0,24,0,0,0,0,51,0,1,0,0,0,0,0,13,0,0,0,21,1,0,0,0,20,0,0,0,0,22,1,0,0,19,0,0,0,0,29,1,0,0,28,0,0,0,0,23,1,0,0,20,0,0,30,0,0,0,26,0,0,0,0,42,1,0,0,0,0,0,0,34,1,0,0,0,14,0,0,0,26,1,0,19,0,0,0,28,1,0,21,0,0,1,21,0,0,0,0,1,17,0,0,0,0,23,0,0,0,20,0,0,0,24,0,0,0,23,0,0,25,1,0,0,22,0,0,0,0,31,1,0,0,11,1,0,22,1,0,48,1,0,0,9,0,1,0,12,1,0,0,21,1,0,0,32,0,0,0,0,15,0,0,1,23,1,0,21,1,0,47,0,1,0,0,3,0,1,0,0,17,1,0,22,1,0,25,0,0,0,22,0,0,0,26,1,0,0,52,0,0,1,0,0,0,0,15,0,1,22,1,0,0,25,0,0,22,0,0,0,23,0,0,24,0,0,0,52,0,0,0,0,0,0,0,52,1,0,0,0,0,0,0,31,0,1,0,0,19,0,0,17,1,0,0,19,0,0,28,0,0,0,0,19,1,0,24,0,0,0,23,0,0,0,0,24,1,0,24,1,0,0,0,0,22,1,0,0,24,1,0,0,22,0,1,0,24,0,1,25,1,0,44,0,0,3,1,0,0,20,1,0,0,24,1,0,0,0,0,19,1,0,0,22,0,1,0,23,0,1,0,25,1,0,0,21,1,0,0,22,1,0,0,23,1,0,26,0,0,0,28,0,0,0,0,18,0,0,0,25,0,0,0,0,29,0,0,0,1,17,1,0,0,0,25,1,0,0,23,0,0,1,27,0,1,0,18,0,0,23,1,0,27,1,0,0,20,1,0,20,1,0,26,0,0,0,19,0,27,0,0,0,23,1,0,0,28,0,0,1,0,15,39,1,0,0,0,0,15,1,0,0,27,0,1,0,13,1,32,0,1,0,0,12],"id":"call_00_Oa8HTzXO89hDxPkAtKSn1634","name":"bash","args":["","{","\"","description","\"",": ","\"","Write"," notes",".txt"," with"," the"," specified"," text","\"",", ","\"","command","\"",": ","\"","echo"," '","tok","63","z"," tok","c","7","y"," tok","ib","x"," tok","of","w"," tok","uj","v"," tok","10","nu"," tok","16","rt"," tok","1","c","vs"," tok","1","iz","r"," tok","1","p","3","q"," tok","1","v","7","p"," tok","21","bo"," tok","2","a","4"," tok","8","e","3"," to","kei","2"," tok","km","1"," tok","qq","0"," tok","w","tz"," tok","12","xy"," tok","191","x"," tok","1","f","5","w"," tok","1","l","9","v"," tok","1","r","du"," tok","1","x","ht"," tok","23","ls"," tok","4","k","8"," to","ka","o","7"," tok","gs","6"," to","km","w","5"," to","kt","04"," tok","z","43"," tok","158","2"," tok","1","bc","1"," tok","1","hg","0"," tok","1","nj","z"," tok","1","t","ny"," tok","1","z","rx"," tok","qd"," tok","6","uc"," tok","cy","b"," tok","j","2","a"," tok","p","69"," tok","va","8"," tok","11","e","7"," tok","17","i","6"," tok","1","dm","5"," tok","1","j","q","4"," tok","1","pu","3"," tok","1","vy","2"," tok","222","1"," tok","30","h"," tok","94","g"," tok","f","8","f"," to","kl","ce"," tok","rg","d"," tok","x","kc"," tok","13","ob"," tok","19","sa"," tok","1","fw","9"," tok","1","m","08"," tok","1","s","47"," tok","1","y","86"," tok","24","c","5"," tok","5","al"," tok","bek"," to","kh","ij"," to","kn","mi"," to","kt","qh"," tok","zug"," tok","15","y","f"," tok","1","c","2","e"," tok","1","i","6","d"," tok","1","o","ac"," tok","1","ue","b"," tok","20","ia"," tok","1","g","q"," tok","7","kp"," tok","do","o"," tok","js","n"," tok","p","wm"," tok","w","0","l"," tok","124","k"," tok","188","j"," tok","1","e","ci"," tok","1","k","gh"," tok","1","q","kg"," tok","1","wof"," tok","22","se"," tok","3","qu"," tok","9","ut"," tok","f","ys"," to","km","2","r"," to","ks","6","q"," to","ky","ap"," tok","14","eo"," tok","1","ain"," tok","1","g","mm"," tok","1","m","ql"," tok","1","s","uk"," tok","1","yy","j"," tok","252","i"," tok","60","y"," tok","c","4","x"," to","ki","8","w"," tok","oc","v"," tok","ugu"," tok","10","kt"," tok","16","os"," tok","1","cs","r"," tok","1","iw","q"," tok","1","p","0","p"," tok","1","v","4","o"," tok","218","n"," tok","273"," tok","8","b","2"," tok","ef","1"," to","kk","j","0"," tok","qm","z"," tok","w","q","y"," tok","12","ux"," tok","18","yw"," tok","1","f","2","v"," tok","1","l","6","u"," tok","1","rat"," tok","1","xes"," tok","23","ir"," tok","4","h","7"," tok","al","6"," to","kg","p","5"," tok","mt","4"," to","ks","x","3"," tok","z","12"," tok","155","1"," tok","1","b","90"," tok","1","h","cz"," tok","1","ng","y"," tok","1","tk","x"," tok","1","z","ow"," to","kn","c"," tok","6","rb"," tok","c","va"," tok","iz","9"," tok","p","38"," tok","v","77"," tok","11","b","6"," tok","17","f","5"," tok","1","dj","4"," tok","1","jn","3"," tok","1","pr","2"," tok","1","vv","1"," tok","21","z","0"," tok","2","xg"," tok","91","f"," tok","f","5","e"," to","kl","9","d"," tok","rd","c"," tok","x","hb"," tok","13","la"," tok","19","p","9"," tok","1","ft","8"," tok","1","lx","7"," tok","1","s","16"," tok","1","y","55"," tok","249","4"," tok","57","k"," tok","bb","j"," to","kh","fi"," to","kn","jh"," tok","kt","ng"," tok","z","rf"," tok","15","ve"," tok","1","b","zd"," tok","1","i","3","c"," tok","1","o","7","b"," tok","1","uba"," tok","20","f","9"," tok","1","dp"," tok","7","ho"," tok","d","ln"," tok","j","pm"," tok","pt","l"," tok","v","xk"," tok","121","j"," tok","185","i"," tok","1","e","9","h"," tok","1","kd","g"," tok","1","qh","f"," tok","1","w","le"," tok","22","pd"," tok","3","nt"," tok","9","rs"," tok","f","vr"," to","kl","z","q"," to","ks","3","p"," to","ky","7","o"," tok","14","bn"," tok","1","af","m"," tok","1","g","jl"," tok","1","mn","k"," tok","1","sr","j"," tok","1","y","vi"," tok","24","zh"," tok","5","xx"," tok","c","1","w"," to","ki","5","v"," to","ko","9","u"," tok","ud","t"," tok","10","hs"," tok","16","lr"," tok","1","cp","q"," tok","1","it","p"," tok","1","ox","o"," tok","1","v","1","n"," tok","215","m"," tok","242"," tok","881"," tok","ec","0"," tok","f","z"," tok","q","jy"," tok","wn","x","'"," >"," notes",".txt","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","works","pace","-w","rite","\"",", ","\"","just","ification","\"",": ","\"","Need"," to"," write"," the"," notes",".txt"," file"," as"," requested"," by"," the"," user",".","\"","}"]}} +{"type":"assistant/chunk","seq":1728,"time":1785403681516,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The sandbox denied the file write. I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode) and provide a justification."}}}} +{"type":"assistant/chunk","seq":1729,"time":1785403681516,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Oa8HTzXO89hDxPkAtKSn1634","name":"bash","arguments":"{\"description\": \"Write notes.txt with the specified text\", \"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"Need to write the notes.txt file as requested by the user.\"}"}}}} +{"type":"assistant/chunk","seq":1730,"time":1785403681516,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":930,"cacheReadTokens":6272,"reasoningTokens":38}}}} +{"type":"assistant/chunk","seq":1731,"time":1785403681516,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":1732,"time":1785403681517,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The sandbox denied the file write. I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode) and provide a justification."},{"type":"tool-call","id":"call_00_Oa8HTzXO89hDxPkAtKSn1634","name":"bash","arguments":"{\"description\": \"Write notes.txt with the specified text\", \"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"Need to write the notes.txt file as requested by the user.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"9a0932ab-d9d9-4df3-a362-4c930d4bd7d1"},"usage":{"inputTokens":122,"outputTokens":930,"cacheReadTokens":6272,"reasoningTokens":38}},"sourceEventSeqs":[852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,995,996,997,998,999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041,1042,1043,1044,1045,1046,1047,1048,1049,1050,1051,1052,1053,1054,1055,1056,1057,1058,1059,1060,1061,1062,1063,1064,1065,1066,1067,1068,1069,1070,1071,1072,1073,1074,1075,1076,1077,1078,1079,1080,1081,1082,1083,1084,1085,1086,1087,1088,1089,1090,1091,1092,1093,1094,1095,1096,1097,1098,1099,1100,1101,1102,1103,1104,1105,1106,1107,1108,1109,1110,1111,1112,1113,1114,1115,1116,1117,1118,1119,1120,1121,1122,1123,1124,1125,1126,1127,1128,1129,1130,1131,1132,1133,1134,1135,1136,1137,1138,1139,1140,1141,1142,1143,1144,1145,1146,1147,1148,1149,1150,1151,1152,1153,1154,1155,1156,1157,1158,1159,1160,1161,1162,1163,1164,1165,1166,1167,1168,1169,1170,1171,1172,1173,1174,1175,1176,1177,1178,1179,1180,1181,1182,1183,1184,1185,1186,1187,1188,1189,1190,1191,1192,1193,1194,1195,1196,1197,1198,1199,1200,1201,1202,1203,1204,1205,1206,1207,1208,1209,1210,1211,1212,1213,1214,1215,1216,1217,1218,1219,1220,1221,1222,1223,1224,1225,1226,1227,1228,1229,1230,1231,1232,1233,1234,1235,1236,1237,1238,1239,1240,1241,1242,1243,1244,1245,1246,1247,1248,1249,1250,1251,1252,1253,1254,1255,1256,1257,1258,1259,1260,1261,1262,1263,1264,1265,1266,1267,1268,1269,1270,1271,1272,1273,1274,1275,1276,1277,1278,1279,1280,1281,1282,1283,1284,1285,1286,1287,1288,1289,1290,1291,1292,1293,1294,1295,1296,1297,1298,1299,1300,1301,1302,1303,1304,1305,1306,1307,1308,1309,1310,1311,1312,1313,1314,1315,1316,1317,1318,1319,1320,1321,1322,1323,1324,1325,1326,1327,1328,1329,1330,1331,1332,1333,1334,1335,1336,1337,1338,1339,1340,1341,1342,1343,1344,1345,1346,1347,1348,1349,1350,1351,1352,1353,1354,1355,1356,1357,1358,1359,1360,1361,1362,1363,1364,1365,1366,1367,1368,1369,1370,1371,1372,1373,1374,1375,1376,1377,1378,1379,1380,1381,1382,1383,1384,1385,1386,1387,1388,1389,1390,1391,1392,1393,1394,1395,1396,1397,1398,1399,1400,1401,1402,1403,1404,1405,1406,1407,1408,1409,1410,1411,1412,1413,1414,1415,1416,1417,1418,1419,1420,1421,1422,1423,1424,1425,1426,1427,1428,1429,1430,1431,1432,1433,1434,1435,1436,1437,1438,1439,1440,1441,1442,1443,1444,1445,1446,1447,1448,1449,1450,1451,1452,1453,1454,1455,1456,1457,1458,1459,1460,1461,1462,1463,1464,1465,1466,1467,1468,1469,1470,1471,1472,1473,1474,1475,1476,1477,1478,1479,1480,1481,1482,1483,1484,1485,1486,1487,1488,1489,1490,1491,1492,1493,1494,1495,1496,1497,1498,1499,1500,1501,1502,1503,1504,1505,1506,1507,1508,1509,1510,1511,1512,1513,1514,1515,1516,1517,1518,1519,1520,1521,1522,1523,1524,1525,1526,1527,1528,1529,1530,1531,1532,1533,1534,1535,1536,1537,1538,1539,1540,1541,1542,1543,1544,1545,1546,1547,1548,1549,1550,1551,1552,1553,1554,1555,1556,1557,1558,1559,1560,1561,1562,1563,1564,1565,1566,1567,1568,1569,1570,1571,1572,1573,1574,1575,1576,1577,1578,1579,1580,1581,1582,1583,1584,1585,1586,1587,1588,1589,1590,1591,1592,1593,1594,1595,1596,1597,1598,1599,1600,1601,1602,1603,1604,1605,1606,1607,1608,1609,1610,1611,1612,1613,1614,1615,1616,1617,1618,1619,1620,1621,1622,1623,1624,1625,1626,1627,1628,1629,1630,1631,1632,1633,1634,1635,1636,1637,1638,1639,1640,1641,1642,1643,1644,1645,1646,1647,1648,1649,1650,1651,1652,1653,1654,1655,1656,1657,1658,1659,1660,1661,1662,1663,1664,1665,1666,1667,1668,1669,1670,1671,1672,1673,1674,1675,1676,1677,1678,1679,1680,1681,1682,1683,1684,1685,1686,1687,1688,1689,1690,1691,1692,1693,1694,1695,1696,1697,1698,1699,1700,1701,1702,1703,1704,1705,1706,1707,1708,1709,1710,1711,1712,1713,1714,1715,1716,1717,1718,1719,1720,1721,1722,1723,1724,1725,1726,1727,1728,1729,1730,1731],"surfaceOp":"append"} +{"type":"tool/call","seq":1733,"time":1785403681518,"data":{"turn":1,"step":2,"callId":"call_00_Oa8HTzXO89hDxPkAtKSn1634","name":"bash","arguments":"{\"description\": \"Write notes.txt with the specified text\", \"command\": \"echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"Need to write the notes.txt file as requested by the user.\"}"}} +{"type":"approval/asked","seq":1734,"time":1785403681519,"data":{"id":"c9284e32-66fe-40d1-baba-a4a467d00f0d","toolName":"bash","callId":"call_00_Oa8HTzXO89hDxPkAtKSn1634","reason":"escalate sandbox to workspace-write: Need to write the notes.txt file as requested by the user."}} +{"type":"approval/decided","seq":1735,"time":1785403681598,"data":{"id":"c9284e32-66fe-40d1-baba-a4a467d00f0d","outcome":"allowed-once"}} +{"type":"tool/result","seq":1736,"time":1785403681611,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_Oa8HTzXO89hDxPkAtKSn1634"},"content":[{"type":"tool-result","toolCallId":"call_00_Oa8HTzXO89hDxPkAtKSn1634","content":[{"type":"text","text":"(no output)"}],"isError":false}],"role":"user","id":"7f38d1c8-3dff-440b-be2d-a7c6a0a332cd"}},"sourceEventSeqs":[1733],"surfaceOp":"append"} +{"type":"step/end","seq":1737,"time":1785403681612,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":1738,"time":1785403681613,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":1739,"time":1785403682638,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":1740,"time0":1785403682639,"data":{"turn":1,"step":3,"index":0,"dt":[165,1,0,0,0,0,0,42,1,0,0,0,0],"texts":["The"," file"," was"," written"," successfully","."," Let"," me"," verify"," it"," was"," created"," correctly","."]}} +{"type":"assistant/chunk","seq":1754,"time":1785403682934,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":1755,"time0":1785403682935,"data":{"turn":1,"step":3,"index":1,"dt":[0,0,0,0,0,44,0,0,0,0,0],"id":"call_00_1izt6taEc9Avror1q7UM6022","name":"read","args":["","{","\"","file","_path","\"",": ","\"","notes",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":1767,"time":1785403683035,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was written successfully. Let me verify it was created correctly."}}}} +{"type":"assistant/chunk","seq":1768,"time":1785403683035,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1izt6taEc9Avror1q7UM6022","name":"read","arguments":"{\"file_path\": \"notes.txt\"}"}}}} +{"type":"assistant/chunk","seq":1769,"time":1785403683036,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":42,"outputTokens":59,"cacheReadTokens":7296,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":1770,"time":1785403683036,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":1771,"time":1785403683037,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was written successfully. Let me verify it was created correctly."},{"type":"tool-call","id":"call_00_1izt6taEc9Avror1q7UM6022","name":"read","arguments":"{\"file_path\": \"notes.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"db47847b-6227-40c8-9afd-45424fde25e1"},"usage":{"inputTokens":42,"outputTokens":59,"cacheReadTokens":7296,"reasoningTokens":14}},"sourceEventSeqs":[1739,1740,1741,1742,1743,1744,1745,1746,1747,1748,1749,1750,1751,1752,1753,1754,1755,1756,1757,1758,1759,1760,1761,1762,1763,1764,1765,1766,1767,1768,1769,1770],"surfaceOp":"append"} +{"type":"tool/call","seq":1772,"time":1785403683037,"data":{"turn":1,"step":3,"callId":"call_00_1izt6taEc9Avror1q7UM6022","name":"read","arguments":"{\"file_path\": \"notes.txt\"}"}} +{"type":"tool/result","seq":1773,"time":1785403683042,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_1izt6taEc9Avror1q7UM6022"},"content":[{"type":"tool-result","toolCallId":"call_00_1izt6taEc9Avror1q7UM6022","content":[{"type":"text","text":"{{cwd}}/workspace/notes.txt\nfile\n\n1: tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"64de91fe-45e9-4bc2-9430-ca8b0d31c2b9"}},"sourceEventSeqs":[1772],"surfaceOp":"append"} +{"type":"step/end","seq":1774,"time":1785403683042,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":1775,"time":1785403683043,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":1776,"time":1785403684230,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":1777,"time0":1785403684231,"data":{"turn":1,"step":4,"index":0,"dt":[83,30,0,1,0,17,1,0,23,0,25,0,33,0,0,18,0,0,0,0,44,1,0,0,0,0],"texts":["The"," file"," was"," created"," successfully"," with"," the"," exact"," text"," on"," one"," line"," as"," requested","."," Now"," I"," need"," to"," reply"," with"," the"," single"," word"," D","ONE","."]}} +{"type":"assistant/chunk","seq":1804,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":1805,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":1806,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":1807,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully with the exact text on one line as requested. Now I need to reply with the single word DONE."}}}} +{"type":"assistant/chunk","seq":1808,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":1809,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":964,"outputTokens":30,"cacheReadTokens":7296,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":1810,"time":1785403684514,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":1811,"time":1785403684515,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file was created successfully with the exact text on one line as requested. Now I need to reply with the single word DONE."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"af385b17-60fe-435d-838d-4932b6b39bf6"},"usage":{"inputTokens":964,"outputTokens":30,"cacheReadTokens":7296,"reasoningTokens":27}},"sourceEventSeqs":[1776,1777,1778,1779,1780,1781,1782,1783,1784,1785,1786,1787,1788,1789,1790,1791,1792,1793,1794,1795,1796,1797,1798,1799,1800,1801,1802,1803,1804,1805,1806,1807,1808,1809,1810],"surfaceOp":"append"} +{"type":"step/end","seq":1812,"time":1785403684515,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":1813,"time":1785403684515,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/approval-composer/ui.expected.md b/apps/web/tests/snapshots/approval-composer/ui.expected.md new file mode 100644 index 0000000000..501462a777 --- /dev/null +++ b/apps/web/tests/snapshots/approval-composer/ui.expected.md @@ -0,0 +1,3 @@ +- text: "等待审批 escalate sandbox to workspace-write: Need to write the notes.txt file as requested by the user. echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt" +- button "拒绝" +- button "允许一次" diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7a7f228fb0..59219f1283 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -25,6 +25,7 @@ "tests/scaffold.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", + "tests/approval-composer.e2e.ts", "tests/steering.e2e.ts", "tests/navigation-panes.e2e.ts", "tests/lifecycle-chrome.e2e.ts", diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css index c3d8ef47bd..1d9a11f7b5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css @@ -19,6 +19,13 @@ border-radius: 20px; background: var(--dsw-specific-input-major); box-shadow: var(--dsw-shadow-lv2); + /* Elevated surface in dark, same as the menus: `.body` inside scrolls once + the justification or command passes the cap, so the thumb takes the l2 + pair. Declared on the card because the elevation belongs to the surface, + and the custom properties inherit down to the region that actually + scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } /* Tinted full-width header band. */ @@ -40,11 +47,22 @@ background: var(--dsw-alias-state-warn-primary); } +/* Scroll region: an agent's justification and its command are unbounded model + text (a one-line `cd` or a 40-line heredoc), and the seat sits in a + fixed-height column — uncapped, a long command pushed the action row past + the viewport and the approval could not be answered at all. The strip and + the action row stay outside, so the buttons are always on screen. */ .body { display: flex; flex-direction: column; gap: 6px; - padding: 12px 16px 14px; + /* border-box so the cap is the region's OUTER height: the composer's draft + area counts its padding inside the same number, and the two seats are + only interchangeable if they occupy the same box. */ + box-sizing: border-box; + max-height: var(--dsh-composer-text-max-height); + overflow-y: auto; + padding: 12px 16px 0; } /* The model's justification is the panel's message, not a footnote. */ @@ -63,11 +81,13 @@ word-break: break-all; } +/* Card-level row, not body content: it carries the body's former bottom pad so + the resting card keeps the draft's metrics while the scroll cap applies. */ .actionRow { display: flex; justify-content: flex-end; gap: 8px; - margin-top: 8px; + padding: 8px 16px 14px; } .allow, diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx index 7b8d52a6ec..715f9f292f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx @@ -4,7 +4,11 @@ // pending, this panel occupies the composer slot in place of the InputBar: // an amber "Waiting for approval" strip on the card top, the model's // justification as the headline, the paired command in muted code text, and -// a right-aligned refuse/allow action row. One-shot: the buttons disable +// a right-aligned refuse/allow action row. Justification and command are +// unbounded model text, so they scroll inside the card at the shared composer +// cap (`data-approval-scroll`) and the action row stays outside it — the +// buttons must be reachable no matter how long the command is. +// One-shot: the buttons disable // after a click and the panel leaves (the InputBar returns) on the broadcast // resolved frame. The draft's "Always allow this type" is deferred with // grant storage. @@ -53,17 +57,17 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
等待审批
-
+
{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}
{command !== undefined &&
{command}
} -
- - -
+
+
+ +
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index e240fea889..70cd99126a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -142,6 +142,14 @@ display: flex; flex: none; flex-direction: column; + /* One cap for every scrolling text region a composer seat can hold: the + InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the + takeover panels' bodies top out at the same height, so electing a + takeover never grows the footer past the card it replaces. Declared on + the seat because it is the chain's only shared ancestor — fallback and + elected overlay are siblings — and custom properties inherit down to + whichever entry is mounted. */ + --dsh-composer-text-max-height: 336px; } /* Active phase: header is ordinary column chrome above the scrollport (not diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index f0a59942f7..14aebe899b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -209,7 +209,9 @@ .mirror { visibility: hidden; pointer-events: none; - max-height: 336px; + /* 14-line cap, shared with the composer takeovers (declared on + ConversationRoot .composerSeat). */ + max-height: var(--dsh-composer-text-max-height); overflow: hidden; } diff --git a/tsconfig.host.json b/tsconfig.host.json index f55010712e..d7bf3690c6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -12,6 +12,7 @@ "apps/web/tests/support.ts", "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", + "apps/web/tests/approval-composer.e2e.ts", "apps/web/tests/steering.e2e.ts", "apps/web/tests/navigation-panes.e2e.ts", "apps/web/tests/lifecycle-chrome.e2e.ts", From dd56f6c6467ea11d8b7c11973089c93352c737cc Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 17:54:15 +0800 Subject: [PATCH 24/37] docs: regenerate config/cordis/event catalogs for the web card tag The re-exports for WebResultView shift line numbers in packages/core/tools; regenerate the generated catalogs the static gate checks. --- docs/config-catalog.md | 4 ++-- docs/cordis-catalog/events.md | 12 ++++++------ docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 12 ++++++------ packages/cordis/tool-cordis/src/api-catalog.ts | 18 +++++++++++++++++- 5 files changed, 32 insertions(+), 16 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 191d96b255..d7886a8e6d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1844,7 +1844,7 @@ export interface Config { } ``` -Source: [`packages/web/tool-web/src/index.ts:35`](../packages/web/tool-web/src/index.ts) +Source: [`packages/web/tool-web/src/index.ts:37`](../packages/web/tool-web/src/index.ts) ## `@deepseek-ai/dsh-tool-workflow` @@ -1890,7 +1890,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:578`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:582`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index dafa342d5a..25e89b27f1 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -841,7 +841,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:156`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts) ### `tools/code-dispatch-log` — waterfall @@ -865,7 +865,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:142`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -887,7 +887,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:117`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -910,7 +910,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:125`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:129`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -931,7 +931,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:102`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -950,7 +950,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:146`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:150`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c655de3a70..de5a206ba5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2174,7 +2174,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:700`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:704`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b9538b89d5..09951e7381 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:142`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:117`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:129`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:150`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 60f7d330d8..9bf86f08c7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2697,7 +2697,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolResultView', - declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;', + declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;', }, { name: 'ToolRunContext', @@ -2803,6 +2803,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WebFetchResult', declaration: 'export interface WebFetchResult {\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}', }, + { + name: 'WebFetchResultView', + declaration: 'export interface WebFetchResultView {\n card: \'web\';\n kind: \'fetch\';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n content?: ContentBlock[];\n}', + }, + { + name: 'WebResultView', + declaration: 'export type WebResultView = WebSearchResultView | WebFetchResultView;', + }, { name: 'WebRoute', declaration: 'export interface WebRoute {\n kind: WebRouteKind;\n path: string;\n handler: (req: IncomingMessage, res: ServerResponse) => void | Promise;\n}', @@ -2823,10 +2831,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WebSearchResult', declaration: 'export interface WebSearchResult {\n readonly content?: string;\n readonly sources: readonly WebSearchSource[];\n readonly truncated: boolean;\n}', }, + { + name: 'WebSearchResultView', + declaration: 'export interface WebSearchResultView {\n card: \'web\';\n kind: \'search\';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n content?: ContentBlock[];\n}', + }, { name: 'WebSearchSource', declaration: 'export interface WebSearchSource {\n readonly url: string;\n readonly title?: string;\n readonly snippet?: string;\n readonly publishedAt?: string;\n}', }, + { + name: 'WebSource', + declaration: 'export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n}', + }, { name: 'WorkflowMeta', declaration: 'export interface WorkflowMeta {\n name: string;\n description: string;\n whenToUse?: string;\n phases?: WorkflowPhase[];\n}', From 14482fcef16eea6e3e9b2d828db8e2652f1a9a3d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 18:11:05 +0800 Subject: [PATCH 25/37] fix(web): keep the approval scenario's goldens platform-neutral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The answered-transcript golden captured the OS's own refusal of the denied first attempt — "bash: notes.txt: Operation not permitted" on macOS against "bash: line 1: notes.txt: Read-only file system" on Linux — so it passed locally and failed the Linux snapshot lane. The scenario now keeps one golden (the waiting panel, platform-neutral) and asserts the answered state on the world instead: the decided outcome, the file the escalated command actually wrote, DONE, the panel gone, and the composer re-enabled. The file assertion is stronger evidence than the transcript dump it replaces — it proves the grant reached the executor. --- ...07-30-approval-panel-command-cap.i18n.yaml | 4 +- .../2026-07-30-approval-panel-command-cap.md | 4 +- ...026-07-30-approval-panel-command-cap.zh.md | 2 + apps/web/tests/approval-composer.e2e.ts | 17 +++--- .../approval-composer/answered.expected.md | 57 ------------------- 5 files changed, 17 insertions(+), 67 deletions(-) delete mode 100644 apps/web/tests/snapshots/approval-composer/answered.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml index 908f03860e..148914309c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.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/bug-fix/2026-07-30-approval-panel-command-cap.md -2026-07-30-approval-panel-command-cap.md: a9282f132e655833cfe687409c287c5afd538d50 -2026-07-30-approval-panel-command-cap.zh.md: 7eb40942e10134d43478e53e06c584bb97d3bb8f +2026-07-30-approval-panel-command-cap.md: f16edd337a568bc5eb8e2f0d5ca04158a77f6cdf +2026-07-30-approval-panel-command-cap.zh.md: c41589d07dd617f61c89ce2182d274477ea78b86 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md index a9282f132e..f16edd337a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md @@ -43,6 +43,8 @@ Confirmed both directions against the built client. With the cap reverted, the r Reproducing the off-screen buttons needs a card taller than the scrollport, not merely a tall card. The composer seat is `position: sticky; bottom: 0`, so while the card still fits it stays pinned to the viewport bottom and the buttons remain visible — at 900x1000 the uncapped card ate the whole transcript yet kept its action row on screen. Only once the card outgrows the scrollport does sticky stop being able to hold the bottom edge, and the row goes under. -The geometry block and the goldens are replay-only, so record mode reaches the fixture write instead of aborting on layout. +The geometry block and the golden are replay-only, so record mode reaches the fixture write instead of aborting on layout. + +The scenario keeps exactly one golden — the waiting panel — and asserts the answered state on the world instead (the decided outcome, the file the escalated command wrote, `DONE`, the panel gone, the composer re-enabled). An answered-transcript golden was recorded first and failed on Linux CI: the denied first attempt renders the OS's own refusal, and that text is platform-specific (`bash: notes.txt: Operation not permitted` on macOS against `bash: line 1: notes.txt: Read-only file system` on Linux). Any scenario whose transcript contains a sandbox-denied command inherits that, so the denial belongs in assertions, never in a golden. The panel ships as a client-module bundle: `pnpm run build:web` alone does not pick up a change to `ApprovalPanel.module.css` or a new `data-` hook in `ApprovalPanel.tsx` — the package build must run first, or the browser lane asserts against an older client than the tree. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md index 7eb40942e1..c41589d07d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md @@ -45,4 +45,6 @@ Status: implemented 几何断言块与 golden 仅在回放模式下执行,这样录制模式才能走到写入 fixture 那一步,而不是在布局检查处中断。 +该场景只保留一份 golden —— 等待中的面板;回应之后的状态改为对世界作断言(决策结果、越权命令写出的那个文件、`DONE`、面板消失、输入框重新可用)。最初还录了一份"已回应会话流"的 golden,它在 Linux CI 上失败了:第一次被拒绝的尝试渲染的是操作系统自己的拒绝文本,而这段文本因平台而异(macOS 为 `bash: notes.txt: Operation not permitted`,Linux 为 `bash: line 1: notes.txt: Read-only file system`)。任何会话流中含有被沙箱拒绝命令的场景都会继承这一点,因此这类拒绝只能进断言,绝不能进 golden。 + 该面板以客户端模组包的形式发布:单跑 `pnpm run build:web` 不会带上对 `ApprovalPanel.module.css` 的改动,也不会带上 `ApprovalPanel.tsx` 中新增的 `data-` 钩子——必须先执行包构建,否则浏览器测试通道会对着一个比工作树更旧的客户端做断言。 diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index c9d5c493c6..2c6a78d2e5 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -29,10 +29,9 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/approval-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +// The scenario's one golden: the waiting panel. Everything the answered state +// proves is asserted directly — see the world-state block at the end. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') -// Second golden: the answered transcript — the granted escalation ran and the -// turn finished, the state the waiting golden cannot see. -const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') const MODE = webSnapshotMode() // Irreducible payload: the command has to be long enough to pass the card's @@ -160,19 +159,23 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { return } // World state: the granted escalation is what let the command run, and the - // panel leaves with the regular composer restored. + // panel leaves with the regular composer restored. Asserted on the world + // and the DOM rather than through a transcript golden — the denied first + // attempt renders the OS's own refusal ("Operation not permitted" on + // macOS, "Read-only file system" on Linux), so the answered transcript is + // not a platform-neutral golden surface. expect(JSON.stringify(sessionEvents.filter(e => e.type === 'approval/decided').at(-1))) .toContain('allowed-once') + const written = await readFile(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'), 'utf8') + expect(written).toContain(TOKENS.slice(0, 64)) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 20_000 }).toBeGreaterThanOrEqual(1) expect(await page.locator('[data-approval-key]').count()).toBe(0) await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) - const answered = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(ANSWERED_EXPECTED, answered, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 300_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/approval-composer/answered.expected.md b/apps/web/tests/snapshots/approval-composer/answered.expected.md deleted file mode 100644 index ac2e9b4941..0000000000 --- a/apps/web/tests/snapshots/approval-composer/answered.expected.md +++ /dev/null @@ -1,57 +0,0 @@ -- banner: - - navigation "Session hierarchy": - - button "Write a file named notes.txt" [disabled] - - tablist: - - tab "Chat" [selected] - - tab "Trajectory" -- img -- text: "/permission read-only Permission preset: read-only. Write a file named notes.txt in the workspace containing exactly this text on one line: tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh toktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokkfz tokqjy tokwnx. Use one bash command with the literal text inline. Then reply with the single word DONE and stop. {{clock}}" -- button "复制": - - img -- button "在新对话中分支": - - img -- button "编辑": - - img -- button "Think The user wants me to write a file named notes.txt with a specific line of text. Let me do this with a single bash command using echo.": - - img - - img - - text: Think The user wants me to write a file named notes.txt with a specific line of text. Let me do this with a single bash command using echo. -- img -- text: Bash Write notes.txt with the specified text 失败 workspace echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt 退出码 1 -- button "复制" -- text: "[stderr] bash: notes.txt: Operation not permitted [sandbox: file access denied under read-only mode] [sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]" -- button "Think The sandbox denied the file write. I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode) and provide a justification.": - - img - - img - - text: Think The sandbox denied the file write. I need to retry with sandbox_permissions set to "workspace-write" (the narrowest wider mode) and provide a justification. -- img -- text: Bash Write notes.txt with the specified text 已完成 workspace echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt -- button "复制" -- text: (no output) -- button "Think The file was written successfully. Let me verify it was created correctly.": - - img - - img - - text: Think The file was written successfully. Let me verify it was created correctly. -- img -- text: Read -- button "notes.txt" -- button "Think The file was created successfully with the exact text on one line as requested. Now I need to reply with the single word DONE.": - - img - - img - - text: Think The file was created successfully with the exact text on one line as requested. Now I need to reply with the single word DONE. -- paragraph: DONE -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} -- textbox "Message the agent" -- button "Add attachment": - - img -- 'button "Access mode, current: Read Only"': Read Only -- button "Plan mode off, press to turn on": Plan off -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img -- button "Send message" [disabled] -- text: 1 turns · 4 steps Tool call {{duration}} Cache hit 96% Input 27.4K tok · Output 1.9K tok From 12b55adf6b8063971c766947f59dca2c4cde465f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 18:35:20 +0800 Subject: [PATCH 26/37] fix(web): keyboard reach and resting metrics on the approval scroll region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the approval takeover: The scroll region is now a tab stop (tabIndex + named role="group"). The question composer's scroll body needs none — its option rows are focusable and pull the container along — but this one holds nothing but text, so a keyboard-only user could reach the buttons and never the command's tail, and approve what they could not finish reading. The action row's padding reproduces the 14px gap it had inside the body: the flex gap of 6 plus its 8px top margin, neither of which reaches it now that the row sits outside the scroll region. The resting card is unchanged again. --- .../2026-07-30-approval-panel-command-cap.i18n.yaml | 4 ++-- .../bug-fix/2026-07-30-approval-panel-command-cap.md | 2 ++ .../bug-fix/2026-07-30-approval-panel-command-cap.zh.md | 4 +++- apps/web/tests/snapshots/approval-composer/ui.expected.md | 3 ++- .../src/client/skeleton/ApprovalPanel.module.css | 8 +++++--- .../ui-conversation/src/client/skeleton/ApprovalPanel.tsx | 5 ++++- 6 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml index 148914309c..dbb3928f0c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.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/bug-fix/2026-07-30-approval-panel-command-cap.md -2026-07-30-approval-panel-command-cap.md: f16edd337a568bc5eb8e2f0d5ca04158a77f6cdf -2026-07-30-approval-panel-command-cap.zh.md: c41589d07dd617f61c89ce2182d274477ea78b86 +2026-07-30-approval-panel-command-cap.md: 941f7eda187f263f2d8af6aa643d493c92a3669b +2026-07-30-approval-panel-command-cap.zh.md: 939a700934f6467947028d988da9a694169e203e diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md index f16edd337a..941f7eda18 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.md @@ -16,6 +16,8 @@ The panel's justification and command move into one scroll region (`data-approva The cap is one value with two consumers, declared as `--dsh-composer-text-max-height: 336px` on `ConversationRoot`'s `.composerSeat` — the composer chain's only shared ancestor, since the fallback InputBar and an elected takeover render as siblings. `InputBar`'s mirror and the panel's scroll region both read it, so the seat cannot cap its two states differently: what the designer asked for ("unify it with the input box's max height") is now a fact of the stylesheet rather than a number repeated in two files. The region is `box-sizing: border-box` so the cap is its outer height, the same box the composer's draft area occupies. +The region is a tab stop (`tabIndex={0}`, named `role="group"`). Unlike the question composer's scroll body, whose option rows are focusable and pull the container along, this one holds nothing but text: without its own tab stop a keyboard-only user could reach the buttons and never the command's tail, and approve what they could not finish reading. + The panel's card rebinds `--dsh-scrollbar-thumb{,-hover}` to the l2 pair, as every scrolling surface on an elevated background must ([scrollbar contract](../../../../packages/client/ui-theme/src/styles/scrollbar.css)). ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md index c41589d07d..939a700934 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-approval-panel-command-cap.zh.md @@ -16,11 +16,13 @@ Status: implemented 这个上限是一个值、两个消费者,以 `--dsh-composer-text-max-height: 336px` 声明在 `ConversationRoot` 的 `.composerSeat` 上——它是 composer 链唯一的共同祖先,因为兜底的 InputBar 与被选中的接管面板是兄弟节点。`InputBar` 的 mirror 与面板的滚动区域都读取它,于是同一个容器不可能给它的两种状态设出不同上限:设计同学要求的"可以跟输入框最大高度统一",如今是样式表中的一个事实,而不是抄在两个文件里的一个数字。该区域取 `box-sizing: border-box`,因此上限指的是它的外框高度,与 composer 草稿区占据的是同一个盒子。 +该区域自身是一个 Tab 停靠点(`tabIndex={0}`,带名称的 `role="group"`)。提问 composer 的滚动体不需要这样做——它的选项行本身可聚焦,会把容器一起带过去;而这里除文本之外别无内容:没有自己的停靠点,仅用键盘的用户能走到按钮却走不到命令尾部,于是可能批准了自己没读完的东西。 + 面板卡片把 `--dsh-scrollbar-thumb{,-hover}` 重新绑定到 l2 那一对,这是每一个位于高层表面上的滚动区域都必须做的([滚动条约定](../../../../packages/client/ui-theme/src/styles/scrollbar.css))。 ## 曾考虑的替代方案 -**给整张卡片设上限,而不是给文本区域设。** 一条声明,不需要重构结构,而且它读起来就是字面意义上的"与输入框相同的最大高度"。之所以否决:卡片还装着状态条和操作按钮行——总高 336px 时,理由与命令只能分到约 250px,比它们所取代的草稿区更窄,而且两边数字能对上纯属状态条高度的巧合。给文本区域设上限,才能让两种状态在同一文本高度处收住,而这正是让底部不再跳动的那条性质。 +**给整张卡片设上限,而不是给文本区域设。** 一条声明,不需要重构结构,而且它读起来就是字面意义上的"与输入框相同的最大高度"。之所以否决:卡片还装着状态条和操作按钮行——总高 336px 时,理由与命令只能分到约 250px,比它们所取代的草稿区更矮,而且两边数字能对上纯属状态条高度的巧合。给文本区域设上限,才能让两种状态在同一文本高度处收住,而这正是让底部不再跳动的那条性质。 **像提问 composer 那样按视口设上限(`min(60vh, 520px)`)。** 同为接管面板的兄弟组件已经这么做了,因此这是本地既有先例。之所以否决:设计同学的要求是与 InputBar 对齐,而两个接管面板形态并不相同——提问 composer 的滚动内容是一组需要用户互相比较的选项,能占多少视口就该占多少;审批面板的滚动内容则是一条命令,用户在决定之前扫读即可。按视口设上限还会让容器高度在被选中时再次跳动,只是方向相反。 diff --git a/apps/web/tests/snapshots/approval-composer/ui.expected.md b/apps/web/tests/snapshots/approval-composer/ui.expected.md index 501462a777..469ca78790 100644 --- a/apps/web/tests/snapshots/approval-composer/ui.expected.md +++ b/apps/web/tests/snapshots/approval-composer/ui.expected.md @@ -1,3 +1,4 @@ -- text: "等待审批 escalate sandbox to workspace-write: Need to write the notes.txt file as requested by the user. echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt" +- text: 等待审批 +- group "审批详情": "escalate sandbox to workspace-write: Need to write the notes.txt file as requested by the user. echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt" - button "拒绝" - button "允许一次" diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css index 1d9a11f7b5..872620092f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css @@ -81,13 +81,15 @@ word-break: break-all; } -/* Card-level row, not body content: it carries the body's former bottom pad so - the resting card keeps the draft's metrics while the scroll cap applies. */ +/* Card-level row, not body content. Its padding reproduces the metrics the row + had inside the body: 14px above (the flex gap of 6 plus the row's 8px top + margin, neither of which reaches it out here) and the body's former 14px + bottom pad below, so the resting card is unchanged. */ .actionRow { display: flex; justify-content: flex-end; gap: 8px; - padding: 8px 16px 14px; + padding: 14px 16px 14px; } .allow, diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx index 715f9f292f..8ecd3c5350 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx @@ -57,7 +57,10 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
等待审批
-
+ {/* Tab stop: the region scrolls once the command passes the cap and + holds nothing focusable of its own, so without one a keyboard-only + user cannot reach the command's tail before answering. */} +
{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}
{command !== undefined &&
{command}
}
From e034a173d612894b53d128797e702407da815ee7 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 18:54:51 +0800 Subject: [PATCH 27/37] test(snapshot): re-record ACP goldens for the web card tag web_fetch now projects presentationMeta ({url, statusCode, truncated}) onto its tool/result, so the web-fetch scenario carries that meta; cordis-inspect-jsdoc shifts with the widened ToolResultView type surface. Model-facing text is unchanged. Refreshed keyless. --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/web-fetch/session.jsonl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index ea8dad9a96..80ca341065 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n content?: ContentBlock[];\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n content?: ContentBlock[];\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl index 396860773e..f93462e134 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl +++ b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":78,"time":1785078729804,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":79,"time":1785078729807,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."},{"type":"tool-call","id":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"58db7df1-5331-49ca-b34f-09c59d8d8c85"},"usage":{"inputTokens":5405,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":44}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"} {"type":"tool/call","seq":80,"time":1785078729809,"data":{"turn":1,"step":1,"callId":"call_00_sxjOyfDYN07koiE7jiIa5326","name":"web_fetch","arguments":"{\"url\": \"http://127.0.0.1:43117/menu.html\"}"}} -{"type":"tool/result","seq":81,"time":1785078729843,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_sxjOyfDYN07koiE7jiIa5326"},"content":[{"type":"tool-result","toolCallId":"call_00_sxjOyfDYN07koiE7jiIa5326","content":[{"type":"text","text":"Fetched http://127.0.0.1:43117/menu.html (HTTP 200)\n\nMenu\n\n# Café menu\n\nPrices include **service & _tax_** — updated daily.\n\n- Espresso\n- Flat white\n\n| Drink | Price |\n| --- | --- |\n| Espresso | €2 |\n| Flat white | €3 |\n\nSee [today’s specials](https://fixture.invalid/specials)."}],"isError":false}],"role":"user","id":"fca910ec-ed8f-45a9-8dda-1e88cfd41126"}},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"tool/result","seq":81,"time":1785078729843,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_sxjOyfDYN07koiE7jiIa5326"},"content":[{"type":"tool-result","toolCallId":"call_00_sxjOyfDYN07koiE7jiIa5326","content":[{"type":"text","text":"Fetched http://127.0.0.1:43117/menu.html (HTTP 200)\n\nMenu\n\n# Café menu\n\nPrices include **service & _tax_** — updated daily.\n\n- Espresso\n- Flat white\n\n| Drink | Price |\n| --- | --- |\n| Espresso | €2 |\n| Flat white | €3 |\n\nSee [today’s specials](https://fixture.invalid/specials)."}],"isError":false}],"role":"user","id":"fca910ec-ed8f-45a9-8dda-1e88cfd41126"},"meta":{"url":"http://127.0.0.1:43117/menu.html","statusCode":200,"truncated":false}},"sourceEventSeqs":[80],"surfaceOp":"append"} {"type":"step/end","seq":82,"time":1785078729847,"data":{"turn":1,"step":1}} {"type":"step/start","seq":83,"time":1785078729848,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":84,"time":1785078730611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} From 6b7987d813d9840c08290f04b4cb2f9e68a30b08 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 19:01:27 +0800 Subject: [PATCH 28/37] test(snapshot): re-apply web card type surface after master merge --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index c47cb8c89f..6c16bbfb2d 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n content?: ContentBlock[];\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n content?: ContentBlock[];\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From cc1bba31d8fbde19cd7d371c3c23f3bf377b7b79 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 20:01:41 +0800 Subject: [PATCH 29/37] fix(tool-web): align fetch card truncation, drop view content copies, sync card docs Address the code-review bot findings on the web result card: - web_fetch's card truncated now derives from the shared renderFetchOutput helper, matching the effective truncation the model-facing text reflects (provider cap, source cut, or output cap), instead of the provider-only flag. - Drop the redundant content copy from both web result views; a UI without the web capability falls back to the raw tool/result content. Narrow the TUI transcript view.content access accordingly. - Set the result-state title from the call args (query/url) so a window- truncated replay keeps a title. - Project meta from the seam result types rather than hand-rolled value types. - Sync the card vocabulary across core tools README, docs/core-data-structures, the adding-a-tool cookbook, and the tool-web package README (both languages, re-recorded pairings); regenerate the cordis api-catalog and cordis-inspect snapshot; revise the Agent Note. --- .../2026-07-30-web-result-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-result-card.md | 10 +-- .../feature/2026-07-30-web-result-card.zh.md | 10 +-- docs/cookbook/adding-a-tool.i18n.yaml | 6 +- docs/cookbook/adding-a-tool.md | 1 + docs/cookbook/adding-a-tool.zh.md | 1 + docs/core-data-structures/tools.i18n.yaml | 4 +- docs/core-data-structures/tools.md | 2 +- docs/core-data-structures/tools.zh.md | 2 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/presentation.ts | 51 ++++++----- packages/ui/tui/src/components/transcript.ts | 5 +- packages/web/tool-web/README.i18n.yaml | 4 +- packages/web/tool-web/README.md | 2 +- packages/web/tool-web/README.zh.md | 2 +- packages/web/tool-web/src/fetch.ts | 87 +++++++++++++------ packages/web/tool-web/src/index.ts | 2 +- packages/web/tool-web/src/search.ts | 34 +++----- packages/web/tool-web/tests/tool-web.spec.ts | 55 ++++++++---- 23 files changed, 175 insertions(+), 121 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml index 498f6557f8..ea105fa9a0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.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-07-30-web-result-card.md -2026-07-30-web-result-card.md: 675c93ebfda0d74b2809e5d12fb55df85020646e -2026-07-30-web-result-card.zh.md: be02cbbfa272590c43b088d194dda0dbfab7adc0 +2026-07-30-web-result-card.md: da8fc8162e4e52b76162c50c751d31ef6a9c3b1d +2026-07-30-web-result-card.zh.md: 286d9ed659dbea20858798723d9c040f551df0b3 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md index 675c93ebfd..da8fc8162e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md @@ -14,15 +14,15 @@ Add one `card: 'web'` result arm to `ToolResultView` (`packages/core/tools/src/p One tag with a `kind` discriminant, not two tags. Both calls are web retrieval and a web frontend renders them with one component family (a retrieval card whose body differs by kind), so a shared `card` keeps every card consumer's switch to one added arm and lets the frontend branch on `kind` inside it. Two tags would force every present and future consumer to add two arms for what is one visual family. The `kind` values match the two tools' existing generic call-view `kind`s, so a call and its result read as the same category. -`presentationMeta` is mandatory here, not a convenience. The structured result object a tool returns from `execute` does NOT reach a client over the wire — only the model-facing `render` text and, when declared, the `output.presentationMeta` JSON projected onto the `tool/result` event's `meta` do. Because the render text is lossy for `web_search`'s sources, projecting the sources through `presentationMeta` is the only faithful route to `{url, title?, snippet?, publishedAt?}` at the consumer. This mirrors the write/edit diff template (`packages/fs/tool-fs/src/diff.ts`): a `*MetaFromValue` projector feeds `output.presentationMeta`, and a `*MetaFromResult` narrower reads `result.meta` back with a defensive fallback to the generic card. `web_fetch`'s meta carries `url`/`statusCode`/`truncated` only; its body is already markdown in the result content, so it is not duplicated into meta. +`presentationMeta` carries what render text cannot. The structured result object a tool returns from `execute` does NOT reach a client over the wire — only the model-facing `render` text and, when declared, the `output.presentationMeta` JSON projected onto the `tool/result` event's `meta` do. For `web_search` the meta is the ONLY faithful route to `{url, title?, snippet?, publishedAt?}`: the render collapses those fields into one lossy free-text line, so a consumer cannot reparse them. For `web_fetch` the meta is a smaller but real gain: `url`/`statusCode` are recoverable from the deterministic `Fetched (HTTP )` header line, but `truncated` is the effective truncation — provider cap, pre-conversion source cut, or the deployment's `fetchMaxOutputChars` output cap — which a client cannot recompute because it does not know that cap. The fetch card and the model-facing text derive `truncated` from one shared `renderFetchOutput(result, maxOutputChars)` helper, so the card never disagrees with the footer the model saw. This mirrors the write/edit diff template (`packages/fs/tool-fs/src/diff.ts`): a `*MetaFromValue` projector feeds `output.presentationMeta`, and a `*MetaFromResult` narrower reads `result.meta` back with a defensive fallback to the generic card. `web_fetch`'s body is already markdown in the result content, so it is not duplicated into meta. -Each result view carries an optional `content?: ContentBlock[]` set to the model-facing result content. A UI without the `web` capability — including the TUI, whose transcript renderer has no `web` arm — renders that content through its existing generic/default path (`packages/ui/tui/src/components/transcript.ts`, `renderBody`'s `view.content ?? this.result?.content`), so the new tag needs no dedicated TUI arm and the TUI keeps compiling and rendering the text. +Neither result view carries a `content` copy. A UI without the `web` capability — including the TUI, whose transcript renderer has no `web` arm — falls back to the raw `tool/result` content through its existing generic/default path (`packages/ui/tui/src/components/transcript.ts`, `renderBody` narrows to `view.card === 'generic' ? view.content : undefined` then falls back to `this.result?.content`). Copying the result content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it and the fallback path renders the identical text. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time. `presentResult` returns `undefined` (the generic card) on an error result and on absent or malformed `meta`, because presentation runs on replay of arbitrary logged results (possibly from an older schema) and must never throw. The narrowers validate every field defensively; an empty source list is valid meta, not malformed. ## Consequences -The web frontend consumer is a separate later PR: this PR adds the contract arm and makes the two tools emit it, with no client-side rendering. Any existing `ToolResultView` consumer that switches exhaustively must add a `web` arm; the TUI does not switch exhaustively and needs none. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change. +The web frontend consumer is a separate later PR: this PR adds the contract arm and makes the two tools emit it, with no client-side rendering. The one observable change is that the `web_search`/`web_fetch` `tool/result` events now persist a `data.meta` payload (the `web-fetch` keyless snapshot is refreshed accordingly); the model-facing render text and the TUI presentation are unchanged (the TUI falls back to the same result content). The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer PR that renders it, delivered there. Any existing `ToolResultView` consumer that switches exhaustively must add a `web` arm; the TUI does not switch exhaustively and needs none. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change. A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag. @@ -32,11 +32,11 @@ A future web tool that wants this card declares `presentResult` returning a `car **Reparse the render text in `presentResult` instead of projecting meta.** Rejected for `web_search`: the render's source list is lossy (title-or-hostname label, snippet and date concatenated into free text), so reparsing cannot faithfully recover the structured fields. `presentationMeta` is the only route that preserves them. -**Carry the fetch body in meta too.** Rejected: the body is already the model-facing markdown in the result content, and duplicating it into meta would double the persisted payload for no gain; the view points a UI at the existing content. +**Carry the fetch body in meta, or copy the result content into either view.** Rejected: the body is already the model-facing markdown in the result content, and duplicating it into meta or into a view `content` field would double the persisted or delivered payload for no gain; a UI without the `web` capability falls back to the existing result content, which is the same text. ## Testing -`packages/web/tool-web/tests/tool-web.spec.ts` covers, per-file to the 100% gate: `searchMetaFromValue`/`fetchMetaFromValue` projection including omission of absent optional fields; `searchMetaFromResult`/`fetchMetaFromResult` narrowing with a round-trip and every malformed-shape rejection (non-object, wrong field types, a malformed source entry) plus the empty-source-list accept; `presentSearchResult`/`presentFetchResult` typed views including the truncated signal, the error-result fallback, and the malformed-meta fallback; and two real-registry executions asserting the tool projects the meta onto `result.meta` and its registered `presentResult` derives the `card: 'web'` view. +`packages/web/tool-web/tests/tool-web.spec.ts` covers, per-file to the 100% gate: `searchMetaFromValue`/`fetchMetaFromValue` projection including omission of absent optional fields, and the fetch `truncated` projection agreeing with the render footer both when only the output cap cut the body and when nothing did; `searchMetaFromResult`/`fetchMetaFromResult` narrowing with a round-trip and every malformed-shape rejection (non-object, wrong field types, a malformed source entry) plus the empty-source-list accept; `presentSearchResult`/`presentFetchResult` typed views including the args-derived title, the absence of a `content` copy, the truncated signal, the error-result fallback, and the malformed-meta fallback; and two real-registry executions asserting the tool projects the meta onto `result.meta` and its registered `presentResult` derives the `card: 'web'` view. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md index be02cbbfa2..286d9ed659 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md @@ -14,15 +14,15 @@ Status: implemented 采用一个标签加 `kind` 判别,而非两个标签。两个调用都是 web 检索,web 前端会用同一族组件渲染它们(一个检索卡片,正文按 kind 不同),因此共用一个 `card` 让每个 card 消费者的 switch 只需新增一个分支,并让前端在其内部按 `kind` 分岔。两个标签会迫使当前及未来每个消费者为本属同一视觉族的东西添加两个分支。这两个 `kind` 取值与两个工具既有的 generic 调用视图 `kind` 一致,因此一个调用与它的结果读起来是同一类别。 -`presentationMeta` 在这里是必需的,而非便利手段。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会。由于 render 文本对 `web_search` 的来源是有损的,经 `presentationMeta` 投影来源,是在消费端得到忠实 `{url, title?, snippet?, publishedAt?}` 的唯一途径。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的 meta 只携带 `url`/`statusCode`/`truncated`;其正文已是结果内容中的 markdown,因此不重复写入 meta。 +`presentationMeta` 携带 render 文本无法携带的东西。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会。对 `web_search`,meta 是得到 `{url, title?, snippet?, publishedAt?}` 的**唯一**忠实途径:render 把这些字段压进一行有损的自由文本,消费者无法重新解析。对 `web_fetch`,meta 是更小但真实的收益:`url`/`statusCode` 可从确定格式的 `Fetched (HTTP )` header 行还原,但 `truncated` 是有效截断——provider cap、转换前源截断,或部署的 `fetchMaxOutputChars` 输出上限——客户端无法重算,因为它不知道那个上限。抓取卡片与面向模型的文本都从同一个 `renderFetchOutput(result, maxOutputChars)` helper 派生 `truncated`,因此卡片绝不会与模型看到的脚注分叉。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的正文已是结果内容中的 markdown,因此不重复写入 meta。 -每个结果视图携带一个可选的 `content?: ContentBlock[]`,设为面向模型的结果内容。不具备 `web` 能力的 UI——包括其 transcript 渲染器没有 `web` 分支的 TUI——经由既有的 generic/默认路径渲染该内容(`packages/ui/tui/src/components/transcript.ts` 中 `renderBody` 的 `view.content ?? this.result?.content`),因此新标签无需专门的 TUI 分支,TUI 继续编译并渲染文本。 +两个结果视图都不携带 `content` 副本。不具备 `web` 能力的 UI——包括其 transcript 渲染器没有 `web` 分支的 TUI——经由既有的 generic/默认路径回退到原始 `tool/result` 内容(`packages/ui/tui/src/components/transcript.ts` 中 `renderBody` 先收窄为 `view.card === 'generic' ? view.content : undefined`,再回退到 `this.result?.content`)。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title`(`args.query`/`args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。 `presentResult` 在错误结果、以及 `meta` 缺失或畸形时返回 `undefined`(即 generic 卡片),因为 presentation 会在对任意已记录结果(可能来自旧 schema)的重放中运行,绝不能抛错。收窄器防御性地校验每个字段;空来源列表是有效 meta,而非畸形。 ## Consequences -web 前端消费者是一个独立的后续 PR:本 PR 新增契约分支并让两个工具发出它,不含客户端渲染。任何做穷尽 switch 的现有 `ToolResultView` 消费者都必须新增一个 `web` 分支;TUI 并不穷尽 switch,无需新增。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。 +web 前端消费者是一个独立的后续 PR:本 PR 新增契约分支并让两个工具发出它,不含客户端渲染。唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件现在持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照随之刷新);面向模型的 render 文本与 TUI 呈现不变(TUI 回退到相同的结果内容)。渲染 `web` 卡片的组装应用 transcript 快照属于渲染它的消费者 PR,在那里交付。任何做穷尽 switch 的现有 `ToolResultView` 消费者都必须新增一个 `web` 分支;TUI 并不穷尽 switch,无需新增。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。 未来想用此卡片的 web 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。 @@ -32,11 +32,11 @@ web 前端消费者是一个独立的后续 PR:本 PR 新增契约分支并让 **在 `presentResult` 里重新解析 render 文本,而非投影 meta。** 对 `web_search` 否决:render 的来源列表是有损的(title 或 hostname 标签,snippet 与日期拼进自由文本),因此重新解析无法忠实恢复结构化字段。`presentationMeta` 是唯一保留它们的途径。 -**把抓取正文也放进 meta。** 否决:正文已是结果内容中面向模型的 markdown,把它复制进 meta 会为无收益的目的翻倍持久化载荷;视图让 UI 指向既有内容。 +**把抓取正文放进 meta,或把结果内容复制进任一视图。** 否决:正文已是结果内容中面向模型的 markdown,把它复制进 meta 或视图的 `content` 字段会为无收益的目的翻倍持久化或投递载荷;不具备 `web` 能力的 UI 回退到既有的结果内容,那是相同的文本。 ## Testing -`packages/web/tool-web/tests/tool-web.spec.ts` 覆盖以下内容,满足按文件 100% 的门禁:`searchMetaFromValue`/`fetchMetaFromValue` 投影,含对缺席可选字段的省略;`searchMetaFromResult`/`fetchMetaFromResult` 收窄,含一次往返与每种畸形形状的拒绝(非对象、字段类型错误、畸形来源条目)以及空来源列表的接受;`presentSearchResult`/`presentFetchResult` 类型化视图,含 truncated 信号、错误结果回退与畸形 meta 回退;以及两次真实注册表执行,断言工具把 meta 投影到 `result.meta` 上,其注册的 `presentResult` 推导出 `card: 'web'` 视图。 +`packages/web/tool-web/tests/tool-web.spec.ts` 覆盖以下内容,满足按文件 100% 的门禁:`searchMetaFromValue`/`fetchMetaFromValue` 投影,含对缺席可选字段的省略,以及抓取 `truncated` 投影在仅输出上限截断正文时、以及在毫无截断时都与 render 脚注一致;`searchMetaFromResult`/`fetchMetaFromResult` 收窄,含一次往返与每种畸形形状的拒绝(非对象、字段类型错误、畸形来源条目)以及空来源列表的接受;`presentSearchResult`/`presentFetchResult` 类型化视图,含从参数派生的 title、无 `content` 副本、truncated 信号、错误结果回退与畸形 meta 回退;以及两次真实注册表执行,断言工具把 meta 投影到 `result.meta` 上,其注册的 `presentResult` 推导出 `card: 'web'` 视图。 ## Related diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 423737be39..29ad71c634 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -adding-a-tool.md: d06e3d8e3c7da1f71a55bf9c4f56cd4b2cc03697 -adding-a-tool.zh.md: 53f608eba3b26b124f873990fa13ce1572c0baf2 +# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md +adding-a-tool.md: a85de0feeeee307ac645f8c2967bb44521d059a8 +adding-a-tool.zh.md: 8e4e6a1128f4f2ad3b4d42c2b88edaf4d8d89af1 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index d06e3d8e3c..a85de0feee 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -78,6 +78,7 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha - `generic` supplies an optional title and content. - `terminal` supplies raw output and optional exit metadata; each UI renders its capable or fallback view. - `diff` supplies applied hunks, often derived by `output.presentationMeta` and carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because the completed view replaces the pending card. + - `web` supplies a completed web retrieval, discriminated by `kind: 'search' | 'fetch'` (the structured search sources or the fetch summary), derived from `result.meta`; it carries no body copy, so a UI without the `web` capability falls back to the raw result content. (tool-web `web_search`/`web_fetch`.) Hard rules (they bite if broken): diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 53f608eba3..8e4e6a1128 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -78,6 +78,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 - `generic` 提供可选的标题和内容。 - `terminal` 提供原始输出和可选的退出元数据;各 UI 根据自身能力渲染对应视图或回退视图。 - `diff` 提供已应用的 hunk,通常由 `output.presentationMeta` 派生并通过持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为完成后的视图会替换 pending 卡片。 + - `web` 提供已完成的 web 检索,以 `kind: 'search' | 'fetch'` 区分(结构化的搜索来源或抓取摘要),由 `result.meta` 派生;它不携带正文副本,因此不具备 `web` 能力的 UI 回退到原始结果内容。(tool-web `web_search`/`web_fetch`。) 硬性规则(违反会出问题): diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index fa49f46c59..a6cf2bae68 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/tools.md -tools.md: dad7f7421caa94940801407fd4ef7fd936eb05c9 -tools.zh.md: 8386e5870e665e90ee0dbada8cb98084281001a7 +tools.md: 3c94f1093001e8c65365cc5baa50c1393d53b3ee +tools.zh.md: 7a6aad81c4cfe83be8625411e4313d0c36018821 diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index dad7f7421c..3c94f10930 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -447,7 +447,7 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on: - `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file). -- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), or `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet. +- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet. `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`) and `FileDiff` (`{ path, oldText, newText }`) are the shared file-card vocabulary. The design is pinned in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); the TUI and host/client runtime project this neutral vocabulary into their own views. diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 8386e5870e..7a6aad81c4 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -447,7 +447,7 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } 工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI(命令行界面)日志行),提供方无关,使工具在不依赖任何客户端协议的情况下描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发: - `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。 -- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、或 `{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果。 +- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 UI 回退到原始结果内容——正文不会重复进视图)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果。 `ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)与 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;TUI 和 host/client 运行时将这套中性词汇投影为各自的视图。 diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 6c16bbfb2d..3d5114db41 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n content?: ContentBlock[];\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n content?: ContentBlock[];\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 6bb4557ae6..3490248aaf 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2805,7 +2805,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WebFetchResultView', - declaration: 'export interface WebFetchResultView {\n card: \'web\';\n kind: \'fetch\';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n content?: ContentBlock[];\n}', + declaration: 'export interface WebFetchResultView {\n card: \'web\';\n kind: \'fetch\';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n}', }, { name: 'WebResultView', @@ -2833,7 +2833,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WebSearchResultView', - declaration: 'export interface WebSearchResultView {\n card: \'web\';\n kind: \'search\';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n content?: ContentBlock[];\n}', + declaration: 'export interface WebSearchResultView {\n card: \'web\';\n kind: \'search\';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n}', }, { name: 'WebSearchSource', diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 429e76ed6a..e888160b0f 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: e5adb153e77d7a2d8c4068b016194ab6abb6473e -README.zh.md: c67a2f2ee4ac2a9d587c6efbf2b5c60d14fc58c2 +README.md: e7f395f8c1d6417db856e590f5267cf6887e4d12 +README.zh.md: acb4c047bf86e36c828882ff751d4be1f627f99e diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index e5adb153e7..e7f395f8c1 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names: - Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. -- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`. +- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content). Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index c67a2f2ee4..acb4c047bf 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -108,7 +108,7 @@ ctx.tools.register(defineTool({ 工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称: - 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。 -- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }` 或 `{ card: 'diff', title?, diffs }`。 +- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }` 或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。 返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。 diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index f73ddb06d2..d1e7d552b5 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -179,11 +179,11 @@ export interface DiffResultView { /** * One citeable source in a completed {@link WebSearchResultView}, the faithful - * projection of one web-search source. The render text a web tool returns is - * lossy — its markdown list collapses `title`/`snippet`/`publishedAt` into one - * free-text line and labels a source by title OR hostname — so a UI cannot - * reliably recover these fields by reparsing that text. A tool therefore - * projects this structured shape through `output.presentationMeta`, and its + * projection of one web-search source. The presentation projection of `dsh-web`'s + * `WebSearchSource`: that seam type is the authoritative shape (core cannot depend + * on the web seam, so the two are declared separately and MUST evolve together). + * A web tool projects this shape through `output.presentationMeta` because the + * render text cannot losslessly carry it (see the web-result-card Agent Note); its * `presentResult` reads it back. */ export interface WebSource { @@ -202,18 +202,25 @@ export interface WebSource { * by a web tool whose call retrieves from the web (`web_search`, `web_fetch`). * One `kind`-tagged union carries both shapes because both are web retrieval and * a UI renders them with one component family; a UI switches on `kind`. An - * incapable UI falls back to `content` (the reformatted model-facing text). This - * is the result-time analogue of the `web_search`/`web_fetch` calls' generic - * call views (`kind: 'search'`/`'fetch'`); those tools keep their generic - * pending card and add only this completed card. + * incapable UI falls back to the raw `tool/result` content (this view carries no + * `content` copy — see the web-result-card Agent Note). This is the result-time + * analogue of the `web_search`/`web_fetch` calls' generic call views + * (`kind: 'search'`/`'fetch'`); those tools keep their generic pending card and + * add only this completed card. + * + * The `kind` field here is this union's own discriminant, NOT a + * {@link ToolCallKind}: the two values deliberately match the tools' pending + * `ToolCallKind` (`'search'`/`'fetch'`) so a call and its result read as one + * category, but a new arm is a union edit plus a consumer branch, not any + * arbitrary `ToolCallKind` value. */ export type WebResultView = WebSearchResultView | WebFetchResultView /** * The completed state of a `web_search` call: the structured sources the model * cited, an optional provider answer, and whether the source list was cut to the - * result cap. A capable UI renders the sources as a citation list; an incapable - * UI renders `content`. + * result cap. A capable UI renders the sources as a citation list; a UI without + * the `web` capability falls back to the raw `tool/result` content. */ export interface WebSearchResultView { card: 'web' @@ -224,21 +231,15 @@ export interface WebSearchResultView { sources: WebSource[] /** The provider-generated answer text, when any. */ answer?: string - /** True when the tool cut the source list to its result cap. */ + /** True when the seam cut the source list to honor the result cap. */ truncated: boolean - /** - * UI-facing fallback content (harness {@link ContentBlock}s), reformatted from - * the model-facing result. A UI without the `web` capability renders this. - * Omit to let the UI render the raw result content. - */ - content?: ContentBlock[] } /** * The completed state of a `web_fetch` call: the fetched URL, its HTTP status, * and whether the content was cut. The body itself is already markdown in the - * result content, so this card carries the retrieval summary and leaves the body - * to `content`. + * raw `tool/result` content, so this card carries only the retrieval summary and + * a UI without the `web` capability falls back to that content. */ export interface WebFetchResultView { card: 'web' @@ -249,12 +250,10 @@ export interface WebFetchResultView { url: string /** HTTP status code of the fetched response. */ statusCode: number - /** True when the provider or the output cap cut the content. */ - truncated: boolean /** - * UI-facing fallback content (harness {@link ContentBlock}s): the already-markdown - * body. A UI without the `web` capability renders this. Omit to let the UI - * render the raw result content. + * True when the provider capped the decoded body, or the output cap or a + * pre-conversion source cut trimmed the rendered text (the effective + * truncation the model-facing text also reflects). */ - content?: ContentBlock[] + truncated: boolean } diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 58d3d6a178..20ac234799 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -502,7 +502,10 @@ export class ToolCardComponent implements Component { // rather than under the dim result-output color. return { prelude: [...hunks, footer], lines: [] } } - const content = view.content ?? this.result?.content + // The web card carries no `content` copy, so a `web` result view falls back + // to the raw result content here (`view.card === 'generic'` narrows the union, + // mirroring line 392). + const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content const prelude: string[] = [] const lines: string[] = [] // The presenter title headlines the body now that the header is a fixed diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml index d239e581fa..4a853a1fa8 100644 --- a/packages/web/tool-web/README.i18n.yaml +++ b/packages/web/tool-web/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/web/tool-web/README.md -README.md: 9b78920b1b6c611118294421dec1e75e381ed5d6 -README.zh.md: d36258d3a5bd8af6716e1fd9c3384389e8395e23 +README.md: 7bee0d2d30fbbcf582fd7b60eb5d9130b6bdf888 +README.zh.md: 3d708839c9ffbdd89df08678fd6997fc6c45ee07 diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 9b78920b1b..7bee0d2d30 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam. +The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam. Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md index d36258d3a5..3d708839c9 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 `presentCall`。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。 +面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。 每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。 diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 246505adb5..293ce1db76 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -246,26 +246,52 @@ function renderBody(body: WebFetchBody, maxInputChars: number): RenderedBody { /** The truncation notice appended when the provider or the output cap cut content. */ const TRUNCATION_FOOTER = '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' +/** A rendered fetch output: the model-facing text and its effective truncation. */ +interface RenderedFetch { + /** The complete bounded output — header, rendered body, and truncation footer. */ + text: string + /** + * True when the provider capped the body, a pre-conversion source cut applied, + * or the complete output exceeded `maxOutputChars`. This is the effective + * truncation the returned text reflects (its footer), wider than the + * provider-only `WebFetchResult.truncated`. + */ + truncated: boolean +} + /** - * Format a fetch result as one model-facing text block, bounded as a whole. - * The same cap limits the source prefix processed synchronously, then applies - * again where the complete output — header, rendered body, and footer — is known. + * Render a fetch result to its bounded model-facing text and effective + * truncation. The single source of both the `render` text and the fetch card's + * `truncated`, so the card never disagrees with the text the model saw. The cap + * limits the source prefix processed synchronously, then applies again where the + * complete output — header, rendered body, and footer — is known. * * @param result - the seam's fetch outcome. * @param maxOutputChars - cap on the complete returned string; a cut body gets * the same fetch-something-narrower notice as provider-side truncation. - * @returns a `Fetched (HTTP )` header, the rendered body, and a - * truncation notice when the provider or the cap cut the content. + * @returns the complete `Fetched (HTTP )`-headed text and whether + * the provider, a source cut, or the cap trimmed the content. */ -export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string { +export function renderFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch { const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n` const rendered = renderBody(result.body, maxOutputChars) const prefix = `${header}${rendered.text}` const truncated = result.truncated || rendered.sourceTruncated || prefix.length > maxOutputChars const full = `${prefix}${truncated ? TRUNCATION_FOOTER : ''}` - if (full.length <= maxOutputChars) return full - if (maxOutputChars < TRUNCATION_FOOTER.length) return full.slice(0, maxOutputChars) - return `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}` + if (full.length <= maxOutputChars) return { text: full, truncated } + if (maxOutputChars < TRUNCATION_FOOTER.length) return { text: full.slice(0, maxOutputChars), truncated } + return { text: `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`, truncated } +} + +/** + * Format a fetch result as one model-facing text block, bounded as a whole. + * + * @param result - the seam's fetch outcome. + * @param maxOutputChars - cap on the complete returned string. + * @returns the complete text from {@link renderFetchOutput}. + */ +export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string { + return renderFetchOutput(result, maxOutputChars).text } /** @@ -284,33 +310,34 @@ export function presentFetchCall(args: { url: string }): GenericCallView { * header line. Attached opaquely (as `JsonValue`) on the tool result and * persisted with the session log, so `presentResult` reproduces the fetch card * on replay. The body itself is already markdown in the result content, so it is - * not duplicated here. + * not duplicated here. `truncated` is the effective truncation the render text + * reflects, which a client cannot recompute (it does not know the deployment's + * `fetchMaxOutputChars`); this is why fetch meta is carried, not derived from the + * header line (see the web-result-card Agent Note). */ export interface WebFetchMeta { /** The final URL after allowed redirects. */ url: string /** HTTP status code of the fetched response. */ statusCode: number - /** True when the provider or the output cap cut the content. */ - truncated: boolean -} - -/** The `web_fetch` canonical output value projected into presentation meta. */ -type WebFetchValue = { - url: string - statusCode: number + /** True when the provider, a source cut, or the output cap trimmed the content. */ truncated: boolean } /** * Project a validated `web_fetch` output value into its replayable presentation - * meta ({@link WebFetchMeta} as opaque JSON). + * meta ({@link WebFetchMeta} as opaque JSON). `truncated` is the effective + * truncation the model-facing text reflects (via {@link renderFetchOutput}), not + * the provider-only `WebFetchResult.truncated`, so the fetch card never disagrees + * with the returned text. * - * @param value - the canonical `web_fetch` output value. - * @returns the URL, status code, and truncation flag. + * @param value - the canonical `web_fetch` output value (the seam's result shape). + * @param maxOutputChars - the deployment's output cap, the same one + * {@link formatFetchOutput} applies to the render text. + * @returns the URL, status code, and effective truncation flag. */ -export function fetchMetaFromValue(value: WebFetchValue): JsonValue { - return { url: value.url, statusCode: value.statusCode, truncated: value.truncated } +export function fetchMetaFromValue(value: WebFetchResult, maxOutputChars: number): JsonValue { + return { url: value.url, statusCode: value.statusCode, truncated: renderFetchOutput(value, maxOutputChars).truncated } } /** @@ -330,23 +357,27 @@ export function fetchMetaFromResult(meta: unknown): WebFetchMeta | undefined { /** * Completed-call presentation: a `web` fetch card carrying the retrieval summary - * from `meta` alongside the already-markdown body as fallback content. + * from `meta`. It sets no `content` copy — a UI without the `web` capability + * falls back to the raw `tool/result` content, the already-markdown body (see the + * web-result-card Agent Note). * + * @param args - the raw tool arguments; `url` becomes the result-state title so a + * window-truncated replay that dropped the call head still has one. * @param result - the final model-facing tool result; `meta` carries the summary. * @returns the fetch result view, or `undefined` (generic card) on failure or * malformed meta. */ -export function presentFetchResult(result: ToolResult): WebFetchResultView | undefined { +export function presentFetchResult(args: { url: string }, result: ToolResult): WebFetchResultView | undefined { if (result.isError) return undefined const meta = fetchMetaFromResult(result.meta) if (meta === undefined) return undefined return { card: 'web', kind: 'fetch', + title: args.url, url: meta.url, statusCode: meta.statusCode, truncated: meta.truncated, - content: result.content, } } @@ -405,7 +436,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar }, }, render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }], - presentationMeta: (_args, value) => fetchMetaFromValue(value), + presentationMeta: (_args, value) => fetchMetaFromValue(value, maxOutputChars), }, timeoutMs, // Provider reads do not mutate parent-agent state. @@ -424,6 +455,6 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar } }, presentCall: presentFetchCall, - presentResult: (_args, result) => presentFetchResult(result), + presentResult: (args, result) => presentFetchResult(args, result), })) } diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 397e2bf7bb..f9236ecc4f 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -14,7 +14,7 @@ import { applyWebFetchTool } from './fetch.ts' export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult } from './search.ts' export type { WebSearchMeta } from './search.ts' -export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts' +export { applyWebFetchTool, formatFetchOutput, renderFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts' export type { WebFetchMeta } from './fetch.ts' /** Cordis plugin name used by loader diagnostics. */ diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 792adcc5e3..95016af29f 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -88,36 +88,27 @@ export function presentSearchCall(args: { query: string }): GenericCallView { * The `web_search` tool's private `tool/result` `meta` payload: the structured * sources, the optional provider answer, and the truncation flag. Attached * opaquely (as `JsonValue`) on the tool result and persisted with the session - * log, so `presentResult` reproduces the search card on replay. The render text - * is lossy — its markdown source list collapses each source's title, snippet, - * and date into one free-text line labelled by title OR hostname — so reparsing - * that text cannot recover the per-source fields; this projection is the only - * faithful route to them. + * log, so `presentResult` reproduces the search card on replay. This projection + * is the only faithful route to the per-source fields, which the lossy render + * text cannot carry (the owning rationale is the web-result-card Agent Note). */ export interface WebSearchMeta { /** The faithful structured sources, in result order. */ sources: WebSource[] - /** True when the tool cut the source list to its result cap. */ + /** True when the seam cut the source list to honor the result cap. */ truncated: boolean /** The provider-generated answer text, when any. */ answer?: string } -/** The `web_search` canonical output value projected into presentation meta. */ -type WebSearchValue = { - content?: string - sources: readonly WebSource[] - truncated: boolean -} - /** * Project a validated `web_search` output value into its replayable * presentation meta ({@link WebSearchMeta} as opaque JSON). * - * @param value - the canonical `web_search` output value. + * @param value - the canonical `web_search` output value (the seam's result shape). * @returns the structured sources, the truncation flag, and the answer when present. */ -export function searchMetaFromValue(value: WebSearchValue): JsonValue { +export function searchMetaFromValue(value: WebSearchResult): JsonValue { return { sources: value.sources.map(source => ({ url: source.url, @@ -163,24 +154,27 @@ export function searchMetaFromResult(meta: unknown): WebSearchMeta | undefined { /** * Completed-call presentation: a `web` search card carrying the faithful - * structured sources from `meta` alongside the model-facing text as fallback - * content. + * structured sources from `meta`. It sets no `content` copy — a UI without the + * `web` capability falls back to the raw `tool/result` content, which is the + * same text (see the web-result-card Agent Note). * + * @param args - the raw tool arguments; `query` becomes the result-state title so + * a window-truncated replay that dropped the call head still has one. * @param result - the final model-facing tool result; `meta` carries the sources. * @returns the search result view, or `undefined` (generic card) on failure or * malformed meta. */ -export function presentSearchResult(result: ToolResult): WebSearchResultView | undefined { +export function presentSearchResult(args: { query: string }, result: ToolResult): WebSearchResultView | undefined { if (result.isError) return undefined const meta = searchMetaFromResult(result.meta) if (meta === undefined) return undefined return { card: 'web', kind: 'search', + title: args.query, sources: meta.sources, truncated: meta.truncated, ...meta.answer !== undefined ? { answer: meta.answer } : {}, - content: result.content, } } @@ -254,6 +248,6 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: } }, presentCall: presentSearchCall, - presentResult: (_args, result) => presentSearchResult(result), + presentResult: (args, result) => presentSearchResult(args, result), })) } diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 9fc90396a8..c28fa65352 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -140,35 +140,36 @@ describe('web_search presentation meta and result view', () => { }) }) - it('presents a completed search as a web/search card carrying the structured sources and fallback content', () => { + it('presents a completed search as a web/search card carrying the structured sources, titled by the query', () => { const meta = searchMetaFromValue({ content: 'an answer', truncated: true, sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], }) - expect(presentSearchResult(toolResult(meta, 'rendered'))).toEqual({ + expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'rendered'))).toEqual({ card: 'web', kind: 'search', + title: 'q', answer: 'an answer', truncated: true, sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }], - content: [{ type: 'text', text: 'rendered' }], }) }) it('omits the answer from the view when meta carries none', () => { const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] }) - const view = presentSearchResult(toolResult(meta)) + const view = presentSearchResult({ query: 'q' }, toolResult(meta)) expect(view).toBeDefined() expect(view && 'answer' in view).toBe(false) + expect(view && 'content' in view).toBe(false) }) it('falls back to the generic card on an error result', () => { const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] }) - expect(presentSearchResult(toolResult(meta, 'body', true))).toBeUndefined() + expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'body', true))).toBeUndefined() }) it('falls back to the generic card on absent or malformed meta', () => { - expect(presentSearchResult(toolResult(undefined))).toBeUndefined() + expect(presentSearchResult({ query: 'q' }, toolResult(undefined))).toBeUndefined() expect(searchMetaFromResult(undefined)).toBeUndefined() expect(searchMetaFromResult(null)).toBeUndefined() expect(searchMetaFromResult('nope')).toBeUndefined() @@ -358,30 +359,54 @@ describe('fetch formatting', () => { }) describe('web_fetch presentation meta and result view', () => { - it('projects url, status, and truncation into meta', () => { - expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true })) + const NO_CAP = 1_000_000 + + it('projects url, status, and the provider truncation into meta', () => { + expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true, body: { kind: 'text', content: 'x' } }, NO_CAP)) .toEqual({ url: 'https://a.test', statusCode: 404, truncated: true }) }) - it('presents a completed fetch as a web/fetch card carrying the summary and the markdown body as fallback content', () => { - const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false }) - expect(presentFetchResult(toolResult(meta, '# Title'))).toEqual({ + it('projects truncated: true when the output cap cut a body the provider did not, matching the render footer', () => { + // The provider reports truncated: false, but conversion outgrows the cap, so + // the render text carries the truncation footer. The meta must agree. + const value = { + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html' as const, content: `

${'_'.repeat(1000)}

` }, + } + const meta = fetchMetaFromValue(value, 500) as { truncated: boolean } + expect(meta.truncated).toBe(true) + expect(formatFetchOutput(value, 500)).toContain('Content truncated') + }) + + it('projects truncated: false when neither the provider nor the cap cut the body', () => { + const value = { + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'text' as const, content: 'short' }, + } + const meta = fetchMetaFromValue(value, NO_CAP) as { truncated: boolean } + expect(meta.truncated).toBe(false) + expect(formatFetchOutput(value, NO_CAP)).not.toContain('Content truncated') + }) + + it('presents a completed fetch as a web/fetch card carrying the summary, titled by the url, without content', () => { + const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: '# Title' } }, NO_CAP) + expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, '# Title'))).toEqual({ card: 'web', kind: 'fetch', + title: 'https://a.test', url: 'https://a.test', statusCode: 200, truncated: false, - content: [{ type: 'text', text: '# Title' }], }) }) it('falls back to the generic card on an error result', () => { - const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false }) - expect(presentFetchResult(toolResult(meta, 'body', true))).toBeUndefined() + const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: 'ok' } }, NO_CAP) + expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, 'body', true))).toBeUndefined() }) it('falls back to the generic card on absent or malformed meta', () => { - expect(presentFetchResult(toolResult(undefined))).toBeUndefined() + expect(presentFetchResult({ url: 'https://a.test' }, toolResult(undefined))).toBeUndefined() expect(fetchMetaFromResult(undefined)).toBeUndefined() expect(fetchMetaFromResult(null)).toBeUndefined() expect(fetchMetaFromResult('nope')).toBeUndefined() From 990d1bbc35b6497273a1302f1c390bb539894a33 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 20:25:13 +0800 Subject: [PATCH 30/37] fix(cli): declare the config-plane providers the shared base config mounts The master merge moved the TUI composition into `apps/cli/config/base.cordis.yml` and I carried the `settings-local` / `credentials-local` rows across without adding them to the resolver manifest. Bare specifiers in an app config resolve through that manifest's dependencies, so the whole tree failed to boot: dsh: plugin(s) failed to load: @deepseek-ai/dsh-settings-local, @deepseek-ai/dsh-credentials-local which took every TUI PTY smoke with it. `verify-cordis-config` did not catch it, so the boot smoke was the first signal. --- apps/cli/package.json | 4 +++- pnpm-lock.yaml | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index e3d1976c52..1027deb25a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", @@ -71,7 +72,6 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", @@ -81,6 +81,7 @@ "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", + "@deepseek-ai/dsh-settings-local": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", @@ -98,6 +99,7 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fe5bfb83c..fd4d07a7b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:^ + version: link:../../packages/credentials/credentials-local '@deepseek-ai/dsh-frontend': specifier: workspace:^ version: link:../web @@ -315,6 +318,9 @@ importers: '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:^ version: link:../../packages/session-title/session-title-first-message-llm + '@deepseek-ai/dsh-settings-local': + specifier: workspace:^ + version: link:../../packages/settings/settings-local '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill From 689dcfe7d59d9f1300d043f7613685734ea998a9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 20:48:19 +0800 Subject: [PATCH 31/37] fix(web-presenter): dim-Markdown web fallback, memoize fetch conversion, strip note residue Route a `web` result card's raw-content fallback through the TUI's dim Markdown path (render() only recognized `card: 'generic'` as markdown content, so web fallback rendered as bare undimmed text). Memoize renderFetchOutput per (result, maxOutputChars) so the registry's twin output.render / output.presentationMeta calls on the same frozen result run one HTML->markdown conversion instead of two. Remove the trailing ``/`` protocol residue from both sides of the web-result-card Agent Note and re-record the pairing. --- .../2026-07-30-web-result-card.i18n.yaml | 4 +-- .../feature/2026-07-30-web-result-card.md | 2 -- .../feature/2026-07-30-web-result-card.zh.md | 1 - packages/ui/tui/src/components/transcript.ts | 20 ++++++++---- packages/ui/tui/tests/tui.spec.ts | 22 +++++++++++++ packages/web/tool-web/src/fetch.ts | 31 +++++++++++++++++++ packages/web/tool-web/tests/tool-web.spec.ts | 21 +++++++++++++ 7 files changed, 90 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml index ea105fa9a0..b792018613 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.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-07-30-web-result-card.md -2026-07-30-web-result-card.md: da8fc8162e4e52b76162c50c751d31ef6a9c3b1d -2026-07-30-web-result-card.zh.md: 286d9ed659dbea20858798723d9c040f551df0b3 +2026-07-30-web-result-card.md: 3c1f3e69e612d76b959af3304203dcc4ad125019 +2026-07-30-web-result-card.zh.md: e8a3f38d51f4dc81362f5090ff553cfaff21823c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md index da8fc8162e..3c1f3e69e6 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md @@ -42,5 +42,3 @@ A future web tool that wants this card declares `presentResult` returning a `car - [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `web` arm. - [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent that carried the bash `terminal` render intent to the browser; the web frontend consumer of this arm is its analogue, deferred to a later PR. - - diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md index 286d9ed659..e8a3f38d51 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md @@ -42,4 +42,3 @@ web 前端消费者是一个独立的后续 PR:本 PR 新增契约分支并让 - [标签化的工具调用渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md) —— 本卡片以 `web` 分支扩展的 `card` 标签词汇表。 - [Web terminal card](2026-07-28-web-terminal-card.md) —— 把 bash `terminal` 渲染意图带到浏览器的先例;本分支的 web 前端消费者是它的对应物,推迟到后续 PR。 - diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 20ac234799..04f35f78c4 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -389,10 +389,17 @@ export class ToolCardComponent implements Component { const glyph = this.result === undefined ? '○' : '●' const rawBody = this.renderBody() const view = this.resultView ?? this.callView - const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined - const unknownXml = this.definition === undefined && genericContent !== undefined + // A generic card's own content, or a web card's fallback to the raw result + // content (the `web` view carries no `content` copy), both render as one dim + // Markdown block below, so links/lists/headings keep the unified dim styling + // rather than reading as bare text. Terminal and diff cards own their body + // styling, so they are excluded (mirrors renderBody's fallback at line 511). + const markdownContent = view.card === 'generic' + ? view.content ?? this.result?.content + : view.card === 'web' ? this.result?.content : undefined + const unknownXml = this.definition === undefined && markdownContent !== undefined ? renderUnknownXml( - displayText(contentText(genericContent)), + displayText(contentText(markdownContent)), this.maxOutputLines, this.visibility === 'expanded', displayText, @@ -405,7 +412,7 @@ export class ToolCardComponent implements Component { // A generic card renders title and result as one Markdown document, so the // document's own block spacing is preserved, then dims every row — the whole // card body reads as one dim block under the status-colored header. - const body = unknownXml ?? (genericContent !== undefined && rawBody.lines.length > 0 + const body = unknownXml ?? (markdownContent !== undefined && rawBody.lines.length > 0 ? this.dimBody(rawBody, width) : [...rawBody.prelude, ...rawBody.lines]) const visibleBody = unknownXml !== undefined || this.visibility === 'expanded' @@ -503,8 +510,9 @@ export class ToolCardComponent implements Component { return { prelude: [...hunks, footer], lines: [] } } // The web card carries no `content` copy, so a `web` result view falls back - // to the raw result content here (`view.card === 'generic'` narrows the union, - // mirroring line 392). + // to the raw result content here (`view.card === 'generic'` narrows the + // generic union arm; a `web` card takes the same fallback, mirroring the + // `markdownContent` selection in render()). const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content const prelude: string[] = [] const lines: string[] = [] diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index d99780dedb..3ad808fd74 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4375,6 +4375,14 @@ describe('tool cards and surface replay', () => { name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Known XML' }), }, + // A web card carries no `content` copy, so it falls back to the raw result + // content, which must still render through the dim Markdown path (bold + // markers stripped) rather than as bare text. + webCard: { + name: 'webCard', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'Fetch page', kind: 'fetch' }), + presentResult: () => ({ card: 'web', kind: 'fetch', title: 'https://a.test', url: 'https://a.test', statusCode: 200, truncated: false }), + }, } it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => { @@ -4395,6 +4403,7 @@ describe('tool cards and surface replay', () => { ['c11', 'terminalResult', '{}'], ['c12', 'symbolic', '{}'], ['c13', 'knownXml', '{}'], + ['c16', 'webCard', '{}'], ] as const appendAssistant(result.session, [ { type: 'text', text: 'Calling tools' }, @@ -4488,6 +4497,14 @@ describe('tool cards and surface replay', () => { isError: false, }), }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'c16' as never, + content: [{ type: 'text', text: 'Fetched **body** text' }], + isError: false, + }), + }, { surfaceOp: 'append' }) result.session.append('tool/result', { turn: 1, step: 1, @@ -4537,6 +4554,11 @@ describe('tool cards and surface replay', () => { expect(output).toContain('Empty card') expect(output).toContain('converted terminal') expect(output).toContain('literal') + // A web card carries no `content` copy, so it falls back to the raw result + // content, which still renders through the dim Markdown path: the bold + // markers are stripped rather than shown literally. + expect(output).toContain('Fetched body text') + expect(output).not.toContain('Fetched **body** text') expect(output).toContain('path: /tmp/a.txt') expect(output).toContain('line (number="1"): hello') expect(output).not.toContain('') diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 293ce1db76..924f878d8a 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -266,6 +266,11 @@ interface RenderedFetch { * limits the source prefix processed synchronously, then applies again where the * complete output — header, rendered body, and footer — is known. * + * The tool registry calls this once through `output.render` and again through + * `output.presentationMeta`, both with the same frozen result value; the + * conversion is memoized per `(result, maxOutputChars)` so the synchronous DOM + * parse and turndown walk run once, not twice, on the same body. + * * @param result - the seam's fetch outcome. * @param maxOutputChars - cap on the complete returned string; a cut body gets * the same fetch-something-narrower notice as provider-side truncation. @@ -273,6 +278,32 @@ interface RenderedFetch { * the provider, a source cut, or the cap trimmed the content. */ export function renderFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch { + const byCap = renderCache.get(result) ?? new Map() + const cached = byCap.get(maxOutputChars) + if (cached !== undefined) return cached + const computed = computeFetchOutput(result, maxOutputChars) + byCap.set(maxOutputChars, computed) + renderCache.set(result, byCap) + return computed +} + +/** + * Per-result memo for {@link renderFetchOutput}, keyed first on the frozen + * result value so a garbage-collected result drops its entry, then on the output + * cap (a deployment constant per registration). Collapses the registry's twin + * `render`/`presentationMeta` calls into one HTML→markdown conversion. + */ +const renderCache = new WeakMap>() + +/** + * The uncached conversion behind {@link renderFetchOutput}. Separated so the + * memo wraps exactly one call site and the conversion logic stays pure. + * + * @param result - the seam's fetch outcome. + * @param maxOutputChars - cap on the complete returned string. + * @returns the bounded text and effective truncation. + */ +function computeFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch { const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n` const rendered = renderBody(result.body, maxOutputChars) const prefix = `${header}${rendered.text}` diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index c28fa65352..fa07c4ba56 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -388,6 +388,27 @@ describe('web_fetch presentation meta and result view', () => { expect(formatFetchOutput(value, NO_CAP)).not.toContain('Content truncated') }) + it('converts one HTML body once across the render and meta projections of the same result', () => { + // The registry calls output.render and output.presentationMeta with the same + // frozen result value; the memo must collapse them into one turndown walk so + // a large or deeply nested page is not parsed and converted twice. A second + // cap on the same result is a distinct entry, so it converts again. + const spy = vi.spyOn(TurndownService.prototype, 'turndown') + const value = { + url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html' as const, content: '

hello

' }, + } + try { + formatFetchOutput(value, NO_CAP) + fetchMetaFromValue(value, NO_CAP) + expect(spy).toHaveBeenCalledTimes(1) + formatFetchOutput(value, NO_CAP - 1) + expect(spy).toHaveBeenCalledTimes(2) + } finally { + spy.mockRestore() + } + }) + it('presents a completed fetch as a web/fetch card carrying the summary, titled by the url, without content', () => { const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: '# Title' } }, NO_CAP) expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, '# Title'))).toEqual({ From 7d0cf7223817f3a0f67be56cf70838b18e8a3dd0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 21:13:09 +0800 Subject: [PATCH 32/37] fix(web-presenter): align note with shipped TUI web arm, symbolize ref, guard branch Rewrite the Agent Note's stale 'TUI has no web arm' claim to match the web fallback branch this PR added to transcript.ts. Replace the hardcoded line-number comment with a symbolic reference, and mark the web arm's unreachable optional-chain undefined side with a reasoned v8 ignore for the per-file 100% branch gate. --- .../feature/2026-07-30-web-result-card.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-30-web-result-card.md | 2 +- .../feature/2026-07-30-web-result-card.zh.md | 2 +- packages/ui/tui/src/components/transcript.ts | 10 ++++++++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml index b792018613..3b30da4b96 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.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-07-30-web-result-card.md -2026-07-30-web-result-card.md: 3c1f3e69e612d76b959af3304203dcc4ad125019 -2026-07-30-web-result-card.zh.md: e8a3f38d51f4dc81362f5090ff553cfaff21823c +2026-07-30-web-result-card.md: deec27832aba2d5d868889f7306cbaef4f0b90b4 +2026-07-30-web-result-card.zh.md: 037e029332fbb665d90860d7e11c2fd117e6eb45 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md index 3c1f3e69e6..deec27832a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md @@ -16,7 +16,7 @@ One tag with a `kind` discriminant, not two tags. Both calls are web retrieval a `presentationMeta` carries what render text cannot. The structured result object a tool returns from `execute` does NOT reach a client over the wire — only the model-facing `render` text and, when declared, the `output.presentationMeta` JSON projected onto the `tool/result` event's `meta` do. For `web_search` the meta is the ONLY faithful route to `{url, title?, snippet?, publishedAt?}`: the render collapses those fields into one lossy free-text line, so a consumer cannot reparse them. For `web_fetch` the meta is a smaller but real gain: `url`/`statusCode` are recoverable from the deterministic `Fetched (HTTP )` header line, but `truncated` is the effective truncation — provider cap, pre-conversion source cut, or the deployment's `fetchMaxOutputChars` output cap — which a client cannot recompute because it does not know that cap. The fetch card and the model-facing text derive `truncated` from one shared `renderFetchOutput(result, maxOutputChars)` helper, so the card never disagrees with the footer the model saw. This mirrors the write/edit diff template (`packages/fs/tool-fs/src/diff.ts`): a `*MetaFromValue` projector feeds `output.presentationMeta`, and a `*MetaFromResult` narrower reads `result.meta` back with a defensive fallback to the generic card. `web_fetch`'s body is already markdown in the result content, so it is not duplicated into meta. -Neither result view carries a `content` copy. A UI without the `web` capability — including the TUI, whose transcript renderer has no `web` arm — falls back to the raw `tool/result` content through its existing generic/default path (`packages/ui/tui/src/components/transcript.ts`, `renderBody` narrows to `view.card === 'generic' ? view.content : undefined` then falls back to `this.result?.content`). Copying the result content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it and the fallback path renders the identical text. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time. +Neither result view carries a `content` copy. A UI that does not render the structured `web` card falls back to the raw `tool/result` content. The TUI does exactly this: it renders no structured web body, and its transcript renderer routes a `web` view's fallback content through the same dim Markdown path as a generic card's content (`packages/ui/tui/src/components/transcript.ts`, where both `render` and `renderBody` narrow the `generic` arm to `view.content` and give a `web` view the same `this.result?.content` fallback). Copying the result content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it and the fallback path renders the identical text. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time. `presentResult` returns `undefined` (the generic card) on an error result and on absent or malformed `meta`, because presentation runs on replay of arbitrary logged results (possibly from an older schema) and must never throw. The narrowers validate every field defensively; an empty source list is valid meta, not malformed. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md index e8a3f38d51..037e029332 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md @@ -16,7 +16,7 @@ Status: implemented `presentationMeta` 携带 render 文本无法携带的东西。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会。对 `web_search`,meta 是得到 `{url, title?, snippet?, publishedAt?}` 的**唯一**忠实途径:render 把这些字段压进一行有损的自由文本,消费者无法重新解析。对 `web_fetch`,meta 是更小但真实的收益:`url`/`statusCode` 可从确定格式的 `Fetched (HTTP )` header 行还原,但 `truncated` 是有效截断——provider cap、转换前源截断,或部署的 `fetchMaxOutputChars` 输出上限——客户端无法重算,因为它不知道那个上限。抓取卡片与面向模型的文本都从同一个 `renderFetchOutput(result, maxOutputChars)` helper 派生 `truncated`,因此卡片绝不会与模型看到的脚注分叉。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的正文已是结果内容中的 markdown,因此不重复写入 meta。 -两个结果视图都不携带 `content` 副本。不具备 `web` 能力的 UI——包括其 transcript 渲染器没有 `web` 分支的 TUI——经由既有的 generic/默认路径回退到原始 `tool/result` 内容(`packages/ui/tui/src/components/transcript.ts` 中 `renderBody` 先收窄为 `view.card === 'generic' ? view.content : undefined`,再回退到 `this.result?.content`)。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title`(`args.query`/`args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。 +两个结果视图都不携带 `content` 副本。不渲染结构化 `web` 卡片的 UI 回退到原始 `tool/result` 内容。TUI 正是如此:它不渲染结构化的 web 正文,其 transcript 渲染器把 `web` 视图的回退内容与 generic 卡片的内容路由进同一条 dim Markdown 路径(`packages/ui/tui/src/components/transcript.ts` 中 `render` 与 `renderBody` 都把 `generic` 分支收窄为 `view.content`,并给 `web` 视图相同的 `this.result?.content` 回退)。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title`(`args.query`/`args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。 `presentResult` 在错误结果、以及 `meta` 缺失或畸形时返回 `undefined`(即 generic 卡片),因为 presentation 会在对任意已记录结果(可能来自旧 schema)的重放中运行,绝不能抛错。收窄器防御性地校验每个字段;空来源列表是有效 meta,而非畸形。 diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 04f35f78c4..774e982f81 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -393,10 +393,16 @@ export class ToolCardComponent implements Component { // content (the `web` view carries no `content` copy), both render as one dim // Markdown block below, so links/lists/headings keep the unified dim styling // rather than reading as bare text. Terminal and diff cards own their body - // styling, so they are excluded (mirrors renderBody's fallback at line 511). + // styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback). const markdownContent = view.card === 'generic' ? view.content ?? this.result?.content - : view.card === 'web' ? this.result?.content : undefined + : view.card === 'web' + // A web resultView is only assigned alongside this.result (the result + // handler sets both) and the pending callView is never a web card, so + // the optional-chain undefined side is unreachable here. + /* v8 ignore next */ + ? this.result?.content + : undefined const unknownXml = this.definition === undefined && markdownContent !== undefined ? renderUnknownXml( displayText(contentText(markdownContent)), From 9ea613d859c6a3f4ca2d7cdb2497c5fd19c03668 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 21:18:50 +0800 Subject: [PATCH 33/37] fix(docs): drop the withdrawn sandbox-policy paths edge from the module graph The sandbox read-deny mitigation was withdrawn, so sandbox-policy no longer imports dsh-paths; the recorded graph still claimed the edge. --- docs/module-graph.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 7199e3c5a5..3d7f1c5818 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -482,7 +482,6 @@ flowchart TD pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_policy --> pkg_invariants - pkg_sandbox_policy --> pkg_paths pkg_sandbox_policy --> pkg_sandbox pkg_sandbox_policy --> pkg_session pkg_session_projection --> pkg_invariants @@ -1092,7 +1091,7 @@ flowchart TD | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | From fb0063de48749915b4033dc99437f0c89ac6eb69 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 21:28:54 +0800 Subject: [PATCH 34/37] fix(tool-web): unexport renderFetchOutput so its memo stays behind the frozen path renderFetchOutput has no external consumer: only formatFetchOutput and fetchMetaFromValue call it, both through the registry, which deep-freezes the result value. Exporting it let a hypothetical caller mutate a cached input or the returned RenderedFetch and desync the card's truncated flag from the model text. Drop it from the barrel and document that the memo needs no defensive copy because every caller is internal and read-only. --- packages/web/tool-web/src/fetch.ts | 14 +++++++++----- packages/web/tool-web/src/index.ts | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 924f878d8a..d642615874 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -266,10 +266,14 @@ interface RenderedFetch { * limits the source prefix processed synchronously, then applies again where the * complete output — header, rendered body, and footer — is known. * - * The tool registry calls this once through `output.render` and again through - * `output.presentationMeta`, both with the same frozen result value; the - * conversion is memoized per `(result, maxOutputChars)` so the synchronous DOM - * parse and turndown walk run once, not twice, on the same body. + * Package-internal: the only callers are {@link formatFetchOutput} and + * {@link fetchMetaFromValue}, both reached through the tool registry, which + * deep-freezes the result value before calling `output.render` and + * `output.presentationMeta`. The conversion is memoized per + * `(result, maxOutputChars)` so the synchronous DOM parse and turndown walk run + * once, not twice, on that same frozen value. Keeping it unexported means no + * caller can mutate a cached input or the returned {@link RenderedFetch}, so the + * memo needs no defensive copy. * * @param result - the seam's fetch outcome. * @param maxOutputChars - cap on the complete returned string; a cut body gets @@ -277,7 +281,7 @@ interface RenderedFetch { * @returns the complete `Fetched (HTTP )`-headed text and whether * the provider, a source cut, or the cap trimmed the content. */ -export function renderFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch { +function renderFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch { const byCap = renderCache.get(result) ?? new Map() const cached = byCap.get(maxOutputChars) if (cached !== undefined) return cached diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index f9236ecc4f..397e2bf7bb 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -14,7 +14,7 @@ import { applyWebFetchTool } from './fetch.ts' export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult } from './search.ts' export type { WebSearchMeta } from './search.ts' -export { applyWebFetchTool, formatFetchOutput, renderFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts' +export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts' export type { WebFetchMeta } from './fetch.ts' /** Cordis plugin name used by loader diagnostics. */ From f070597378d2c211ddf160e24258402d633fd718 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 21:55:22 +0800 Subject: [PATCH 35/37] fix(tool-web): share one source projection between search execute and meta The web_search execute result and searchMetaFromValue each spread the same {url, title?, snippet?, publishedAt?} projection over a seam source, which the duplication gate flags as a clone. Extract projectSource, typed on the seam's WebSearchSource, so both sites carry a byte-identical shape from one definition. --- packages/web/tool-web/src/search.ts | 38 +++++++++++++++++++---------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 95016af29f..20979e4035 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, JsonValue, ToolResult, WebSearchResultView, WebSource } from '@deepseek-ai/dsh-tools' -import type { WebSearchResult } from '@deepseek-ai/dsh-web' +import type { WebSearchResult, WebSearchSource } from '@deepseek-ai/dsh-web' import type {} from '@deepseek-ai/dsh-system-prompt' /** @@ -101,6 +101,28 @@ export interface WebSearchMeta { answer?: string } +/** + * Project one seam source into a plain object that omits every absent optional + * field. Shared by the canonical `execute` result and its replayable + * presentation meta so both carry byte-identical source shapes. + * + * @param source - one source from the `ctx.web` search outcome. + * @returns `{ url }` plus each present optional field. + */ +function projectSource(source: WebSearchSource): { + url: string + title?: string + snippet?: string + publishedAt?: string +} { + return { + url: source.url, + ...source.title !== undefined ? { title: source.title } : {}, + ...source.snippet !== undefined ? { snippet: source.snippet } : {}, + ...source.publishedAt !== undefined ? { publishedAt: source.publishedAt } : {}, + } +} + /** * Project a validated `web_search` output value into its replayable * presentation meta ({@link WebSearchMeta} as opaque JSON). @@ -110,12 +132,7 @@ export interface WebSearchMeta { */ export function searchMetaFromValue(value: WebSearchResult): JsonValue { return { - sources: value.sources.map(source => ({ - url: source.url, - ...source.title !== undefined ? { title: source.title } : {}, - ...source.snippet !== undefined ? { snippet: source.snippet } : {}, - ...source.publishedAt !== undefined ? { publishedAt: source.publishedAt } : {}, - })), + sources: value.sources.map(projectSource), truncated: value.truncated, ...value.content !== undefined ? { answer: value.content } : {}, } @@ -238,12 +255,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: ) return { ...result.content !== undefined ? { content: result.content } : {}, - sources: result.sources.map(source => ({ - url: source.url, - ...source.title !== undefined ? { title: source.title } : {}, - ...source.snippet !== undefined ? { snippet: source.snippet } : {}, - ...source.publishedAt !== undefined ? { publishedAt: source.publishedAt } : {}, - })), + sources: result.sources.map(projectSource), truncated: result.truncated, } }, From 54c60f40791ea798c1b214735e601ce36d9128f2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 23:23:18 +0800 Subject: [PATCH 36/37] fix(llm,settings): refuse post-disposal route replacement and teardown notifications Two lifecycle holes the registry and the consumer helper left open. `AdapterRegistrationHandle.replace` had no liveness guard: after the handle's disposer ran, a replace put routes back into the registry with nothing left to release them, so the adapter leaked permanently. `owned` being empty cannot carry that fact, because `replace([])` is the legal empty-section state, so the disposer records it explicitly. `installSettingsSection`'s watcher lacked the guard its own disposer carries: a stored change landing while the consumer unloads reached `onChange`, which re-registers routes against a fiber whose resources are being released. Also documents `withFileLock` in the atomic-write README (it claimed one export), records the age-based lock takeover as a known limitation, and lists ctx.settings and ctx.credentials in the architecture capability table. --- docs/architecture.i18n.yaml | 4 +-- docs/architecture.md | 2 ++ docs/architecture.zh.md | 2 ++ docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +-- docs/core-data-structures/core.md | 4 +++ docs/core-data-structures/core.zh.md | 4 +++ packages/llm/llm/src/index.ts | 13 ++++++++ packages/llm/llm/tests/service.spec.ts | 28 ++++++++++++++++ packages/settings/settings/src/index.ts | 5 +++ .../settings/settings/tests/settings.spec.ts | 32 +++++++++++++++++++ packages/util/atomic-write/README.i18n.yaml | 4 +-- packages/util/atomic-write/README.md | 13 ++++++-- packages/util/atomic-write/README.zh.md | 13 ++++++-- 14 files changed, 119 insertions(+), 11 deletions(-) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index dfff50708f..d7054b8b3c 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 1fd9bd128d1bcc0dd91d46131981ea4fc331bd74 -architecture.zh.md: 8521f09c6e415f9f8d1c0a44f7534b59c876decc +architecture.md: bfea67b9f83958e16b58e63b99e326349f6eff15 +architecture.zh.md: c2fd6cdd84ad2f6435faebffa0c4c1a6da0ade96 diff --git a/docs/architecture.md b/docs/architecture.md index 1fd9bd128d..bfea67b9f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,6 +46,8 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks, one optional asynchronous provider | +| `ctx.settings` | [`settings/`](../packages/settings/README.md) | per-plugin user-settings namespaces layered over composition entries | +| `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | named secret references resolved per operation, never inlined in configuration | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 8521f09c6e..c2fd6cdd84 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -46,6 +46,8 @@ | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 | +| `ctx.settings` | [`settings/`](../packages/settings/README.md) | 按插件划分的用户设置命名空间,分层叠加在装配条目之上 | +| `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | 具名密钥引用,按操作解析,绝不内联进配置 | | `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) | | `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 271cbf2720..60ba4414ea 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -866,7 +866,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:211`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:215`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index c8cb8302d1..e998f1f4a4 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 5c79f454f50a059d72a592df45d504ee78835e0b -core.zh.md: 258517c625822bdbd64138baf3df186b075bb5c6 +core.md: 09b437a8483134230d4b941c20940c5655bc53f0 +core.zh.md: 2025707db397203dbaec52c59172f83367f2033e diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 5c79f454f5..09b437a848 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -201,6 +201,10 @@ interface AdapterRegistrationHandle { * one synchronous section, so no request can observe a gap. An empty array * is legal here (a settings section that emptied holds zero routes while * staying registered), unlike an empty initial registration. + * + * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration + * has been released: its routes are gone and its disposer has already run, + * so anything registered afterwards would have no owner left to release it. * @param providers - the complete next route set for this registration. */ replace(providers: string[]): void diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 258517c625..2025707db3 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -207,6 +207,10 @@ interface AdapterRegistrationHandle { * one synchronous section, so no request can observe a gap. An empty array * is legal here (a settings section that emptied holds zero routes while * staying registered), unlike an empty initial registration. + * + * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration + * has been released: its routes are gone and its disposer has already run, + * so anything registered afterwards would have no owner left to release it. * @param providers - the complete next route set for this registration. */ replace(providers: string[]): void diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 73ac6ed900..3759f9f8df 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -199,6 +199,10 @@ export interface AdapterRegistrationHandle { * one synchronous section, so no request can observe a gap. An empty array * is legal here (a settings section that emptied holds zero routes while * staying registered), unlike an empty initial registration. + * + * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration + * has been released: its routes are gone and its disposer has already run, + * so anything registered afterwards would have no owner left to release it. * @param providers - the complete next route set for this registration. */ replace(providers: string[]): void @@ -227,10 +231,14 @@ export class LlmService extends Service { // The routes this registration currently holds; `replace` rewrites it, and // the disposer releases whatever it holds at disposal time. const owned = new Set() + // The disposer has run: `owned` being empty cannot say so on its own, + // because `replace([])` legally leaves a live registration holding none. + let released = false const dispose = this.ctx.effect(function* (this: LlmService) { if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER') this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned)) yield () => { + released = true for (const provider of owned) this.adapters.delete(provider) owned.clear() } @@ -239,6 +247,11 @@ export class LlmService extends Service { // synchronous fire-and-forget — discard the (always-resolved) promise. const handle = (() => void dispose()) as AdapterRegistrationHandle handle.replace = (next: string[]): void => { + // Registering here would leak: the effect's disposer already ran, so + // nothing remains to release whatever this call would put in the map. + if (released) { + throw new LlmError('a disposed adapter registration cannot replace its routes', 'REGISTRATION_DISPOSED') + } this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned)) } return handle diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 1afb3c4b6d..f723c7f474 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1381,4 +1381,32 @@ describe('LlmService', () => { disposeAgain() expect(ctx.llm.listProviders()).toEqual([]) }) + + it('refuses to replace routes on a registration that was already released', async () => { + // The leak this prevents: the effect's disposer has run, so a route added + // afterwards would sit in the registry with nothing left to release it. + const ctx = new Context() + await ctx.plugin(LlmService) + + const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT)) + handle() + expect(() => { handle.replace(['leaked']) }) + .toThrow(/disposed adapter registration cannot replace its routes/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + it('still allows an empty route set on a live registration', async () => { + // `replace([])` is the settings-section-emptied case: legal, and it must + // not be mistaken for disposal by the guard above. + const ctx = new Context() + await ctx.plugin(LlmService) + + const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT)) + handle.replace([]) + expect(ctx.llm.listProviders()).toEqual([]) + handle.replace(['m2']) + expect(ctx.llm.listProviders()).toEqual([{ id: 'm2', name: 'm2' }]) + handle() + expect(ctx.llm.listProviders()).toEqual([]) + }) }) diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 9293564335..241bc41bfa 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -612,6 +612,11 @@ export function installSettingsSection( }) hooks.onChange() scope.watch(() => { + // A stored change landing while the consumer unloads reaches the watcher + // before the registration is released, and `onChange` is exactly as + // harmful here as in the disposer above: it re-registers routes against + // a fiber whose resources are being let go. + if (isUnloading(ctx)) return hooks.onChange() }) }) diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 5f9ac7dae7..9e3a8dd7e5 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -724,4 +724,36 @@ describe('installSettingsSection', () => { await new Promise(resolve => setTimeout(resolve, 20)) expect(changes).toEqual(['user']) }) + + it('stays silent for a stored change that lands while the consumer unloads', async () => { + // The watcher outlives the start of teardown by the width of the unload, + // so a document change arriving in that window reaches it. Notifying then + // is exactly as harmful as notifying from the disposer. + const { ctx, provider } = await boot({ doc: { 'helper-ns': { theme: 'user' } } }) + const entry = { theme: 'entry' } + let current: () => { theme: string } = () => entry + const changes: string[] = [] + const consumer = ctx.plugin({ + inject: ['settings'], + apply: (child: Context) => { + installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, { + setSource: (source) => { + current = source + }, + onChange: () => { + changes.push(current().theme) + }, + }) + }, + }) + await consumer + await vi.waitFor(() => { + expect(changes).toEqual(['user']) + }) + + const unloading = consumer.dispose() + provider.pushExternal({ 'helper-ns': { theme: 'racing' } }) + await unloading + expect(changes).toEqual(['user']) + }) }) diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml index e1f2ea37f5..ffa4d7ccbb 100644 --- a/packages/util/atomic-write/README.i18n.yaml +++ b/packages/util/atomic-write/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/util/atomic-write/README.md -README.md: 2cd57a0fa42601e393a41de68af3f9b1e2f033b5 -README.zh.md: e8f18a8ec6ef6077f15cebed0062fabc0638ee0e +README.md: be9f896eb24e28aedc2c04858da8b8da9da548dc +README.zh.md: 19a067dc84f12d334e5c31dda58e7cf78dac51f9 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index 2cd57a0fa4..be9f896eb2 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -7,14 +7,20 @@ Zero-dependency atomic file replacement shared by file-backed stores that must n ## Surface ```ts -import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' declare const text: string +declare const render: (previous: string) => string await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) + +// Read-modify-write against the same file from several processes. +await withFileLock('/home/u/.dsh/settings.yaml', async () => { + await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 }) +}) ``` -One export. The contract, in the order failures would exploit it: +`writeFileAtomic` commits one already-rendered string. The contract, in the order failures would exploit it: - **Exclusive-create temp** (`wx`, random suffix): the open refuses to follow a symlink planted at a guessable temp path. - **The fresh inode carries `mode` through the rename**: replacing a wider-permission file narrows it without a chmod race. `mode` is required so the permission decision stays visible at every call site (subject to the process umask, like every fresh inode). @@ -22,6 +28,8 @@ One export. The contract, in the order failures would exploit it: - **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic. - Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content. +`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A lock older than the stale age is treated as a crashed holder and broken — see [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) for what that costs. + ## Model Experience None, as this is a pure filesystem primitive; nothing here reaches a model request. @@ -34,3 +42,4 @@ None; nothing here enters a request prefix. - **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy. - **String content only** — no `Buffer` or stream form until a consumer needs one. +- **The lock takes over by age, not by ownership** (`TODO(settings-lock-ownership)`) — a holder slower than the stale age has its lock broken by a waiter, and release unlinks the path unconditionally, so a slow writer can remove a successor's lock. Two writers can then overlap and one cycle's result be lost. The stale age is set well above any write this repo performs, so the exposure is a paused or swapped-out process; ownership-safe acquisition and release is the fix. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index e8f18a8ec6..19a067dc84 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -7,14 +7,20 @@ ## 接口面 ```ts -import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' declare const text: string +declare const render: (previous: string) => string await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) + +// Read-modify-write against the same file from several processes. +await withFileLock('/home/u/.dsh/settings.yaml', async () => { + await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 }) +}) ``` -仅一个导出。契约按故障利用它的先后顺序列出: +`writeFileAtomic` 提交一份已经渲染好的字符串。契约按故障利用它的先后顺序列出: - **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。 - **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。 @@ -22,6 +28,8 @@ await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) - **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。 - 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。 +`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。超过陈旧时限的锁被视为持有者已崩溃并被打破——其代价见[Known Limitations and Deferred Work](#known-limitations-and-deferred-work)。 + ## Model Experience 无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。 @@ -34,3 +42,4 @@ await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) - **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。 - **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。 +- **锁按时长而非归属接管**(`TODO(settings-lock-ownership)`)——持有者若慢于陈旧时限,其锁会被等待方打破,而释放又无条件删除该路径,因此慢写入方可能删掉后继者的锁。两个写入方随之重叠,一轮循环的结果可能丢失。陈旧时限远高于本仓库的任何一次写入,因此暴露面是被暂停或被换出的进程;修法是按归属安全地获取与释放。 From 16802cd612bfdfe7401e63b6adaf5b4a88509bfe Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 00:38:48 +0800 Subject: [PATCH 37/37] chore: retrigger CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub created no workflow runs and no check runs for eaaec8173, leaving the two required contexts waiting for a status that was never going to arrive. Empty commit, identical tree — this only makes GitHub emit the pull_request event again.