From 1ee167aeaca76ef483db6d2e3a2c6ba1f110161f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 19:49:30 +0800 Subject: [PATCH 01/24] feat(fs): append recovery remedy to guarded-mutation errors write/edit failures with FS_STALE_VERSION or FS_NOT_OBSERVED now reach the model with the correct recovery instruction appended (re-read / read, then retry) while preserving the structured code and chaining the cause. The edit-intent waterfall sits inside the same try, so the policy's FS_NOT_OBSERVED refusal is remediated too. Re-recorded the fs-policy-reject keyless snapshot and the bilingual README pairs. --- .../snapshots/fs-policy-reject/session.jsonl | 2 +- packages/fs/fs-policy/README.i18n.yaml | 4 +- packages/fs/fs-policy/README.md | 2 +- packages/fs/fs-policy/README.zh.md | 2 +- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- packages/fs/tool-fs/src/edit.ts | 14 +++- packages/fs/tool-fs/src/error.ts | 34 ++++++++ packages/fs/tool-fs/src/write.ts | 6 +- packages/fs/tool-fs/tests/error.spec.ts | 35 ++++++++ packages/fs/tool-fs/tests/integration.spec.ts | 80 +++++++++++++++++++ packages/fs/tool-fs/tests/tools.spec.ts | 3 +- 13 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 packages/fs/tool-fs/src/error.ts create mode 100644 packages/fs/tool-fs/tests/error.spec.ts diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 07d1408c9e..87cd7427f9 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","seq":78,"time":1785487602271,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":79,"time":1785487602271,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8bd34189-fb62-4106-9c25-b6022d48e059"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[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":1785487602272,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":81,"time":1785487602280,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}],"isError":true}],"role":"user","id":"c4018c31-b6fd-4f14-af3c-e609863bf501"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"tool/result","seq":81,"time":1785487602280,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first — read the file, then retry"}],"isError":true}],"role":"user","id":"c4018c31-b6fd-4f14-af3c-e609863bf501"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[80],"surfaceOp":"append"} {"type":"step/end","seq":82,"time":1785487602280,"data":{"turn":1,"step":1}} {"type":"step/start","seq":83,"time":1785487602287,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":84,"time":1783611704931,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/fs/fs-policy/README.i18n.yaml b/packages/fs/fs-policy/README.i18n.yaml index 6690227dbc..5168b43d34 100644 --- a/packages/fs/fs-policy/README.i18n.yaml +++ b/packages/fs/fs-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/fs/fs-policy/README.md -README.md: dc4e9377793570c80b8d71ec84196bebe7fe583a -README.zh.md: aa0cb25899f5906ac9f531583ba48d01ad6095b4 +README.md: f6b3292bdc6e5565df0393a59c50d4e594921401 +README.zh.md: 2b30e6223719301df776b5d1cb7c674cb7ef7ff7 diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index dc4e937779..f6b3292bdc 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -55,7 +55,7 @@ Because the plugin influences the world only through events, removing it does no #### What the model sees -This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown. +This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code; observation state is never shown. #### Token effect diff --git a/packages/fs/fs-policy/README.zh.md b/packages/fs/fs-policy/README.zh.md index aa0cb25899..2b30e62237 100644 --- a/packages/fs/fs-policy/README.zh.md +++ b/packages/fs/fs-policy/README.zh.md @@ -55,7 +55,7 @@ await ctx.plugin(FsPolicy) #### 模型看到的内容 -该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装;观察状态绝不会显示。 +该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码;观察状态绝不会显示。 #### Token 影响 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index fbe2e69043..8f462ed19b 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: a695d0ba8fb1d600689d2b68763e8423d1591da5 -README.zh.md: 5c600ab70b46da640637aec64efc1c0f0d0d54c0 +README.md: 246b1c8797e9a2ddc630724729edf8e2f1185bfc +README.zh.md: 6cfc3d750b0f6ffc9ee886f4d0f058885bc19083 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index a695d0ba8f..246b1c8797 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` (including a missing edit target) gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. #### Token effect diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 5c600ab70b..6cfc3d750b 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -136,7 +136,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( lines)`;提供方和策略模板在各自包的 README 中逐字列出。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION`(包括编辑目标缺失)追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。 #### Token 影响 diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 951c0b7b57..fcd04cb17c 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -11,6 +11,7 @@ import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh- import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta } from './diff.ts' +import { remediateFsError } from './error.ts' import { sessionResolveOptions } from './session-cwd.ts' import type { FsSandboxSurface } from './sandbox.ts' @@ -116,10 +117,13 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot)) // Single-slot decision: the policy plugin returns { version: vObserved } or // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit). - // No stat — the bare default never manufactures a version basis. - const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) + // No stat — the bare default never manufactures a version basis. The intent + // slot itself can throw FS_NOT_OBSERVED for an unread target, so it sits + // inside the try: both that refusal and the provider's guarded-mutation + // failure get the model-facing remedy below. let outcome try { + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) outcome = await ctx.fs.editText( target, { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, @@ -128,8 +132,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { sandboxPolicy, ) } catch (error: unknown) { - // A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through. - throw sandbox.mapError(error, sandboxPolicy) + // A sandbox denial becomes the shared [sandbox: …] marker (the model + // recognizes it from bash); stale/not-observed failures gain their + // model-facing remedy; anything else passes through. + throw remediateFsError(sandbox.mapError(error, sandboxPolicy)) } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) diff --git a/packages/fs/tool-fs/src/error.ts b/packages/fs/tool-fs/src/error.ts new file mode 100644 index 0000000000..e67616887f --- /dev/null +++ b/packages/fs/tool-fs/src/error.ts @@ -0,0 +1,34 @@ +/** + * Model-facing remediation for guarded-mutation failures. The provider's + * `FS_STALE_VERSION` and `FS_NOT_OBSERVED` messages state the condition but + * not the only correct recovery (re-read / read the file), so this package + * appends the remedy at the model boundary; provider messages stay + * machine-oriented and unchanged. + * @module @deepseek-ai/dsh-tool-fs/src/error + */ + +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsErrorCode } from '@deepseek-ai/dsh-fs' + +/** The remedy appended to each remediable failure code's message. */ +const REMEDIES: Partial> = { + FS_STALE_VERSION: 're-read the file, then retry', + FS_NOT_OBSERVED: 'read the file, then retry', +} + +/** + * Append the correct recovery instruction to a guarded-mutation failure's + * message. `FS_STALE_VERSION` (the file changed since this session's last + * observation, including a missing target) recovers only by re-reading; + * `FS_NOT_OBSERVED` (no prior read by this session) by reading. The `FsError` + * code is preserved so retry/permission/UI layers keep routing on it, and the + * original error chains as `cause`. Anything else passes through untouched. + * @param error - the caught value from a write/edit execution. + * @returns a remediated `FsError` for the two guarded-mutation codes, else the original value. + */ +export function remediateFsError(error: unknown): unknown { + if (!(error instanceof FsError)) return error + const remedy = REMEDIES[error.code] + if (!remedy) return error + return new FsError(`${error.message} — ${remedy}`, error.code, { cause: error }) +} diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 37a6d67e59..56e2be488b 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -12,6 +12,7 @@ import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta } from './diff.ts' +import { remediateFsError } from './error.ts' import { sessionResolveOptions } from './session-cwd.ts' import type { FsSandboxSurface } from './sandbox.ts' @@ -113,8 +114,9 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy) } catch (error: unknown) { // A sandbox denial becomes the shared [sandbox: …] marker (the model - // recognizes it from bash); any other error passes through. - throw sandbox.mapError(error, sandboxPolicy) + // recognizes it from bash); stale/not-observed failures gain their + // model-facing remedy; anything else passes through. + throw remediateFsError(sandbox.mapError(error, sandboxPolicy)) } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) diff --git a/packages/fs/tool-fs/tests/error.spec.ts b/packages/fs/tool-fs/tests/error.spec.ts new file mode 100644 index 0000000000..671eb32d9d --- /dev/null +++ b/packages/fs/tool-fs/tests/error.spec.ts @@ -0,0 +1,35 @@ +/** + * Unit tests for the model-facing error remediation: the remedy appended to + * guarded-mutation failures, code preservation, and passthrough behavior. + */ + +import { describe, expect, it } from 'vitest' +import { FsError } from '@deepseek-ai/dsh-fs' +import { remediateFsError } from '../src/error.ts' + +describe('remediateFsError', () => { + it('appends the re-read remedy to FS_STALE_VERSION, preserving the code and chaining the cause', () => { + const original = new FsError('cannot edit "x": file changed since it was read', 'FS_STALE_VERSION') + const remedied = remediateFsError(original) as FsError + expect(remedied).toBeInstanceOf(FsError) + expect(remedied.message).toBe('cannot edit "x": file changed since it was read — re-read the file, then retry') + expect(remedied.code).toBe('FS_STALE_VERSION') + expect(remedied.cause).toBe(original) + }) + + it('appends the read remedy to FS_NOT_OBSERVED', () => { + const remedied = remediateFsError(new FsError('edit requires reading "x" first', 'FS_NOT_OBSERVED')) as FsError + expect(remedied.message).toBe('edit requires reading "x" first — read the file, then retry') + expect(remedied.code).toBe('FS_NOT_OBSERVED') + }) + + it('leaves other FsError codes untouched', () => { + const original = new FsError('no match anywhere', 'FS_EDIT_NOT_FOUND') + expect(remediateFsError(original)).toBe(original) + }) + + it('leaves non-FsError values untouched', () => { + const original = new Error('boom') + expect(remediateFsError(original)).toBe(original) + }) +}) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index c835baebb9..3482b38569 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -71,6 +71,9 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) + // The model-facing text names the remedy, not just the condition. + expect(text(result)).toContain('without reading it first') + expect(text(result)).toContain('read the file, then retry') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') }) @@ -89,6 +92,23 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // The model-facing text names the remedy, not just the condition. + expect(text(result)).toContain('file changed since it was read') + expect(text(result)).toContain('re-read the file, then retry') + }) + + it('the stale remedy is actionable: re-reading the changed file unblocks the retried write', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + await call('read', { file_path: 'a.txt' }) + await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change + const stale = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(stale.isError).toBe(true) + expect(stale.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // Follow the remedy: re-read (refreshes the observed version), then retry. + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const retried = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(retried.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced') }) }) @@ -131,6 +151,9 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) + // The policy's refusal reaches the model with the read remedy appended. + expect(text(result)).toContain('edit requires reading') + expect(text(result)).toContain('read the file, then retry') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') }) @@ -155,6 +178,23 @@ describe('default deployment (with dsh-fs-policy)', () => { const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // The model-facing text names the remedy, not just the condition. + expect(text(result)).toContain('file changed since it was read') + expect(text(result)).toContain('re-read the file, then retry') + }) + + it('the stale remedy is actionable: re-reading the changed file unblocks the retried edit', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + await call('read', { file_path: 'a.txt' }) + await writeFile(join(dir, 'a.txt'), 'hello brave world') // out-of-band change + const stale = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(stale.isError).toBe(true) + expect(stale.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // Follow the remedy: re-read (refreshes the observed version), then retry. + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const retried = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(retried.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello brave there') }) it('rejects an ambiguous match without replace_all', async () => { @@ -194,6 +234,43 @@ describe('default deployment (with dsh-fs-policy)', () => { }) }) + describe('deleted observed target (fail-closed corner)', () => { + it('a deleted observed file stays un-writable and un-editable in-session: the remedy cannot unblock it', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + await call('read', { file_path: 'a.txt' }) + await rm(join(dir, 'a.txt')) // out-of-band deletion + + // Edit of the missing target: stale (the missing-target path shares the + // stale code and the re-read remedy). + const edit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' }) + expect(edit.isError).toBe(true) + expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + + // Re-reading the missing file FAILS with FS_NOT_FOUND and records no + // observation, so the retried edit fails identically: the observed entry + // is never cleared for a deleted target. + const reread = await call('read', { file_path: 'a.txt' }) + expect(reread.isError).toBe(true) + expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) + const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' }) + expect(retriedEdit.isError).toBe(true) + expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + + // Write cannot recreate it either: the stale observation still forces + // replaceIfVersion, which rejects a missing target ("file no longer exists"). + const write = await call('write', { file_path: 'a.txt', content: 'fresh' }) + expect(write.isError).toBe(true) + expect(write.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + + // The dead end lifts once the file exists again and is freshly observed. + await writeFile(join(dir, 'a.txt'), 'restored') + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const recovered = await call('write', { file_path: 'a.txt', content: 'fresh' }) + expect(recovered.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('fresh') + }) + }) + describe('stat budget', () => { it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => { await writeFile(join(dir, 'a.txt'), 'hello world') @@ -264,6 +341,9 @@ describe('bare provider (no dsh-fs-policy)', () => { const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) + // Even without policy, the stale text carries the re-read remedy. + expect(text(result)).toContain('file changed since it was read') + expect(text(result)).toContain('re-read the file, then retry') }) it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => { diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 914a1bf7de..ad01237c2b 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -397,12 +397,13 @@ describe('write tool', () => { expect(text(result)).toContain('file_path must be a non-empty string') }) - it('propagates a backend FsError as an isError result carrying its code', async () => { + it('propagates a backend FsError as an isError result carrying its code and remedy', async () => { const { ctx, fs } = await setup() fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION') const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } }) + expect(text(result)).toContain('re-read the file, then retry') }) }) From 044df0e0c8b30f1dab97db8e473317dc66b060c0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 19:49:38 +0800 Subject: [PATCH 02/24] docs(notes): record model-facing error remedy decision The tool-fs error wrapper decision: guarded-mutation failures gain their recovery instruction at the model boundary while the provider messages and structured codes stay unchanged; includes the deleted-target fail-closed corner. --- .../2026-08-03-fs-tool-error-remedy.i18n.yaml | 6 ++++ .../2026-08-03-fs-tool-error-remedy.md | 32 +++++++++++++++++++ .../2026-08-03-fs-tool-error-remedy.zh.md | 32 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md create mode 100644 .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml new file mode 100644 index 0000000000..98500c284e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.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-08-03-fs-tool-error-remedy.md +2026-08-03-fs-tool-error-remedy.md: f227c31365725652b130e097d70c79d3daab3684 +2026-08-03-fs-tool-error-remedy.zh.md: 11acd0cf48924833ced91591d5ea1424735969cd diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md new file mode 100644 index 0000000000..f227c31365 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md @@ -0,0 +1,32 @@ +# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary + +Status: implemented + +English | [中文](2026-08-03-fs-tool-error-remedy.zh.md) + +## Problem + +Guarded `write` and `edit` failures reach the model with messages that state the condition but not the only correct recovery: `FS_STALE_VERSION` ("file changed since it was read") and `FS_NOT_OBSERVED` ("edit requires reading … first"). The model must guess that the recovery is a re-read (or a first read) followed by a retry, and the retry/permission/UI layers that route on the structured code see the same message text. The provider-owned messages are part of the storage seam's machine-oriented vocabulary ([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)), so the remedy cannot live there without leaking model-facing wording into every consumer of `FsError`. + +## Decision + +`dsh-tool-fs` owns a model-facing error wrapper, `remediateFsError` in `src/error.ts`, applied in `write.ts` and `edit.ts` after the sandbox denial mapping. It appends the recovery instruction to the two guarded-mutation codes and passes everything else through untouched: + +- `FS_STALE_VERSION` (including a missing edit target, which shares the stale code) gains `— re-read the file, then retry`. +- `FS_NOT_OBSERVED` gains `— read the file, then retry`. + +The structured `FsError` code is preserved so retry/permission/UI layers keep routing on it, and the original error chains as `cause`. Provider messages stay machine-oriented and unchanged. + +In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as the provider mutation, so the policy plugin's `FS_NOT_OBSERVED` refusal thrown from the intent slot also receives the remedy — both refusal paths reach the model with the same recovery wording. + +## Alternatives considered + +- **Append the remedy to the provider messages in `dsh-fs` / `dsh-fs-local`.** Rejected because those messages are machine-oriented seam vocabulary consumed by retry, permission, and UI layers as well as the model surface; model-facing wording belongs at the model boundary, where `dsh-tool-fs` already owns result formatting ([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)). +- **Add the recovery to prompt guidance instead.** Rejected because the failure arrives mid-task; a static instruction does not reliably reach the retry decision, while the error message is present exactly when the model must act. +- **Signal the remedy with a new `FsError` code.** Rejected because the two failures are the same conditions retry layers already handle; splitting the code would fork routing on identical semantics. + +## Consequences + +Model-visible text for the two codes changes; the `fs-policy-reject` keyless snapshot is re-recorded, and the READMEs of `dsh-tool-fs` and `dsh-fs-policy` pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-`FsError` values) and the assembled tool paths assert the remedy reaches the model for both codes. + +The remedy is not a promise: a deleted observed target cannot be unblocked, because re-reading a missing file fails with `FS_NOT_FOUND` and records no observation. That dead end is pinned fail-closed in the integration tests — the retried mutation fails identically until the target exists again and is freshly observed. diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md new file mode 100644 index 0000000000..11acd0cf48 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary + +Status: implemented + +[English](2026-08-03-fs-tool-error-remedy.md) | 中文 + +## Problem + +受防护的 `write` 与 `edit` 失败以只陈述条件、不给出唯一正确恢复方式的消息到达模型:`FS_STALE_VERSION`("file changed since it was read")与 `FS_NOT_OBSERVED`("edit requires reading … first")。模型必须自行猜测恢复方式是重新读取(或首次读取)后重试,而基于结构化错误码路由的重试/权限/UI 层看到的也是同一段消息文本。提供方拥有的消息属于存储接缝的面向机器词汇([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)),因此恢复指令不能放在那里,否则会把面向模型的措辞泄漏给 `FsError` 的每个消费者。 + +## Decision + +`dsh-tool-fs` 拥有一个面向模型的错误包装 `remediateFsError`(位于 `src/error.ts`),在 `write.ts` 与 `edit.ts` 中于沙箱拒绝映射之后应用。它为两个受防护变更错误码追加恢复指令,其余错误原样透传: + +- `FS_STALE_VERSION`(包括缺失的编辑目标——它与陈旧错误共用同一错误码)追加 `— re-read the file, then retry`。 +- `FS_NOT_OBSERVED` 追加 `— read the file, then retry`。 + +结构化 `FsError` 错误码保持不变,使重试/权限/UI 层继续基于它路由;原始错误作为 `cause` 链入。提供方消息保持面向机器且不变。 + +在 `edit.ts` 中,`fs/edit-intent` waterfall 现在与提供方变更位于同一个 `try` 内,因此策略插件从 intent 槽抛出的 `FS_NOT_OBSERVED` 拒绝也会获得恢复指令——两条拒绝路径都以相同的恢复措辞到达模型。 + +## Alternatives considered + +- **在 `dsh-fs` / `dsh-fs-local` 的提供方消息中追加恢复指令。** 被拒绝:这些消息是面向机器的接缝词汇,除模型表面外还被重试、权限与 UI 层消费;面向模型的措辞应位于模型边界,即 `dsh-tool-fs` 已经拥有结果格式化之处([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md))。 +- **改为在提示词引导中加入恢复方式。** 被拒绝:失败发生在任务中途;静态指令无法可靠地影响重试决策,而错误消息恰好在模型必须行动时出现。 +- **用新的 `FsError` 错误码表达恢复指令。** 被拒绝:这两种失败本就是重试层已处理的相同条件;拆分错误码会让语义相同的路由分叉。 + +## Consequences + +两个错误码的模型可见文本发生变化;`fs-policy-reject` 无密钥快照被重新录制,`dsh-tool-fs` 与 `dsh-fs-policy` 的 README 逐字固定追加后的文本。单元测试直接覆盖包装器(恢复指令文本、错误码保留、cause 链、其他错误码与非 `FsError` 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。 + +恢复指令不是承诺:已删除的观察目标无法被解除阻塞,因为重新读取缺失文件会以 `FS_NOT_FOUND` 失败且不记录观察。这一死胡同在集成测试中以 fail-closed 方式固定——在目标重新存在并被新鲜观察之前,重试的变更以相同方式失败。 From 318142ebe9aaef1bcf27c3949f8f59b1557c21e4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 19:21:04 +0800 Subject: [PATCH 03/24] docs(user): add a model-provider configuration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide tier said how to compose plugins with `cordis.yml` but never how to reach a provider other than DeepSeek, so the two things a person actually does — give a catalog provider its key from the Models page, and declare a gateway the installed catalog does not ship — had no home outside package READMEs. The new page covers both entry points and the relationship between them: the Models page and `$DSH_HOME/settings.yaml` write one document, over a `llm-pi-ai` adapter that mounts dormant until that document names routes. It carries the settings shape, catalog replacement and its capacity fallbacks, credential references, and the four failures a misconfigured route produces, and links the generated config catalog for exhaustive fields. It sits between Quick start and Configuration in the guide sidebar, which is where a reader hits the question. --- docs/user/guide/providers.i18n.yaml | 6 ++ docs/user/guide/providers.md | 113 +++++++++++++++++++++++++++ docs/user/guide/providers.zh.md | 113 +++++++++++++++++++++++++++ docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 1 + docs/user/guide/quickstart.zh.md | 1 + website/docs.ts | 10 ++- 7 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 docs/user/guide/providers.i18n.yaml create mode 100644 docs/user/guide/providers.md create mode 100644 docs/user/guide/providers.zh.md diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml new file mode 100644 index 0000000000..04b27adb51 --- /dev/null +++ b/docs/user/guide/providers.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/user/guide/providers.md +providers.md: 2ae1093699d8eb26171a2403db155113d84e437e +providers.zh.md: 6ce513c659140ed18716bd5c8f75c428ad981f2b diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md new file mode 100644 index 0000000000..2ae1093699 --- /dev/null +++ b/docs/user/guide/providers.md @@ -0,0 +1,113 @@ +# Configure model providers + +English | [中文](providers.zh.md) + +Harness ships with DeepSeek and mounts a generic multi-provider adapter alongside it, for the providers in pi-ai's installed catalog — Anthropic, OpenAI, and the rest — and for any OpenAI-compatible gateway or self-hosted server. You have two entry points: the **Models** page in the web UI, and `$DSH_HOME/settings.yaml`. Both write the same document, and a change takes effect on the next request without a restart. + +## Where providers come from + +`cordis.yml` decides which **adapters** are installed; the settings document decides which **providers** run. The shipped composition carries two LLM adapters: + +- `llm-deepseek` serves the `deepseek-official` route, the one available out of the box. +- `llm-pi-ai` mounts **dormant**: zero routes and no extra entries in the model picker until an `llm-pi-ai:` settings section supplies provider profiles, at which point those routes register live and drop again when the section empties. + +Adding a provider therefore rarely means editing `cordis.yml` — writing settings is enough, and that is exactly what the Models page does. + +## Configure from the web UI + +Start `pnpm run dsh web` and open **Settings → Models**. + +**Give DeepSeek its key.** The DeepSeek card carries one API-key field; fill it in, save, and the provider is ready. + +**Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe. + +**Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model. + +**Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. + +Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.env`, and the profile records only the variable name that references it. + +## settings.yaml for advanced configuration + +The document lives at `$DSH_HOME/settings.yaml` (`$DSH_HOME` defaults to `~/.dsh`). The Models page writes this file, and you can edit it directly; neither source outranks the other. + +```yaml +llm-deepseek: + reasoningEffort: high + +llm-pi-ai: + providers: + # Catalog route: endpoint, protocol, and models come from pi-ai; you supply + # the credential. + openai: + apiKeyEnv: OPENAI_API_KEY + + # Also a catalog route, moved to a private proxy, with its catalog narrowed + # to one model and that model's capacity corrected. Every unset field still + # comes from the catalog. + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY + baseURL: https://proxy.example.com:8443 + reasoning: high + models: + - id: claude-sonnet-4-5 + contextWindow: 200000 + + # Hand-declared route: pi-ai ships nothing under this key, so the profile + # supplies the whole provider. + acme-gateway: + displayName: Acme Gateway + apiKeyEnv: ACME_GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.acme.example/v1 + models: + - id: acme-large + name: Acme Large + contextWindow: 65536 + maxTokens: 4096 +``` + +A settings section merges over the matching `cordis.yml` configuration **per provider**, so you can override one field of one route and leave the rest as the composition set them. + +A profile the adapter could not serve is refused **where it is written**: a hand-declared route needs `api`, `baseURL`, and at least one model, and a profile missing any of them fails naming the offending route and model rather than being stored and quietly disabling the whole namespace. When an already-stored document is broken by an external edit, settings keeps the last good value and warns. + +## The model catalog + +A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit. + +Only the four fields the harness consumes are configurable: `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no consumer, and reasoning is not per-model configurable at all — it rides the installed catalog entry. + +A model neither the entry nor the catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields: a deployment whose gateway serves smaller models corrects them once. + +Model ids are not lifecycle configuration. Requesting a model the route does not configure fails with `UNKNOWN_MODEL` before any provider request goes out. + +## Credentials + +Prefer `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. A literal `apiKey` is the escape hatch. Omitting both is what leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold. + +References resolve from `$DSH_HOME/.env` — what the Models page's key fields write — and from the matching environment variable when no credential service is mounted. One credential serves every model on its route. + +## Point an agent at the new provider + +A configured route appears in the web model picker and can be switched at any time. To change the default, edit the `agent-loop` entry's `provider` and `model` in `cordis.yml`: + +```yaml +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: + - id: main + provider: acme-gateway + model: acme-large +``` + +## Troubleshooting + +- **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable. +- **`UNKNOWN_MODEL`** — the requested model is not in the route's configured catalog. Add it to `models`, or use an id the catalog already carries. +- **`settings-rejected`** — the written profile cannot be served, and the message names the route and model. For a hand-declared route, check that `api`, `baseURL`, and `models` are all present. +- **Fetching available models answers 401** — the endpoint refused the interrogation. Check the key; if the base URL points at an Anthropic-style gateway, note that the interrogation reads only the OpenAI-compatible `GET /models`, so enter the models by hand instead. + +## Exact field reference + +The complete fields, types, and defaults each plugin currently supports live in the generated [plugin configuration catalog](../../config-catalog.md). Each adapter's own semantics belong to its README: [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md). For `cordis.yml` itself, see [Configuration](./config.md). diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md new file mode 100644 index 0000000000..6ce513c659 --- /dev/null +++ b/docs/user/guide/providers.zh.md @@ -0,0 +1,113 @@ +# 配置模型提供方 + +[English](providers.md) | 中文 + +Harness 出厂就带 DeepSeek,同时挂着一个通用的多提供方适配器,用来接入 Anthropic、OpenAI 这类内置目录里的提供方,或任何 OpenAI 兼容的网关与自建服务。你有两个入口:Web 界面的**模型**页,以及 `$DSH_HOME/settings.yaml`。两者写的是同一份文档,改完下一次请求即生效,不用重启。 + +## 提供方从哪里来 + +`cordis.yml` 决定装了哪些**适配器**,settings 文档决定跑哪些**提供方**。出厂组合里有两个 LLM 适配器: + +- `llm-deepseek` 提供 `deepseek-official` 路由,是默认可用的那个。 +- `llm-pi-ai` 以**休眠**状态挂载:零路由,模型选择器里也不会多出条目,直到 settings 里的 `llm-pi-ai:` 段落给出 provider profile,路由才注册上来;段落清空则一并撤下。 + +因此新增一个提供方通常不需要改 `cordis.yml`,写 settings 就够了——而模型页做的正是这件事。 + +## 在 Web 界面里配置 + +启动 `pnpm run dsh web`,打开**设置 → 模型**。 + +**填 DeepSeek 的密钥。** DeepSeek 卡片上只有一个 API 密钥输入框,填好保存即可开始用。 + +**添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个(anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。 + +**添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID(请求里点名它、也作为凭据名的小写标识)、API 地址、协议,以及至少一个模型。 + +**让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 + +密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.env`,profile 里只记录引用它的变量名。 + +## settings.yaml:进阶配置 + +文档位于 `$DSH_HOME/settings.yaml`(`$DSH_HOME` 默认是 `~/.dsh`)。模型页写的就是这个文件,你也可以直接编辑它——两个来源没有主次之分。 + +```yaml +llm-deepseek: + reasoningEffort: high + +llm-pi-ai: + providers: + # Catalog route: endpoint, protocol, and models come from pi-ai; you supply + # the credential. + openai: + apiKeyEnv: OPENAI_API_KEY + + # Also a catalog route, moved to a private proxy, with its catalog narrowed + # to one model and that model's capacity corrected. Every unset field still + # comes from the catalog. + anthropic: + apiKeyEnv: ANTHROPIC_API_KEY + baseURL: https://proxy.example.com:8443 + reasoning: high + models: + - id: claude-sonnet-4-5 + contextWindow: 200000 + + # Hand-declared route: pi-ai ships nothing under this key, so the profile + # supplies the whole provider. + acme-gateway: + displayName: Acme Gateway + apiKeyEnv: ACME_GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.acme.example/v1 + models: + - id: acme-large + name: Acme Large + contextWindow: 65536 + maxTokens: 4096 +``` + +settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上,所以你可以只覆盖某个路由的一个字段,其余保持组合里的样子。 + +一份服务不了的 profile 会在**写入处**被拒绝:手工声明的路由必须给出 `api`、`baseURL` 和至少一个模型,缺了会带着路由名和模型名报错,而不是存下来再让整个命名空间静默失效。已经存好的文档被外部改坏时,settings 会保留上一次的好值并告警。 + +## 模型目录 + +`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑。 + +可配置的只有 harness 会消费的四个字段:`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态没有消费方,推理能力也不按模型配置——它随内置目录条目走。 + +两处容量都没给出的模型,取路由级兜底 `defaultContextWindow`(262144)与 `defaultMaxTokens`(32768)。这两个数按定义就是猜测,所以它们是路由字段:网关服务的模型更小时改一次即可。 + +模型 id 不是生命周期配置:请求一个该路由没有配置的模型,会在任何网络请求之前以 `UNKNOWN_MODEL` 失败。 + +## 凭据 + +优先用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件;`apiKey` 字面量是应急出口。两者都不给,才表示这个路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。 + +引用解析自 `$DSH_HOME/.env`(模型页的密钥输入框写的就是它),没有挂载凭据服务时则直接读同名环境变量。一份凭据供该路由上的所有模型使用。 + +## 让 agent 用上新提供方 + +配好的路由会出现在 Web 的模型选择器里,随时可切。要改默认值,就在 `cordis.yml` 里改 `agent-loop` 那条的 `provider` 与 `model`: + +```yaml +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: + - id: main + provider: acme-gateway + model: acme-large +``` + +## 排错 + +- **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 +- **`UNKNOWN_MODEL`** — 请求的模型不在该路由配置的目录里。把它加进 `models`,或改用目录里已有的 id。 +- **`settings-rejected`** — 写入的 profile 服务不了,错误信息会点名具体的路由和模型。手工声明的路由检查 `api`、`baseURL`、`models` 是否齐全。 +- **获取可用模型返回 401** — 端点拒绝了这次探测。检查密钥;若地址指向的是 Anthropic 风格网关,注意探测只读 OpenAI 兼容的 `GET /models`,此时手工填写模型即可。 + +## 精确字段参考 + +每个插件当前支持的完整字段、类型与默认值见自动生成的[插件配置目录](../../config-catalog.md)。两个适配器各自的语义由它们的 README 负责:[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 与 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md)。`cordis.yml` 本身的写法见[配置文件](./config.md)。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 2cf494f71a..74fd06f83d 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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/user/guide/quickstart.md -quickstart.md: 199b3f092159fa6fbaf3ae298151487c924ac6f1 -quickstart.zh.md: 9327ed646ba211bcce6426beb6bf76fca50acbf6 +quickstart.md: 8a9ed716d9395448aadfb97d0935bd42ee06e6c1 +quickstart.zh.md: 3652b0453f870640b278ce6f1355e67e85983ffe diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 199b3f0921..8a9ed716d9 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -57,5 +57,6 @@ headless-agent uses the `@deepseek-ai/dsh-cli-demo` app. `dsh web` instead compo ## Next steps +- [Model providers](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 9327ed646b..3652b0453f 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -57,5 +57,6 @@ headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app。`dsh web` 则组合 [`ap ## 下一步 +- [配置模型提供方](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的 tool 或后端 diff --git a/website/docs.ts b/website/docs.ts index 8d42ae3209..2b9c4654c4 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -130,13 +130,21 @@ const homeAndGuide = pairedPages([ section: { root: '入门', en: 'Guide' }, order: 2, }, + { + source: 'docs/user/guide/providers.md', + route: 'guide/providers.md', + label: { root: '配置模型提供方', en: 'Model providers' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, + order: 3, + }, { source: 'docs/user/guide/config.md', route: 'guide/config.md', label: { root: '配置文件', en: 'Configuration' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, - order: 3, + order: 4, }, ]) From a231b56eba22681e0015b6339f2116381584308b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 19:58:31 +0800 Subject: [PATCH 04/24] docs(user): show the Models page in the provider guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page told a reader to open Settings → Models and named the two buttons, which is thin help for someone who has never seen the page. Two screenshots per language carry it instead: the Models page with its provider card and both add actions, and the custom-provider form with the fields it asks for. They are the first images under docs/. The projector rewrites a repository-relative image to a raw.githubusercontent URL pinned at the built commit, so nothing is copied into the site bundle, and the pairing gate takes no signature from image nodes — which is what lets each language carry its own localized capture. --- docs/user/guide/providers-custom-form.png | Bin 0 -> 58692 bytes docs/user/guide/providers-custom-form.zh.png | Bin 0 -> 57720 bytes docs/user/guide/providers-models-page.png | Bin 0 -> 75818 bytes docs/user/guide/providers-models-page.zh.png | Bin 0 -> 70021 bytes docs/user/guide/providers.i18n.yaml | 4 ++-- docs/user/guide/providers.md | 4 ++++ docs/user/guide/providers.zh.md | 4 ++++ 7 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 docs/user/guide/providers-custom-form.png create mode 100644 docs/user/guide/providers-custom-form.zh.png create mode 100644 docs/user/guide/providers-models-page.png create mode 100644 docs/user/guide/providers-models-page.zh.png diff --git a/docs/user/guide/providers-custom-form.png b/docs/user/guide/providers-custom-form.png new file mode 100644 index 0000000000000000000000000000000000000000..bbbedde794dcf1f5b55fc7b9418ff7bca3e0cfde GIT binary patch literal 58692 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

78JYMMj+bnR^OS}))lPMzM4`fmdx>_mty#FX7 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 VNAUC%3!fJ?5@YXdS7&o3`JaP0h|&N6 literal 0 HcmV?d00001 diff --git a/docs/user/guide/providers-custom-form.zh.png b/docs/user/guide/providers-custom-form.zh.png new file mode 100644 index 0000000000000000000000000000000000000000..2c4812033145261e312c3e79348c38a4cd3d514b GIT binary patch literal 57720 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 LVLc;}}Q+v*3hLcuR#J#;$0B(|`}t&U&%c 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 z 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�)jwp);+2!aY8P>`UAK{{FQ;txeV5Yp?ah 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 V2}{D{Yioca44$rjF6*2UngBln29f{( literal 0 HcmV?d00001 diff --git a/docs/user/guide/providers-models-page.png b/docs/user/guide/providers-models-page.png new file mode 100644 index 0000000000000000000000000000000000000000..f3ffe6e90f6768c31a6816ff7728488eff70a893 GIT binary patch literal 75818 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∋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

(z(za4OSP(Lu_}EYqB{<4 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 Sw>7+jl9N%sl`DP!$$tSSuJy+N literal 0 HcmV?d00001 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 04b27adb51..8a3f518eca 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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/user/guide/providers.md -providers.md: 2ae1093699d8eb26171a2403db155113d84e437e -providers.zh.md: 6ce513c659140ed18716bd5c8f75c428ad981f2b +providers.md: c555e62d5343ccc758c0c6e699d30ffec229f2df +providers.zh.md: 6c4154c86db3d95c6b083519533954fc4cc90e45 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 2ae1093699..c555e62d53 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -17,12 +17,16 @@ Adding a provider therefore rarely means editing `cordis.yml` — writing settin Start `pnpm run dsh web` and open **Settings → Models**. +![The Models page: the DeepSeek card, with Add provider and Add a custom provider below it](providers-models-page.png) + **Give DeepSeek its key.** The DeepSeek card carries one API-key field; fill it in, save, and the provider is ready. **Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe. **Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model. +![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) + **Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save. Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.env`, and the profile records only the variable name that references it. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 6ce513c659..6c4154c86d 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -17,12 +17,16 @@ Harness 出厂就带 DeepSeek,同时挂着一个通用的多提供方适配器 启动 `pnpm run dsh web`,打开**设置 → 模型**。 +![模型页:DeepSeek 卡片,以及添加提供方与添加自定义提供方两个入口](providers-models-page.zh.png) + **填 DeepSeek 的密钥。** DeepSeek 卡片上只有一个 API 密钥输入框,填好保存即可开始用。 **添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个(anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。 **添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID(请求里点名它、也作为凭据名的小写标识)、API 地址、协议,以及至少一个模型。 +![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) + **让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。 密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.env`,profile 里只记录引用它的变量名。 From 2426be893a7200bcd3d508ab30704d370a8c61c2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 20:21:17 +0800 Subject: [PATCH 05/24] fix(scripts): let the documentation site carry its own images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An image the manifest does not publish was rewritten to a raw.githubusercontent URL, which serves a private repository to nobody: raw answers 404 unauthenticated, and a github.com session does not authenticate it. Every image on the site was broken for every reader, and no gate could say so — link checking asks whether the file exists in the repository, not whether a site reader can fetch it. The projector now copies a referenced image into the generated tree beside the page and points the reference at it, so Vite bundles it like any other site asset and repository visibility cannot reach the published page. The copy lands beside the page so one relative URL is right from both locale trees and a page's assets leave with the page; two sources projecting onto one path throw rather than letting the last copy win. Canonical Markdown keeps writing ordinary repository-relative paths, so the same file still renders when read in the repository. The guide this was found through is renamed to its subject: 配置模型. --- ...8-06-doc-site-carries-its-images.i18n.yaml | 6 +++ .../2026-08-06-doc-site-carries-its-images.md | 39 ++++++++++++++++++ ...26-08-06-doc-site-carries-its-images.zh.md | 39 ++++++++++++++++++ docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 2 +- docs/user/guide/quickstart.zh.md | 2 +- scripts/project-doc-site.spec.ts | 35 +++++++++++++++- scripts/project-doc-site.ts | 41 ++++++++++++++++--- website/docs.ts | 2 +- 12 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md create mode 100644 .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml new file mode 100644 index 0000000000..75018c8374 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.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/process/2026-08-06-doc-site-carries-its-images.md +2026-08-06-doc-site-carries-its-images.md: 21593c2cadb6b2aaf52350ab61156ad892bc4163 +2026-08-06-doc-site-carries-its-images.zh.md: 54aee878a9d0f16d1fe3b219da7b248fb5148fa3 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md new file mode 100644 index 0000000000..21593c2cad --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md @@ -0,0 +1,39 @@ +# Agent Note: The documentation site carries its own images + +Status: implemented + +English | [中文](2026-08-06-doc-site-carries-its-images.zh.md) + +## Problem + +`scripts/project-doc-site.ts` rewrote every repository-relative target that the publication manifest does not publish into a GitHub URL, and for an image that meant `https://raw.githubusercontent.com////`. Nothing in the site build copies files: `srcDir` is the disposable `.generated` tree, VitePress sets no `publicDir` (its default, `/public`, is inside the tree the projector deletes on every run), and only Markdown is written there. + +That works only for a public repository. This one is private, and `raw.githubusercontent.com` answers 404 to an unauthenticated request — a browser session on github.com does not authenticate it either, since GitHub's own UI serves private blobs through separately signed URLs. Every image on the site was therefore broken for every reader, and no gate said so: `verify-md-links` and the projector check that the target file *exists in the repository*, which is a different question from whether a site reader can fetch it. + +## Decision + +`rewriteMarkdown` takes an optional `placeImage(absPath): string`. When a page references an image the manifest does not publish as a page, the projector copies that file into the generated tree beside the page and rewrites the reference to `./`; Vite then bundles it like any other site asset. Nothing about repository visibility can reach the published page. + +The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. Two sources that would project onto one path throw, in the same spirit as the existing duplicate-route check, rather than letting whichever copied last win. + +`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text. + +Canonical Markdown keeps writing ordinary repository-relative image paths, so the same file renders on GitHub and on the site. No document carries a site-absolute URL to satisfy VitePress. + +## Alternatives considered + +**Set `publicDir` outside `.generated` and reference site-absolute URLs.** Fewer moving parts in the projector, but every image reference would then be broken when the same Markdown is read in the repository, and canonical docs are read both ways. + +**Host images on the assets branch, as demo GIFs already are.** That branch exists to keep large binaries out of the main history, and its raw URLs have exactly the same visibility problem. It remains the right home for recordings; it does not solve this. + +**Wait for the repository to become public.** It would fix the symptom without making the site self-contained, and the site would silently depend on GitHub's availability and rate limits for every image. + +## Consequences + +Images in published documentation now work regardless of who is reading or whether the repository is public, and the site build has no runtime dependency on GitHub for them. The generated tree grows by one copy of each referenced image per locale — the four screenshots in the model-provider guide add roughly 270 KB per locale. + +Images referenced from *unpublished* documents are untouched: they still resolve to GitHub raw, and still fail for a private repository. Nothing consumes them today, and a document that is not on the site has no site build to carry its assets. + +## Testing + +`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales). diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md new file mode 100644 index 0000000000..54aee878a9 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 文档站点自带图片 + +Status: implemented + +[English](2026-08-06-doc-site-carries-its-images.md) | 中文 + +## Problem + +`scripts/project-doc-site.ts` 会把发布清单未收录的仓库相对目标一律改写成 GitHub 地址,对图片而言就是 `https://raw.githubusercontent.com////`。站点构建不拷贝任何文件:`srcDir` 是用完即弃的 `.generated` 树,VitePress 没有设置 `publicDir`(其默认值 `/public` 恰好位于投影每次运行时删除的那棵树里),而写进去的只有 Markdown。 + +这只对公开仓库成立。本仓库是私有的,而 `raw.githubusercontent.com` 对未认证请求一律回 404——github.com 上的登录会话也不能认证它,因为 GitHub 自家界面是用另一套单独签名的地址提供私有 blob 的。于是站点上的每一张图片对每一位读者都是坏的,却没有任何门禁能说出来:`verify-md-links` 与投影校验的是目标文件**在仓库里是否存在**,那与站点读者能否取到它是两个问题。 + +## Decision + +`rewriteMarkdown` 新增可选的 `placeImage(absPath): string`。当页面引用了一张清单未作为页面发布的图片时,投影把该文件复制进生成树中该页面的旁边,并把引用改写为 `./`;随后 Vite 会像处理其他站点资源一样打包它。仓库可见性再也影响不到已发布页面。 + +副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/` 与 `en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。两个来源若会投影到同一路径则抛错——与既有的重复路由检查同一个立场——而不是让最后拷贝的那个静默胜出。 + +`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。 + +正本 Markdown 照旧写普通的仓库相对图片路径,因此同一份文件在 GitHub 上和站点上都能正常显示。没有任何文档为了迁就 VitePress 而写站内绝对 URL。 + +## Alternatives considered + +**把 `publicDir` 设到 `.generated` 之外,并使用站内绝对 URL。** 投影这边的活动部件更少,但同一份 Markdown 在仓库中阅读时,每一处图片引用都会是坏的,而正本文档是两种方式都要读的。 + +**把图片放到 assets 分支,就像演示 GIF 那样。** 那个分支的存在是为了让大体积二进制不进主线历史,而它的 raw 地址有着完全相同的可见性问题。它仍然是录屏的正确归宿;但它解决不了这件事。 + +**等仓库转为公开。** 那只是消除症状,不会让站点自给自足,而且每一张图片都会让站点隐式依赖 GitHub 的可用性与限流。 + +## Consequences + +已发布文档中的图片,现在无论谁在阅读、无论仓库是否公开都能显示,站点构建也不再为图片依赖 GitHub 的运行时可达性。生成树会为每个 locale 各增加一份被引用图片的副本——配置模型指南里的四张截图,每个 locale 约 270 KB。 + +**未发布**文档引用的图片不受影响:它们仍解析到 GitHub raw,对私有仓库仍然失败。今天没有任何消费方用到它们,而不在站点上的文档也没有站点构建可以承载其资源。 + +## Testing + +`scripts/project-doc-site.spec.ts` 覆盖:placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 8a3f518eca..324bcfb5c3 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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/user/guide/providers.md -providers.md: c555e62d5343ccc758c0c6e699d30ffec229f2df -providers.zh.md: 6c4154c86db3d95c6b083519533954fc4cc90e45 +providers.md: d96cab0fa09583d81d98863169819fdd78d636e7 +providers.zh.md: d413fec2f9d703e31e82e50fcbe83b24bd58ee39 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index c555e62d53..d96cab0fa0 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -1,4 +1,4 @@ -# Configure model providers +# Configure models English | [中文](providers.zh.md) diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 6c4154c86d..d413fec2f9 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -1,4 +1,4 @@ -# 配置模型提供方 +# 配置模型 [English](providers.md) | 中文 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 74fd06f83d..257f4919cc 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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/user/guide/quickstart.md -quickstart.md: 8a9ed716d9395448aadfb97d0935bd42ee06e6c1 -quickstart.zh.md: 3652b0453f870640b278ce6f1355e67e85983ffe +quickstart.md: e81e0ff57384156ee2963d4788519d2384c362ea +quickstart.zh.md: 9755da8bf078c817f9c2a00134360b5169c536bf diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 8a9ed716d9..e81e0ff573 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -57,6 +57,6 @@ headless-agent uses the `@deepseek-ai/dsh-cli-demo` app. `dsh web` instead compo ## Next steps -- [Model providers](./providers.md) — reach providers beyond DeepSeek, and custom gateways +- [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 3652b0453f..9755da8bf0 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -57,6 +57,6 @@ headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app。`dsh web` 则组合 [`ap ## 下一步 -- [配置模型提供方](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 +- [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的 tool 或后端 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 185acf2db5..c6402d7fc8 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -93,7 +93,7 @@ describe('rewriteMarkdown', () => { })).toBe('[B](./reference-root/b.md)\n') }) - it('uses raw GitHub content for unpublished images', () => { + it('uses raw GitHub content for unpublished images when nothing places them', () => { const { root, pages } = fixture() expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { locale: 'en', @@ -105,6 +105,39 @@ describe('rewriteMarkdown', () => { })).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n') }) + it('hands an image to the placer and uses the URL it returns', () => { + // A raw GitHub URL cannot serve a private repository, so the site build + // carries images itself; the placer is what puts them there. + const { root, pages } = fixture() + const placed: string[] = [] + expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + placeImage: (absPath) => { + placed.push(absPath.split('/').pop() ?? '') + return './logo.svg' + }, + })).toBe('![logo](./logo.svg)\n') + expect(placed).toEqual(['logo.svg']) + }) + + it('leaves a published page link to the route even when a placer exists', () => { + const { root, pages } = fixture() + expect(rewriteMarkdown('[B](b.md)\n', { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + placeImage: () => { throw new Error('a page link must not be placed as an asset') }, + })).toBe('[B](./reference/b.md)\n') + }) + it('does not rewrite Markdown-looking text inside code fences', () => { const { root, pages } = fixture() const source = '```md\n[B](b.md)\n```\n' diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 592bbcfdee..ef821bd00e 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -5,8 +5,8 @@ * tier, while this adapter rewrites cross-source links for the public site. */ -import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, extname, posix, relative, resolve, sep } from 'node:path' +import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' @@ -38,6 +38,15 @@ export interface RewriteMarkdownOptions { pages: DocsPage[] repoRoot: string repositoryRef: string + /** + * Place one referenced image beside the projected page and return the URL to + * reach it from that page. A GitHub raw URL cannot serve this repository — + * `raw.githubusercontent.com` answers 404 for a private one, and no reader of + * the site is authenticated to it — so an image travels into the generated + * tree and Vite bundles it like any other site asset. Omitted by callers that + * only rewrite text, which then leave images pointing at the repository. + */ + placeImage?: (absPath: string) => string } function repoPath(absPath: string, repoRoot: string): string { @@ -222,9 +231,11 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) ? options.locale === 'root' ? 'en' : 'root' : options.locale const page = published.get(targetPath)?.get(targetLocale) - const nextUrl = page === undefined - ? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') - : routeTarget(options.route, page.route, suffix) + const nextUrl = page !== undefined + ? routeTarget(options.route, page.route, suffix) + : node.type === 'image' && options.placeImage !== undefined + ? options.placeImage(absPath) + : githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') const start = node.position?.start.offset const end = node.position?.end.offset @@ -299,6 +310,8 @@ export function docsSourceFiles(): string[] { /** Rebuild the disposable VitePress source tree from the publication manifest. */ export function projectDocs(): void { const routes = new Set() + /** Projected asset path to the source it came from, for collision detection. */ + const assets = new Map() const repositoryRef = process.env.GITHUB_SHA ?? 'master' rmSync(generatedRoot, { recursive: true, force: true }) @@ -319,6 +332,24 @@ export function projectDocs(): void { pages: docsPages, repoRoot: root, repositoryRef, + placeImage: (absPath) => { + // Beside the page that references it, under its own basename: each + // locale's route tree gets its own copy, so one relative URL is correct + // from both. Two sources that would land on one name are a collision + // rather than a silent overwrite of whichever copied last. + const name = basename(absPath) + const target = resolve(dirname(output), name) + const claimed = assets.get(target) + if (claimed !== undefined && claimed !== absPath) { + throw new Error( + `project-doc-site: ${repoPath(absPath, root)} and ${repoPath(claimed, root)}` + + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`, + ) + } + assets.set(target, absPath) + copyFileSync(absPath, target) + return `./${name}` + }, }) writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page)) } diff --git a/website/docs.ts b/website/docs.ts index 2b9c4654c4..7bc2225865 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -133,7 +133,7 @@ const homeAndGuide = pairedPages([ { source: 'docs/user/guide/providers.md', route: 'guide/providers.md', - label: { root: '配置模型提供方', en: 'Model providers' }, + label: { root: '配置模型', en: 'Configure models' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 3, From 9ff7eb84f0c3c3ab28ca888db056fb703e2a3ea8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 20:35:02 +0800 Subject: [PATCH 06/24] docs: propose API key format validation --- ...-08-06-api-key-format-validation.i18n.yaml | 6 ++ .../2026-08-06-api-key-format-validation.md | 101 ++++++++++++++++++ ...2026-08-06-api-key-format-validation.zh.md | 101 ++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml create mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md create mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml new file mode 100644 index 0000000000..f62a18e0eb --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.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/proposed/bug-fix/2026-08-06-api-key-format-validation.md +2026-08-06-api-key-format-validation.md: dc19baa8b697998df2892f0840a35a8232cc92de +2026-08-06-api-key-format-validation.zh.md: 28073660b1d4868fecf5ce419726d6d997383392 diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md new file mode 100644 index 0000000000..dc19baa8b6 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md @@ -0,0 +1,101 @@ +# Agent Note: Validate API key format before it reaches an HTTP header + +Status: proposed + +English | [中文](2026-08-06-api-key-format-validation.zh.md) + +## Problem + +An API key holding characters no HTTP header value can carry is accepted by every configuration surface and fails only when a request is built, far from the field that caused it. + +Paste a key containing an emoji, CJK text, or a full-width punctuation mark into the web Models page and the save reports success. The first turn then fails with `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255` — the index and code point are UTF-16 internals with no action attached, and they disclose the code point of one character of the key. `llm-deepseek` produces this because `fetch` builds the `Bearer` header inside the `try` at [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts), whose `catch` labels every failure `TRANSPORT`; that label is in `DEFAULT_RETRYABLE_CODES`, so a permanent, deterministic fault is also retried three times. + +`llm-pi-ai` is worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wraps every failure as `could not reach `, so a local key fault is reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sends an illegal key before anything is stored. + +Whitespace passes every check. `ProviderEditor` tests `keyDraft.length` and `resolveAdapterOptions` tests `config.apiKey.length`, so a key of three spaces stores and then authenticates as `Bearer` plus blanks. `llm-pi-ai` rejects an empty literal `apiKey` in `resolveProfiles`, but applies no check whatsoever to a credential- or environment-sourced key — which is the path the Models page writes, and therefore the path users actually take. + +Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. + +## Proposal + +One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. + +This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. + +A second, narrower rule catches a pasted environment line: reject input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. + +### Invariants belong at every layer; heuristics belong where the human is + +The charset rule is an invariant. A non-ASCII character *cannot* travel in a header value for any provider, so enforcing it in the browser, in each resolver, and on every credential read is consistent by construction rather than by agreement. + +The shape rule is a guess about how people paste, so it runs **only in the browser**. `llm-pi-ai` fronts OpenAI, Anthropic, and arbitrary hand-declared gateways whose key formats this repository does not own; a gateway issuing a key shaped like `TENANT1=abc` would, if the rule ran in the resolver, be locked out with no escape — the settings page would refuse it and a hand-written `.env` would be rejected on read. Confining the heuristic to the surface where the paste happens keeps the environment as the way through. + +### Absence is a configuration state, not a missing key + +"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. + +**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` exists to carry this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. + +**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` already skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field must never block submit, or editing a base URL would demand re-entering the key. + +**Provided, but empty or whitespace-only.** This is the one error, because the user expressed an intent to set a key and supplied nothing. `llm-pi-ai` already words it correctly in `resolveProfiles` — *has an empty apiKey; omit it to use ambient authentication* — and that shape, naming the legitimate alternative rather than just refusing, is what the other surfaces adopt. + +`normalizeApiKey` therefore takes `string`, never `string | undefined`. + +### Where the rule lives + +`normalizeApiKey` is a new module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`). + +The client cannot import it: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema today. Each side names the other in a comment. + +### What each surface does + +| Surface | Change | +|---|---| +| `dsh-llm` | Add `normalizeApiKey`; add `INVALID_CREDENTIAL`, deliberately outside `DEFAULT_RETRYABLE_CODES`. | +| `llm-deepseek` `resolveAdapterOptions` | Normalize a present `apiKey`, throwing beside the existing beyond-schema bounds; use the trimmed value. An absent one still falls through to `apiKeyEnv`. Closes dsh-external#210. | +| `llm-deepseek` `resolveApiKey` | Normalize what the credentials seam or environment returns; reject with `INVALID_CREDENTIAL` naming the Models page, never echoing the key. | +| `llm-pi-ai` `resolveProfiles` | Widen the existing emptiness check to the shared rule, keeping its "omit it to use ambient authentication" wording. | +| `llm-pi-ai` `resolveApiKey` | Normalize the credential and environment paths, which are unchecked today. A profile naming no credential still returns `undefined` untouched, so ambient and OAuth routes are unaffected. | +| `llm-pi-ai` `discoverModels` | Normalize before building the header, so an illegal key stops reporting as an unreachable endpoint. A probe carrying no key stays unauthenticated as it is today. | +| `ui-models` | Mirror the charset rule, add the shape heuristic, trim `keyDraft` before probe and `credentials.set`, and fix the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure, so typed input is never silently discarded. Gate submit and show the failure on the field, matching the existing `modelFailure` pattern. | + +`ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. + +`credentials-local` is deliberately untouched. It stores credentials generally, and printable-ASCII is a constraint of HTTP headers rather than of credential storage; its existing refusal of values no dotenv style can represent stays as it is. + +## Alternatives considered + +**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It loses because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. + +**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. + +**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe. + +**Enforcing in `credentials-local.set`.** It would catch every writer at once, including a hand-edited file. It loses because that provider stores credentials of every kind, and a rule derived from HTTP header encoding does not belong to it. + +**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. + +**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on today's code, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verifies nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this note makes reliable; building it first would produce a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call at save time would be an unexpected behavior rather than a missing one. + +## Acceptance criteria + +- The browser, both resolvers, and both credential reads accept and reject the same *provided* strings: whitespace-only, padded, interior-space, C0 control, emoji, CJK, and full-width inputs are refused; a printable-ASCII key is accepted, trimmed. +- A profile naming no credential still resolves to no key, and a route authenticating through the installed provider's own ambient discovery or OAuth keeps working untouched. +- A blank key field saves the rest of the card without writing a credential; a field holding only whitespace fails on the field instead of being silently dropped. +- A rejected key names the API key field in the web UI and blocks submit; nothing is written to settings or credentials. +- A key that reaches a resolver illegally fails as `INVALID_CREDENTIAL` with a message naming where to fix it, containing no part of the key, and is not retried. +- `llm-pi-ai` discovery reports an illegal key as a key fault, not as an unreachable endpoint. +- A legal key still travels the existing `credentials.set` path unchanged. + +## Risks + +The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. + +Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. + +The charset predicate exists twice, once per source plane. The layout forbids sharing it, and the duplication gate may flag the pair; each side carries its own test and names its twin. + +The costliest way to get this wrong is to treat absence as invalidity. A rule applied to `undefined` would break every route authenticating through ambient discovery or OAuth — `openai-codex` cannot take a key at all — and a blank field that blocked submit would make editing any other setting demand re-entering the key. Both belong in the tests, not only in this note. + +Keys already stored by an earlier build are read through `resolveApiKey`, so an illegal stored value begins failing at resolution rather than at request time. That is the intent — the diagnosis improves — but it moves the failure earlier for anyone currently holding one. diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md new file mode 100644 index 0000000000..28073660b1 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -0,0 +1,101 @@ +# Agent Note: 在 API Key 进入 HTTP header 之前校验其格式 + +Status: proposed + +[English](2026-08-06-api-key-format-validation.md) | 中文 + +## Problem + +一个含有 HTTP header value 无法承载的字符的 API Key,会被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。 + +把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。 + +同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach `,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 + +空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 + +来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 + +## Proposal + +一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 + +这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 + +第二条更窄的规则用于识别整行粘贴的环境变量:拒绝匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 + +### 不变量属于每一层,启发式属于人所在的那一层 + +字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。 + +形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。 + +### 「没有 Key」是一种配置状态,不是缺失 + +在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 + +**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 的存在就是为了承载这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 + +**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时本就完全跳过 `credentials.set`,这一点保持不变:留空绝不能拦截提交,否则改一个 base URL 都得重新输一遍 Key。 + +**已提供,但为空或纯空白。** 这是唯一的错误,因为用户表达了设置 Key 的意图却什么都没给。`llm-pi-ai` 在 `resolveProfiles` 中的措辞本就是对的——*has an empty apiKey; omit it to use ambient authentication*——这种指明合法替代路径而非单纯拒绝的形态,正是其他界面要采用的。 + +因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 + +### 规则住在哪里 + +`normalizeApiKey` 是 `dsh-llm` seam 的新模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。 + +客户端无法引入它:client 包只 reference client 包,因此 `packages/client/ui-models` 镜像这个断言并持有本地化文案,正如今天 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。 + +### 各个界面各做什么 + +| 界面 | 改动 | +|---|---| +| `dsh-llm` | 新增 `normalizeApiKey`;新增 `INVALID_CREDENTIAL`,刻意不进 `DEFAULT_RETRYABLE_CODES`。 | +| `llm-deepseek` `resolveAdapterOptions` | 归一化已提供的 `apiKey`,与既有的超出 schema 的边界检查并排抛错;使用 trim 后的值。缺省的 `apiKey` 仍照旧回落到 `apiKeyEnv`。关闭 dsh-external#210。 | +| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值;以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | +| `llm-pi-ai` `resolveProfiles` | 把既有的空值检查扩展为这条共享规则,并保留其「omit it to use ambient authentication」的措辞。 | +| `llm-pi-ai` `resolveApiKey` | 归一化今天完全未受检的凭据与环境路径。不指定任何凭据的 profile 仍原样返回 `undefined`,ambient 与 OAuth 路由不受影响。 | +| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 不再被报成端点不可达。不带 Key 的探测照旧保持未鉴权。 | +| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则以字段级失败呈现,使已输入的内容绝不被静默丢弃。按既有 `modelFailure` 的模式拦截提交并在字段上呈现失败。 | + +`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。 + +`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。 + +## Alternatives considered + +**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 + +**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 + +**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。 + +**在 `credentials-local.set` 中执行。** 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该 provider 存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。 + +**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 + +**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在今天的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本 Agent Note 要让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 + +## Acceptance criteria + +- 浏览器、两个 resolver 与两处凭据读取接受与拒绝同一组**已提供**的字符串:纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角输入均被拒绝;可打印 ASCII 的 Key 被接受并 trim。 +- 不指定任何凭据的 profile 仍解析为「没有 Key」,通过内置 provider 自身的 ambient 发现或 OAuth 鉴权的路由原样可用。 +- 留空的 Key 输入框可以保存卡片其余部分而不写入凭据;只含空白的输入框则以字段级失败呈现,而不是被静默丢弃。 +- 被拒绝的 Key 在 Web UI 中定位到 API Key 字段并拦截提交;settings 与凭据均不写入。 +- 非法抵达 resolver 的 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置、不含 Key 的任何片段,且不被重试。 +- `llm-pi-ai` 的探测把非法 Key 报为 Key 故障,而非端点不可达。 +- 合法 Key 仍沿既有 `credentials.set` 路径原样通过。 + +## Risks + +形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 + +限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 + +字符集断言存在两份,每个 source plane 一份。布局禁止共享它,重复检测门禁可能会标记这一对;两侧各自带测试并在注释中指名其孪生体。 + +把这件事做错的最大代价,是把「未指定」当成「非法」。一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由——`openai-codex` 根本无法接受 Key——而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都应落在测试里,而不只是写在本 Agent Note 中。 + +早先版本已存下的 Key 会经 `resolveApiKey` 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。这正是意图所在——诊断变好了——但对当前正持有这类值的人而言,失败点提前了。 From 5b842895a8749f17224263b361ad3696200944cc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 20:43:50 +0800 Subject: [PATCH 07/24] feat(llm): define the legal API key shape in the seam --- packages/llm/llm/src/api-key.ts | 41 +++++++++++++++ packages/llm/llm/src/error.ts | 9 ++++ packages/llm/llm/src/index.ts | 34 ++++++++++++- packages/llm/llm/tests/api-key.spec.ts | 70 ++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 packages/llm/llm/src/api-key.ts create mode 100644 packages/llm/llm/tests/api-key.spec.ts diff --git a/packages/llm/llm/src/api-key.ts b/packages/llm/llm/src/api-key.ts new file mode 100644 index 0000000000..85d0b1ed60 --- /dev/null +++ b/packages/llm/llm/src/api-key.ts @@ -0,0 +1,41 @@ +/** + * The one definition of a well-formed provider API key, shared by every + * adapter that puts one in an HTTP header. + * @module @deepseek-ai/dsh-llm/api-key + */ + +/** + * Characters an HTTP header value carries verbatim and every known provider + * key uses: printable ASCII, space excluded. A key outside this set cannot + * reach any provider — `fetch` refuses to build the header — so this is a + * transport invariant rather than one provider's policy. Latin-1 is excluded + * deliberately: a header could carry it, but no provider issues it, and + * admitting it trades a local explained refusal for an opaque 401. + */ +const LEGAL_API_KEY = /^[\x21-\x7E]+$/ + +/** Why a supplied API key cannot be used. */ +export type ApiKeyRejection = 'empty' | 'illegalCharacters' + +/** The verdict on one supplied API key. */ +export type ApiKeyCheck = + | { readonly ok: true; readonly value: string } + | { readonly ok: false; readonly reason: ApiKeyRejection } + +/** + * Judge one *supplied* API key, trimming surrounding whitespace first. + * + * Trimming is silent because a padded key has one unambiguous reading; every + * other defect is reported. Absence is a configuration state this function + * never sees — a profile naming no credential authenticates through the + * provider's own ambient discovery or OAuth — so callers decide whether a + * value was supplied before asking. + * @param raw - the key exactly as configured, stored, or typed. + * @returns the trimmed key, or why it cannot be used. + */ +export function normalizeApiKey(raw: string): ApiKeyCheck { + const value = raw.trim() + if (value.length === 0) return { ok: false, reason: 'empty' } + if (!LEGAL_API_KEY.test(value)) return { ok: false, reason: 'illegalCharacters' } + return { ok: true, value } +} diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index fbb8bccca5..9ff193f1f8 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -38,6 +38,15 @@ export const QUOTA_EXCEEDED_CODE = 'QUOTA' */ export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE' +/** + * Canonical provider-neutral code for a credential that was supplied but + * cannot be used — malformed rather than absent. Distinct from + * `MISSING_CREDENTIAL` because the fix differs: correct the stored value + * rather than supply one. Deliberately outside the default retryable set — + * a malformed credential fails identically on every attempt. + */ +export const INVALID_CREDENTIAL_CODE = 'INVALID_CREDENTIAL' + /** Structured codes and plain phrases that explicitly name a context bound being exceeded. */ const STRUCTURED_CONTEXT_OVERFLOW = new RegExp( String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 74ca171f64..287bfc2f34 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -25,13 +25,15 @@ import type { ResolvedRetryPolicy } from './retry-policy.ts' import type { ProviderRequestId } from './brand.ts' import { callConfigEquals, deepFreeze } from './call-config.ts' import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' -import { HarnessError } from './error.ts' +import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts' import { normalizeLlmFailure } from './adapter-failure.ts' +import { normalizeApiKey } from './api-key.ts' export * from './attribution.ts' export * from './brand.ts' export * from './never.ts' export * from './error.ts' +export * from './api-key.ts' export * from './types.ts' export * from './message.ts' export * from './retry-policy.ts' @@ -122,6 +124,36 @@ export class LlmError extends HarnessError { } } +/** + * Accept one supplied credential, or refuse it as unusable. + * + * A stored key arrives from the credentials seam, a `.env` line, or a shell + * export, all of which pick up surrounding whitespace, so trimming is silent. + * Anything else fails here rather than inside `fetch`, whose ByteString + * refusal names a UTF-16 code point instead of the setting to change. The key + * never enters the message: `ref` names where to fix it, and echoing any part + * of a secret into a log or a UI is the failure this diagnosis avoids. + * + * Lives beside {@link LlmError} rather than in `./api-key.ts` so the predicate + * module stays dependency-free; both adapters share this one diagnosis instead + * of keeping near-identical local copies. + * @param raw - the credential exactly as supplied. + * @param pkg - the refusing package name, prefixed to the diagnostic. + * @param ref - the credential reference the value resolved through. + * @returns the trimmed, usable key. + */ +export function assertUsableApiKey(raw: string, pkg: string, ref: string): string { + const checked = normalizeApiKey(raw) + if (checked.ok) return checked.value + throw new LlmError( + checked.reason === 'empty' + ? `${pkg}: the API key stored as ${ref} is blank; re-enter it on the web Models page` + : `${pkg}: the API key stored as ${ref} contains characters no HTTP header can carry;` + + ' re-enter it on the web Models page, pasting the raw key only', + INVALID_CREDENTIAL_CODE, + ) +} + /** One model call whose config and adapter registration were resolved together. */ export interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ diff --git a/packages/llm/llm/tests/api-key.spec.ts b/packages/llm/llm/tests/api-key.spec.ts new file mode 100644 index 0000000000..a04a103fb9 --- /dev/null +++ b/packages/llm/llm/tests/api-key.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { assertUsableApiKey, INVALID_CREDENTIAL_CODE, normalizeApiKey } from '@deepseek-ai/dsh-llm' + +describe('normalizeApiKey', () => { + it('accepts a printable-ASCII key unchanged', () => { + expect(normalizeApiKey('sk-0123456789abcdef')).toEqual({ ok: true, value: 'sk-0123456789abcdef' }) + }) + + it('trims surrounding whitespace before judging', () => { + expect(normalizeApiKey(' sk-abc\t\n')).toEqual({ ok: true, value: 'sk-abc' }) + }) + + it.each([ + ['an empty string', ''], + ['spaces only', ' '], + ['a tab only', '\t'], + ])('rejects %s as empty', (_label, raw) => { + expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'empty' }) + }) + + it.each([ + ['an emoji', 'sk-\u{1F600}abc'], + ['CJK text', 'sk-你好'], + ['full-width punctuation', 'sk-abc,'], + ['an interior space', 'sk-abc def'], + ['a C0 control character', 'sk-abc\x01'], + ['a latin-1 character', 'sk-café'], + ])('rejects %s as illegal characters', (_label, raw) => { + expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'illegalCharacters' }) + }) + + it('accepts the printable-ASCII boundary characters', () => { + expect(normalizeApiKey('!~')).toEqual({ ok: true, value: '!~' }) + }) + + it('publishes a code distinct from a missing credential', () => { + expect(INVALID_CREDENTIAL_CODE).toBe('INVALID_CREDENTIAL') + }) +}) + +describe('assertUsableApiKey', () => { + it('returns the trimmed key when it is usable', () => { + expect(assertUsableApiKey(' sk-abc ', 'llm-deepseek', 'DEEPSEEK_API_KEY')).toBe('sk-abc') + }) + + it('refuses a blank stored credential, naming the reference', () => { + expect(() => assertUsableApiKey(' ', 'llm-deepseek', 'DEEPSEEK_API_KEY')) + .toThrow(/llm-deepseek: the API key stored as DEEPSEEK_API_KEY is blank/) + }) + + it('refuses an unusable stored credential with the invalid-credential code', () => { + try { + assertUsableApiKey('sk-\u{1F600}', 'llm-pi-ai', 'ACME_API_KEY') + expect.fail('an illegal key must throw') + } catch (error) { + expect((error as { code: string }).code).toBe(INVALID_CREDENTIAL_CODE) + expect((error as Error).message).toContain('llm-pi-ai') + expect((error as Error).message).toContain('ACME_API_KEY') + } + }) + + it('never echoes the key it refuses', () => { + try { + assertUsableApiKey('sk-\u{1F600}supersecret', 'llm-deepseek', 'DEEPSEEK_API_KEY') + expect.fail('an illegal key must throw') + } catch (error) { + expect((error as Error).message).not.toContain('supersecret') + } + }) +}) From 88f5de57559d76fdcefd6d79621c466534c9996d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 20:57:30 +0800 Subject: [PATCH 08/24] docs: regenerate cordis catalog and event graph for shifted index.ts lines --- docs/cordis-catalog/events.md | 4 ++-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 44e0727101..b8eae843cc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -493,7 +493,7 @@ The provider topology changed: an adapter registered or unregistered routes, or 'llm/adapters-updated'(): void ``` -Source: [`packages/llm/llm/src/index.ts:71`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) ### `llm/stream` — waterfall @@ -517,7 +517,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:60`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 896c17fb56..6b30d80751 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -941,7 +941,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../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:255`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:287`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3de02d1b4c..b2d8feaa1d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -28,8 +28,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:141`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:95`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | From b6b57ceda3c4b1a71b8741361b538699e2bcd2f3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 20:57:43 +0800 Subject: [PATCH 09/24] docs(llm): document the invalid-credential code --- packages/llm/llm/README.i18n.yaml | 4 ++-- packages/llm/llm/README.md | 5 +++++ packages/llm/llm/README.zh.md | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 5e4daa179b..efdb8ea511 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: ca34ffdeaafdbe061e030c80997b7234ce36a1bd -README.zh.md: 1f95d3cd641126e129f94fe31269454a1bcce972 +README.md: 618d5f9f7c69c3ff2b420ae3fec96604802bf1be +README.zh.md: 4b99c477eae694d1315d90920e835fcfde3b571a diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index ca34ffdeaa..618d5f9f7c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -63,6 +63,10 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). +### API key validation (`api-key.ts`) + +Every adapter that puts a credential in an HTTP header judges it the same way before use. `normalizeApiKey(raw)` trims surrounding whitespace, then accepts any non-empty printable-ASCII value (`/^[\x21-\x7E]+$/`, space excluded) or reports why not as an `ApiKeyRejection` (`'empty'` | `'illegalCharacters'`), both carried in the `ApiKeyCheck` result. Absence is never judged: a caller decides whether a value was supplied before asking, since a profile naming no credential authenticates through the provider's own ambient discovery or OAuth. + ### Classes - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. @@ -73,6 +77,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail. - `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits. - `EMPTY_RESPONSE_CODE` — the provider-neutral code both adapters use for a degenerate provider completion: a terminal `stop` that carried no content blocks at all. Classified as an error finish (not a successful empty message) because the attempt produced nothing durable; `dsh-llm-retry` retries it by default. +- `INVALID_CREDENTIAL_CODE` — the provider-neutral code for a credential that was supplied but cannot be used: malformed rather than absent, so the fix is to correct the stored value rather than supply one — the distinction from `MISSING_CREDENTIAL`. Deliberately excluded from the default retryable set, since a malformed credential fails identically on every attempt. `assertUsableApiKey(raw, pkg, ref)` throws `LlmError` with this code, the one shared diagnosis every adapter uses for an unusable stored credential. ### Real adapters diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 1f95d3cd64..4b99c477ea 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -63,6 +63,10 @@ 每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 +### API 密钥校验(`api-key.ts`) + +每个要把凭据放进 HTTP 标头的适配器,使用前都以同一套规则校验它。`normalizeApiKey(raw)` 先去除首尾空白,再接受任意非空的可打印 ASCII 值(`/^[\x21-\x7E]+$/`,不含空格),否则以 `ApiKeyRejection`(`'empty'` | `'illegalCharacters'`)说明拒绝原因,二者一并包含在 `ApiKeyCheck` 结果中。缺失从不参与校验:调用方会在询问之前自行判断是否提供了值——未点名凭据的 profile 会转由提供方自身的环境发现或 OAuth 完成认证。 + ### 类 - `LlmAdapter`:提供方适配器的抽象基类。唯一必需方法是 `stream()`。 @@ -73,6 +77,7 @@ - `CONTEXT_WINDOW_EXCEEDED_CODE`:当请求超过模型上下文窗口时,无论通过 HTTP 异常抛出还是带内 finish 交付,两个 DeepSeek 适配器都使用的提供方无关 code。`isContextWindowExceededError(detail)` 是它们针对 OpenAI 兼容提供方详细信息的共享保守分类器。 - `QUOTA_EXCEEDED_CODE`:帐户配额、余额、点数、预算或用量限制耗尽时使用的非短暂提供方无关 code。`isQuotaExceededError(detail)` 使这些失败与请求速率限制保持区分。 - `EMPTY_RESPONSE_CODE`:两个适配器都使用的提供方无关 code,用于表示退化的提供方生成结果:一个未携带任何内容块的终止 `stop`。它会被分类为错误 finish(而非成功空消息),因为尝试未产生持久内容;`dsh-llm-retry` 默认重试它。 +- `INVALID_CREDENTIAL_CODE`:已提供但无法使用的凭据所用的提供方无关 code——格式错误而非缺失,修复方式是改正已存储的值而非补供一个,这正是它与 `MISSING_CREDENTIAL` 的区别。它被刻意排除在默认可重试集合之外:格式错误的凭据每次尝试都会以同样方式失败。`assertUsableApiKey(raw, pkg, ref)` 会以该 code 抛出 `LlmError`,是每个适配器判定已存储凭据不可用时共用的诊断。 ### 真实适配器 From a48b84c001c885b2dc209bbcef2f6b87a03cc7c4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 21:14:39 +0800 Subject: [PATCH 10/24] fix(scripts): only publish images the repository owns, and keep their suffix Review found four real gaps in the image placement this PR introduced. Link rewriting only needs a target to exist, but publication copies its bytes onto the site: a reference reaching out of the tree through `../..` or a symlink would put a build-machine file on a published page. Only a regular file whose real path stays inside the repository is copied now, and anything else fails the projection naming the page and the target. A placed reference kept none of its `?query` or `#fragment`, which the GitHub branch has always carried and which decides what an SVG view fragment or a Vite query means. The suffix rides along again, and the file name is percent-encoded because the destination is a Markdown inline target. Page outputs and placed images now claim projected paths from one map, so the "fail loud rather than overwrite" invariant covers a page and an image landing on one path, not only two images. `docsSourceFiles()` reports placed images, so replacing a screenshot re-projects under `docs:dev` instead of serving the previous copy until something touches the page. The guide said to set `agent-loop`'s `agents` to change the default model, which does nothing for `dsh web`: that default is `api-gateway`'s, and the shipped composition leaves `agents` empty. It also promised that a catalog provider needs only an API key, which is false for Bedrock, Vertex, Azure, and Codex. Both are corrected. The projection note and the doc-site skill carried the superseded "a repository image becomes a raw GitHub URL" rule; both now describe what ships. --- ...13-documentation-site-projection.i18n.yaml | 4 +- ...026-07-13-documentation-site-projection.md | 2 +- ...-07-13-documentation-site-projection.zh.md | 2 +- ...8-06-doc-site-carries-its-images.i18n.yaml | 4 +- .../2026-08-06-doc-site-carries-its-images.md | 8 +- ...26-08-06-doc-site-carries-its-images.zh.md | 8 +- .agents/skills/dsh-doc-site-sync/SKILL.md | 1 + docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 17 +-- docs/user/guide/providers.zh.md | 17 +-- scripts/project-doc-site.spec.ts | 56 +++++++++- scripts/project-doc-site.ts | 105 ++++++++++++++---- 12 files changed, 178 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 74ded5f605..7fa4d3fbba 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.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/process/2026-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: 2452c9dfa53e05061446df2fe650f3b4d6428c01 -2026-07-13-documentation-site-projection.zh.md: 6f1c79ac502a04714cd77f680108dbff035b048c +2026-07-13-documentation-site-projection.md: f19d9b309aa22821a75086dc07ee302097631ba0 +2026-07-13-documentation-site-projection.zh.md: cc5e94e709f0639fd35ad81165b199cc5c9effc0 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index 2452c9dfa5..f19d9b309a 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -18,7 +18,7 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. -The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image becomes a raw GitHub URL. Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. +The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index 6f1c79ac50..cc5e94e709 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -18,7 +18,7 @@ Status: implemented 各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 -投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会变成 GitHub raw URL。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml index 75018c8374..32b51699e2 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.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/process/2026-08-06-doc-site-carries-its-images.md -2026-08-06-doc-site-carries-its-images.md: 21593c2cadb6b2aaf52350ab61156ad892bc4163 -2026-08-06-doc-site-carries-its-images.zh.md: 54aee878a9d0f16d1fe3b219da7b248fb5148fa3 +2026-08-06-doc-site-carries-its-images.md: 9109808874579b79d85c2e22b0987110f41ddc42 +2026-08-06-doc-site-carries-its-images.zh.md: d601112e8870150c363d8533e85ef86e7f3f8ffc diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md index 21593c2cad..9109808874 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md @@ -14,7 +14,11 @@ That works only for a public repository. This one is private, and `raw.githubuse `rewriteMarkdown` takes an optional `placeImage(absPath): string`. When a page references an image the manifest does not publish as a page, the projector copies that file into the generated tree beside the page and rewrites the reference to `./`; Vite then bundles it like any other site asset. Nothing about repository visibility can reach the published page. -The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. Two sources that would project onto one path throw, in the same spirit as the existing duplicate-route check, rather than letting whichever copied last win. +The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. One map claims every projected path — pages and images alike — so a second source for one path throws, in the same spirit as the existing duplicate-route check, rather than letting whichever wrote last win. + +Only a regular file whose real path stays inside the repository is copied; anything else fails the projection naming the page and the target. Link rewriting needs to know a target *exists*, but publication copies its bytes onto the site, so a reference escaping the repository — through `../..` or a symlink out of the tree — would put a build-machine file on a published page. The reference's `?query` or `#fragment` rides along to the placed URL exactly as the GitHub branch has always carried it, and the file name is percent-encoded because the destination is a Markdown inline target. + +`docsSourceFiles()` reports the placed images alongside the Markdown, so the dev server's watcher re-projects when a screenshot is replaced instead of serving the previous copy until something touches the page. `placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text. @@ -36,4 +40,4 @@ Images referenced from *unpublished* documents are untouched: they still resolve ## Testing -`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales). +`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a placed reference keeping its fragment, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `publishableImage` is covered directly: a regular file inside the repository resolves, while a symlink whose target escapes it, a path outside it, and a directory are all refused. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales). diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md index 54aee878a9..d601112e88 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md @@ -14,7 +14,11 @@ Status: implemented `rewriteMarkdown` 新增可选的 `placeImage(absPath): string`。当页面引用了一张清单未作为页面发布的图片时,投影把该文件复制进生成树中该页面的旁边,并把引用改写为 `./`;随后 Vite 会像处理其他站点资源一样打包它。仓库可见性再也影响不到已发布页面。 -副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/` 与 `en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。两个来源若会投影到同一路径则抛错——与既有的重复路由检查同一个立场——而不是让最后拷贝的那个静默胜出。 +副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/` 与 `en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。一张表登记所有被投影的路径——页面与图片一视同仁——同一路径出现第二个来源就抛错,与既有的重复路由检查同一个立场,而不是让最后写入的那个静默胜出。 + +只有真实路径位于仓库内的普通文件才会被拷贝,其余一律让投影失败并点名页面与目标。链接改写只需要知道目标**存在**,但发布是把它的字节拷上站点,因此一个逃出仓库的引用——经由 `../..` 或指向树外的符号链接——会把构建机上的文件放到已发布页面上。引用自带的 `?query` 或 `#fragment` 会随安置后的 URL 一同保留,与 GitHub 分支一贯的做法一致;文件名做百分号编码,因为目标位于 Markdown 内联目标的位置。 + +`docsSourceFiles()` 会连同被安置的图片一起上报,于是替换截图时开发服务器的 watcher 会重新投影,而不是一直服务旧副本直到有人碰一下页面。 `placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。 @@ -36,4 +40,4 @@ Status: implemented ## Testing -`scripts/project-doc-site.spec.ts` 覆盖:placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。 +`scripts/project-doc-site.spec.ts` 覆盖:placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、被安置的引用保留其 fragment、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`publishableImage` 另有直接覆盖:仓库内的普通文件被接受,而目标逃出仓库的符号链接、仓库外的路径与目录一律拒绝。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。 diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 4d88d3f04f..3f93a6560a 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -46,6 +46,7 @@ Write normal repository-relative Markdown links in canonical docs. The projector - A target present in the manifest becomes a site-relative route. - An existing target outside the manifest becomes a GitHub source link, including supported line suffixes. +- An image is the exception: its file is copied into the generated tree and referenced from there, so the site serves it regardless of repository visibility. It must be a regular file inside the repository. - External URLs, site-absolute URLs, email links, and fragment-only links remain unchanged. - A missing repository-relative target fails projection instead of silently producing a broken link. diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 324bcfb5c3..665eae8457 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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/user/guide/providers.md -providers.md: d96cab0fa09583d81d98863169819fdd78d636e7 -providers.zh.md: d413fec2f9d703e31e82e50fcbe83b24bd58ee39 +providers.md: 66b6cf25c61a252fbd10a85f8c79c246eeae8abe +providers.zh.md: a2c33c90be971e09ab29e2355ca6a7ae6f947c39 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index d96cab0fa0..66b6cf25c6 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -23,6 +23,8 @@ Start `pnpm run dsh web` and open **Settings → Models**. **Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe. +That holds for providers that authenticate with an API key. The catalog also carries Bedrock, Vertex, Azure, and Codex, which need AWS credentials and a region, an ADC project, an `api-version`, and OAuth respectively: filling in the key field alone will not make them work. Those authenticate through pi-ai's own environment discovery, with credentials prepared the way each one requires. + **Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model. ![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) @@ -93,18 +95,19 @@ References resolve from `$DSH_HOME/.env` — what the Models page's key fields w ## Point an agent at the new provider -A configured route appears in the web model picker and can be switched at any time. To change the default, edit the `agent-loop` entry's `provider` and `model` in `cordis.yml`: +A configured route appears in the web model picker and can be switched at any time, which is how most people use it. + +A new session's default model comes from the `api-gateway` entry (`@deepseek-ai/dsh-host-apiproxy`) and its `provider` and `model`, which ship as `deepseek-official` and `deepseek-v4-flash`. To change that default, override the entry in `$DSH_HOME/config.yaml`: ```yaml -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' +- id: api-gateway config: - agents: - - id: main - provider: acme-gateway - model: acme-large + provider: acme-gateway + model: acme-large ``` +A patch replaces that entry's whole `config`, so write out every key it needs to keep. A composition you assemble yourself — headless, for instance — sets `agent-loop`'s `agents` instead. + ## Troubleshooting - **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index d413fec2f9..a2c33c90be 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -23,6 +23,8 @@ Harness 出厂就带 DeepSeek,同时挂着一个通用的多提供方适配器 **添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个(anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。 +只对以 API 密钥认证的提供方成立。目录里也有 Bedrock、Vertex、Azure、Codex:它们分别需要 AWS 凭据与区域、ADC 项目配置、`api-version`、OAuth,只填密钥框不会让它们工作——这类提供方靠 pi-ai 自己的环境发现认证,凭据按各自的原生方式准备。 + **添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID(请求里点名它、也作为凭据名的小写标识)、API 地址、协议,以及至少一个模型。 ![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) @@ -93,18 +95,19 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 ## 让 agent 用上新提供方 -配好的路由会出现在 Web 的模型选择器里,随时可切。要改默认值,就在 `cordis.yml` 里改 `agent-loop` 那条的 `provider` 与 `model`: +配好的路由会出现在 Web 的模型选择器里,随时可切,这也是最常用的方式。 + +新会话的默认模型来自 `api-gateway` 那条(`@deepseek-ai/dsh-host-apiproxy`)的 `provider` 与 `model`,出厂值是 `deepseek-official` 与 `deepseek-v4-flash`。要改默认值,就在 `$DSH_HOME/config.yaml` 里覆盖该条: ```yaml -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' +- id: api-gateway config: - agents: - - id: main - provider: acme-gateway - model: acme-large + provider: acme-gateway + model: acme-large ``` +补丁会整体替换该条的 `config`,所以要把这条需要保留的键一并写出。自行组装的 `cordis.yml`(例如 headless)改的则是 `agent-loop` 的 `agents`。 + ## 排错 - **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index c6402d7fc8..6770381526 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -1,12 +1,14 @@ /** Tests for the documentation website projection adapter. */ import { execFileSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { docsPages, type DocsPage } from '../website/docs.ts' -import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts' +import { + addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown, +} from './project-doc-site.ts' const roots: string[] = [] const repositoryRoot = resolve(import.meta.dirname, '..') @@ -63,6 +65,32 @@ describe('website source layout', () => { }) }) +describe('publishableImage', () => { + it('accepts a regular file inside the repository', () => { + const { root } = fixture() + const real = realpathSync(join(root, 'packages/logo.svg')) + expect(publishableImage(join(root, 'packages/logo.svg'), realpathSync(root))).toBe(real) + }) + + it('refuses a target whose real path escapes the repository', () => { + // Publication copies the bytes onto the site, so a reference reaching a + // build-machine file must not be treated as an image the repository owns. + const { root } = fixture() + const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-site-outside-')) + roots.push(outside) + writeFileSync(join(outside, 'secret.png'), 'not really a png\n') + symlinkSync(join(outside, 'secret.png'), join(root, 'packages/linked.png')) + + expect(publishableImage(join(root, 'packages/linked.png'), realpathSync(root))).toBeUndefined() + expect(publishableImage(join(outside, 'secret.png'), realpathSync(root))).toBeUndefined() + }) + + it('refuses a directory', () => { + const { root } = fixture() + expect(publishableImage(join(root, 'packages'), realpathSync(root))).toBeUndefined() + }) +}) + describe('rewriteMarkdown', () => { it('maps published pages and pins unpublished source links', () => { const { root, pages } = fixture() @@ -107,7 +135,9 @@ describe('rewriteMarkdown', () => { it('hands an image to the placer and uses the URL it returns', () => { // A raw GitHub URL cannot serve a private repository, so the site build - // carries images itself; the placer is what puts them there. + // carries images itself; the placer is what puts them there. The stand-in + // derives its URL the way the real one does, so a placer that stopped + // returning the basename would fail here rather than pass on a constant. const { root, pages } = fixture() const placed: string[] = [] expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { @@ -118,13 +148,29 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', placeImage: (absPath) => { - placed.push(absPath.split('/').pop() ?? '') - return './logo.svg' + const name = absPath.split('/').pop() ?? '' + placed.push(name) + return `./${name}` }, })).toBe('![logo](./logo.svg)\n') expect(placed).toEqual(['logo.svg']) }) + it('keeps a placed image\u2019s query or fragment', () => { + // An SVG view fragment and a Vite query both change what the reference + // means, and the GitHub branch has always carried them. + const { root, pages } = fixture() + expect(rewriteMarkdown('![logo](../packages/logo.svg#view)\n', { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`, + })).toBe('![logo](./logo.svg#view)\n') + }) + it('leaves a published page link to the route even when a placer exists', () => { const { root, pages } = fixture() expect(rewriteMarkdown('[B](b.md)\n', { diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index ef821bd00e..02a64b023a 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -5,7 +5,9 @@ * tier, while this adapter rewrites cross-source links for the public site. */ -import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, +} from 'node:fs' import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' @@ -234,7 +236,9 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) const nextUrl = page !== undefined ? routeTarget(options.route, page.route, suffix) : node.type === 'image' && options.placeImage !== undefined - ? options.placeImage(absPath) + // The suffix rides along exactly as the GitHub branch keeps it: an SVG + // view fragment or a Vite query changes what the reference means. + ? `${options.placeImage(absPath)}${suffix}` : githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') const start = node.position?.start.offset @@ -302,19 +306,78 @@ export function projectedPageContent(markdown: string, page: DocsPage): string { return markdown.slice(0, closing + closingDelimiter.length) } -/** Canonical Markdown files watched by the local VitePress dev server. */ +/** + * The repository file one image reference resolves to, or `undefined` when the + * target is not a local file this build may publish. + * @param absPath - resolved image target. + * @param repoRoot - repository root every published image must stay inside. + * @returns the file's real path, or `undefined` when it must not be copied. + * + * Only a regular file whose real path stays inside the repository qualifies. + * Publication copies the bytes into the site, so a reference escaping the + * repository — `../../.ssh/id_rsa`, or a symlink pointing out of the tree — + * would put a build-machine file on the site; `existsSync` alone, which is all + * link resolution needs, does not answer that. + */ +export function publishableImage(absPath: string, repoRoot: string): string | undefined { + const real = realpathSync(absPath) + const inside = real === repoRoot || real.startsWith(`${repoRoot}${sep}`) + return inside && statSync(real).isFile() ? real : undefined +} + +/** Every local image a published page references, resolved to its repository file. */ +function referencedImages(): string[] { + const found = new Set() + for (const page of docsPages) { + const sourceAbs = resolve(root, page.source) + if (!existsSync(sourceAbs)) continue + rewriteMarkdown(readFileSync(sourceAbs, 'utf8'), { + sourcePath: page.source, + locale: page.locale, + route: page.route, + pages: docsPages, + repoRoot: root, + repositoryRef: 'master', + placeImage: (absPath) => { + const real = publishableImage(absPath, root) + if (real !== undefined) found.add(real) + return '' + }, + }) + } + return [...found] +} + +/** + * Files watched by the local VitePress dev server: every canonical Markdown + * source, plus the images they publish. Without the images, replacing a + * screenshot leaves the previous copy in the generated tree until something + * touches the Markdown beside it. + */ export function docsSourceFiles(): string[] { - return [...new Set(docsPages.map(page => resolve(root, page.source)))] + return [...new Set([...docsPages.map(page => resolve(root, page.source)), ...referencedImages()])] } /** Rebuild the disposable VitePress source tree from the publication manifest. */ export function projectDocs(): void { const routes = new Set() - /** Projected asset path to the source it came from, for collision detection. */ - const assets = new Map() + /** Projected path to the repository file that claimed it, pages and images alike. */ + const claimed = new Map() const repositoryRef = process.env.GITHUB_SHA ?? 'master' rmSync(generatedRoot, { recursive: true, force: true }) + /** Reserve one projected path, refusing a second source for it. */ + const claim = (target: string, sourceAbs: string): void => { + const holder = claimed.get(target) + if (holder !== undefined && holder !== sourceAbs) { + throw new Error( + `project-doc-site: ${repoPath(sourceAbs, root)} and ${repoPath(holder, root)}` + + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`, + ) + } + claimed.set(target, sourceAbs) + } + for (const page of docsPages) { if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`) routes.add(page.route) @@ -323,6 +386,9 @@ export function projectDocs(): void { throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`) } const output = resolve(generatedRoot, page.route) + // Claimed before the images are placed: a page and an image landing on one + // path would otherwise overwrite each other in whichever order they ran. + claim(output, sourceAbs) mkdirSync(dirname(output), { recursive: true }) const markdown = readFileSync(sourceAbs, 'utf8') const projected = rewriteMarkdown(markdown, { @@ -333,22 +399,23 @@ export function projectDocs(): void { repoRoot: root, repositoryRef, placeImage: (absPath) => { - // Beside the page that references it, under its own basename: each - // locale's route tree gets its own copy, so one relative URL is correct - // from both. Two sources that would land on one name are a collision - // rather than a silent overwrite of whichever copied last. - const name = basename(absPath) - const target = resolve(dirname(output), name) - const claimed = assets.get(target) - if (claimed !== undefined && claimed !== absPath) { + const real = publishableImage(absPath, root) + if (real === undefined) { throw new Error( - `project-doc-site: ${repoPath(absPath, root)} and ${repoPath(claimed, root)}` - + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`, + `project-doc-site: ${page.source} references image ${repoPath(absPath, root)},` + + ' which is not a regular file inside the repository.', ) } - assets.set(target, absPath) - copyFileSync(absPath, target) - return `./${name}` + // Beside the page that references it, under its own basename: each + // locale's route tree gets its own copy, so one relative URL is correct + // from both. + const name = basename(real) + const target = resolve(dirname(output), name) + claim(target, real) + copyFileSync(real, target) + // Encoded because the destination is a Markdown inline target, where an + // unescaped space would end it early. + return `./${encodeURI(name)}` }, }) writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page)) From 5514dd2bd6409d076bfa963a1c835fdd97e3f61b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 21:39:57 +0800 Subject: [PATCH 11/24] fix(llm-deepseek): refuse an API key no header can carry --- docs/config-catalog.md | 6 +++- packages/llm/llm-deepseek/src/index.ts | 27 +++++++++++++---- .../llm/llm-deepseek/tests/adapter.spec.ts | 30 +++++++++++++++++++ .../llm-deepseek/tests/dynamic-config.spec.ts | 21 ++++++++++++- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 89f1529387..a3e63d994d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -642,7 +642,11 @@ Requires: `llm` * reasoning effort resolves to `high`. */ export interface Config { - /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ + /** + * Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. Trimmed + * and format-checked by {@link resolveAdapterOptions}; a value no HTTP header can carry fails + * there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index cd2bb9a24e..6aaab15573 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -13,7 +13,7 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError, normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -58,7 +58,11 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ * reasoning effort resolves to `high`. */ export interface Config { - /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ + /** + * Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. Trimmed + * and format-checked by {@link resolveAdapterOptions}; a value no HTTP header can carry fails + * there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string @@ -174,8 +178,21 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } + // An absent apiKey is not a failure: it falls through to apiKeyEnv below. + // A supplied one must be usable, so a malformed literal fails here beside + // the other beyond-schema bounds instead of inside `fetch`. + let apiKey: string | undefined + if (config.apiKey !== undefined) { + const checked = normalizeApiKey(config.apiKey) + if (!checked.ok) { + throw new Error(checked.reason === 'empty' + ? 'llm-deepseek: apiKey is empty; omit it to resolve the key from apiKeyEnv' + : 'llm-deepseek: apiKey contains characters no HTTP header can carry; paste the raw key only') + } + apiKey = checked.value + } return { - ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {}, + ...apiKey === undefined ? {} : { apiKey }, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, defaults: { @@ -223,12 +240,12 @@ export function apply(ctx: Context, config: Config): void { const credentials = ctx.get('credentials') if (credentials !== undefined) { const hit = await credentials.resolve(ref) - if (hit !== undefined) return hit.value + if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref) } 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 + if (ambient !== undefined && ambient.length > 0) return assertUsableApiKey(ambient, 'llm-deepseek', ref) } throw new LlmError( `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 9d104ace08..56ac3eb138 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -991,3 +991,33 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) }) + +describe('API key format', () => { + it('trims a padded literal apiKey', () => { + expect(resolveAdapterOptions({ apiKey: ' sk-abc ' }).apiKey).toBe('sk-abc') + }) + + it('leaves an omitted apiKey absent so apiKeyEnv still resolves it', () => { + expect(resolveAdapterOptions({}).apiKey).toBeUndefined() + }) + + it('rejects a literal apiKey of whitespace only', () => { + expect(() => resolveAdapterOptions({ apiKey: ' ' })) + .toThrow(/apiKey is empty; omit it/) + }) + + it('rejects a literal apiKey no header can carry', () => { + expect(() => resolveAdapterOptions({ apiKey: 'sk-\u{1F600}' })) + .toThrow(/no HTTP header can carry/) + }) + + it('never echoes the key in the rejection', () => { + const secret = 'sk-\u{1F600}supersecret' + expect(() => resolveAdapterOptions({ apiKey: secret })).toThrow() + try { + resolveAdapterOptions({ apiKey: secret }) + } catch (error) { + expect((error as Error).message).not.toContain('supersecret') + } + }) +}) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 11df9e1d81..e593e3a61d 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/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, { INVALID_CREDENTIAL_CODE } 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' @@ -103,6 +103,25 @@ describe('request-level dynamic configuration', () => { expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') }) + it('rejects a stored credential no header can carry, never echoing it in the failure', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const dir = await home() + const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) + const secret = 'sk-\u{1F600}supersecret' + + // The real credentials seam (the path the web Models page writes through), + // not a hand-built stub: this package's own dynamic-config harness already + // boots one, and round-tripping the value through its actual store/read + // path is stronger evidence than a canned in-memory return would be. + await ctx.credentials.set(KEY_REF, secret) + const result = await prompt(ctx) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: INVALID_CREDENTIAL_CODE } }) + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.message).not.toContain(secret) + expect(result.finish.failure.message).not.toContain('supersecret') + expect(result.finish.failure.message).not.toContain('ByteString') + }) + 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' }) From b1660ab8a447c66ae9c3356bc60640b5cab3e10e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 21:40:06 +0800 Subject: [PATCH 12/24] docs(llm-deepseek): document the invalid-credential refusal --- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 3eb54a7a9f..6daba653f8 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: 0cd265cadb2b2a619613761062ab2cef209bec83 -README.zh.md: 1883b054277adfd6c3d02b2a76ead9b3f8b0138f +README.md: 51d5cf6a7049a2b1257ce3e9a284e777d3bdcdbc +README.zh.md: cc059a6011f7dc1ee0ab93dbd822de4b540b7e66 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 0cd265cadb..51d5cf6a70 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -53,7 +53,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, 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. +- **`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. Every key is trimmed and format-checked before use — a literal `apiKey` at connection-facts resolution (plugin load, or the next settings snapshot), a stored or ambient value at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time check throws `LlmError('INVALID_CREDENTIAL')` naming the failing entry point but never any part of the key. 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-official')` always reports the current policy. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 1883b05427..cc059a6011 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -53,7 +53,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 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在连接事实解析时(插件加载或下一次 settings 快照)校验,已存储的值或环境变量值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时校验会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的入口,但绝不透露密钥的任何部分。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 From 45d78c92722155922a87e16a69d7bfe40d5c4eda Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 22:12:50 +0800 Subject: [PATCH 13/24] fix(llm-pi-ai): refuse an unusable API key before the header is built --- docs/config-catalog.md | 8 +++- packages/llm/llm-pi-ai/src/config.ts | 23 ++++++++-- packages/llm/llm-pi-ai/src/discovery.ts | 26 ++++++++++- packages/llm/llm-pi-ai/src/index.ts | 4 +- packages/llm/llm-pi-ai/tests/config.spec.ts | 24 ++++++++++ .../llm/llm-pi-ai/tests/discovery.spec.ts | 45 ++++++++++++++++++- 6 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 packages/llm/llm-pi-ai/tests/config.spec.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a3e63d994d..6c860cbd9d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -704,7 +704,11 @@ export interface Config { /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** Literal provider credential; prefer {@link apiKeyEnv}. With both 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. Trimmed and format-checked by {@link resolveProfiles}; a + * value no HTTP header can carry fails there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string @@ -776,7 +780,7 @@ export interface PiAiModelProfile { 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:122`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:126`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 7473dbb7ae..7e8374ab9f 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -19,7 +19,7 @@ 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 { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { resolveRouteModels } from './catalog.ts' import type { PiAiModelProfile } from './catalog.ts' @@ -38,7 +38,11 @@ export type { PiAiModelProfile } from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { - /** Literal provider credential; prefer {@link apiKeyEnv}. With both 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. Trimmed and format-checked by {@link resolveProfiles}; a + * value no HTTP header can carry fails there rather than inside `fetch`. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string @@ -220,8 +224,18 @@ export function resolveProfiles( for (const [provider, source] of entries) { rejectRemovedFields(provider, source) if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') - if (source.apiKey !== undefined && source.apiKey.trim().length === 0) { - throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`) + // Omission selects the installed provider's own auth — ambient discovery + // or OAuth — so only a supplied key is judged. + let apiKey: string | undefined + if (source.apiKey !== undefined) { + const checked = normalizeApiKey(source.apiKey) + if (!checked.ok) { + throw new Error(checked.reason === 'empty' + ? `llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication` + : `llm-pi-ai: provider "${provider}" has an apiKey containing characters no HTTP header can carry;` + + ' paste the raw key only') + } + apiKey = checked.value } if (source.baseURL !== undefined && source.baseURL.length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) @@ -252,6 +266,7 @@ export function resolveProfiles( const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source resolved.set(provider, { ...rest, + ...apiKey === undefined ? {} : { apiKey }, provider, displayName, ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index bff2c9a7ca..014c9c2f3e 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -22,7 +22,7 @@ * @module dsh-llm-pi-ai/discovery */ -import { LlmError } from '@deepseek-ai/dsh-llm' +import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai/dsh-llm' import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm' import { attributionHeaders } from '@deepseek-ai/dsh-llm' import { catalogModels } from './catalog.ts' @@ -161,6 +161,25 @@ function readListing(body: unknown): LlmDiscoveredModel[] { return models } +/** + * Accept one probe key, or refuse it before the header is built. Without this + * the `fetch` below would throw a ByteString `TypeError` that this function's + * catch reports as `could not reach ` — blaming the network for a local, + * deterministic fault. + * @param raw - the key typed into the form or read from storage. + * @returns the trimmed, usable key. + */ +function usableProbeKey(raw: string): string { + const checked = normalizeApiKey(raw) + if (checked.ok) return checked.value + throw new LlmError( + checked.reason === 'empty' + ? 'this provider\'s API key is blank; enter it on the Models page, or clear it to probe unauthenticated' + : 'this provider\'s API key contains characters no HTTP header can carry; paste the raw key only', + INVALID_CREDENTIAL_CODE, + ) +} + /** * Interrogate one draft provider endpoint for the models it advertises. * @param request - the endpoint, protocol, and one-shot credential to use. @@ -216,7 +235,10 @@ export async function discoverModels( // stored one is only asked for here, past the catalog short-circuit and the // protocol check, so a route answered from the registry costs no credential // lookup — and no diagnostic about a credential it never needed. - const apiKey = request.apiKey ?? await storedApiKey?.() + // A probe carrying no key stays unauthenticated, which is how a route that + // relies on the provider's own ambient discovery is meant to be asked. + const supplied = request.apiKey ?? await storedApiKey?.() + const apiKey = supplied === undefined ? undefined : usableProbeKey(supplied) let response: Response try { response = await fetch(url, { diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 0d058e94ac..c30fd3db6f 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -43,7 +43,7 @@ */ import type { Context } from 'cordis' -import { LlmError } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' @@ -145,7 +145,7 @@ export function apply(ctx: Context, config: Config): void { // 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 + if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref) 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,` diff --git a/packages/llm/llm-pi-ai/tests/config.spec.ts b/packages/llm/llm-pi-ai/tests/config.spec.ts new file mode 100644 index 0000000000..90f8487ad8 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/config.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { resolveProfiles } from '../src/config.ts' + +describe('API key format', () => { + it('trims a padded literal apiKey into the resolved profile', () => { + const resolved = resolveProfiles({ openai: { apiKey: ' sk-abc ', baseURL: 'https://acme.test' } }) + expect(resolved.get('openai')?.apiKey).toBe('sk-abc') + }) + + it('keeps an omitted apiKey absent so ambient authentication still applies', () => { + const resolved = resolveProfiles({ openai: { baseURL: 'https://acme.test' } }) + expect(resolved.get('openai')?.apiKey).toBeUndefined() + }) + + it('still tells an empty apiKey to omit itself', () => { + expect(() => resolveProfiles({ openai: { apiKey: ' ', baseURL: 'https://acme.test' } })) + .toThrow(/omit it to use ambient authentication/) + }) + + it('rejects an apiKey no header can carry', () => { + expect(() => resolveProfiles({ openai: { apiKey: 'sk-\u{1F600}', baseURL: 'https://acme.test' } })) + .toThrow(/no HTTP header can carry/) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 916700fbbf..63b43ecdab 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -1,6 +1,6 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' @@ -12,6 +12,9 @@ const servers: Server[] = [] const touchedEnv: string[] = [] afterEach(async () => { + // A no-op when the test never stubbed `fetch`; only 'probe key format' + // below installs one. + vi.unstubAllGlobals() for (const name of touchedEnv.splice(0)) Reflect.deleteProperty(process.env, name) await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) }) @@ -311,3 +314,43 @@ describe('draft-provider model discovery', () => { .rejects.toMatchObject({ code: 'NO_DISCOVERY' }) }) }) + +describe('probe key format', () => { + it('reports an illegal probe key as a credential fault, not an unreachable endpoint', async () => { + await expect(discoverModels({ + baseURL: 'https://acme.test', + api: 'openai-completions', + apiKey: 'sk-\u{1F600}', + })).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' }) + }) + + it('reports a blank probe key as a credential fault too', async () => { + // A cleared form field arrives as '', not an absent key; it must fail the + // same way a typed-in illegal key does, rather than probing unauthenticated. + await expect(discoverModels({ + baseURL: 'https://acme.test', + api: 'openai-completions', + apiKey: '', + })).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' }) + }) + + it('leaves a probe with no key unauthenticated', async () => { + // The file's other cases capture headers through a real local HTTP server + // (`listingServer`); this one has no route or stored key to resolve, so + // the smallest real double is a `fetch` stub, scoped to this test and + // unstubbed by the shared `afterEach` above. + const requests: RequestInit[] = [] + vi.stubGlobal('fetch', async (_url: string | URL, init?: RequestInit) => { + requests.push(init ?? {}) + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + + await discoverModels({ baseURL: 'https://acme.test', api: 'openai-completions' }) + + const headers = new Headers(requests[0]?.headers) + expect(headers.has('authorization')).toBe(false) + }) +}) From 665b5697bb46d87b85c832dec2689685f37edbd7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 22:13:02 +0800 Subject: [PATCH 14/24] docs(llm-pi-ai): document the invalid-credential refusal --- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 4 ++-- packages/llm/llm-pi-ai/README.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index b4e9cffabb..bd322be07f 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: af0e952dd8dbd9767b98229ee6b87262007d6738 -README.zh.md: f8a19999f08aa8a6963874d57bf74370797b951c +README.md: 0dcf15d6caf365a1f8e75088cb363eaa6560a6ec +README.zh.md: 79be5d320c0f4411f7cf8a0bd72c887048929dcb diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index af0e952dd8..0dcf15d6ca 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -67,7 +67,7 @@ Resolution still fails loud, naming the offending route and model, when a route 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; 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 section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. +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. Every key is trimmed and format-checked before use — a literal `apiKey` when profiles resolve (plugin load, or the next settings snapshot), a value `apiKeyEnv` resolves at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. 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 section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, 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, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. @@ -85,7 +85,7 @@ The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answ A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand. -A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. +A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint. Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index f8a19999f0..79be5d320c 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -67,7 +67,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器经由一个 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 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在 profile 解析时(插件加载,或下一次 settings 快照)校验,`apiKeyEnv` 解析出的值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时的拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 @@ -85,7 +85,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 -草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。 +草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验:HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。 询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 From cf9eade39d5019ccbbd90e0f1a969274074ea691 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 22:30:43 +0800 Subject: [PATCH 15/24] feat(web): refuse an unusable API key on the field that holds it --- .../src/client/CustomProviderCard.tsx | 14 ++- .../ui-models/src/client/ProviderEditor.tsx | 19 +++- .../client/ui-models/src/client/apiKey.ts | 50 +++++++++ .../client/ui-models/src/client/locales.ts | 6 ++ .../ui-models/tests/components.spec.tsx | 50 +++++++++ .../ui-models/tests/provider-form.spec.tsx | 102 ++++++++++++++++++ 6 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 packages/client/ui-models/src/client/apiKey.ts diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index b4c655472a..a252d99586 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -18,6 +18,7 @@ import { useState } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' @@ -80,8 +81,14 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // bad row is named by its position here too. Capacities have route-level // fallbacks; what a route cannot default is at least one model. const modelFailure = validateDeepSeekModels(models) + const keyFailure = apiKeyFailure(keyDraft) + // The typed key with paste whitespace removed. A blank field yields an empty + // string, which the create path reads as "no key supplied" — a route may + // legitimately authenticate through the provider's own ambient discovery. + const keyValue = keyDraft.trim() const ready = route.length > 0 && !routeInvalid && !routeTaken && baseURL.length > 0 && models.length > 0 && modelFailure === undefined + && keyFailure === undefined // The one blocked gate worth a line under the form. The route id is omitted // because its own field already explains itself, and a satisfied card says // nothing at all rather than printing an empty paragraph. @@ -112,8 +119,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { expectedRevision: openedAt, }) if (!response.result.ok) return response.result.error.message - if (keyDraft.length > 0) { - const stored = await api.credentials.set({ ref: keyRef, value: keyDraft }) + if (keyValue.length > 0) { + const stored = await api.credentials.set({ ref: keyRef, value: keyValue }) // The profile landed; saying the key did not is the only honest report, // and the row is now editable so the key can be entered again there. if (!stored.result.ok) return stored.result.error.message @@ -208,6 +215,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { disabled={disabled} onChange={(event) => { setKeyDraft(event.target.value) }} /> + {keyFailure === undefined ? null :

{t(keyFailure)}

} { const value = getPath(source, [key]) - return typeof value === 'string' && value.length > 0 ? value : undefined + return typeof value === 'string' && value.trim().length > 0 ? value : undefined } const setField = (key: string, next: string | undefined): void => { setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next)) @@ -172,6 +173,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // The model list is validated by the same per-row checker for both families, // so a bad row is named by its position rather than by a blanket message. const modelFailure = validateDeepSeekModels(getPath(draft, ['models'])) + const keyFailure = apiKeyFailure(keyDraft) + // What a probe or a write must carry: the typed key with paste whitespace + // removed. A blank field yields an empty string, which both call sites read + // as "no key supplied" rather than as a key — that is how a card whose + // provider already has a stored key is edited without re-entering it. + const keyValue = keyDraft.trim() // What the form currently shows, which is what an interrogation must ask: // an edited-but-unsaved endpoint, and a key typed but not yet stored. const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api') @@ -183,7 +190,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { provider: props.provider, ...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL }, ...probeApi === undefined ? {} : { api: probeApi }, - ...keyDraft.length === 0 ? {} : { apiKey: keyDraft }, + ...keyValue.length === 0 ? {} : { apiKey: keyValue }, } /** * The write for this card, or a failure message. Every edit travels as @@ -226,8 +233,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { : response.result.error.message } } - if (keyDraft.length > 0) { - const stored = await api.credentials.set({ ref: keyRef, value: keyDraft }) + if (keyValue.length > 0) { + const stored = await api.credentials.set({ ref: keyRef, value: keyValue }) if (!stored.result.ok) return stored.result.error.message } setKeyDraft('') @@ -313,6 +320,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { disabled={disabled || keyLocked} onChange={(event) => { setKeyDraft(event.target.value) }} /> + {keyFailure === undefined ? null :

{t(keyFailure)}

}
{t('customized')} @@ -396,7 +404,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { { props.onClose(false) }} diff --git a/packages/client/ui-models/src/client/apiKey.ts b/packages/client/ui-models/src/client/apiKey.ts new file mode 100644 index 0000000000..a9d5bb3d32 --- /dev/null +++ b/packages/client/ui-models/src/client/apiKey.ts @@ -0,0 +1,50 @@ +/** + * Browser-side judgement of a typed API key. + * @module @deepseek-ai/dsh-client-ui-models/apiKey + */ + +/** + * Twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`: printable ASCII, space + * excluded. Client packages reference only client packages, so the charset + * rule is mirrored here rather than imported; keep the two in step, as + * `validateDeepSeekModels` is kept in step with the host's `catalogModel`. + */ +const LEGAL_API_KEY = /^[\x21-\x7E]+$/ + +/** + * A pasted `NAME=value` environment line. Restricted to an upper-case + * identifier so a real key cannot match: `sk-` forms break at the hyphen. + * This heuristic runs only here — a resolver applying it could lock a user + * out of a gateway whose key legitimately takes this shape, with the + * environment refusing it too and no way through. + */ +const ENV_LINE = /^[A-Z][A-Z0-9_]*=/ + +/** Copy key naming why a typed key cannot be saved. */ +export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped' + +/** Whether a value is wrapped in one matching pair of quotes. */ +function isQuoted(value: string): boolean { + const first = value[0] + if (first !== '"' && first !== '\'' && first !== '`') return false + return value.length > 1 && value.endsWith(first) +} + +/** + * Judge the key input's current value. + * + * An empty field is not a failure: every card opens with it empty even when a + * key is already stored, where it means keep that one. A field holding only + * whitespace is a failure rather than an empty field, so typed input is never + * silently discarded. + * @param draft - the key input's current value, untrimmed. + * @returns the copy key for a field-level failure, or `undefined` to allow submit. + */ +export function apiKeyFailure(draft: string): ApiKeyFailureKey | undefined { + if (draft.length === 0) return undefined + const value = draft.trim() + if (value.length === 0) return 'keyBlank' + if (ENV_LINE.test(value) || isQuoted(value)) return 'keyLooksWrapped' + if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters' + return undefined +} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 19463d98fa..fbfc85c7f1 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -46,6 +46,9 @@ export const en = { addModel: 'Add model', removeModel: 'Delete model', modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', + keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', + keyIllegalCharacters: 'This API key contains characters that cannot be sent. Paste the raw key only.', + keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', modelIdDuplicate: 'Model ID must be unique.', modelNameInvalid: 'Display name cannot be empty.', @@ -130,6 +133,9 @@ export const zh: typeof en = { addModel: '添加模型', removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', + keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', + keyIllegalCharacters: '该 API 密钥含有无法发送的字符。请只粘贴原始密钥。', + keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', modelIdDuplicate: '模型 ID 不能重复。', modelNameInvalid: '显示名称不能为空。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index aa9082e7dd..d9034ecd44 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -11,6 +11,7 @@ import { pathOps } from '../src/client/ProviderEditor.tsx' import { DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, } from '../src/client/DeepSeekModelsEditor.tsx' +import { apiKeyFailure } from '../src/client/apiKey.ts' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' @@ -1080,3 +1081,52 @@ describe('ModelsSection', () => { expect(failure).toBe('connection lost') }) }) + +describe('apiKeyFailure', () => { + it('treats a blank field as no failure — it means keep the stored key', () => { + expect(apiKeyFailure('')).toBeUndefined() + }) + + it.each([ + ['a printable-ASCII key', 'sk-0123456789'], + ['a padded key, which the caller trims', ' sk-abc '], + ['the printable-ASCII boundary characters', '!~'], + ['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'], + ])('accepts %s', (_label, draft) => { + expect(apiKeyFailure(draft)).toBeUndefined() + }) + + it.each([ + ['spaces', ' '], + ['a tab', '\t'], + ])('fails a field holding only %s instead of silently dropping it', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyBlank') + }) + + it.each([ + ['an emoji', 'sk-\u{1F600}'], + ['CJK text', 'sk-你好'], + ['full-width punctuation', 'sk-abc,'], + ['an interior space', 'sk-abc def'], + ['a C0 control character', 'sk-abc\x01'], + ['a latin-1 character', 'sk-café'], + ])('fails %s as illegal characters', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters') + }) + + it.each([ + ['a pasted environment line', 'DEEPSEEK_API_KEY=sk-abc'], + ['double quotes', '"sk-abc"'], + ['single quotes', '\'sk-abc\''], + ['backticks', '`sk-abc`'], + ])('fails %s as wrapped', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyLooksWrapped') + }) + + it('needs a matching closing quote before it calls a value wrapped', () => { + // A lone quote and an unbalanced one are legal printable ASCII, so the + // heuristic leaves them alone rather than guessing at a paste error. + expect(apiKeyFailure('"')).toBeUndefined() + expect(apiKeyFailure('"a')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 99e85b0d10..a167710153 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -862,4 +862,106 @@ describe('hand-declared providers', () => { await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() }) expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy() }) + + it('refuses an unusable key on the field and blocks creation', () => { + const { mutate, set } = mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + // A hand-declared route reaches the same judgement as an edited one, so a + // key that no header can carry never becomes a profile plus a bad secret. + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() + expect(buttonNamed(en.create).disabled).toBe(true) + expect(mutate).not.toHaveBeenCalled() + expect(set).not.toHaveBeenCalled() + }) + + it('creates without a key when the route authenticates some other way', async () => { + const { set, onClose } = mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'ambient-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.click(screen.getByText(en.create)) + + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(set).not.toHaveBeenCalled() + }) +}) + +describe('API key field', () => { + it('submits with a blank key field without writing a credential', async () => { + const { mutate, set } = await mountSection() + openEditor('openai') + + // The field opens empty even for a provider whose key is stored, where it + // means "keep that one" — so editing anything else must not require it. + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://moved.example/v1' } }) + expect(buttonNamed(en.apply).disabled).toBe(false) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalled() }) + expect(set).not.toHaveBeenCalled() + }) + + it('blocks submit and names the field when the key holds only whitespace', async () => { + const { mutate, set } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } }) + + expect(screen.getByText(en.keyBlank)).toBeTruthy() + expect(buttonNamed(en.apply).disabled).toBe(true) + expect(mutate).not.toHaveBeenCalled() + expect(set).not.toHaveBeenCalled() + }) + + it('blocks submit when the key contains characters no header can carry', async () => { + const { set } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() + expect(buttonNamed(en.apply).disabled).toBe(true) + expect(set).not.toHaveBeenCalled() + }) + + it('blocks submit when a whole NAME=value line was pasted', async () => { + await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'OPENAI_API_KEY=sk-abc' } }) + + expect(screen.getByText(en.keyLooksWrapped)).toBeTruthy() + expect(buttonNamed(en.apply).disabled).toBe(true) + }) + + it('trims a padded key before storing it', async () => { + const { set } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' sk-abc ' } }) + expect(buttonNamed(en.apply).disabled).toBe(false) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(set).toHaveBeenCalled() }) + expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc') + }) + + it('carries the trimmed key into an interrogation, not the padded draft', async () => { + const { discover } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' sk-abc ' } }) + fireEvent.click(screen.getByRole('button', { name: en.fetchModels })) + + await waitFor(() => { expect(discover).toHaveBeenCalled() }) + expect(firstProbe(discover)).toMatchObject({ apiKey: 'sk-abc' }) + }) }) From a89c26b6110420ff59582528839d01230c970e9a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 22:42:56 +0800 Subject: [PATCH 16/24] test(web): pin the API key field refusal end to end --- ...-08-06-api-key-format-validation.i18n.yaml | 6 + .../2026-08-06-api-key-format-validation.md | 105 ++++++++++++++++++ ...2026-08-06-api-key-format-validation.zh.md | 105 ++++++++++++++++++ ...-08-06-api-key-format-validation.i18n.yaml | 6 - .../2026-08-06-api-key-format-validation.md | 101 ----------------- ...2026-08-06-api-key-format-validation.zh.md | 101 ----------------- apps/web/tests/models-settings.e2e.ts | 19 ++++ 7 files changed, 235 insertions(+), 208 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md delete mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml delete mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md delete mode 100644 .agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml new file mode 100644 index 0000000000..42b42a591a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.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-08-06-api-key-format-validation.md +2026-08-06-api-key-format-validation.md: 9ec247cb2ba2578158759ec1115c5d3a95778cc4 +2026-08-06-api-key-format-validation.zh.md: 63c6a8c17ee93b4b68eb3505d5499756e9fb2401 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md new file mode 100644 index 0000000000..9ec247cb2b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -0,0 +1,105 @@ +# Agent Note: Validate API key format before it reaches an HTTP header + +Status: implemented + +English | [中文](2026-08-06-api-key-format-validation.zh.md) + +## Problem + +An API key holding characters no HTTP header value can carry was accepted by every configuration surface and failed only when a request was built, far from the field that caused it. + +Pasting a key containing an emoji, CJK text, or a full-width punctuation mark into the web Models page reported a successful save. The first turn then failed with `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255` — the index and code point are UTF-16 internals with no action attached, and they disclose the code point of one character of the key. `llm-deepseek` produced this because `fetch` builds the `Bearer` header inside the `try` in [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts), whose `catch` labels every failure `TRANSPORT`; that label is in `DEFAULT_RETRYABLE_CODES`, so a permanent, deterministic fault was also retried three times. + +`llm-pi-ai` was worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wrapped every failure as `could not reach `, so a local key fault was reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sent an illegal key before anything was stored. + +Whitespace passed every check. `ProviderEditor` tested `keyDraft.length` and `resolveAdapterOptions` tested `config.apiKey.length`, so a key of three spaces stored and then authenticated as `Bearer` plus blanks. `llm-pi-ai` rejected an empty literal `apiKey` in `resolveProfiles`, but applied no check whatsoever to a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take. + +Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. + +## Decision + +One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. + +This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. + +A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. + +### Invariants belong at every layer; heuristics belong where the human is + +The charset rule is an invariant. A non-ASCII character *cannot* travel in a header value for any provider, so enforcing it in the browser, in each resolver, and on every credential read is consistent by construction rather than by agreement. + +The shape rule is a guess about how people paste, so it runs **only in the browser**. `llm-pi-ai` fronts OpenAI, Anthropic, and arbitrary hand-declared gateways whose key formats this repository does not own; a gateway issuing a key shaped like `TENANT1=abc` would, if the rule ran in the resolver, be locked out with no escape — the settings page would refuse it and a hand-written `.env` would be rejected on read. Confining the heuristic to the surface where the paste happens keeps the environment as the way through. + +### Absence is a configuration state, not a missing key + +"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. + +**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` carries this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. + +**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field never blocks submit, or editing a base URL would demand re-entering the key. + +**Provided, but empty or whitespace-only.** This is the one error, because the user expressed an intent to set a key and supplied nothing. `llm-pi-ai` already worded it correctly in `resolveProfiles` — *has an empty apiKey; omit it to use ambient authentication* — and that shape, naming the legitimate alternative rather than just refusing, is what the other surfaces adopt. + +`normalizeApiKey` therefore takes `string`, never `string | undefined`. + +### Where the rule lives + +`normalizeApiKey` is a module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`). + +Both adapters also need the identical "refuse a stored credential" diagnosis, differing only by package prefix. `LlmError` is declared in the seam's `index.ts`, so `assertUsableApiKey(raw, pkg, ref)` lives there beside it and neither adapter carries a local copy. The predicate module stays dependency-free: importing `LlmError` into `api-key.ts` would cycle with `index.ts`'s re-export of it. + +The client cannot import any of this: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate in its own `apiKey.ts` and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema. Each side names the other in a comment. + +### What each surface does + +| Surface | Behavior | +|---|---| +| `dsh-llm` | Owns `normalizeApiKey`, `assertUsableApiKey`, and `INVALID_CREDENTIAL_CODE`, which is deliberately outside `DEFAULT_RETRYABLE_CODES`. | +| `llm-deepseek` `resolveAdapterOptions` | Normalizes a present `apiKey`, throwing beside the other beyond-schema bounds; uses the trimmed value. An absent one falls through to `apiKeyEnv`. | +| `llm-deepseek` `resolveApiKey` | Normalizes what the credentials seam or environment returns, rejecting with `INVALID_CREDENTIAL` naming the Models page and never echoing the key. | +| `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. | +| `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. | +| `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. | +| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit is gated and the failure renders on the field, matching the existing `modelFailure` pattern. | + +`ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. `CustomProviderCard` carries the same judgement for a hand-declared route. + +`credentials-local` is deliberately untouched. It stores credentials generally, and printable-ASCII is a constraint of HTTP headers rather than of credential storage; its existing refusal of values no dotenv style can represent stands as it was. + +## Alternatives considered + +**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It lost because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. + +**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. + +**A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the seam, so the seam can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for. + +**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose request header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe. + +**Enforcing in `credentials-local.set`.** It would catch every writer at once, including a hand-edited file. It lost because that provider stores credentials of every kind, and a rule derived from HTTP header encoding does not belong to it. + +**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. + +**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one. + +## Consequences + +A malformed key is refused at the field that holds it, and a malformed stored key fails as `INVALID_CREDENTIAL` with a message naming where to fix it and no fragment of the key. Because that code sits outside `DEFAULT_RETRYABLE_CODES`, a deterministic credential fault is no longer retried three times as a transport blip. `llm-pi-ai` discovery reports an illegal probe key as a credential fault instead of an unreachable endpoint. + +The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. + +Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. + +The charset predicate exists twice, once per source plane. The layout forbids sharing it; each side carries its own test and names its twin. + +Keys already stored by an earlier build are read through `resolveApiKey`, so an illegal stored value fails at resolution rather than at request time. The diagnosis improves, but the failure moves earlier for anyone currently holding one. + +The costliest way to get this wrong would have been to treat absence as invalidity: a rule applied to `undefined` breaks every route authenticating through ambient discovery or OAuth, and a blank field that blocked submit makes editing any other setting demand re-entering the key. Both are pinned by tests rather than left to care. + +## Testing + +`packages/llm/llm/tests/api-key.spec.ts` drives `normalizeApiKey` and `assertUsableApiKey` over the whole input table — empty, whitespace-only, padded, interior-space, C0 control, emoji, CJK, full-width, latin-1, and the printable-ASCII boundary — and pins that a refusal carries `INVALID_CREDENTIAL` and no part of the key. + +`packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header. + +`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md new file mode 100644 index 0000000000..63c6a8c17e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -0,0 +1,105 @@ +# Agent Note: 在 API Key 进入 HTTP header 之前校验其格式 + +Status: implemented + +[English](2026-08-06-api-key-format-validation.md) | 中文 + +## Problem + +一个含有 HTTP header value 无法承载的字符的 API Key,曾被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。 + +把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。 + +同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach `,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 + +空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 + +来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 + +## Decision + +一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 + +这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 + +第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 + +### 不变量属于每一层,启发式属于人所在的那一层 + +字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。 + +形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。 + +### 「没有 Key」是一种配置状态,不是缺失 + +在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 + +**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 承载着这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 + +**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时完全跳过 `credentials.set`,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。 + +**已提供,但为空或纯空白。** 这是唯一的错误,因为用户表达了设置 Key 的意图却什么都没给。`llm-pi-ai` 在 `resolveProfiles` 中的措辞本就是对的——*has an empty apiKey; omit it to use ambient authentication*——这种指明合法替代路径而非单纯拒绝的形态,正是其他界面所采用的。 + +因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 + +### 规则住在哪里 + +`normalizeApiKey` 是 `dsh-llm` seam 的一个模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。 + +两个适配器同样都需要那句完全相同的「拒绝一个已存储凭据」的诊断,差别仅在包名前缀。`LlmError` 声明在 seam 的 `index.ts` 中,因此 `assertUsableApiKey(raw, pkg, ref)` 就住在它旁边,两个适配器都不再各留一份。断言模块本身保持零依赖:把 `LlmError` 引入 `api-key.ts` 会与 `index.ts` 对它的再导出成环。 + +客户端无法引入其中任何一个:client 包只 reference client 包,因此 `packages/client/ui-models` 在自己的 `apiKey.ts` 中镜像这个断言并持有本地化文案,正如 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。 + +### 各个界面各做什么 + +| 界面 | 行为 | +|---|---| +| `dsh-llm` | 拥有 `normalizeApiKey`、`assertUsableApiKey` 与 `INVALID_CREDENTIAL_CODE`,后者刻意不进 `DEFAULT_RETRYABLE_CODES`。 | +| `llm-deepseek` `resolveAdapterOptions` | 归一化已提供的 `apiKey`,与其他超出 schema 的边界检查并排抛错;使用 trim 后的值。缺省的 `apiKey` 回落到 `apiKeyEnv`。 | +| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值,以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | +| `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 | +| `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 | +| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 | +| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交受拦截,失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | + +`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。`CustomProviderCard` 为手工声明的路由承载同一套判定。 + +`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。 + +## Alternatives considered + +**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 + +**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 + +**在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 seam 中,因此 seam 完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。 + +**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的请求 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。 + +**在 `credentials-local.set` 中执行。** 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该 provider 存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。 + +**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 + +**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 + +## Consequences + +格式错误的 Key 在持有它的那个字段上就被拒绝;格式错误的已存储 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置且不含 Key 的任何片段。由于该 code 位于 `DEFAULT_RETRYABLE_CODES` 之外,一个确定性的凭据故障不再被当作瞬时传输抖动重试三次。`llm-pi-ai` 的探测把非法 Key 报为凭据故障,而非端点不可达。 + +形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 + +限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 + +字符集断言存在两份,每个 source plane 一份。布局禁止共享它;两侧各自带测试并在注释中指名其孪生体。 + +早先版本已存下的 Key 会经 `resolveApiKey` 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。诊断变好了,但对当前正持有这类值的人而言,失败点提前了。 + +把这件事做错的最大代价,会是把「未指定」当成「非法」:一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由,而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都由测试钉住,而不是仅仰赖谨慎。 + +## Testing + +`packages/llm/llm/tests/api-key.spec.ts` 以整张输入表驱动 `normalizeApiKey` 与 `assertUsableApiKey`——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 `INVALID_CREDENTIAL` 且不含 Key 的任何部分。 + +`packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 + +`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 拦截提交、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml deleted file mode 100644 index f62a18e0eb..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/proposed/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: dc19baa8b697998df2892f0840a35a8232cc92de -2026-08-06-api-key-format-validation.zh.md: 28073660b1d4868fecf5ce419726d6d997383392 diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md deleted file mode 100644 index dc19baa8b6..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.md +++ /dev/null @@ -1,101 +0,0 @@ -# Agent Note: Validate API key format before it reaches an HTTP header - -Status: proposed - -English | [中文](2026-08-06-api-key-format-validation.zh.md) - -## Problem - -An API key holding characters no HTTP header value can carry is accepted by every configuration surface and fails only when a request is built, far from the field that caused it. - -Paste a key containing an emoji, CJK text, or a full-width punctuation mark into the web Models page and the save reports success. The first turn then fails with `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255` — the index and code point are UTF-16 internals with no action attached, and they disclose the code point of one character of the key. `llm-deepseek` produces this because `fetch` builds the `Bearer` header inside the `try` at [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts), whose `catch` labels every failure `TRANSPORT`; that label is in `DEFAULT_RETRYABLE_CODES`, so a permanent, deterministic fault is also retried three times. - -`llm-pi-ai` is worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wraps every failure as `could not reach `, so a local key fault is reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sends an illegal key before anything is stored. - -Whitespace passes every check. `ProviderEditor` tests `keyDraft.length` and `resolveAdapterOptions` tests `config.apiKey.length`, so a key of three spaces stores and then authenticates as `Bearer` plus blanks. `llm-pi-ai` rejects an empty literal `apiKey` in `resolveProfiles`, but applies no check whatsoever to a credential- or environment-sourced key — which is the path the Models page writes, and therefore the path users actually take. - -Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. - -## Proposal - -One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. - -This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. - -A second, narrower rule catches a pasted environment line: reject input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. - -### Invariants belong at every layer; heuristics belong where the human is - -The charset rule is an invariant. A non-ASCII character *cannot* travel in a header value for any provider, so enforcing it in the browser, in each resolver, and on every credential read is consistent by construction rather than by agreement. - -The shape rule is a guess about how people paste, so it runs **only in the browser**. `llm-pi-ai` fronts OpenAI, Anthropic, and arbitrary hand-declared gateways whose key formats this repository does not own; a gateway issuing a key shaped like `TENANT1=abc` would, if the rule ran in the resolver, be locked out with no escape — the settings page would refuse it and a hand-written `.env` would be rejected on read. Confining the heuristic to the surface where the paste happens keeps the environment as the way through. - -### Absence is a configuration state, not a missing key - -"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller. - -**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` exists to carry this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated. - -**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` already skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field must never block submit, or editing a base URL would demand re-entering the key. - -**Provided, but empty or whitespace-only.** This is the one error, because the user expressed an intent to set a key and supplied nothing. `llm-pi-ai` already words it correctly in `resolveProfiles` — *has an empty apiKey; omit it to use ambient authentication* — and that shape, naming the legitimate alternative rather than just refusing, is what the other surfaces adopt. - -`normalizeApiKey` therefore takes `string`, never `string | undefined`. - -### Where the rule lives - -`normalizeApiKey` is a new module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`). - -The client cannot import it: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema today. Each side names the other in a comment. - -### What each surface does - -| Surface | Change | -|---|---| -| `dsh-llm` | Add `normalizeApiKey`; add `INVALID_CREDENTIAL`, deliberately outside `DEFAULT_RETRYABLE_CODES`. | -| `llm-deepseek` `resolveAdapterOptions` | Normalize a present `apiKey`, throwing beside the existing beyond-schema bounds; use the trimmed value. An absent one still falls through to `apiKeyEnv`. Closes dsh-external#210. | -| `llm-deepseek` `resolveApiKey` | Normalize what the credentials seam or environment returns; reject with `INVALID_CREDENTIAL` naming the Models page, never echoing the key. | -| `llm-pi-ai` `resolveProfiles` | Widen the existing emptiness check to the shared rule, keeping its "omit it to use ambient authentication" wording. | -| `llm-pi-ai` `resolveApiKey` | Normalize the credential and environment paths, which are unchecked today. A profile naming no credential still returns `undefined` untouched, so ambient and OAuth routes are unaffected. | -| `llm-pi-ai` `discoverModels` | Normalize before building the header, so an illegal key stops reporting as an unreachable endpoint. A probe carrying no key stays unauthenticated as it is today. | -| `ui-models` | Mirror the charset rule, add the shape heuristic, trim `keyDraft` before probe and `credentials.set`, and fix the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure, so typed input is never silently discarded. Gate submit and show the failure on the field, matching the existing `modelFailure` pattern. | - -`ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. - -`credentials-local` is deliberately untouched. It stores credentials generally, and printable-ASCII is a constraint of HTTP headers rather than of credential storage; its existing refusal of values no dotenv style can represent stays as it is. - -## Alternatives considered - -**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It loses because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express. - -**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here. - -**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe. - -**Enforcing in `credentials-local.set`.** It would catch every writer at once, including a hand-edited file. It loses because that provider stores credentials of every kind, and a rule derived from HTTP header encoding does not belong to it. - -**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. - -**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on today's code, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verifies nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this note makes reliable; building it first would produce a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call at save time would be an unexpected behavior rather than a missing one. - -## Acceptance criteria - -- The browser, both resolvers, and both credential reads accept and reject the same *provided* strings: whitespace-only, padded, interior-space, C0 control, emoji, CJK, and full-width inputs are refused; a printable-ASCII key is accepted, trimmed. -- A profile naming no credential still resolves to no key, and a route authenticating through the installed provider's own ambient discovery or OAuth keeps working untouched. -- A blank key field saves the rest of the card without writing a credential; a field holding only whitespace fails on the field instead of being silently dropped. -- A rejected key names the API key field in the web UI and blocks submit; nothing is written to settings or credentials. -- A key that reaches a resolver illegally fails as `INVALID_CREDENTIAL` with a message naming where to fix it, containing no part of the key, and is not retried. -- `llm-pi-ai` discovery reports an illegal key as a key fault, not as an unreachable endpoint. -- A legal key still travels the existing `credentials.set` path unchanged. - -## Risks - -The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. - -Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. - -The charset predicate exists twice, once per source plane. The layout forbids sharing it, and the duplication gate may flag the pair; each side carries its own test and names its twin. - -The costliest way to get this wrong is to treat absence as invalidity. A rule applied to `undefined` would break every route authenticating through ambient discovery or OAuth — `openai-codex` cannot take a key at all — and a blank field that blocked submit would make editing any other setting demand re-entering the key. Both belong in the tests, not only in this note. - -Keys already stored by an earlier build are read through `resolveApiKey`, so an illegal stored value begins failing at resolution rather than at request time. That is the intent — the diagnosis improves — but it moves the failure earlier for anyone currently holding one. diff --git a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md deleted file mode 100644 index 28073660b1..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ /dev/null @@ -1,101 +0,0 @@ -# Agent Note: 在 API Key 进入 HTTP header 之前校验其格式 - -Status: proposed - -[English](2026-08-06-api-key-format-validation.md) | 中文 - -## Problem - -一个含有 HTTP header value 无法承载的字符的 API Key,会被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。 - -把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。 - -同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach `,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。 - -空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。 - -来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 - -## Proposal - -一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 - -这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 - -第二条更窄的规则用于识别整行粘贴的环境变量:拒绝匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 - -### 不变量属于每一层,启发式属于人所在的那一层 - -字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。 - -形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。 - -### 「没有 Key」是一种配置状态,不是缺失 - -在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。 - -**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 的存在就是为了承载这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。 - -**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时本就完全跳过 `credentials.set`,这一点保持不变:留空绝不能拦截提交,否则改一个 base URL 都得重新输一遍 Key。 - -**已提供,但为空或纯空白。** 这是唯一的错误,因为用户表达了设置 Key 的意图却什么都没给。`llm-pi-ai` 在 `resolveProfiles` 中的措辞本就是对的——*has an empty apiKey; omit it to use ambient authentication*——这种指明合法替代路径而非单纯拒绝的形态,正是其他界面要采用的。 - -因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。 - -### 规则住在哪里 - -`normalizeApiKey` 是 `dsh-llm` seam 的新模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。 - -客户端无法引入它:client 包只 reference client 包,因此 `packages/client/ui-models` 镜像这个断言并持有本地化文案,正如今天 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。 - -### 各个界面各做什么 - -| 界面 | 改动 | -|---|---| -| `dsh-llm` | 新增 `normalizeApiKey`;新增 `INVALID_CREDENTIAL`,刻意不进 `DEFAULT_RETRYABLE_CODES`。 | -| `llm-deepseek` `resolveAdapterOptions` | 归一化已提供的 `apiKey`,与既有的超出 schema 的边界检查并排抛错;使用 trim 后的值。缺省的 `apiKey` 仍照旧回落到 `apiKeyEnv`。关闭 dsh-external#210。 | -| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值;以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 | -| `llm-pi-ai` `resolveProfiles` | 把既有的空值检查扩展为这条共享规则,并保留其「omit it to use ambient authentication」的措辞。 | -| `llm-pi-ai` `resolveApiKey` | 归一化今天完全未受检的凭据与环境路径。不指定任何凭据的 profile 仍原样返回 `undefined`,ambient 与 OAuth 路由不受影响。 | -| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 不再被报成端点不可达。不带 Key 的探测照旧保持未鉴权。 | -| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则以字段级失败呈现,使已输入的内容绝不被静默丢弃。按既有 `modelFailure` 的模式拦截提交并在字段上呈现失败。 | - -`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。 - -`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。 - -## Alternatives considered - -**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。 - -**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。 - -**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。 - -**在 `credentials-local.set` 中执行。** 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该 provider 存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。 - -**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 - -**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在今天的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本 Agent Note 要让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 - -## Acceptance criteria - -- 浏览器、两个 resolver 与两处凭据读取接受与拒绝同一组**已提供**的字符串:纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角输入均被拒绝;可打印 ASCII 的 Key 被接受并 trim。 -- 不指定任何凭据的 profile 仍解析为「没有 Key」,通过内置 provider 自身的 ambient 发现或 OAuth 鉴权的路由原样可用。 -- 留空的 Key 输入框可以保存卡片其余部分而不写入凭据;只含空白的输入框则以字段级失败呈现,而不是被静默丢弃。 -- 被拒绝的 Key 在 Web UI 中定位到 API Key 字段并拦截提交;settings 与凭据均不写入。 -- 非法抵达 resolver 的 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置、不含 Key 的任何片段,且不被重试。 -- `llm-pi-ai` 的探测把非法 Key 报为 Key 故障,而非端点不可达。 -- 合法 Key 仍沿既有 `credentials.set` 路径原样通过。 - -## Risks - -形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 - -限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 - -字符集断言存在两份,每个 source plane 一份。布局禁止共享它,重复检测门禁可能会标记这一对;两侧各自带测试并在注释中指名其孪生体。 - -把这件事做错的最大代价,是把「未指定」当成「非法」。一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由——`openai-codex` 根本无法接受 Key——而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都应落在测试里,而不只是写在本 Agent Note 中。 - -早先版本已存下的 Key 会经 `resolveApiKey` 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。这正是意图所在——诊断变好了——但对当前正持有这类值的人而言,失败点提前了。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 1d9117dc85..0468e0e9d1 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -75,6 +75,25 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE) }, 60_000) + it('refuses a key no HTTP header can carry before anything is written', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key')) + const dialog = page.getByRole('dialog', { name: '设置' }) + const key = dialog.getByLabel('API 密钥') + const save = dialog.getByRole('button', { name: '保存', exact: true }) + + // The paste that used to save cleanly and then fail the first turn with a + // ByteString TypeError now names the field that holds it. + await key.fill('sk-\u{1F600}minimax') + await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').waitFor({ timeout: 10_000 }) + await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false) + + // Clearing it restores submit: an empty field means "keep what is stored", + // never a refusal, or editing any other setting would demand the key. + await key.fill('') + await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true) + expect(await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').count()).toBe(0) + }, 60_000) + it('stores the key under the derived reference and the route registers live', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add')) const dialog = page.getByRole('dialog', { name: '设置' }) From 6b75bb0425bad75fdaa9cb7a1be932ee8276b758 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 23:15:08 +0800 Subject: [PATCH 17/24] fix(web): say the API key format is wrong rather than naming the characters --- apps/web/tests/models-settings.e2e.ts | 4 ++-- packages/client/ui-models/src/client/locales.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 0468e0e9d1..e5e7d09c5f 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -84,14 +84,14 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // The paste that used to save cleanly and then fail the first turn with a // ByteString TypeError now names the field that holds it. await key.fill('sk-\u{1F600}minimax') - await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').waitFor({ timeout: 10_000 }) + await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 }) await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false) // Clearing it restores submit: an empty field means "keep what is stored", // never a refusal, or editing any other setting would demand the key. await key.fill('') await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true) - expect(await dialog.getByText('该 API 密钥含有无法发送的字符。请只粘贴原始密钥。').count()).toBe(0) + expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0) }, 60_000) it('stores the key under the derived reference and the route registers live', async () => { diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index fbfc85c7f1..0d50c03e63 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -47,7 +47,7 @@ export const en = { removeModel: 'Delete model', modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', - keyIllegalCharacters: 'This API key contains characters that cannot be sent. Paste the raw key only.', + keyIllegalCharacters: 'This API key is not in a valid format. Please check it.', keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', modelIdDuplicate: 'Model ID must be unique.', @@ -134,7 +134,7 @@ export const zh: typeof en = { removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', - keyIllegalCharacters: '该 API 密钥含有无法发送的字符。请只粘贴原始密钥。', + keyIllegalCharacters: '该 API 密钥格式错误,请检查。', keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', modelIdDuplicate: '模型 ID 不能重复。', From 1019f149c4e4ad2b75d7c9a15e0976ceaa05e7c7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 11:02:38 +0800 Subject: [PATCH 18/24] =?UTF-8?q?fix(web,llm):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20document=20the=20card=20contract,=20pin=20the=20hos?= =?UTF-8?q?t=20diagnosis,=20gate=20the=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cordis-catalog/services.md | 2 +- .../headless-agent/tests/headless.snapshot.ts | 40 +++++++++++++++++++ .../stream-json.expected.jsonl | 12 ++++++ packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 8 +++- .../ui-models/src/client/ModelListEditor.tsx | 13 +++++- .../ui-models/src/client/ProviderEditor.tsx | 2 +- .../client/ui-models/src/client/locales.ts | 2 + .../ui-models/tests/provider-form.spec.tsx | 13 ++++++ .../llm/llm-pi-ai/tests/discovery.spec.ts | 6 ++- packages/llm/llm/src/index.ts | 11 +++-- packages/llm/llm/tests/api-key.spec.ts | 2 +- 14 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6b30d80751..d71619b7d9 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -941,7 +941,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../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:287`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:292`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index eb39254dac..9de860d2b5 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -31,6 +31,10 @@ 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)) +// Same keyless composition as the missing-credential scenario: the endpoint is +// never dialed either way, because a supplied-but-unusable key fails credential +// resolution exactly where an absent one does. +const invalidCredentialScenarioDir = join(snapshotsDir, 'invalid-credential') const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) @@ -254,6 +258,42 @@ describe('headless stream-json snapshots', () => { expect(normalized).toContain('as a last resort') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs actionable invalid-credential guidance through the one-shot app', async () => { + const streamExpected = join(invalidCredentialScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'invalid-credential headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-invalid-credential-', + binScript, + configPath: credentialsConfigPath, + binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'], + tsconfigPath, + env: { + // A key that exists but no HTTP header can carry — the paste this + // change exists for. Before it, `fetch` refused to build the header + // and the turn ended on a retried ByteString TypeError. + DEEPSEEK_API_KEY: 'sk-\u{1F600}pasted-from-a-chat-window', + DEEPSEEK_BASE_URL: '', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + // The durable failure names the reference to correct and the writer that + // usually owns it, and stays true in a composition that mounts no Models + // page at all. + expect(normalized).toContain('the API key resolved from DEEPSEEK_API_KEY contains characters') + expect(normalized).toContain('the web Models page writes it') + // Neither the key nor the transport-level symptom it used to produce may + // reach the user: the code point of one character is still the key. + expect(normalized).not.toContain('pasted-from-a-chat-window') + expect(normalized).not.toContain('ByteString') + }, 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/invalid-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl new file mode 100644 index 0000000000..f521487e42 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl @@ -0,0 +1,12 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} +{"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":"user/message","seq":4,"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":5,"time":0,"data":{"title":"say pong","messageSeqs":[4],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":9,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}} +{"type":"result","sessionId":"{{sessionId}}","output":""} diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index ae296a91aa..3db62f6c31 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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/client/ui-models/README.md -README.md: b55914197e472edec8a8b6d4d3e02036d1697728 -README.zh.md: ca93c3d5a2a85fffb22707f8389f1e979468e2ec +README.md: e3328bb5fd2cf812b05dc26bf534226818132631 +README.zh.md: 20e40cc571a9123b50dfb28565c5562937e03189 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index b55914197e..e3328bb5fd 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A field holding only whitespace fails rather than being silently dropped, and a value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes fails too; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. An empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index ca93c3d5a2..20e40cc571 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。只含空白的输入框会失败,而不是被静默丢弃;形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值也会失败——该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。留空则完全不是失败:在编辑卡片上它意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index a252d99586..a610f2f140 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -215,7 +215,12 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { disabled={disabled} onChange={(event) => { setKeyDraft(event.target.value) }} /> - {keyFailure === undefined ? null :

{t(keyFailure)}

} + {/* A create card has no stored key to keep, so the blank case says + what a blank field means here instead: this route may authenticate + through the provider's own ambient discovery or OAuth. */} + {keyFailure === undefined + ? null + :

{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}

} void /** Endpoint facts for the fetch action. */ probe: ProbeTarget + /** + * Copy key naming why the fetch action is unavailable, or `undefined` when + * it is. The card owns this because the key it would send is judged there: + * asking with a key the form has already refused spends a round trip to be + * told what the field already says. + */ + probeBlocked?: keyof typeof en | undefined /** Wire face the fetch action calls. */ api: Pick /** Section copy. */ @@ -314,8 +321,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 0d50c03e63..85f7c14f97 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -47,6 +47,7 @@ export const en = { removeModel: 'Delete model', modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.', keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', + keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.', keyIllegalCharacters: 'This API key is not in a valid format. Please check it.', keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', @@ -134,6 +135,7 @@ export const zh: typeof en = { removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', + keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。', keyIllegalCharacters: '该 API 密钥格式错误,请检查。', keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index a167710153..13c1511bb2 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -954,6 +954,19 @@ describe('API key field', () => { expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc') }) + it('blocks the interrogation too, rather than spending a round trip on a refused key', async () => { + const { discover } = await mountSection() + openEditor('openai') + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + // The host would refuse this before building the header anyway; asking is + // a round trip to be told what the field already says. + expect(buttonNamed(en.fetchModels).disabled).toBe(true) + expect(buttonNamed(en.fetchModels).title).toBe(en.keyIllegalCharacters) + expect(discover).not.toHaveBeenCalled() + }) + it('carries the trimmed key into an interrogation, not the padded draft', async () => { const { discover } = await mountSection() openEditor('openai') diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 63b43ecdab..85221e7ca2 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -325,8 +325,10 @@ describe('probe key format', () => { }) it('reports a blank probe key as a credential fault too', async () => { - // A cleared form field arrives as '', not an absent key; it must fail the - // same way a typed-in illegal key does, rather than probing unauthenticated. + // The Models page omits `apiKey` entirely for a cleared field rather than + // sending '', so this pins the contract for every other caller: a supplied + // key is judged, and only an absent one probes unauthenticated. '' means + // "I have a key" and is answered as the empty key it is. await expect(discoverModels({ baseURL: 'https://acme.test', api: 'openai-completions', diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 287bfc2f34..0d1f23af19 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -145,11 +145,16 @@ export class LlmError extends HarnessError { export function assertUsableApiKey(raw: string, pkg: string, ref: string): string { const checked = normalizeApiKey(raw) if (checked.ok) return checked.value + // The Models page is named as the writer it usually is, not as the only one: + // the same value can arrive from a hand-edited .env or a shell export in a + // composition that mounts no credentials seam at all, where directing the + // user to a page that deployment does not serve would be a dead end. throw new LlmError( checked.reason === 'empty' - ? `${pkg}: the API key stored as ${ref} is blank; re-enter it on the web Models page` - : `${pkg}: the API key stored as ${ref} contains characters no HTTP header can carry;` - + ' re-enter it on the web Models page, pasting the raw key only', + ? `${pkg}: the API key resolved from ${ref} is blank; set ${ref} to the raw key` + + ' (the web Models page writes it) or export it in the launching environment' + : `${pkg}: the API key resolved from ${ref} contains characters no HTTP header can carry;` + + ` set ${ref} to the raw key alone (the web Models page writes it)`, INVALID_CREDENTIAL_CODE, ) } diff --git a/packages/llm/llm/tests/api-key.spec.ts b/packages/llm/llm/tests/api-key.spec.ts index a04a103fb9..783b054b9b 100644 --- a/packages/llm/llm/tests/api-key.spec.ts +++ b/packages/llm/llm/tests/api-key.spec.ts @@ -45,7 +45,7 @@ describe('assertUsableApiKey', () => { it('refuses a blank stored credential, naming the reference', () => { expect(() => assertUsableApiKey(' ', 'llm-deepseek', 'DEEPSEEK_API_KEY')) - .toThrow(/llm-deepseek: the API key stored as DEEPSEEK_API_KEY is blank/) + .toThrow(/llm-deepseek: the API key resolved from DEEPSEEK_API_KEY is blank/) }) it('refuses an unusable stored credential with the invalid-credential code', () => { From ec1111f18e4b7c3c87773a485133896078033c3b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 11:04:02 +0800 Subject: [PATCH 19/24] docs: keep the API key Agent Note current with the review fixes --- .../bug-fix/2026-08-06-api-key-format-validation.i18n.yaml | 4 ++-- .../bug-fix/2026-08-06-api-key-format-validation.md | 6 ++++-- .../bug-fix/2026-08-06-api-key-format-validation.zh.md | 6 ++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index 42b42a591a..2ffa228261 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.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-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: 9ec247cb2ba2578158759ec1115c5d3a95778cc4 -2026-08-06-api-key-format-validation.zh.md: 63c6a8c17ee93b4b68eb3505d5499756e9fb2401 +2026-08-06-api-key-format-validation.md: a0a99bfcace5422ed021d684c5d5aae48c197af7 +2026-08-06-api-key-format-validation.zh.md: b6dc836cbc7bc828f343d9d376dab6e5c3d424ee diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index 9ec247cb2b..a0a99bfcac 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -60,7 +60,7 @@ The client cannot import any of this: client packages reference only client pack | `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. | | `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. | | `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. | -| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit is gated and the failure renders on the field, matching the existing `modelFailure` pattern. | +| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit **and the endpoint interrogation** are both gated, so a refused key never spends a round trip to be told what the field already says, and the failure renders on the field, matching the existing `modelFailure` pattern. | `ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. `CustomProviderCard` carries the same judgement for a hand-declared route. @@ -102,4 +102,6 @@ The costliest way to get this wrong would have been to treat absence as invalidi `packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header. -`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. +`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit and the interrogation alike, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all. + +The user-visible terminal state is pinned where it is actually assembled: `examples/headless-agent/tests/headless.snapshot.ts` runs the one-shot app against a stored key no header can carry, over the same keyless composition its missing-credential sibling uses, and records that the turn ends on `INVALID_CREDENTIAL` with an actionable message carrying neither the key nor the word `ByteString`. A package test could not have shown that, and the web e2e covers only the browser half. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index 63c6a8c17e..b6dc836cbc 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -60,7 +60,7 @@ Status: implemented | `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 | | `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 | | `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 | -| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交受拦截,失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | +| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交**与端点探测**同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 `modelFailure` 模式一致。 | `ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。`CustomProviderCard` 为手工声明的路由承载同一套判定。 @@ -102,4 +102,6 @@ Status: implemented `packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。 -`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 拦截提交、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 +`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 同时拦截提交与探测、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。 + +用户可见的终态则钉在它真正被组装的位置:`examples/headless-agent/tests/headless.snapshot.ts` 让 one-shot 应用在一个 HTTP 标头无法承载的已存密钥下运行,复用其 missing-credential 兄弟场景的同一套无密钥 composition,并记录该轮以 `INVALID_CREDENTIAL` 结束、消息可操作且既不含密钥也不含 `ByteString` 字样。包级测试无法证明这一点,而 web e2e 只覆盖了浏览器那一半。 From a7d374426803c73d281f8f3fb84882e3458cdde0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 11:16:20 +0800 Subject: [PATCH 20/24] test(web): cover the create card's blank-key copy substitution --- .../ui-models/tests/provider-form.spec.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 13c1511bb2..85125919b5 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -880,6 +880,24 @@ describe('hand-declared providers', () => { expect(set).not.toHaveBeenCalled() }) + it('tells a whitespace-only key what a blank field means on a create card', () => { + const { mutate } = mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } }) + + // There is no stored key to keep here, so the blank case says the thing + // that is true of a route being declared: it may authenticate elsewhere. + expect(screen.getByText(en.keyBlankNew)).toBeTruthy() + expect(screen.queryByText(en.keyBlank)).toBeNull() + expect(buttonNamed(en.fetchModels).title).toBe(en.keyBlankNew) + expect(buttonNamed(en.create).disabled).toBe(true) + expect(mutate).not.toHaveBeenCalled() + }) + it('creates without a key when the route authenticates some other way', async () => { const { set, onClose } = mountCard() From 5a422337f8fa3c1664b392360593c9fece75abe2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 11:35:23 +0800 Subject: [PATCH 21/24] fix(web): silence the stale gate hint, clear whitespace fields, narrow the paste heuristic --- ...-08-06-api-key-format-validation.i18n.yaml | 4 +-- .../2026-08-06-api-key-format-validation.md | 2 +- ...2026-08-06-api-key-format-validation.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 4 +++ .../ui-models/src/client/ProviderEditor.tsx | 7 ++++- .../client/ui-models/src/client/apiKey.ts | 14 +++++---- .../ui-models/tests/components.spec.tsx | 2 ++ .../ui-models/tests/provider-form.spec.tsx | 31 +++++++++++++++++++ 8 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index 2ffa228261..ae2d1d5934 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.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-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: a0a99bfcace5422ed021d684c5d5aae48c197af7 -2026-08-06-api-key-format-validation.zh.md: b6dc836cbc7bc828f343d9d376dab6e5c3d424ee +2026-08-06-api-key-format-validation.md: 4666f6197dbed060d00c77fdd6b87842141c10f4 +2026-08-06-api-key-format-validation.zh.md: 75c98bd29cf009e69ceb450432f540e3f49d99d0 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index a0a99bfcac..4666f6197d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -86,7 +86,7 @@ The client cannot import any of this: client packages reference only client pack A malformed key is refused at the field that holds it, and a malformed stored key fails as `INVALID_CREDENTIAL` with a message naming where to fix it and no fragment of the key. Because that code sits outside `DEFAULT_RETRYABLE_CODES`, a deterministic credential fault is no longer retried three times as a transport blip. `llm-pi-ai` discovery reports an illegal probe key as a credential fault instead of an unreachable endpoint. -The shape heuristic can refuse a real key. Upper-case-identifier-then-`=` and matched surrounding quotes are shapes no known provider issues, and the rule runs only in the browser, so a user who hits it can still set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. +The shape heuristic can refuse a real key. The first draft matched any upper-case identifier followed by `=`, which review showed was broader than intended: an all-upper-case base64 key ending in padding (`ABCD==`) matched an assignment it does not resemble. Requiring a non-`=` character after the separator excludes padding, since base64 only ever pads at the end. What remains — an upper-case name, one `=`, then a value — is a shape no known provider issues, and the rule runs only in the browser, so a user who still hits it can set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported. Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index b6dc836cbc..75c98bd29c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -86,7 +86,7 @@ Status: implemented 格式错误的 Key 在持有它的那个字段上就被拒绝;格式错误的已存储 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置且不含 Key 的任何片段。由于该 code 位于 `DEFAULT_RETRYABLE_CODES` 之外,一个确定性的凭据故障不再被当作瞬时传输抖动重试三次。`llm-pi-ai` 的探测把非法 Key 报为凭据故障,而非端点不可达。 -形状启发式可能拒绝一个真实的 Key。全大写标识符接 `=`、以及首尾成对引号,都是已知 provider 不会签发的形态,且该规则只在浏览器中运行,因此撞上它的用户仍可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 +形状启发式可能拒绝一个真实的 Key。最初的写法匹配任意「全大写标识符接 `=`」,评审指出其覆盖面比预期更宽:一个以 padding 结尾的全大写 base64 Key(`ABCD==`)会命中它并不像的赋值形态。要求分隔符之后必须是非 `=` 字符即可排除 padding——base64 的 padding 只出现在末尾。剩下的形态(大写名称、一个 `=`、然后是值)是已知 provider 不会签发的,且该规则只在浏览器中运行,因此仍撞上它的用户可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。 限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index a610f2f140..032a056144 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -93,6 +93,10 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // because its own field already explains itself, and a satisfied card says // nothing at all rather than printing an empty paragraph. const hint = failure !== undefined || ready + // The key field prints its own failure directly beneath itself, so a card + // blocked only by the key stays silent here rather than answering with the + // next unmet gate — which is satisfied, and reads as a second, false fault. + || keyFailure !== undefined ? undefined : baseURL.length === 0 ? t('customNeedsBaseUrl') diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 79ac122c83..d72c71cc76 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -167,7 +167,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { return typeof value === 'string' && value.trim().length > 0 ? value : undefined } const setField = (key: string, next: string | undefined): void => { - setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next)) + // A value of nothing but whitespace is cleared, not stored: `stringAt` + // already reports it as absent, so the field would otherwise render empty + // while the draft still carried the spaces into `settings.yaml`, where + // both adapters would accept that non-empty string as a real value. + const value = next === undefined || next.trim().length === 0 ? undefined : next + setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value)) } // The model list is validated by the same per-row checker for both families, diff --git a/packages/client/ui-models/src/client/apiKey.ts b/packages/client/ui-models/src/client/apiKey.ts index a9d5bb3d32..5fd1d22ee6 100644 --- a/packages/client/ui-models/src/client/apiKey.ts +++ b/packages/client/ui-models/src/client/apiKey.ts @@ -12,13 +12,15 @@ const LEGAL_API_KEY = /^[\x21-\x7E]+$/ /** - * A pasted `NAME=value` environment line. Restricted to an upper-case - * identifier so a real key cannot match: `sk-` forms break at the hyphen. - * This heuristic runs only here — a resolver applying it could lock a user - * out of a gateway whose key legitimately takes this shape, with the - * environment refusing it too and no way through. + * A pasted `NAME=value` environment line. Two narrowings keep real keys clear + * of it: the name must be upper-case, so `sk-` forms break at the hyphen, and + * the `=` must be followed by something other than another `=`, so base64 + * padding on an all-upper-case key (`ABCD==`) is not mistaken for an + * assignment. This heuristic runs only here — a resolver applying it could + * lock a user out of a gateway whose key legitimately takes this shape, with + * the environment refusing it too and no way through. */ -const ENV_LINE = /^[A-Z][A-Z0-9_]*=/ +const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/ /** Copy key naming why a typed key cannot be saved. */ export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped' diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index d9034ecd44..7228d472cd 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -1092,6 +1092,8 @@ describe('apiKeyFailure', () => { ['a padded key, which the caller trims', ' sk-abc '], ['the printable-ASCII boundary characters', '!~'], ['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'], + ['an all-upper-case key ending in base64 padding', 'ABCD=='], + ['an all-upper-case key ending in one padding character', 'MNOPQRST='], ])('accepts %s', (_label, draft) => { expect(apiKeyFailure(draft)).toBeUndefined() }) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 85125919b5..5d505386e6 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -880,6 +880,22 @@ describe('hand-declared providers', () => { expect(set).not.toHaveBeenCalled() }) + it('stays silent about the other gates when only the key is refused', () => { + mountCard() + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } }) + + // Route, endpoint, and models are all satisfied, so answering with the + // next unmet gate would print a second, false fault beside the real one. + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() + expect(screen.queryByText(en.customNeedsModels)).toBeNull() + expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull() + }) + it('tells a whitespace-only key what a blank field means on a create card', () => { const { mutate } = mountCard() @@ -927,6 +943,21 @@ describe('API key field', () => { expect(set).not.toHaveBeenCalled() }) + it('clears a whitespace-only base URL instead of writing the spaces', async () => { + const { mutate } = await mountSection() + openEditor('openai') + + // The field renders this as empty, so the draft must agree: storing the + // spaces would hand both adapters a non-empty string they accept as a URL. + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: ' ' } }) + fireEvent.click(screen.getByText(en.apply)) + + await waitFor(() => { expect(mutate).toHaveBeenCalled() }) + const ops = firstMutate(mutate).ops + expect(ops.some(op => op.op === 'set' && op.path.includes('baseURL'))).toBe(false) + expect(ops.some(op => op.op === 'unset' && op.path.includes('baseURL'))).toBe(true) + }) + it('blocks submit and names the field when the key holds only whitespace', async () => { const { mutate, set } = await mountSection() openEditor('openai') From 0cb922199d6c5341558640bb6f8a19bb1123b2e9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 13:26:40 +0800 Subject: [PATCH 22/24] fix(apiproxy): open config files through Windows on WSL --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- ...-07-28-tool-call-file-open-in-os.i18n.yaml | 4 +- .../2026-07-28-tool-call-file-open-in-os.md | 5 +- ...2026-07-28-tool-call-file-open-in-os.zh.md | 5 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- .../host/apiproxy/src/native-path-opener.ts | 45 ++++++++++-- .../apiproxy/tests/native-path-opener.spec.ts | 71 ++++++++++++++++++- 14 files changed, 128 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 8ec7ff129e..b4b1fb5110 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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-web-config-plane.md -2026-07-30-web-config-plane.md: 11554077d1848dcdf59b896dd9c29a39fd2f55d4 -2026-07-30-web-config-plane.zh.md: 527c2de8155a56789358b801f9c374e16c81931b +2026-07-30-web-config-plane.md: 0b18cee414df23a2ed8a8b43b76dc06403804691 +2026-07-30-web-config-plane.zh.md: e70c2a47970f943e49393b099c4fcea58dc0fbdc diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 11554077d1..0b18cee414 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -16,7 +16,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. -**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on Linux, and `Invoke-Item` on Windows). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action. +**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action. **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 527c2de815..e70c2a4797 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -16,7 +16,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 -**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。 +**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`;WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 9b8d037c43..a702f1511f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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-28-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: a2c9b52507d32c2d851f811f0ecdd878a60b1e1c -2026-07-28-tool-call-file-open-in-os.zh.md: 725db61869383711042d85cc1508b00eb1b196b6 +2026-07-28-tool-call-file-open-in-os.md: 73f5091888ab2506eab50b827e74c5120394b127 +2026-07-28-tool-call-file-open-in-os.zh.md: c1bc93c472aae8be8cee3bab6cd02f556abca494 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index a2c9b52507..73f5091888 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -12,12 +12,13 @@ Chat tool rows treated the whole summary line as a click target that opened the File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as hover-underline links with a pointer cursor. Clicking the path calls `host.openPath` through `WorkspacesService.openPath`, resolving relative paths against the session cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. -`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, `xdg-open` on Linux. The opener is injectable for tests. URL-only read args (`web_fetch`) are not file links. +`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. ## Alternatives considered - Keep row-click details and add a separate file affordance — rejected; the product ask replaces the row gesture with the file link. - Open files inside an in-app preview — rejected; the ask is the OS default application. +- Treat WSL as desktop Linux — rejected; a WSL process reports `linux`, but a Linux desktop association is optional while its ordinary operator desktop and browser live on Windows. - Reuse `host.pickDirectory`'s timeout exemption — unnecessary; path open hand-off completes quickly under the normal unary deadline. ## Consequences @@ -26,5 +27,5 @@ Clicking a file path in a tool row opens that path on the host. Non-file tool ro ## Risks -- Linux hosts without `xdg-open` fail the RPC; the chat row stays silent while the host returns an internal error. +- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat row stays silent while the host returns an internal error. - Relative paths without a session cwd are forwarded verbatim and may fail on the host. diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index 725db61869..c1bc93c472 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -12,12 +12,13 @@ Status: implemented 文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为悬停下划线链接并使用 pointer 光标。点击路径会经 `WorkspacesService.openPath` 调用 `host.openPath`,相对路径以会话 cwd 为基准解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 -`host.openPath` 是特权一元 RPC,仅接受来自回环地址且同源的浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,Linux 为 `xdg-open`。打开器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 +`host.openPath` 是特权一元 RPC,仅接受来自回环地址且同源的浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 ## 考虑过的替代方案 - 保留整行点击打开 details,另加文件入口 — 否决;产品要求用文件链接替换整行手势。 - 在应用内预览文件 — 否决;要求是操作系统默认应用。 +- 将 WSL 当作桌面 Linux — 否决;WSL 进程报告 `linux`,但 Linux 桌面文件关联并非必有,而其常规用户桌面和浏览器位于 Windows 上。 - 复用 `host.pickDirectory` 的超时豁免 — 不必要;打开路径的交接在常规一元截止时间内即可完成。 ## 后果 @@ -26,5 +27,5 @@ Status: implemented ## 风险 -- 没有 `xdg-open` 的 Linux 宿主会使 RPC 失败;聊天行保持静默,宿主返回内部错误。 +- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天行保持静默,宿主返回内部错误。 - 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。 diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 9fe338ea47..9c61e62c48 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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/client/ui-settings-general/README.md -README.md: 29e48d193d24644f37d219b4df44a8fedf062e53 -README.zh.md: 17ebc9e8ab273aae0e7ea4c764da569da6d9f49f +README.md: ab27e073dc76335efc619f56365d1705007f7ef2 +README.zh.md: 18bbecf67f51ae63bfacd4ba78437bea95b50bee diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 29e48d193d..ab27e073dc 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. -A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. +A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. `src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 17ebc9e8ab..18bbecf67f 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -4,7 +4,7 @@ 设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 -回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。 +回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。 `src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..77fa4afe7d 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5 -README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 +README.md: 395e0d5085878e230fdf7de49a0ca47745bdc270 +README.zh.md: 2ef34f7d6e7ae031dd5f847dfa13827fe4550839 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..395e0d5085 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -32,7 +32,7 @@ A stale continuation discards every partial result, deduplication entry, and cur Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. -`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. +`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). WSL translates the Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item` instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..2ef34f7d6e 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -32,7 +32,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 -`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 +`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。WSL 会通过 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index a9fdd56bc4..fa7ae5d081 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -1,5 +1,6 @@ /** Cross-platform native path and text-document openers used by the local GUI carrier. */ +import { release as osRelease } from 'node:os' import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' /** Testable command boundary; native implementations never invoke a shell. */ @@ -8,6 +9,10 @@ export type PathOpenerRunner = NativeCommandRunner /** Injectable platform facts for deterministic adapter tests. */ export interface PathOpenerInternals { platform?: NodeJS.Platform + /** Kernel release override used to distinguish WSL from desktop Linux. */ + osRelease?: string + /** WSL environment marker override used with the kernel release. */ + env?: Readonly>> run?: PathOpenerRunner } @@ -19,6 +24,36 @@ function powershellLiteral(path: string): string { return `'${path.replace(/'/g, "''")}'` } +/** Whether one environment marker is set to a non-empty value. */ +function present(value: string | undefined): boolean { + return value !== undefined && value !== '' +} + +/** Distinguish WSL from desktop Linux using its process and kernel markers. */ +function isWsl(internals: PathOpenerInternals): boolean { + const env = internals.env ?? process.env + if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true + return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft') +} + +/** Open one Windows-resolvable path through its registered desktop application. */ +async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise { + await run('powershell.exe', [ + '-NoProfile', + '-Command', + `Invoke-Item -LiteralPath ${powershellLiteral(path)}`, + ], signal) +} + +/** Translate a WSL path before handing it to the Windows desktop. */ +async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise { + const translated = await run('wslpath', ['-w', path], signal) + signal.throwIfAborted() + const windowsPath = translated.stdout.replace(/[\r\n]+$/, '') + if (windowsPath === '') throw new Error('wslpath returned no Windows path') + await openWindowsPath(windowsPath, signal, run) +} + /** Dispatch one shell-free platform command for the requested open intent. */ async function openNativePathWithIntent( path: string, @@ -35,15 +70,15 @@ async function openNativePathWithIntent( } if (platform === 'win32') { - await run('powershell.exe', [ - '-NoProfile', - '-Command', - `Invoke-Item -LiteralPath ${powershellLiteral(path)}`, - ], signal) + await openWindowsPath(path, signal, run) return } if (platform === 'linux') { + if (isWsl(internals)) { + await openWslPath(path, signal, run) + return + } await run('xdg-open', [path], signal) return } diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts index 236de1c9a7..0c6c327273 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -14,6 +14,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() vi.mock('node:child_process', () => ({ execFile: execFileMock })) +import { release as osRelease } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts' @@ -34,10 +35,58 @@ describe('native path opener', () => { it('uses the Linux desktop association for text documents', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) - await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run }) + await openNativeTextFile('/tmp/settings.yaml', signal(), { + platform: 'linux', osRelease: '6.8.0-generic', env: {}, run, + }) expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal)) }) + it.each([ + ['distribution marker', { WSL_DISTRO_NAME: 'Ubuntu' }, '6.8.0-generic'], + ['interop marker', { WSL_INTEROP: '/run/WSL/123_interop' }, '6.8.0-generic'], + ['kernel release', {}, '5.15.153.1-microsoft-standard-WSL2'], + ])('hands WSL text documents to the Windows desktop from the %s', async (_label, env, osRelease) => { + const requestSignal = signal() + const run = vi.fn(async command => command === 'wslpath' + ? { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml\r\n', stderr: '' } + : { stdout: '', stderr: '' }) + await openNativeTextFile('/home/test user/settings.yaml', requestSignal, { + platform: 'linux', osRelease, env, run, + }) + expect(run.mock.calls).toEqual([ + ['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal], + [ + 'powershell.exe', + [ + '-NoProfile', + '-Command', + "Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'", + ], + requestSignal, + ], + ]) + }) + + it('rejects an empty WSL path translation before invoking Windows', async () => { + const run = vi.fn(async () => ({ stdout: '\r\n', stderr: '' })) + await expect(openNativeTextFile('/home/test/settings.yaml', signal(), { + platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run, + })).rejects.toThrow('wslpath returned no Windows path') + expect(run).toHaveBeenCalledOnce() + }) + + it('does not invoke Windows when the request aborts during WSL path translation', async () => { + const abort = new AbortController() + const run = vi.fn(async () => { + abort.abort(new Error('closed')) + return { stdout: '\\\\wsl.localhost\\Ubuntu\\home\\test\\settings.yaml\n', stderr: '' } + }) + await expect(openNativeTextFile('/home/test/settings.yaml', abort.signal, { + platform: 'linux', osRelease: '6.8.0-generic', env: { WSL_DISTRO_NAME: 'Ubuntu' }, run, + })).rejects.toThrow('closed') + expect(run).toHaveBeenCalledOnce() + }) + it('opens with Windows Invoke-Item and escapes single quotes', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run }) @@ -60,7 +109,10 @@ describe('native path opener', () => { it('opens with Linux xdg-open', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) - await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run }) + await openNativePath('/tmp/a.txt', signal(), { + platform: 'linux', osRelease: '6.8.0-generic', + env: { WSL_DISTRO_NAME: '', WSL_INTEROP: '' }, run, + }) expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/a.txt'], expect.any(AbortSignal)) }) @@ -71,7 +123,9 @@ describe('native path opener', () => { it('uses the current process platform when no platform override is supplied', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) - await openNativePath('/tmp/platform-default.txt', signal(), { run }) + await openNativePath('/tmp/platform-default.txt', signal(), { + osRelease: '6.8.0-generic', env: {}, run, + }) const expected = process.platform === 'win32' ? 'powershell.exe' : process.platform === 'linux' @@ -80,6 +134,17 @@ describe('native path opener', () => { expect(run.mock.calls[0]?.[0]).toBe(expected) }) + it('samples ambient WSL markers and kernel release when no fact overrides are supplied', async () => { + const ambientWsl = [process.env.WSL_DISTRO_NAME, process.env.WSL_INTEROP] + .some(value => value !== undefined && value !== '') + || osRelease().toLowerCase().includes('microsoft') + const run = vi.fn(async command => command === 'wslpath' + ? { stdout: 'C:\\settings.yaml\n', stderr: '' } + : { stdout: '', stderr: '' }) + await openNativePath('/tmp/ambient-facts.yaml', signal(), { platform: 'linux', run }) + expect(run.mock.calls[0]?.[0]).toBe(ambientWsl ? 'wslpath' : 'xdg-open') + }) + it('runs the default command adapter without a shell and preserves command failures', async () => { execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { callback(null, '', '') From 1f853d85cd33fd91c56e34c98831adbb48f5410d Mon Sep 17 00:00:00 2001 From: fz Date: Thu, 6 Aug 2026 11:09:09 +0800 Subject: [PATCH 23/24] Attribute reader scroll input through the observed-top ledger ChatView's bottom-follow recognized only wheel gestures as reader input, so touch panning, scrollbar dragging, and keyboard paging could not leave the bottom of a streaming transcript. Replace the wheel listener with device-agnostic attribution: a scroll position deviating from the observed-top ledger of the last delivered or written scrollTop is reader input. Adds keyboard-paging and touch-style fling e2e scenarios (red under the old implementation) and the bilingual Agent Note triplet. --- ...cky-composer-conversation-scroll.i18n.yaml | 4 +- ...-29-sticky-composer-conversation-scroll.md | 6 +- ...-sticky-composer-conversation-scroll.zh.md | 6 +- ...-attribution-observed-top-ledger.i18n.yaml | 6 + ...-scroll-attribution-observed-top-ledger.md | 37 +++++ ...roll-attribution-observed-top-ledger.zh.md | 37 +++++ ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- apps/web/tests/chat-scroll-contract.e2e.ts | 145 ++++++++++++++++++ .../src/client/chat/ChatView.tsx | 45 ++---- .../ui-conversation/tests/chat-view.spec.tsx | 22 +-- 12 files changed, 262 insertions(+), 54 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml index b849211296..fdb487f16b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.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-29-sticky-composer-conversation-scroll.md -2026-07-29-sticky-composer-conversation-scroll.md: d3fed7a9d0b1f39f9551fbd85e0f83515b1a2690 -2026-07-29-sticky-composer-conversation-scroll.zh.md: 2beee34d3bb68832d14b7607b43aa11e1425d53d +2026-07-29-sticky-composer-conversation-scroll.md: 8657080078e475d6e90f17a6c0f8cb5b2fb1555d +2026-07-29-sticky-composer-conversation-scroll.zh.md: a588903adf51c6fcdd01f851d4f5e6570f695242 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md index d3fed7a9d0..8657080078 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md @@ -14,7 +14,7 @@ The active conversation column split scrolling: the chat (and trajectory) view o Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host. -Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is stored state rather than raw scroll geometry. A passive wheel listener takes its pre-input baseline from the last main-thread-delivered or programmatically written `scrollTop`, because Chromium may advance compositor geometry before delivering the event; the current non-negative floor excludes a concurrent layout clamp from reader movement. A scroll without matching wheel movement re-pins while following and only refreshes the semantic position while reading. ChatView's single `ResizeObserver` follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write. +Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is stored state rather than raw scroll geometry; how reader input is recognized — device-agnostic deviation from the observed-top ledger of the last delivered or written `scrollTop` — is owned by the [reader-scroll-attribution note](2026-08-06-reader-scroll-attribution-observed-top-ledger.md). ChatView's single `ResizeObserver` follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write. ## Alternatives considered @@ -26,8 +26,8 @@ Chat history prepend follows reader intent through stable rendered node/call ide **Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned. -**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership remain outside the provenance model instead of adding a general input state machine. +**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership were left outside the provenance model instead of adding a general input state machine. The [reader-scroll-attribution note](2026-08-06-reader-scroll-attribution-observed-top-ledger.md) later closed this deferral by generalizing attribution through the observed-top ledger, still without an input state machine. ## Consequences -Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. No session → blank Hero and Hero → active both keep the same textarea DOM node and InputHub draft. +Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve reader scroll decisions, including Chromium's compositor-first delivery and stream-finalization shrink clamp. Follow ownership extends to every reader input under the [reader-scroll-attribution note](2026-08-06-reader-scroll-attribution-observed-top-ledger.md). No session → blank Hero and Hero → active both keep the same textarea DOM node and InputHub draft. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md index 2beee34d3b..a588903adf 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md @@ -14,7 +14,7 @@ Status: implemented 会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。 -Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态。passive wheel 监听器以最近一次由主线程交付或由程序写入的 `scrollTop` 作为输入前基线,因为 Chromium 可能先推进合成器几何状态,之后才交付事件;当前使用的非负下限不会将并发的布局钳制计入读者移动。没有对应滚轮/触控板输入位移的滚动,在跟随状态下会重新贴底,在阅读状态下则只刷新语义位置。`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。 +Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态;读者输入如何被识别——即以与设备无关的方式偏离由最近一次交付或写入的 `scrollTop` 构成的 observed-top ledger——由[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)负责。`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。 ## Alternatives considered @@ -26,8 +26,8 @@ Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图 **把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。 -**为每一种浏览器滚动输入来源建模。** 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权仍不纳入输入来源模型,也不为此新增通用输入状态机。 +**为每一种浏览器滚动输入来源建模。** 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权当时被留在输入来源模型之外,也未为此新增通用输入状态机。[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)后来通过 observed-top ledger 泛化了归因,补上了这一延后事项,且仍未引入输入状态机。 ## Consequences -在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。无 session → blank Hero 与 Hero → active 都保持同一 textarea DOM 节点以及 InputHub 草稿。 +在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留读者的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段的收缩钳制。贴底跟随所有权依据[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)扩展到每一种读者输入。无 session → blank Hero 与 Hero → active 都保持同一 textarea DOM 节点以及 InputHub 草稿。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml new file mode 100644 index 0000000000..1c861833cf --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.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-08-06-reader-scroll-attribution-observed-top-ledger.md +2026-08-06-reader-scroll-attribution-observed-top-ledger.md: 2dcd0178e4216d4d3e2edcd3bfcc05b63606c333 +2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: ffb03fdb68c9cb040e311077dd698b310a033fdd diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md new file mode 100644 index 0000000000..2dcd0178e4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md @@ -0,0 +1,37 @@ +# Agent Note: Reader scroll attribution through the observed-top ledger + +Status: implemented + +English | [中文](2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md) + +## Problem + +ChatView's bottom-follow recognized only wheel/trackpad gestures as reader input: while pinned to the floor, a scroll event without matching wheel movement was treated as programmatic and snapped back. Touch panning, native-scrollbar dragging, and keyboard paging therefore could not leave the bottom of a streaming transcript — on a phone the tail was effectively locked. That wheel-only provenance was a deliberate deferral in the [sticky-composer note](2026-07-29-sticky-composer-conversation-scroll.md), which rejected a general input state machine "for this narrow fix" and left every other scroll source outside the model. + +## Decision + +Reader input is no longer identified by device. ChatView keeps an observed-top ledger (`observedTopRef`): the last `scrollTop` either delivered on the main thread or written by the component, recorded synchronously at every programmatic write site — bottom follow, open restore, prepend anchoring, resize follow, and scroll delivery itself. When a scroll event arrives, a position that deviates from `min(ledger, floor)` by more than half a pixel is reader input; a position on the ledger (a delayed programmatic delivery) or exactly on the shrunken floor (a browser clamp after content shrank) preserves the current ownership state. Ownership then changes only through reader input under the existing threshold rule: within `FOLLOW_THRESHOLD` of the floor re-pins, beyond it releases follow and shows Back to bottom. The wheel listener and its epoch bookkeeping are deleted; the component listens to `scroll` alone, so wheel, touch, scrollbar, keyboard, and any future input source are covered by one rule. + +## Contract change: coalesced shrink-plus-regrow clamps + +A shrink clamp whose layout regrows within the same rendering update before the clamp's scroll event is delivered is geometrically indistinguishable from reader input, so it now reads as the reader and releases follow (Back to bottom recovers). Realistic React-commit-driven shrink and regrow is still absorbed: the layout-effect follow re-pins and re-records the ledger per commit, and a shrink-only clamp lands exactly on `min(ledger, floor)`. Only a non-React reflow that shrinks and regrows inside one update mis-attributes. The previous wheel model kept following in that raced case; the unit contract was rewritten to the absorbed-shrink-only guarantee in the same change. + +## Testing + +Unit specs in `packages/client/ui-conversation/tests/chat-view.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. + +The lane's Chromium cannot synthesize any non-wheel device scrolling, which bounds what the e2e can drive for real: `Input.synthesizeScrollGesture` with a touch source and hand-rolled `Input.dispatchTouchEvent` sequences deliver DOM events but never move a scroller (headless and headed-under-Xvfb alike); the `default` gesture source synthesizes wheel events; and compositor scrollbars ignore synthetic mouse input entirely, with a gutter visible only when `--hide-scrollbars` is removed. Keyboard is the one working non-wheel primitive, so it carries the real-input-pipeline proof, and the fling scenario replays touch's signature — per-frame decaying displacements the component never authored — through the scrollport directly. + +## Alternatives considered + +**Keep the wheel-only model.** Rejected: it is the defect. Touch, scrollbar, and keyboard readers cannot take ownership away from a streaming tail, and each newly supported device would need its own carve-out. + +**Enumerate input devices.** Adding `touchstart`/`pointerdown`/`keydown` listeners beside the wheel epoch was the obvious extension. Rejected: native-scrollbar dragging exposes no input event to latch before its scrolls arrive, device lists rot as browsers add sources, and every listener would need its own compositor-delivery grace window — the input state machine the sticky-composer note already declined to build. + +**Absorb the coalesced shrink-plus-regrow clamp with heuristics.** Floor-mismatch grace windows or deferred rAF re-checks could keep the raced clamp from reading as the reader. Rejected: streaming rewrites the floor at chunk pace (24 ms) against ~16 ms frames, so any grace window either swallows genuine touch input during streaming — reopening the bug this change fixes — or is too short to cover the race it targets. The mis-attribution is accepted and recoverable instead. + +**Drive real touch and scrollbar devices in e2e.** Rejected by the environment, not by preference: every synthesis path (CDP touch gestures, touch event sequences, synthetic mouse on classic scrollbars, headed under Xvfb) was probed and cannot scroll; the details live in Testing above. + +## Consequences + +Every reader input owns bottom-follow uniformly, with less code: the wheel listener, its epoch counter, and the pre-input baseline bookkeeping are gone, and attribution rides state the component already maintained. The sticky-composer note's layout, wheel chaining, and prepend-anchoring decisions are untouched and remain authoritative; its narrow provenance rule is superseded by this note. The cost is the contract change above — a coalesced non-React shrink-plus-regrow clamp now pauses follow until the reader returns to the floor or presses Back to bottom — traded for touch, scrollbar, and keyboard correctness during streaming. The e2e lane gains non-wheel coverage only within what its browser can synthesize; if gesture synthesis starts working in a future Chromium, the fling emulation can be replaced by real touch strokes without changing the asserted contract. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md new file mode 100644 index 0000000000..ffb03fdb68 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 经由 observed-top ledger 的读者滚动归因 + +Status: implemented + +[English](2026-08-06-reader-scroll-attribution-observed-top-ledger.md) | 中文 + +## 问题 + +ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输入:钉在底部(floor)期间,一个没有对应滚轮位移的滚动事件会被视为程序化滚动并被拉回底部。因此触控平移、拖动原生滚动条与键盘翻页都无法离开流式 transcript(文本记录)的底部,在手机上尾部实际上被锁死。这种仅认滚轮的输入来源判定是 [sticky-composer 笔记](2026-07-29-sticky-composer-conversation-scroll.md)中有意的暂缓:该笔记拒绝为「此次窄范围修复」建立通用输入状态机,把其余所有滚动来源都留在模型之外。 + +## 决策 + +读者输入不再依据设备来识别。ChatView 维护一份 observed-top ledger(`observedTopRef`):即最近一次由主线程交付、或由组件自身写入的 `scrollTop`,并在每一个程序化写入点(贴底跟随、打开时恢复、前置锚定、尺寸变化跟随以及滚动交付本身)同步记录。滚动事件到达时,偏离 `min(ledger, floor)` 超过半像素的位置即为读者输入;落在 ledger 上的位置(迟到的程序化交付),或恰好落在收缩后底部上的位置(内容收缩后的浏览器钳制),则维持当前的所有权状态。此后所有权只经由读者输入、按既有阈值规则变化:位置距底部在 `FOLLOW_THRESHOLD` 以内则重新贴底,超出则释放跟随并显示「回到底部」。滚轮监听器及其 epoch 簿记已删除;组件只监听 `scroll`,因此滚轮、触控、滚动条、键盘以及未来任何输入来源都由同一条规则覆盖。 + +## 契约变更:收缩与重新增长被合并的钳制 + +如果一次收缩钳制的布局在同一次渲染更新内、赶在该钳制的滚动事件交付之前重新增长,那么这个事件在几何上与读者输入无法区分,因此它现在会被判读为读者并释放跟随(可经「回到底部」恢复)。现实中由 React 提交(commit)驱动的收缩与重新增长仍会被吸收:layout effect 中的跟随会在每次提交后重新贴底并重新记录 ledger,而仅收缩的钳制会恰好落在 `min(ledger, floor)` 上。只有在同一次更新内先收缩再重新增长的非 React 重排会被误归因。旧的滚轮模型在这种竞态情形下会保持跟随;单元测试契约已在同一变更中改写为只吸收纯收缩的保证。 + +## 测试 + +`packages/client/ui-conversation/tests/chat-view.spec.tsx` 中的单元测试直接钉住 ledger 契约:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 + +该车道的 Chromium 无法合成任何非滚轮的设备滚动,这限定了 e2e 能真实驱动的范围:触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件,却从不移动滚动容器(无头模式与 Xvfb 下的有头模式皆然);`default` 手势来源合成的是滚轮事件;合成器滚动条则完全无视合成的鼠标输入,且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语,因此由它承担真实输入流水线的证明;快滑场景则把触控的特征(组件从未写入过的逐帧衰减位移)直接回放进滚动容器。 + +## 曾考虑的替代方案 + +**保留仅认滚轮的模型。** 否决:它本身就是缺陷所在。触控、滚动条与键盘读者无法从流式尾部夺走所有权,而每种新支持的设备都需要单独开一个特例。 + +**逐一枚举输入设备。** 在滚轮 epoch 旁边加挂 `touchstart`/`pointerdown`/`keydown` 监听器是最显而易见的扩展。否决:拖动原生滚动条在其滚动事件到达之前不暴露任何可供锁存的输入事件;设备清单会随浏览器新增输入来源而逐渐腐化;而且每个监听器都需要自己的合成器交付宽限窗口——这正是 sticky-composer 笔记当初就拒绝构建的那个输入状态机。 + +**用启发式吸收收缩与重新增长被合并的钳制。** 针对底部失配的宽限窗口,或推迟到 rAF 的复查,本可让这种竞态下的钳制不被判读为读者。否决:流式输出以分片节奏(24 ms)改写底部,而帧间隔约 16 ms,因此任何宽限窗口要么会在流式输出期间吞掉真实的触控输入(重新打开本次变更所修复的缺陷),要么短到盖不住它想针对的竞态。转而接受这一误归因,它是可恢复的。 + +**在 e2e 中驱动真实的触控与滚动条设备。** 否决来自环境,而非偏好取舍:每条合成路径(CDP 触控手势、触控事件序列、经典滚动条上的合成鼠标、Xvfb 下的有头模式)都逐一试过,均无法滚动;细节见上文「测试」一节。 + +## 后果 + +每种读者输入现在都以同一方式拥有贴底跟随,而代码更少:滚轮监听器、它的 epoch 计数器以及输入前基线簿记均已移除,归因搭载在组件本就维护的状态之上。sticky-composer 笔记中的布局、滚轮链式处理与前置锚定决策原样保留,仍为权威;其窄范围的输入来源规则由本笔记取代。代价就是上文的契约变更:一次收缩与重新增长被合并的非 React 钳制现在会暂停跟随,直到读者回到底部或按下「回到底部」;以此换来流式输出期间触控、滚动条与键盘的正确性。e2e 车道获得的非滚轮覆盖仅限其浏览器能够合成的范围;若手势合成在未来某个 Chromium 版本中开始可用,可以在不改变所断言契约的前提下,把快滑模拟替换为真实的触控划动。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 638e91a016..5f9a220dc5 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: f8519a9622d2f7216226a695db95dbebdbf24ea1 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 294f3e840e0242d9a0d9c53ac510d44d3b0d100f +2026-07-24-web-gui-browser-e2e-lane.md: 095ad9055c8462ecec67609ed9af55df2a2d7adb +2026-07-24-web-gui-browser-e2e-lane.zh.md: eb20ee378eb5154bafcb7a448202c66dacda9199 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index f8519a9622..095ad9055c 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -42,7 +42,7 @@ The typecheck plane split is structural: the host scaffold, its support module, ### Coverage contract -The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. The required lane additionally carries an 88-turn synthetic Chat scroll contract mixing wrapped Markdown, fenced code, and paired bash calls/results. Real wheel, composer, tool, tab, session, and viewport interactions assert a named settled row's top relative to the transcript scrollport and distance from the true bottom across concurrent history prepend plus paced streaming, pinned/away streaming, tool-disclosure offscreen cycles, expanded-history view/session remount, width reflow, immediate pinned remount, composer resize, and textarea wheel chaining; it deliberately pins neither DOM cardinality nor absolute `scrollTop`, so the same contract can qualify a virtualized implementation. A separate interaction contract over the same fixture pins heterogeneous-row order, independent adjacent tool disclosure, exact user-message clipboard content, a turn-bounded message fork, source/child isolation, and a real follow-up turn in the child; wheel input only navigates to semantic targets and carries no geometry expectation. A short live-history contract starts from a blank workspace and drives consecutive composer turns, including real bash call/result rounds and a paced long final response, pinning one session identity, exact per-turn event ownership, browser echo uniqueness, and composer recovery without timing thresholds. +The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. The required lane additionally carries an 88-turn synthetic Chat scroll contract mixing wrapped Markdown, fenced code, and paired bash calls/results. Real wheel, composer, tool, tab, session, and viewport interactions assert a named settled row's top relative to the transcript scrollport and distance from the true bottom across concurrent history prepend plus paced streaming, pinned/away streaming, tool-disclosure offscreen cycles, expanded-history view/session remount, width reflow, immediate pinned remount, composer resize, and textarea wheel chaining; real keyboard paging and a touch-style fling emulation additionally pin wheel-free bottom-follow ownership ([reader-scroll-attribution note](../bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md)); it deliberately pins neither DOM cardinality nor absolute `scrollTop`, so the same contract can qualify a virtualized implementation. A separate interaction contract over the same fixture pins heterogeneous-row order, independent adjacent tool disclosure, exact user-message clipboard content, a turn-bounded message fork, source/child isolation, and a real follow-up turn in the child; wheel input only navigates to semantic targets and carries no geometry expectation. A short live-history contract starts from a blank workspace and drives consecutive composer turns, including real bash call/result rounds and a paced long final response, pinning one session identity, exact per-turn event ownership, browser echo uniqueness, and composer recovery without timing thresholds. ### CI stance diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 294f3e840e..eb20ee378e 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -42,7 +42,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### 覆盖契约 -该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。必需车道还包含一份合成的 88 轮 Chat 滚动契约,其中混合了换行 Markdown、围栏代码以及成对的 bash 调用/结果。真实 wheel、输入框、工具、tab、会话与 viewport 交互会在并发历史前插加带节奏流式输出、贴底/离底流式输出、工具 disclosure 离屏循环、扩展历史后的视图/会话重新挂载、宽度重排、贴底后立即重新挂载、输入框尺寸变化以及 textarea wheel 链场景中,断言一个具名已结算行相对 transcript scrollport 的顶部位置和到真实底部的距离;它刻意不钉 DOM 基数或绝对 `scrollTop`,因此同一契约可以验收虚拟化实现。另一份基于同一 fixture 的交互契约钉住异构行顺序、相邻工具 disclosure 的独立状态、用户消息剪贴板内容的精确值、以轮次为边界的消息 fork、源会话/子会话隔离,以及子会话中的一次真实追问轮次;wheel 输入只用于导航到语义目标,不承载几何预期。一份简短的实时历史契约从空白工作区开始,连续驱动输入框轮次,其中包括真实的 bash 调用/结果轮次和一段带节奏的长篇最终响应;它钉住单一会话身份、每轮事件的精确归属、浏览器回显唯一性与输入框恢复,不设置时间阈值。 +该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。必需车道还包含一份合成的 88 轮 Chat 滚动契约,其中混合了换行 Markdown、围栏代码以及成对的 bash 调用/结果。真实 wheel、输入框、工具、tab、会话与 viewport 交互会在并发历史前插加带节奏流式输出、贴底/离底流式输出、工具 disclosure 离屏循环、扩展历史后的视图/会话重新挂载、宽度重排、贴底后立即重新挂载、输入框尺寸变化以及 textarea wheel 链场景中,断言一个具名已结算行相对 transcript scrollport 的顶部位置和到真实底部的距离;真实键盘翻页与触摸式惯性滑动模拟额外钉住不依赖 wheel 的贴底跟随所有权([读者滚动归因笔记](../bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md));它刻意不钉 DOM 基数或绝对 `scrollTop`,因此同一契约可以验收虚拟化实现。另一份基于同一 fixture 的交互契约钉住异构行顺序、相邻工具 disclosure 的独立状态、用户消息剪贴板内容的精确值、以轮次为边界的消息 fork、源会话/子会话隔离,以及子会话中的一次真实追问轮次;wheel 输入只用于导航到语义目标,不承载几何预期。一份简短的实时历史契约从空白工作区开始,连续驱动输入框轮次,其中包括真实的 bash 调用/结果轮次和一段带节奏的长篇最终响应;它钉住单一会话身份、每轮事件的精确归属、浏览器回显唯一性与输入框恢复,不设置时间阈值。 ### CI 立场 diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 6509055036..0c75afb4e7 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -40,6 +40,11 @@ const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST' const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE' const TOOL_READY_FILE = '.chat-scroll-tool-ready' const TOOL_RELEASE_FILE = '.chat-scroll-tool-release' +const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e' +const FLING_SESSION_ID = 'chat-scroll-fling-e2e' +const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.' +const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST' +const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE' const HISTORY_FIXTURE = createChatScrollFixture({ markerPrefix: 'HISTORY', @@ -58,6 +63,10 @@ const RESTORE_FIXTURE_B = createChatScrollFixture({ title: 'CHAT_SCROLL_RESTORE_B comparison session', turns: 32, }) +const INPUTS_FIXTURE = createChatScrollFixture({ + markerPrefix: 'INPUTS', + title: 'CHAT_SCROLL_INPUTS non-wheel reader input session', +}) interface ScrollGeometry { readonly distanceFromBottom: number @@ -273,6 +282,34 @@ async function wheelTranscript(page: Page, deltaY: number): Promise { await nextPaint(page) } +/** + * Touch-style momentum fling over the transcript. Headless Chromium in the + * test lane cannot synthesize device scrolling (Input.synthesizeScrollGesture + * and Input.dispatchTouchEvent both deliver DOM events without moving any + * scroller, and compositor scrollbars ignore synthetic mouse input), so the + * fling replays the signature a real pan leaves on the scrollport: per-frame + * decaying displacements the component never authored, carrying no wheel + * events. Wheel-sign semantics: positive deltaY reads downward. + */ +async function flingTranscript(page: Page, deltaY: number): Promise { + await page.locator('[data-conversation-scroll]').evaluate(async (host, delta) => { + const direction = Math.sign(delta) + let remaining = Math.abs(delta) + // Fast launch decaying toward a floor speed, like a released finger. The + // floor stays above the follow threshold so contended frames (streaming + // writes racing the fling) still deviate far enough to read as input. + let velocity = Math.max(120, remaining / 8) + while (remaining > 0) { + const step = Math.min(velocity, remaining) + host.scrollTop += direction * step + remaining -= step + velocity = Math.max(48, velocity * 0.9) + await new Promise(resolve => requestAnimationFrame(() => { resolve() })) + } + }, deltaY) + await nextPaint(page) +} + async function wheelToHistoryStart(page: Page): Promise { for (let attempt = 0; attempt < 12; attempt += 1) { if ((await scrollGeometry(page)).scrollTop <= 1) break @@ -683,4 +720,112 @@ describe('web e2e: long Chat scroll contract', () => { assertClean(world) }) }, 180_000) + + // Keyboard is the only non-wheel device this lane's Chromium can drive for + // real (see flingTranscript for the probe results on touch and scrollbars), + // so it stands in for the whole hardware input pipeline here. + it.skipIf(MODE === 'record')('keyboard paging owns bottom-follow without wheel input', async () => { + await withScrollWorld({ + failureShot: 'web-e2e-chat-scroll-keyboard', + seeds: [{ fixture: INPUTS_FIXTURE, id: INPUTS_SESSION_ID }], + }, async (world) => { + await openSeed( + world.page, + INPUTS_FIXTURE, + INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns), + ) + await expectBottom(world.page) + const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) + + // Focus rides the last seeded tool row (a tabbable button whose keydown + // handler passes scrolling keys through). End first normalizes the + // focus-driven scrollIntoView back to the floor. + const lastToolRow = world.page.locator( + `[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`, + ) + await lastToolRow.focus() + await world.page.keyboard.press('End') + await expectBottom(world.page) + await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0) + for (let press = 0; press < 3; press += 1) { + await world.page.keyboard.press('PageUp') + await nextPaint(world.page) + } + await backToBottom.waitFor({ timeout: 10_000 }) + await expect.poll(async () => (await scrollGeometry(world.page)).distanceFromBottom, { timeout: 10_000 }) + .toBeGreaterThan(100) + await world.page.keyboard.press('End') + await expectBottom(world.page) + await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0) + assertClean(world) + }) + }, 180_000) + + it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => { + await withScrollWorld({ + failureShot: 'web-e2e-chat-scroll-fling-stream', + replay: [ + replayEntry(toolStream()), + replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)), + ], + seeds: [{ fixture: INPUTS_FIXTURE, id: FLING_SESSION_ID }], + }, async (world) => { + const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE) + const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE) + await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns)) + const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) + const settled = world.scaffold.whenTurnSettled(60_000) + let released = false + try { + const composer = world.page.locator('textarea:enabled').last() + await composer.fill(LIVE_FLING_PROMPT) + await world.page.getByRole('button', { name: 'Send message', exact: true }).click() + await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true) + await expectBottom(world.page) + + // Fling away while the turn is mid-flight: the scroll burst alone must + // release bottom ownership, exactly like a wheel scroll would, even + // while streaming keeps re-asserting the floor between frames. + await flingTranscript(world.page, -900) + await backToBottom.waitFor({ timeout: 10_000 }) + const awayAnchor = await visibleFlowAnchor(world.page) + const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length + await writeFile(releasePath, 'release\n') + released = true + await expect.poll( + () => world.events.some(event => event.type === 'tool/result'), + { timeout: 15_000 }, + ).toBe(true) + await expect.poll( + () => world.events.filter(event => event.type === 'assistant/chunk').length, + { timeout: 15_000 }, + ).toBeGreaterThan(chunksBeforeRelease + 5) + await expectSameFlowTop(world.page, awayAnchor) + + // Fling back to the floor: re-pin must come from the reader's scroll + // itself, and follow must then own the still-streaming tail. The + // retry loop chases the floor that streaming keeps pushing down. + for (let attempt = 0; attempt < 8; attempt += 1) { + if ((await scrollGeometry(world.page)).distanceFromBottom <= 1) break + await flingTranscript(world.page, 1_600) + } + await expectBottom(world.page) + await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0) + const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length + await expect.poll( + () => world.events.filter(event => event.type === 'assistant/chunk').length, + { timeout: 15_000 }, + ).toBeGreaterThan(chunksAtRepin + 5) + await expectBottom(world.page) + } finally { + if (!released) await writeFile(releasePath, 'release\n').catch(() => {}) + } + + await settled + await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) + await world.page.getByText(LIVE_FLING_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) + await expectBottom(world.page) + assertClean(world) + }) + }, 180_000) }) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 6058efca98..09433e4085 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -372,9 +372,6 @@ export function ChatView({ const [atBottom, setAtBottom] = useState(true) /** Last position delivered or written on the main thread. */ const observedTopRef = useRef(0) - /** Pre-input position for the current wheel gesture. */ - const wheelStartRef = useRef(null) - const wheelEpochRef = useRef(0) /** Paging anchor: semantic row/position at click, updated by reader scrolls * while the request is pending and restored after the prepend lands. */ const anchorRef = useRef(null) @@ -394,8 +391,6 @@ export function ChatView({ const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}` const toBottom = (el: HTMLElement): void => { - wheelStartRef.current = null - wheelEpochRef.current += 1 anchorRef.current = null el.scrollTop = el.scrollHeight observedTopRef.current = el.scrollTop @@ -472,17 +467,19 @@ export function ChatView({ /* v8 ignore next -- ref-null guard: the handler only fires while mounted. */ if (local === null) return const el = scrollerOf(local) - // Only wheel input may make raw scroll geometry change follow ownership. - // Browser clamping and delayed programmatic scroll events otherwise have - // the same event shape and must preserve the current ownership state. + // Only reader input may make raw scroll geometry change follow ownership: + // a delivered position that deviates from the observed-top ledger (every + // programmatic write records itself there synchronously). This covers + // wheel, touch, scrollbar, and keyboard alike without naming devices. + // Browser shrink-clamps land exactly on the floor min and delayed + // programmatic deliveries land on the ledger itself, so both preserve + // the current ownership state. const floor = Math.max(0, el.scrollHeight - el.clientHeight) - const wheelStart = wheelStartRef.current - const movedByWheel = wheelStart !== null - && Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5 - const isAtBottom = movedByWheel + const movedByReader = Math.abs(el.scrollTop - Math.min(observedTopRef.current, floor)) > 0.5 + const isAtBottom = movedByReader ? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1 : atBottomRef.current - if (!movedByWheel && isAtBottom) { + if (!movedByReader && isAtBottom) { toBottom(el) return } @@ -501,34 +498,18 @@ export function ChatView({ observedTopRef.current = el.scrollTop } - // Bind scroll and the wheel provenance needed to distinguish reader input - // from layout-driven scrolls on the resolved scrollport once per mount. + // Bind the scroll listener on the resolved scrollport once per mount; + // reader-input attribution rides the observed-top ledger, not per-device + // input listeners. useEffect(() => { const local = listRef.current /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */ if (local === null) return const el = scrollerOf(local) const onScroll = (): void => { onScrollRef.current() } - const onWheel = (event: WheelEvent): void => { - if (event.ctrlKey || event.deltaY === 0) return - const startTop = observedTopRef.current - const floor = Math.max(0, el.scrollHeight - el.clientHeight) - const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1 - if (!canMove) return - wheelStartRef.current = startTop - const epoch = ++wheelEpochRef.current - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (wheelEpochRef.current === epoch) wheelStartRef.current = null - }) - }) - } el.addEventListener('scroll', onScroll, { passive: true }) - el.addEventListener('wheel', onWheel, { capture: true, passive: true }) return () => { - wheelStartRef.current = null el.removeEventListener('scroll', onScroll) - el.removeEventListener('wheel', onWheel, true) } }, []) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 2369e8f1d6..ae0340f8a4 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -158,9 +158,9 @@ function makeHarness(init?: Partial) { return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection } } -/** Simulate reader input before the browser delivers the host scroll event. */ +/** Simulate reader input (any device): a delivered position that deviates + * from the observed-top ledger of programmatic writes. */ function readerScroll(element: HTMLElement, top: number): void { - fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 }) element.scrollTop = top fireEvent.scroll(element) } @@ -939,7 +939,7 @@ describe('ChatView', () => { expect(view.queryByLabelText('回到底部')).toBeNull() }) - it('keeps following when a delayed clamp scroll arrives after layout regrows', () => { + it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement @@ -947,12 +947,12 @@ describe('ChatView', () => { scroller.scrollTop = 700 fireEvent.scroll(scroller) - // The wheel cannot move farther down. A stream-finalization shrink clamps - // the old position, then reflow grows the layout before scroll delivery. - fireEvent.wheel(scroller, { deltaY: 120 }) - metrics.setLayout(1_040, 500) + // Stream finalization shrinks the column: the browser clamps the pinned + // position onto the new floor and delivers a scroll event. The clamp + // lands exactly on the ledger's floor min, so it is not reader input. + metrics.setLayout(800, 700) fireEvent.scroll(scroller) - expect(scroller.scrollTop).toBe(740) + expect(scroller.scrollTop).toBe(500) expect(view.queryByLabelText('回到底部')).toBeNull() expect(h.chatScroll.read()).toBeNull() @@ -961,7 +961,7 @@ describe('ChatView', () => { expect(scroller.scrollTop).toBe(900) }) - it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => { + it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement @@ -969,8 +969,10 @@ describe('ChatView', () => { scroller.scrollTop = 700 fireEvent.scroll(scroller) + // Chromium advances compositor geometry before delivering the event: + // attribution must compare against the observed-top ledger, never a + // baseline sampled from already-moved raw geometry. scroller.scrollTop = 500 - fireEvent.wheel(scroller, { deltaY: -200 }) fireEvent.scroll(scroller) expect(view.getByLabelText('回到底部')).toBeTruthy() }) From 0823a3484a6279171e9552696ea39be55ec250c7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 14:03:11 +0800 Subject: [PATCH 24/24] fix(web): report a wrapped paste as the same API key format failure --- .../2026-08-06-api-key-format-validation.i18n.yaml | 4 ++-- .../bug-fix/2026-08-06-api-key-format-validation.md | 2 +- .../2026-08-06-api-key-format-validation.zh.md | 2 +- packages/client/ui-models/README.i18n.yaml | 4 ++-- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- packages/client/ui-models/src/client/apiKey.ts | 12 +++++++++--- packages/client/ui-models/src/client/locales.ts | 2 -- packages/client/ui-models/tests/components.spec.tsx | 4 ++-- .../client/ui-models/tests/provider-form.spec.tsx | 2 +- 10 files changed, 20 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index ae2d1d5934..d5418088d7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.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-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: 4666f6197dbed060d00c77fdd6b87842141c10f4 -2026-08-06-api-key-format-validation.zh.md: 75c98bd29cf009e69ceb450432f540e3f49d99d0 +2026-08-06-api-key-format-validation.md: c85d03119565a25abb37a0d32550d46796148134 +2026-08-06-api-key-format-validation.zh.md: 84e5458675995d2454fa4becc5e6466f511c4932 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index 4666f6197d..c85d031195 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -22,7 +22,7 @@ One rule defines a legal key: **after trimming, non-empty, and every character w This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. -A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen. +A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=[^=]` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen — and requiring a non-`=` character after the separator keeps base64 padding clear of it too. It reports the same format failure as an illegal character rather than its own message: the reader's next move is identical either way, so a separate line would name a cause without changing what to do. ### Invariants belong at every layer; heuristics belong where the human is diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index 75c98bd29c..84e5458675 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -22,7 +22,7 @@ Status: implemented 这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 -第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配。 +第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=[^=]` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配——而要求分隔符之后必须是非 `=` 字符,则让 base64 的 padding 也与之绝缘。它报出的是与非法字符相同的那条格式失败,而不是自己的一句:读到它的人下一步动作完全一样,因此单列一句只会点出一个原因,却不改变该怎么做。 ### 不变量属于每一层,启发式属于人所在的那一层 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 3db62f6c31..ca475596fd 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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/client/ui-models/README.md -README.md: e3328bb5fd2cf812b05dc26bf534226818132631 -README.zh.md: 20e40cc571a9123b50dfb28565c5562937e03189 +README.md: a9799916a91cb7416765c387b59c9172fa477666 +README.zh.md: e73100fa7d23a4025dffe471615401617a4af811 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index e3328bb5fd..a9799916a9 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A field holding only whitespace fails rather than being silently dropped, and a value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes fails too; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. An empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A field holding only whitespace fails rather than being silently dropped, and a value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. An empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 20e40cc571..e73100fa7d 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。只含空白的输入框会失败,而不是被静默丢弃;形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值也会失败——该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。留空则完全不是失败:在编辑卡片上它意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。只含空白的输入框会失败,而不是被静默丢弃;形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝——该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。留空则完全不是失败:在编辑卡片上它意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 diff --git a/packages/client/ui-models/src/client/apiKey.ts b/packages/client/ui-models/src/client/apiKey.ts index 5fd1d22ee6..5e3aa692c8 100644 --- a/packages/client/ui-models/src/client/apiKey.ts +++ b/packages/client/ui-models/src/client/apiKey.ts @@ -22,8 +22,14 @@ const LEGAL_API_KEY = /^[\x21-\x7E]+$/ */ const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/ -/** Copy key naming why a typed key cannot be saved. */ -export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped' +/** + * Copy key naming why a typed key cannot be saved. A wrapped paste reports the + * same format failure as an illegal character: the reader's next move is the + * same either way — look at the key and paste it again — so naming the two + * causes apart would spend the field's one line on a distinction that changes + * nothing about what to do. + */ +export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' /** Whether a value is wrapped in one matching pair of quotes. */ function isQuoted(value: string): boolean { @@ -46,7 +52,7 @@ export function apiKeyFailure(draft: string): ApiKeyFailureKey | undefined { if (draft.length === 0) return undefined const value = draft.trim() if (value.length === 0) return 'keyBlank' - if (ENV_LINE.test(value) || isQuoted(value)) return 'keyLooksWrapped' + if (ENV_LINE.test(value) || isQuoted(value)) return 'keyIllegalCharacters' if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters' return undefined } diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 85f7c14f97..336bf498f5 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -49,7 +49,6 @@ export const en = { keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.', keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.', keyIllegalCharacters: 'This API key is not in a valid format. Please check it.', - keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.', modelIdRequired: 'Model ID is required.', modelIdDuplicate: 'Model ID must be unique.', modelNameInvalid: 'Display name cannot be empty.', @@ -137,7 +136,6 @@ export const zh: typeof en = { keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。', keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。', keyIllegalCharacters: '该 API 密钥格式错误,请检查。', - keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。', modelIdRequired: '模型 ID 不能为空。', modelIdDuplicate: '模型 ID 不能重复。', modelNameInvalid: '显示名称不能为空。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 7228d472cd..5bb2da387c 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -1121,8 +1121,8 @@ describe('apiKeyFailure', () => { ['double quotes', '"sk-abc"'], ['single quotes', '\'sk-abc\''], ['backticks', '`sk-abc`'], - ])('fails %s as wrapped', (_label, draft) => { - expect(apiKeyFailure(draft)).toBe('keyLooksWrapped') + ])('fails %s as a format failure', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters') }) it('needs a matching closing quote before it calls a value wrapped', () => { diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 5d505386e6..11271561b8 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -987,7 +987,7 @@ describe('API key field', () => { fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'OPENAI_API_KEY=sk-abc' } }) - expect(screen.getByText(en.keyLooksWrapped)).toBeTruthy() + expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy() expect(buttonNamed(en.apply).disabled).toBe(true) })