From 7da7d5784dd286457608dd1a854f76f5cbc0e530 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 20:42:48 +0800 Subject: [PATCH 1/6] Open command menu from composer plus button --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 3 +- ...web-input-machine-and-slash-pipeline.zh.md | 3 +- apps/web/tests/lifecycle-chrome.e2e.ts | 33 +++++++++++- .../snapshots/code-mode-round/ui.expected.md | 2 +- .../cordis-tool-round/ui.expected.md | 2 +- .../snapshots/fresh-round-trip/ui.expected.md | 2 +- .../lifecycle-chrome/command-menu.expected.md | 6 +++ .../lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/reloaded.expected.md | 2 +- .../live-interactions/cancel.expected.md | 2 +- .../live-interactions/error-auth.expected.md | 2 +- .../live-interactions/retry.expected.md | 2 +- .../snapshots/message-actions/ui.expected.md | 2 +- .../question-composer/answered.expected.md | 2 +- .../queue-actions/editing.expected.md | 2 +- .../snapshots/queue-actions/ui.expected.md | 2 +- .../snapshots/seeded-history/ui.expected.md | 2 +- .../snapshots/steering/settled.expected.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 26 +++++++++- .../src/client/contract/slots.ts | 8 +-- .../ui-conversation/src/client/input/hub.ts | 11 ++++ .../src/client/skeleton/InputBar.tsx | 20 +++++--- .../tests/apply-inject.spec.tsx | 2 + .../ui-conversation/tests/input-bar.spec.tsx | 31 ++++++++--- .../tests/input-matrix.spec.tsx | 4 +- .../tests/input-scenarios.spec.tsx | 10 ++++ .../ui-conversation/tests/skeleton.spec.tsx | 2 + packages/client/ui-slash/README.i18n.yaml | 4 +- packages/client/ui-slash/README.md | 4 +- packages/client/ui-slash/README.zh.md | 4 +- .../client/ui-slash/src/client/controller.ts | 44 +++++++++++++++- .../client/ui-slash/tests/service.spec.ts | 51 +++++++++++++++++++ 36 files changed, 256 insertions(+), 50 deletions(-) create mode 100644 apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 98423a8c7d..ea630a99f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 92bb91c3e892d928cedf18ec57c725a116b6ffc8 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 5bee6df52f16d935aa4f4ccff8627a2d43d44c8c +2026-07-25-web-input-machine-and-slash-pipeline.md: 2793e9045fe5a3c82f52c65503dd4a8cdf6a0596 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: e3a35c4e55525fedd835eace973f114bd15da37b diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 92bb91c3e8..2793e9045f 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -63,7 +63,7 @@ Calls that stay un-evented (registry registration → explicit call → await): A trigger/menu/pick pipeline with zero knowledge of "commands": - The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects). -- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); a `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. +- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's textarea selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. - Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core. ### hub / facade: the resident shell and the strict-session input body @@ -122,6 +122,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only | | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning | | A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth | +| A second plus-menu component/controller, or an Add/File group above Command | It would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability | | All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity | ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 5bee6df52f..e3a35c4e55 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -63,7 +63,7 @@ occurrence 表与 chip 三投影: 对"命令"零知识的触发/菜单/pick 管线: - service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。 -- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行)、pick 编排(outcome → 自派 bail 事件);`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 +- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的 textarea selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 - 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。 ### hub / facade:常驻外壳与严格 session 输入体 @@ -122,6 +122,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 | | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 | | 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 | +| 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 | | 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 | ## 后果 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index d4d684b1de..746cd37e36 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -25,6 +25,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') +const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md') @@ -56,6 +57,34 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await scaffold?.close() }) + it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher')) + const launcher = page.getByRole('button', { name: 'Commands' }) + await launcher.click() + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await menu.waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('text: Commands') + expect(snapshot).not.toContain('text: Skills') + expect(snapshot).not.toContain('text: Subagents') + const launchedBox = await menu.boundingBox() + await page.locator('textarea').first().press('Escape') + await expect.poll(() => menu.count()).toBe(0) + const input = page.locator('textarea').first() + await input.fill('/') + await menu.waitFor({ timeout: 10_000 }) + const typedBox = await menu.boundingBox() + expect(launchedBox).not.toBeNull() + expect(typedBox).not.toBeNull() + expect(Math.abs(launchedBox!.x - typedBox!.x)).toBeLessThan(1) + expect(Math.abs( + launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height, + )).toBeLessThan(1) + await input.fill('') + await expect.poll(() => menu.count()).toBe(0) + }) + it('sends the first prompt from the empty-state hero (all modes)', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send')) if (MODE !== 'record') { @@ -152,6 +181,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md', 'reloaded.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'reloaded.expected.md', + ]) }) }) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 0282a16f80..31476daefd 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -33,7 +33,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 5b51e47cf4..4425921fdf 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -47,7 +47,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 49c7958292..64c62f85d6 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -30,7 +30,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md new file mode 100644 index 0000000000..47ba98cf05 --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md @@ -0,0 +1,6 @@ +- listbox "Trigger suggestions": + - text: Commands + - option "goal set or view the goal for a long-running task" [selected] + - option "permission Switch the permission preset (sandbox mode + approval policy)" + - option "plan Enter or leave plan mode" + - option "model Select the model for this conversation" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 65abda0dba..783964ed31 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -26,7 +26,7 @@ - text: workspace - img - textbox "Describe what you want to build" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 45e3514fa4..81f1ab608b 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -22,7 +22,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4323c94285..f65b090a16 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -19,7 +19,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 1d78e91c73..0d013f819d 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -12,7 +12,7 @@ - button "编辑": - img - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6a9c808342..11bb665e71 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -22,7 +22,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 19ba02d99d..b15a665c45 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -33,7 +33,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 36752c783a..db0c2cfd3a 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -30,7 +30,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 2594f18294..7e67544f04 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -26,7 +26,7 @@ - button "取消编辑": - img - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 919617bdab..48c288909c 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -20,7 +20,7 @@ - button "删除排队消息": - img - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index efc43a272e..c520dafae9 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -36,7 +36,7 @@ - img - text: 上下文注入 - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 5efbcf385d..1172d3ca5d 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -30,7 +30,7 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "Plan mode off, press to turn on": Plan off diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1588367646..ea2de9fd3d 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: fc466190a744a1c13094ca6ebf62755d5bf49c98 -README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79 +README.md: 8c1a75cd6d6bf8409eda32a75b342a8a84c94706 +README.zh.md: 42fa2df9f43f95ad32a593f79ff303c87cb55a5c diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fc466190a7..8c1a75cd6d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -22,7 +22,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. +The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f6fbff9c1e..42fa2df9f4 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -22,7 +22,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c6b597ae79..0262e82987 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -44,6 +44,10 @@ const ABSENT_LEXICON = { getSnapshot: () => EMPTY_LEXICON, subscribe: () => () => {}, } +const ABSENT_MENU_LAUNCHER = { + getSnapshot: (): string | null => null, + subscribe: () => () => {}, +} /** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { @@ -196,15 +200,29 @@ export function apply(ctx: Context): void { if (sessionId === undefined) { return { keyboard: undefined, + toggleCommandMenu: undefined, stop: undefined, command: undefined, translateHint, - hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON }, + hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER }, } } const shell = inputHub.shell(sessionId) + const slash = inputHub.slash(sessionId) return { keyboard: shell, + toggleCommandMenu: slash === undefined + ? undefined + : (selection) => { + shell.dismissPopup() + const snapshot = shell.snapshot + slash.toggleSource('command', { + trigger: '/', + query: '', + position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline', + span: { ...selection, draftRev: snapshot.draftRev }, + }) + }, stop: () => { scopedConversation(sessions, sessionId).cancel().catch(() => { // Stop failure surfaces via snapshot.promptError; nothing to restore. @@ -217,7 +235,11 @@ export function apply(ctx: Context): void { return result.ok && result.value.matched }, translateHint, - hooks: { notices: shell.notices, lexicon: shell.lexicon }, + hooks: { + notices: shell.notices, + lexicon: shell.lexicon, + menuLauncher: slash?.launcher ?? ABSENT_MENU_LAUNCHER, + }, } }, }, InputBar) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1684e616b2..883dfd9723 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -5,7 +5,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts' +import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -265,14 +265,14 @@ export interface ComposerBarOwnerProps { rightItems?: ReactNode /** composer.dock entries (stats line), rendered under the card inside the bar's width column. */ footer?: ReactNode - onAdd?: () => void - addLabel?: string } /** Injected share of the composer-bar entry (package-internal faces). */ export interface ComposerBarInjected { /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */ keyboard: ComposerKeyboard | undefined + /** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */ + toggleCommandMenu: ((selection: EditSelection) => void) | undefined /** Cancel the in-flight turn; absent with the session. */ stop: (() => void) | undefined /** @@ -294,6 +294,8 @@ export interface ComposerBarInjected { notices: ObservableSnapshot /** Hot plain-text reference lexicon for the decoration scan (decision 21). */ lexicon: ObservableSnapshot> + /** Source name opened by the programmatic menu launcher, or null. */ + menuLauncher: ObservableSnapshot } } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 2641e0dcc4..7b8fa344d6 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -106,6 +106,17 @@ export class InputHub implements InputService { return this.shell(id) } + /** + * Resolve the optional slash controller for composer chrome that launches + * the shared candidate menu without typing a trigger. + * @param id - session id. + * @returns the resident controller, or undefined when ui-slash is absent. + */ + slash(id: SessionId): SlashController | undefined { + const actx = this.sessions().scope(id) + return actx === undefined ? undefined : this.controller(actx) + } + /** * Default sink: optimistic clear + prompt. The session is always a real * host entity (materialized when its workspace was picked), so there is diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 77747e0ae2..97d9a911bf 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -33,13 +33,14 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, + useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, translateHint, + renderSlot, useNotices, useLexicon, useMenuLauncher, useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer, - onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) const lexicon = useLexicon(s => s) + const commandMenuOpen = useMenuLauncher(source => source === 'command') const promptError = useSession(s => s.promptError) ?? null const running = useSession(s => s.running) ?? false const removed = useSession(s => s.removed) ?? false @@ -256,6 +257,11 @@ export function InputBar({ inputRef.current?.focus() } + const onToggleCommandMenu = (): void => { + const el = inputRef.current + if (el !== null) toggleCommandMenu?.(selectionOf(el)) + } + const primaryLabel = running ? 'Stop generating' : 'Send message' const onPrimary = (): void => { if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled @@ -395,11 +401,13 @@ export function InputBar({ diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 87ec0dfde7..954335fde2 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -179,9 +179,11 @@ describe('conversation slot inject surface', () => { // hooks compartment still present so the render side's hook order holds. const absent = injectFn(undefined) expect(absent.keyboard).toBeUndefined() + expect(absent.toggleCommandMenu).toBeUndefined() expect(absent.stop).toBeUndefined() expect(absent.hooks.notices.getSnapshot()).toBeNull() expect(absent.hooks.lexicon.getSnapshot().size).toBe(0) + expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull() // A scope whose service tree lost 'conversation' (the feature fiber // unloaded while a retained inject closure re-runs): fails loud too. const stop = injectFn(ROOT).stop! diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c0bfda7f4a..09abd47458 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -47,6 +47,8 @@ interface BenchOptions { overlay?: React.ReactNode leftItems?: React.ReactNode rightItems?: React.ReactNode + commandMenuOpen?: boolean + toggleCommandMenu?: (selection: { start: number; end: number }) => void } /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ @@ -74,6 +76,7 @@ function bench(over?: BenchOptions) { promptError: over?.promptError ?? null, })) const stop = vi.fn() + const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { slotCalls.push({ key, owner }) @@ -97,8 +100,10 @@ function bench(over?: BenchOptions) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(menuLauncher), stop, command: () => Promise.resolve(true), // Mirrors the en 'command.hint' locale entries the production apply wires in. @@ -120,7 +125,7 @@ function bench(over?: BenchOptions) { const button = view.container.querySelector( `button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`, )! - return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls } + return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher } } describe('Enter semantics', () => { @@ -205,7 +210,7 @@ describe('running and lock semantics (queue cut 1)', () => { const { textarea, view } = bench({ disabled: true }) expect(textarea.disabled).toBe(true) expect(textarea.placeholder).toBe('Session unavailable') - expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Commands') as HTMLButtonElement).disabled).toBe(true) }) it('idle primary sends and disables on empty draft', () => { @@ -438,10 +443,10 @@ describe('strips and variants', () => { }) }) -describe('placeholder chrome and control seats', () => { - it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { +describe('command launcher chrome and control seats', () => { + it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { const { view, slotCalls } = bench() - expect(view.getByLabelText('Add attachment')).toBeTruthy() + expect(view.getByLabelText('Commands')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^Access mode/)).toBeNull() // Both seats dispatched, nothing rendered. @@ -450,6 +455,18 @@ describe('placeholder chrome and control seats', () => { expect(view.queryByLabelText('Model')).toBeNull() }) + it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => { + const toggleCommandMenu = vi.fn() + const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu }) + textarea.setSelectionRange(2, 7) + const launcher = view.getByLabelText('Commands') + expect(launcher.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(launcher) + expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 }) + act(() => { menuLauncher.set('command') }) + expect(launcher.getAttribute('aria-expanded')).toBe('true') + }) + it('the Access chip renders the projection value and submits /permission on pick', async () => { const permissions = { options: [ @@ -489,10 +506,10 @@ describe('placeholder chrome and control seats', () => { expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true) }) - it('disabled locks the Access chip and attach control (running does not)', () => { + it('disabled locks the Access chip and command launcher (running does not)', () => { const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } const { view } = bench({ disabled: true, permissions }) - expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Commands') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(true) cleanup() const live = bench({ running: true, permissions }) diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index a9c00b0748..4e8fc9efee 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -43,8 +43,10 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(createSnapshotStore(null)), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), command: () => Promise.resolve(true), @@ -170,7 +172,7 @@ describe('matrix row: locked (session disabled)', () => { it('disables the textarea and chrome; the machine currency is untouched', () => { const { view, textarea, shell } = bench({ disabled: true }) expect((textarea).disabled).toBe(true) - expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Commands') as HTMLButtonElement).disabled).toBe(true) expect(shell.snapshot.phase).toBe('plain') }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 1c7bbe50ec..b45397294c 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -129,8 +129,18 @@ async function scopedBench(register?: (slash: SlashService) => void) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: (selection) => { + const snapshot = shell.snapshot + controller.toggleSource('command', { + trigger: '/', + query: '', + position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline', + span: { ...selection, draftRev: snapshot.draftRev }, + }) + }, useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(controller.launcher), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), command: () => Promise.resolve(true), diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 3ed459b5a9..d3feba86a6 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -145,8 +145,10 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} + toggleCommandMenu={vi.fn()} useNotices={bindSnapshotSelector(wiring.notices)} useLexicon={bindSnapshotSelector(wiring.lexicon)} + useMenuLauncher={bindSnapshotSelector(createSnapshotStore(null))} stop={stop} command={() => Promise.resolve(true)} translateHint={(key: string) => key} diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml index 494ccdad20..5f1f5f23c4 100644 --- a/packages/client/ui-slash/README.i18n.yaml +++ b/packages/client/ui-slash/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-slash/README.md -README.md: 29f1a71ce20f898ffe2ab3a1c6f3d73a7aecfe38 -README.zh.md: 03dac56870de5b083124716825001009b4293736 +README.md: 5d277a83c5f0bc4bcec5871e0618af28afb7b6d2 +README.zh.md: 195aec6b76517fcf5cfc0933eb39b180f03a8628 diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md index 29f1a71ce2..5d277a83c5 100644 --- a/packages/client/ui-slash/README.md +++ b/packages/client/ui-slash/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. +Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. The same controller exposes `toggleSource` for a chrome launcher to open exactly one registered source over a synthetic selection span; the resulting candidates still use the ordinary menu, keyboard arbitration, pick callback, and scoped input mutations. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration. -MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. +MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it. diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md index 03dac56870..195aec6b76 100644 --- a/packages/client/ui-slash/README.zh.md +++ b/packages/client/ui-slash/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 +输入触发流水线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。同一个 controller 还暴露 `toggleSource`,供 chrome launcher 在一段合成 selection span 上只打开一个已注册 source;所得候选仍走通常的菜单、键盘仲裁、pick callback 与 scoped 输入改写。source 每次调用收到一个 `ClientSessionContext` 投影——会话始终由 agent(智能体)支撑,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` 快照 store 发布。流水线与命令无关:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。 -MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 +MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 `/client` 导出表层是插件主体(`apply`/`inject`)、`SlashService`、`MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。 diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index 3a8e85afc3..0b57f3dd74 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -40,6 +40,12 @@ export interface SlashControllerDeps { export class SlashController { /** Menu state store (per-session; survives session switches, dies with the scope). */ readonly menu: SnapshotStore = createSnapshotStore(MENU_CLOSED) + /** + * Name of the source opened through the programmatic launcher, or null for + * trigger-detected/closed menus. Composer chrome subscribes to this store + * for the launcher's expanded state without owning a second menu model. + */ + readonly launcher: SnapshotStore = createSnapshotStore(null) /** * Aggregated hot reference lexicon, grouped by trigger (decision 21): * sources implementing the lexicon hook are polled with the session @@ -81,6 +87,8 @@ export class SlashController { */ track(draft: string, caret: number, guard: TriggerGuard, draftRev: number): void { if (this.disposed) return + const launched = this.launcher.getSnapshot() !== null + this.clearLauncher() const raw = detectTrigger(draft, caret, guard) if (raw === null) { this.hit = null @@ -90,7 +98,7 @@ export class SlashController { } const hit: TriggerHit = { ...raw, span: { ...raw.span, draftRev } } const prev = this.menu.getSnapshot() - const same = prev.open && prev.hit !== null + const same = !launched && prev.open && prev.hit !== null && prev.hit.trigger === hit.trigger && prev.hit.query === hit.query && prev.hit.span.start === hit.span.start && prev.hit.span.end === hit.span.end this.hit = hit @@ -101,13 +109,40 @@ export class SlashController { this.reduce({ type: 'close' }) return } - if (!prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) { + if (launched || !prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) { this.menu.set(seedGroups(this.menu.getSnapshot(), roster.map(s => s.name))) } this.reduce({ type: 'hit', hit }) this.fetchCandidates(hit, roster) } + /** + * Toggle a menu containing exactly one registered source. The supplied hit + * is a synthetic selection span rather than a typed trigger token, but + * picks deliberately reuse the ordinary source callback and scoped input + * mutation pipeline. + * @param source - registered source name under `hit.trigger`. + * @param hit - synthetic hit carrying position and pick-time draft CAS. + */ + toggleSource(source: string, hit: TriggerHit): void { + if (this.disposed) return + if (this.launcher.getSnapshot() === source && this.menu.getSnapshot().open) { + this.dismiss() + return + } + const match = this.deps.roster.sources(hit.trigger).find(item => item.name === source) + if (match === undefined) { + this.dismiss() + return + } + this.stopFetch() + this.hit = hit + this.launcher.set(source) + this.menu.set(seedGroups(this.menu.getSnapshot(), [source])) + this.reduce({ type: 'hit', hit }) + this.fetchCandidates(hit, [match]) + } + /** * Pointer pick from MenuView: route the clicked candidate through onPick * and execute claim/insert outcomes via the scoped input events. @@ -349,9 +384,14 @@ export class SlashController { this.fetch = null } + private clearLauncher(): void { + if (this.launcher.getSnapshot() !== null) this.launcher.set(null) + } + private reduce(ev: MenuEvent): void { const cur = this.menu.getSnapshot() const next = menuReduce(cur, ev) if (next !== cur) this.menu.set(next) + if (!next.open) this.clearLauncher() } } diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.spec.ts index 099e2bb4f5..6f398442b9 100644 --- a/packages/client/ui-slash/tests/service.spec.ts +++ b/packages/client/ui-slash/tests/service.spec.ts @@ -351,6 +351,57 @@ describe('track', () => { }) }) +describe('programmatic source launcher', () => { + it('opens only the requested source and reuses its ordinary pick span', async () => { + const command = readySource('/', 'command', [{ name: 'goal' }]) + const skill = readySource('/', 'skill', [{ name: 'review' }]) + const { controller } = controllerBench([command.source, skill.source]) + const hit = { + trigger: '/' as const, + query: '', + position: 'leading' as const, + span: { start: 2, end: 5, draftRev: 7 }, + } + + controller.toggleSource('command', hit) + await tick() + + expect(controller.launcher.getSnapshot()).toBe('command') + expect(controller.menu.getSnapshot()).toMatchObject({ + open: true, + hit, + groups: [{ source: 'command', status: 'ready', items: [{ name: 'goal' }] }], + }) + controller.pick('command', 0) + expect(command.picks[0]).toMatchObject({ via: 'menu', span: hit.span }) + expect(skill.picks).toHaveLength(0) + expect(controller.launcher.getSnapshot()).toBeNull() + }) + + it('toggles closed, and typed tracking returns to the full trigger roster', async () => { + const command = readySource('/', 'command', [{ name: 'goal' }]) + const skill = readySource('/', 'skill', [{ name: 'review' }]) + const { controller } = controllerBench([command.source, skill.source]) + const hit = { + trigger: '/' as const, + query: '', + position: 'leading' as const, + span: { start: 0, end: 0, draftRev: 1 }, + } + + controller.toggleSource('command', hit) + controller.toggleSource('command', hit) + expect(controller.menu.getSnapshot().open).toBe(false) + expect(controller.launcher.getSnapshot()).toBeNull() + + controller.toggleSource('command', hit) + controller.track('/g', 2, { tier: 'plain' }, 2) + await tick() + expect(controller.launcher.getSnapshot()).toBeNull() + expect(controller.menu.getSnapshot().groups.map(group => group.source)).toEqual(['command', 'skill']) + }) +}) + describe('scope-birth warm', () => { it('construction warms every source once with the session projection', () => { const cmd = deferredSource('/', 'command') From 1986de9ac14d8eb5ae5b0d8c79341d9faa3e2369 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 22:42:25 +0800 Subject: [PATCH 2/6] fix(web): show plan status only while active --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 3 +- ...web-input-machine-and-slash-pipeline.zh.md | 3 +- apps/web/tests/lifecycle-chrome.e2e.ts | 57 +++++++++- .../snapshots/code-mode-round/ui.expected.md | 1 - .../cordis-tool-round/ui.expected.md | 1 - .../snapshots/fresh-round-trip/ui.expected.md | 1 - .../lifecycle-chrome/hero.expected.md | 1 - .../lifecycle-chrome/plan-active.expected.md | 39 +++++++ .../lifecycle-chrome/reloaded.expected.md | 1 - .../live-interactions/cancel.expected.md | 1 - .../live-interactions/error-auth.expected.md | 1 - .../live-interactions/retry.expected.md | 1 - .../snapshots/message-actions/ui.expected.md | 1 - .../question-composer/answered.expected.md | 1 - .../queue-actions/editing.expected.md | 1 - .../snapshots/queue-actions/ui.expected.md | 1 - .../snapshots/seeded-history/ui.expected.md | 1 - .../snapshots/steering/settled.expected.md | 1 - packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 4 +- packages/client/ui-plan/README.zh.md | 4 +- packages/client/ui-plan/package.json | 2 + .../src/client/PlanModeControl.module.css | 34 +++--- .../ui-plan/src/client/PlanModeControl.tsx | 53 +++++----- packages/client/ui-plan/src/client/index.ts | 24 ++--- .../ui-plan/tests/browser-plugin.spec.ts | 18 ++-- .../ui-plan/tests/plan-mode-control.spec.tsx | 100 +++++++----------- packages/client/ui-plan/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 30 files changed, 212 insertions(+), 157 deletions(-) create mode 100644 apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index ea630a99f9..09ab4376b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 2793e9045fe5a3c82f52c65503dd4a8cdf6a0596 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: e3a35c4e55525fedd835eace973f114bd15da37b +2026-07-25-web-input-machine-and-slash-pipeline.md: c3deadb34d3a633525dde701c92bcc98c05e5d6e +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7a6988423dcdffebb0a28735146439c8ade0a862 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 2793e9045f..c3deadb34d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -101,7 +101,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. - `conversation.composer.dock` — the stats band on the composer's top edge. - `conversation.input.left` / `conversation.input.right` — the tool-row left and right regions. -- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. +- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. The plan seat stays empty while inactive because the shared Command source owns entry; an effective plan target renders the warn-state `Plan ×` status button, whose only action is `/plan off`. - `conversation.hero.workspace` (root scope) — the Workspace picker shared by the no-session and blank Hero; a pick reuses or creates the target blank session through `connectWorkspace`, moving the draft where necessary before switching current. ### Testing discipline @@ -122,6 +122,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only | | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning | | A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth | +| An always-visible Plan on/off toggle | The shared Command source already owns entry; a second entry point turns a status seat into redundant mode chrome | | A second plus-menu component/controller, or an Add/File group above Command | It would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability | | All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity | diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index e3a35c4e55..7a6988423d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -101,7 +101,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 - `conversation.composer.dock`——composer 上沿统计带。 - `conversation.input.left` / `conversation.input.right`——工具行左右区。 -- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。 +- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。plan seat 未激活时保持为空,因为入口归共享 Command source 所有;有效 plan 目标会渲染 warn 状态的 `Plan ×` 状态按钮,其唯一动作是 `/plan off`。 - `conversation.hero.workspace`(root scope)——无 session / blank Hero 共用的 Workspace picker;pick 经 `connectWorkspace` 复用或创建目标 blank session,必要时搬运 draft 后切 current。 ### 测试纪律 @@ -122,6 +122,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 | | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 | | 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 | +| 始终可见的 Plan 开/关切换 | 入口已归共享 Command source 所有;第二个入口会把状态 seat 变成冗余的 mode chrome | | 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 | | 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 | diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 746cd37e36..97ffefccf7 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') +const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md') @@ -85,6 +86,60 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await expect.poll(() => menu.count()).toBe(0) }) + it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => { + const activeScaffold = await launchWebScaffold() + const activePage = await newEnglishPage(browser) + const activeTripwire = watchConsole(activePage) + try { + await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' }) + await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(activePage) + const input = activePage.locator('textarea').first() + await activePage.getByRole('button', { name: 'Commands' }).click() + const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' }) + await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click() + await expect.poll(() => input.inputValue()).toBe('/plan ') + await input.press('Enter') + const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' }) + await planButton.waitFor({ timeout: 10_000 }) + const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd) + await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE) + const planStyle = await planButton.evaluate((element) => { + const probe = document.createElement('span') + probe.style.color = 'var(--dsw-alias-state-warn-label)' + probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)' + document.body.append(probe) + const actual = getComputedStyle(element) + const reference = getComputedStyle(probe) + const result = { + color: actual.color, + backgroundColor: actual.backgroundColor, + borderRadius: actual.borderRadius, + fontSize: actual.fontSize, + referenceColor: reference.color, + referenceBackgroundColor: reference.backgroundColor, + } + probe.remove() + return result + }) + expect(planStyle.color).toBe(planStyle.referenceColor) + expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor) + expect(planStyle.borderRadius).toBe('999px') + expect(planStyle.fontSize).toBe('13px') + await planButton.click() + await expect.poll(() => planButton.count()).toBe(0) + expect(activeTripwire.pageErrors).toEqual([]) + expect(activeTripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined) + throw error + } finally { + await activePage.close() + await activeScaffold.close() + } + }) + it('sends the first prompt from the empty-state hero (all modes)', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send')) if (MODE !== 'record') { @@ -182,7 +237,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'reloaded.expected.md', + 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 31476daefd..7b957e5899 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -36,7 +36,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 4425921fdf..5abf911c83 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -50,7 +50,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 64c62f85d6..ec99a493f3 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -33,7 +33,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 783964ed31..bed9df014c 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -29,7 +29,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md new file mode 100644 index 0000000000..8bb1351e0f --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -0,0 +1,39 @@ +- button "New session" +- button "Collapse sidebar": + - img +- button "New session": + - img + - text: New Session +- text: Workspaces +- button "Group by": + - img +- button "Create workspace": + - img +- button "Search sessions": + - img +- textbox "Search name, keywords..." +- tree "Sessions": + - treeitem "workspace 1 session" [expanded]: + - img + - text: workspace 1 session + - treeitem "New Session now" [selected] +- button "Settings": + - img + - text: Settings +- text: Let's start building +- button "Choose workspace": + - img + - text: workspace + - img +- textbox "Describe what you want to build" +- button "Commands": + - img +- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- button "Plan mode on, press to turn off": Plan +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] +- text: 详情 +- button "关闭详情" +- text: 点击消息流中的工具行查看详情 diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 81f1ab608b..b323b67a30 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -25,7 +25,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index f65b090a16..2b14062211 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -22,7 +22,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 0d013f819d..364cf54ddb 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -15,7 +15,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 11bb665e71..c008fc4dba 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -25,7 +25,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index b15a665c45..369f03c6f1 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -36,7 +36,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index db0c2cfd3a..ec9a074e72 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -33,7 +33,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 7e67544f04..0f890e271f 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -29,7 +29,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 48c288909c..81144381cf 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -23,7 +23,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index c520dafae9..8b6a393664 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -39,7 +39,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 1172d3ca5d..193f19ef05 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -33,7 +33,6 @@ - button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 199210a863..c1abb744cc 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/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-plan/README.md -README.md: 1d22c057b439ff337bf9daadcdba96dd4cca4540 -README.zh.md: 183b8ef7776b60c1f0afa630e04627a474d40391 +README.md: 2f83dd738e11ada5b24a55d1eec97e154ffd2aea +README.zh.md: a026390191179f6492a55ee931a3406d8e1953e8 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 1d22c057b4..2f83dd738e 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Plan-mode status chip, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat (to the right of the access-mode control); the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-or-idle-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster. -Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win). +Plan mode is entered through the `/plan` command path: users can choose Plan from the composer's `+` Command menu or type `/plan`, while this package renders no inactive plan control. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders the warn-colored "Plan ×" status button, which executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win). The chip carries the accessible description "Plan mode on, press to turn off". Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit. @@ -22,4 +22,4 @@ Entering or leaving plan mode changes the active `plan:policy` system-prompt sec - **Plan mode is guidance, not an execution sandbox** — deployments that require enforced read-only planning must compose the independent sandbox and approval policies. - **The chip belongs to the default composer** — a pending whole-composer interaction such as plan review temporarily replaces the InputBar and its chip. -- **No UI entry point** — plan mode is entered by typing `/plan`; a session with the capability but inactive mode shows no affordance in the tool row. +- **No inactive plan control** — entry uses the shared Command source; a session with the capability but inactive mode shows no plan affordance in the tool row. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 183b8ef777..a026390191 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -4,7 +4,7 @@ Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。 -plan mode 只经 `/plan` 命令进入;UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chip,hover 出现的 × 经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 +plan mode 经 `/plan` 命令路径进入:用户可以从 composer 的 `+` Command 菜单选择 Plan,也可以输入 `/plan`,而本包(package)不渲染未激活态 plan 控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染 warn 色的 "Plan ×" 状态按钮,该按钮经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。 @@ -22,4 +22,4 @@ chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`m - **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。 - **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。 -- **无 UI 进入点**——plan mode 靠敲 `/plan` 进入;有能力但未激活的会话在工具行不显示任何入口。 +- **无未激活态 plan 控件**——入口使用共享 Command source;有能力但 mode 未激活的会话在工具行不显示 plan 入口。 diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index a2aec47985..d7524cbc7d 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-plan-mode": "^0.0.1", @@ -48,6 +49,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index f79e9073db..46e893aee8 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -1,5 +1,4 @@ -/* Plan-mode toggle chip: quiet while off; the pressed state takes the - business accent pair (same token pairing as the trajectory user badge). */ +/* Active plan status follows Figma's warn-state pill. */ .wrap { display: inline-flex; @@ -10,30 +9,25 @@ .chip { display: inline-flex; align-items: center; - padding: 4px 8px; + gap: 4px; + min-width: 34px; + padding: 2px 8px; border: none; - border-radius: 8px; - background: transparent; - color: var(--dsw-alias-label-secondary); - font-size: 14px; + border-radius: 999px; + background: var(--dsw-alias-state-warn-tertiary); + color: var(--dsw-alias-state-warn-label); + font-size: 13px; + font-weight: 500; line-height: 20px; cursor: pointer; } .chip:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -/* Hovering keeps the pressed accent: the higher-specificity hover rule above - would otherwise swap it back to the neutral hover wash. */ -.chip[aria-pressed='true'], -.chip[aria-pressed='true']:hover:not(:disabled) { - color: var(--dsw-alias-state-business-primary); - background: var(--dsw-alias-state-business-tertiary); + color: var(--dsw-alias-state-warn-primary); } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-label-secondary); + outline: 2px solid var(--dsw-alias-state-warn-label); outline-offset: 2px; } @@ -42,6 +36,12 @@ cursor: default; } +.close { + display: inline-flex; + align-items: center; + color: currentColor; +} + .error { color: var(--dsw-alias-state-error-primary); font-size: 12px; diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index 961d7435ef..a6fc74f77b 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and // its {locked} owner share). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -11,16 +12,14 @@ export type PlanChipProps = PropsRuntime<'conversation.input.plan'> & InjectFace /** - * Plan-mode toggle over the host-computed `plan` projection. The chip renders - * whenever the capability is present and reflects the effective target as its - * pressed state (`pending ? !active : active` — a folded host value, not - * client optimism, so an arriving frame corrects it). Clicking executes - * /plan or /plan off toward the opposite target. + * Plan-mode status over the host-computed `plan` projection. The chip renders + * only while the effective target is plan mode (`pending ? !active : active` + * — a folded host value, not client optimism) and executes /plan off. */ -export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) { +export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) { const plan = useProjection('plan') - const [busy, setBusy] = useState(false) - const [error, setError] = useState<{ text: string; detail: string } | null>(null) + const [leaving, setLeaving] = useState(false) + const [error, setError] = useState(null) const aliveRef = useRef(true) useEffect(() => { @@ -30,25 +29,21 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) } }, []) - // Absent capability (no plan-mode host plugin / no session yet): no seat - // content — without the capability there is nothing to toggle. if (plan === undefined) return null const target = plan.pending ? !plan.active : plan.active + if (!target) return null - const toggle = (): void => { - // No busy/locked guard: both disable the button, so no click arrives. - const on = !target - const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败' - setBusy(true) + const off = (): void => { + setLeaving(true) setError(null) - void setPlanMode(on).then((failure) => { + void exitPlanMode().then((failure) => { if (!aliveRef.current) return - setBusy(false) - setError(failure === null ? null : { text: failText, detail: failure }) + setLeaving(false) + setError(failure) }, (reason: unknown) => { if (!aliveRef.current) return - setBusy(false) - setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) }) + setLeaving(false) + setError(reason instanceof Error ? reason.message : String(reason)) }) } @@ -57,17 +52,17 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) - {error !== null && {error.text}} + {error !== null && 退出 plan mode 失败} ) } diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 77779efb02..d85e508922 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -1,11 +1,11 @@ /** * Plan control plugin, browser half: occupies the composer's named - * `conversation.input.plan` seat with a plan-mode toggle chip. While the - * `plan` projection is present the chip renders in both states and executes - * /plan or /plan off through `command.execute` toward the opposite target; - * an absent projection (no capability) leaves the seat empty. Reads ride the - * generic projection pair through the standard-kit `useProjection` (an absent - * key is capability absence); zero client-side plan state. + * `conversation.input.plan` seat with an active-state status chip. Plan mode + * is entered through the command source; while the projection's effective + * target is plan mode the chip renders and executes /plan off through + * `command.execute`, otherwise the seat stays empty. Reads ride the generic + * projection pair through the standard-kit `useProjection`; zero client-side + * plan state. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -18,11 +18,10 @@ import { PlanChip } from './PlanModeControl.tsx' /** Injected business face of the composer plan seat. */ export interface PlanChipInjected { /** - * Switch plan mode by executing /plan (on) or /plan off. - * @param on - desired target: true enters plan mode, false leaves it. + * Leave plan mode by executing /plan off. * @returns null on admitted execution; a user-visible failure line otherwise. */ - setPlanMode: (on: boolean) => Promise + exitPlanMode: () => Promise } /** @@ -39,12 +38,11 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.slots.register({ name: 'conversation.input.plan', inject: (sessionId: SessionId): PlanChipInjected => ({ - setPlanMode: async (on) => { - const line = on ? '/plan' : '/plan off' + exitPlanMode: async () => { const connection = ctx.get('connection') as ConnectionHandle - const { result } = await connection.api.commands.execute({ sessionId, line }) + const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' }) if (!result.ok) return `${result.error.message}(${result.error.code})` - if (!result.value.matched) return `未知命令:${line}` + if (!result.value.matched) return '未知命令:/plan off' return null }, }), diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index 1051d52777..aa0bf35b55 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -1,9 +1,9 @@ /** * ui-plan browser half on a real SlotsService: the plugin occupies the - * conversation-declared `conversation.input.plan` single seat with the plan - * toggle chip; the injected face executes /plan or /plan off by direction and - * folds admission outcomes into null (admitted) or a user-visible failure - * line; teardown empties the seat (HMR safety). + * conversation-declared `conversation.input.plan` single seat with the active + * plan status chip; the injected face executes /plan off and folds admission + * outcomes into null (admitted) or a user-visible failure line; teardown + * empties the seat (HMR safety). */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' @@ -49,7 +49,7 @@ describe('ui-plan browser apply', () => { .rejects.toThrow(/slot "conversation.input.plan" is not declared/) }) - it('registers the chip, executes /plan by direction, and unregisters on teardown', async () => { + it('registers the chip, executes /plan off, and unregisters on teardown', async () => { const b = await bench() const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -57,22 +57,20 @@ describe('ui-plan browser apply', () => { expect(entry.component).toBe(PlanChip) const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) - await expect(injected.setPlanMode(false)).resolves.toBeNull() + await expect(injected.exitPlanMode()).resolves.toBeNull() expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) - await expect(injected.setPlanMode(true)).resolves.toBeNull() - expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' }) // Business failure folds to the composer-visible line. b.execute.mockResolvedValueOnce({ result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, } as never) - await expect(injected.setPlanMode(false)).resolves.toBe('gone(session-not-found)') + await expect(injected.exitPlanMode()).resolves.toBe('gone(session-not-found)') // Unmatched admission (plan-mode not composed host-side) is also a failure line. b.execute.mockResolvedValueOnce({ result: { ok: true as const, value: { matched: false as const } }, } as never) - await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan') + await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off') await fiber.dispose() expect(b.slots.entries('conversation.input.plan')).toHaveLength(0) diff --git a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx index 63f6bd7a6e..2489b53be0 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx @@ -1,11 +1,9 @@ // @vitest-environment jsdom /** * PlanChip over the `plan` projection: nothing renders while the capability - * is absent; with the capability present the chip renders in both states with - * aria-pressed following the effective target (pending folds — /plan shows - * pressed immediately, /plan off unpressed immediately); clicking executes - * the command toward the opposite target and surfaces direction-specific - * failures while the projection still owns the displayed state. + * is absent or the effective target is the default mode; while plan mode is + * the target, the chip executes /plan off and remains visible through failures + * until the projection confirms the exit. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -18,98 +16,74 @@ afterEach(cleanup) function setup( plan: PlanProjection | undefined, - setPlanMode = vi.fn((_on: boolean) => Promise.resolve(null)), + exitPlanMode = vi.fn(() => Promise.resolve(null)), locked = false, ) { const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan }) const useProjection = (_key: string, selector?: (v: unknown) => unknown) => bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value)) - const props = { useProjection, locked, setPlanMode } as unknown as PlanChipProps + const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps const view = render() - return { store, setPlanMode, view } + return { store, exitPlanMode, view } } -const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' }) -const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' }) +const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' }) describe('PlanChip', () => { - it('renders nothing while the capability is absent', () => { + it('renders nothing for an absent capability or a default-mode target', () => { const absent = setup(undefined) expect(absent.view.container.innerHTML).toBe('') + cleanup() + const inactive = setup({ active: false, pending: false }) + expect(inactive.view.container.innerHTML).toBe('') + cleanup() + const leaving = setup({ active: true, pending: true }) + expect(leaving.view.container.innerHTML).toBe('') }) - it('reflects the effective target as the pressed state, folding pending', () => { - setup({ active: false, pending: false }) - expect(offChip().getAttribute('aria-pressed')).toBe('false') - cleanup() + it('renders the Plan status for active and pending-entry targets', () => { setup({ active: true, pending: false }) - expect(onChip().getAttribute('aria-pressed')).toBe('true') + expect(chip().textContent).toBe('Plan') cleanup() - // /plan just ran (command/run folded, plan/mode not yet): target is plan. setup({ active: false, pending: true }) - expect(onChip().getAttribute('aria-pressed')).toBe('true') - cleanup() - // Active with a pending exit: the target is default — already unpressed. - setup({ active: true, pending: true }) - expect(offChip().getAttribute('aria-pressed')).toBe('false') + expect(chip().textContent).toBe('Plan') }) - it('unpressed chip executes /plan (on) once and follows the projection up', async () => { + it('executes /plan off once and follows the projection down', async () => { let resolve!: (value: string | null) => void - const setPlanMode = vi.fn((_on: boolean) => new Promise((done) => { resolve = done })) - const { store } = setup({ active: false, pending: false }, setPlanMode) - fireEvent.click(offChip()) - expect(setPlanMode).toHaveBeenCalledTimes(1) - expect(setPlanMode).toHaveBeenLastCalledWith(true) - // Busy while its own call is in flight. - fireEvent.click(offChip()) - expect(setPlanMode).toHaveBeenCalledTimes(1) + const exitPlanMode = vi.fn(() => new Promise((done) => { resolve = done })) + const { store } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) resolve(null) - // The command's run record folds: target flips, the chip presses. - store.set({ value: { active: false, pending: true } }) - await waitFor(() => { - expect(onChip().getAttribute('aria-pressed')).toBe('true') - }) - }) - - it('pressed chip executes /plan off and follows the projection down', async () => { - const setPlanMode = vi.fn((_on: boolean) => Promise.resolve(null)) - const { store } = setup({ active: true, pending: false }, setPlanMode) - fireEvent.click(onChip()) - expect(setPlanMode).toHaveBeenLastCalledWith(false) store.set({ value: { active: true, pending: true } }) await waitFor(() => { - expect(offChip().getAttribute('aria-pressed')).toBe('false') + expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull() }) }) it('disables under the locked owner prop', () => { setup({ active: true, pending: false }, vi.fn(), true) - expect((onChip() as HTMLButtonElement).disabled).toBe(true) + expect((chip() as HTMLButtonElement).disabled).toBe(true) }) - it('surfaces direction-specific admission and transport failures while staying visible', async () => { - const exitFailing = vi.fn() + it('surfaces admission and transport failures while staying visible', async () => { + const exitPlanMode = vi.fn() .mockResolvedValueOnce('host said no') .mockRejectedValueOnce(new Error('network down')) .mockRejectedValueOnce('socket closed') - setup({ active: true, pending: false }, exitFailing) - fireEvent.click(onChip()) + setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no') - expect(onChip()).toBeTruthy() + expect(chip()).toBeTruthy() - fireEvent.click(onChip()) + fireEvent.click(chip()) expect(await screen.findByTitle('network down')).toBeTruthy() - fireEvent.click(onChip()) + fireEvent.click(chip()) expect(await screen.findByTitle('socket closed')).toBeTruthy() - cleanup() - - const enterFailing = vi.fn().mockResolvedValueOnce('agent busy') - setup({ active: false, pending: false }, enterFailing) - fireEvent.click(offChip()) - expect((await screen.findByText('进入 plan mode 失败')).getAttribute('title')).toBe('agent busy') - expect(offChip()).toBeTruthy() }) it('ignores in-flight fulfillment and rejection after unmount', () => { @@ -118,14 +92,14 @@ describe('PlanChip', () => { { active: true, pending: false }, vi.fn(() => new Promise((done) => { resolve = done })), ) - fireEvent.click(onChip()) + fireEvent.click(chip()) successful.view.unmount() expect(() => { resolve(null) }).not.toThrow() let reject!: (reason: unknown) => void - const setPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) - const { view } = setup({ active: true, pending: false }, setPlanMode) - fireEvent.click(onChip()) + const exitPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) + const { view } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) view.unmount() expect(() => { reject(new Error('late')) }).not.toThrow() }) diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 4ab13662f2..23ce289533 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slots" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a014fa2333..58fc06888f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1367,6 +1367,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots From f9f8148e794a9ba3ea59135cd5ca7f3586855269 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:08:54 +0800 Subject: [PATCH 3/6] refactor(credentials,llm): remove speculative mutation and route lifecycle --- ...est-level-llm-config-credentials.i18n.yaml | 4 +- ...29-request-level-llm-config-credentials.md | 12 +- ...request-level-llm-config-credentials.zh.md | 12 +- ...undaries-and-atomic-registration.i18n.yaml | 4 +- ...tial-boundaries-and-atomic-registration.md | 10 +- ...l-boundaries-and-atomic-registration.zh.md | 10 +- ...redentials-and-static-llm-routes.i18n.yaml | 6 + ...-only-credentials-and-static-llm-routes.md | 35 ++ ...ly-credentials-and-static-llm-routes.zh.md | 35 ++ apps/cli/composition.md | 3 - apps/cli/config/base.cordis.yml | 27 +- apps/cli/package.json | 1 - apps/cli/src/tui.ts | 6 +- apps/cli/tests/tui-keyless-smoke.e2e.ts | 8 +- docs/config-catalog.md | 19 +- docs/cordis-catalog/events.md | 26 -- docs/cordis-catalog/services.md | 50 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 30 +- docs/core-data-structures/core.zh.md | 30 +- .../credentials.i18n.yaml | 4 +- docs/core-data-structures/credentials.md | 34 +- docs/core-data-structures/credentials.zh.md | 34 +- docs/event-producer-consumer.md | 1 - docs/module-graph.md | 9 +- .../headless-agent/tests/headless.snapshot.ts | 7 +- .../stream-json.expected.jsonl | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 41 +- packages/credentials/README.i18n.yaml | 4 +- packages/credentials/README.md | 10 +- packages/credentials/README.zh.md | 12 +- .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 39 +- .../credentials-local/README.zh.md | 39 +- .../credentials-local/package.json | 3 - .../credentials-local/src/index.ts | 431 +----------------- .../credentials-local/src/invariant.ts | 3 +- .../credentials-local/tests/drain.spec.ts | 71 --- .../credentials-local/tests/local.spec.ts | 217 ++------- .../tests/review-fixes.spec.ts | 202 -------- .../credentials-local/tests/watcher.spec.ts | 223 --------- .../credentials-local/tsconfig.json | 3 - .../credentials/credentials/README.i18n.yaml | 4 +- packages/credentials/credentials/README.md | 26 +- packages/credentials/credentials/README.zh.md | 28 +- packages/credentials/credentials/src/index.ts | 128 +----- .../credentials/credentials/src/invariant.ts | 19 +- .../credentials/tests/credentials.spec.ts | 45 +- .../credentials/tests/invariant.spec.ts | 21 - .../credentials/credentials/tests/memory.ts | 39 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 12 +- packages/llm/llm-deepseek/README.zh.md | 12 +- packages/llm/llm-deepseek/src/index.ts | 37 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 5 +- .../llm-deepseek/tests/dynamic-config.spec.ts | 26 +- .../tests/loader-composition.spec.ts | 24 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 12 +- packages/llm/llm-pi-ai/README.zh.md | 12 +- packages/llm/llm-pi-ai/src/config.ts | 20 +- packages/llm/llm-pi-ai/src/index.ts | 57 +-- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +- .../llm-pi-ai/tests/dynamic-config.spec.ts | 163 ++----- .../tests/loader-composition.spec.ts | 54 +-- packages/llm/llm-retry/README.i18n.yaml | 4 +- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/README.zh.md | 2 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 4 +- packages/llm/llm/README.zh.md | 4 +- packages/llm/llm/src/index.ts | 114 +---- packages/llm/llm/tests/service.spec.ts | 29 +- packages/settings/settings-local/package.json | 2 - packages/settings/settings-local/src/index.ts | 80 +++- .../settings/settings-local/src/invariant.ts | 2 +- .../settings/settings-local/tsconfig.json | 3 - packages/settings/settings/src/index.ts | 45 +- .../settings/settings/tests/settings.spec.ts | 75 --- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 2 +- packages/ui/app-boot/README.zh.md | 2 +- packages/util/README.i18n.yaml | 4 +- packages/util/README.md | 1 - packages/util/README.zh.md | 1 - packages/util/atomic-write/README.i18n.yaml | 6 - packages/util/atomic-write/README.md | 45 -- packages/util/atomic-write/README.zh.md | 45 -- packages/util/atomic-write/package.json | 37 -- packages/util/atomic-write/src/index.ts | 157 ------- packages/util/atomic-write/src/invariant.ts | 30 -- .../atomic-write/tests/atomic-write.spec.ts | 48 -- .../util/atomic-write/tests/invariant.spec.ts | 18 - packages/util/atomic-write/tsconfig.json | 15 - pnpm-lock.yaml | 21 - scripts/gen-cordis-catalog.ts | 3 - scripts/type-equiv.manifest.json | 15 - .../verify-package-readme-model-experience.ts | 1 - tsconfig.host.json | 1 - 100 files changed, 559 insertions(+), 2752 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md create mode 100644 .agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md delete mode 100644 packages/credentials/credentials-local/tests/drain.spec.ts delete mode 100644 packages/credentials/credentials-local/tests/review-fixes.spec.ts delete mode 100644 packages/credentials/credentials-local/tests/watcher.spec.ts delete mode 100644 packages/util/atomic-write/README.i18n.yaml delete mode 100644 packages/util/atomic-write/README.md delete mode 100644 packages/util/atomic-write/README.zh.md delete mode 100644 packages/util/atomic-write/package.json delete mode 100644 packages/util/atomic-write/src/index.ts delete mode 100644 packages/util/atomic-write/src/invariant.ts delete mode 100644 packages/util/atomic-write/tests/atomic-write.spec.ts delete mode 100644 packages/util/atomic-write/tests/invariant.spec.ts delete mode 100644 packages/util/atomic-write/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index c7861321a0..4ec3f9de2d 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992 -2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a +2026-07-29-request-level-llm-config-credentials.md: ec00b52bdbe8f00d334618f3e5347974a3928e67 +2026-07-29-request-level-llm-config-credentials.zh.md: 29835b9fb320e6b31cb49a632ff0e56d88fb3f44 diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md index f12a2496a7..ec00b52bdb 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-29-request-level-llm-config-credentials.zh.md) -> Scope: the first production consumers of `ctx.settings` (the two LLM adapter plugins), the new `packages/credentials/` capability family, and the `packages/util/atomic-write` extraction. The follow-up wire surface (`settings.*`/`credentials.*` RPC, secret-role masking, the web settings form) is a separate PR and not part of this note's shipped scope. +> Scope: the first production consumers of `ctx.settings` (the two LLM adapter plugins) and the `packages/credentials/` capability family. The later [read-only credentials and static routes](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md) decision removes speculative credential mutation, the atomic-write extraction, and settings-driven route lifecycle; this note owns the surviving request-resolution rationale. ## Problem @@ -12,18 +12,18 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti ## Decision -**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (`DUPLICATE_ADAPTER` ordering, `NO_ADAPTER` windows, a deferred-activation state machine) and makes a missing key a *request-time* actionable failure (`MISSING_CREDENTIAL` naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the `ctx.llm` registry snapshots at `registerAdapter` (plus pi-ai's route *set*) — re-registers the same adapter instance in one synchronous section when it changes. +**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk and a per-stream credential resolver instead of rebuilding their fibers. Connection, credential, and request-transport facts are read for the operation, while an in-flight stream keeps the facts it started with. A missing key is a request-time `MISSING_CREDENTIAL` failure while the route remains registered. Provider routes and their retry policies are composition-fixed instead of triggering registration swaps. -**Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable. +**Secrets are references, values live behind `ctx.credentials`.** Configuration can carry `apiKeyEnv: DEEPSEEK_API_KEY`; the read-only credential seam resolves it per operation. `credentials-local` checks the live process environment first, then parses `$DSH_HOME/.env` on demand, with no cache or mutation surface. Resolution order in the adapters is a non-empty literal `apiKey` first, then the seam, then — only without a mounted seam — the named raw environment variable. -**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision. +**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and `cordis.yml` entry as the composition `base`. `resolveAdapterOptions` and `resolveProfiles` remain the explicit validation steps, and a bad live snapshot keeps the last good request facts while a bad entry config fails load. pi-ai's `providers` is a non-empty dict keyed by its composition-owned routes; the user layer may override request facts for those routes but cannot add or remove them. ## Alternatives considered - **A bridge plugin (`dsh-llm-models`) owning one unified `models` dict** — with per-plugin namespaces there is nothing left to bridge, and the adapter-mapping rules it needed were pure invented indirection. - **Secrets in settings.yaml under `role('secret')` masking** — deleting the problem (references) beats mitigating it (mask + backfill + sync warnings); the coding-agent cohort is unanimous. -- **Registry-level live retry policy** — making `providerRetryPolicy` re-read per call would silently change the `ctx.llm` capture contract every registration relies on; re-registering the route in place keeps that contract and stays observable. +- **Registry-level live retry policy** — making `providerRetryPolicy` re-read per call would silently change the `ctx.llm` capture contract every registration relies on; retry policy therefore stays fixed with the composition-owned route. ## Consequences -Onboarding is restart-free end to end (pinned by the `missing-credential` headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount `settings-local` + `credentials-local` by default and inline no `!!js` key plumbing. `runLoaderSmoke` gained `expectedExitCode` so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact `role('secret')` fields before any RPC exposes `describe()`, settings-layer arrays still replace wholesale (the deepseek `models` list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). Review of this seam later reworked where the store lives and who may read it, made one request resolve one configuration generation, and made route replacement atomic ([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md)). +Booting without a key remains valid: the first request fails with the named reference, and an externally supplied environment or dotenv value reaches the next request without restart. The demos mount `settings-local` and the read-only `credentials-local` provider by default and inline no `!!js` key plumbing. The credential-management RPC/UI and registration mutation are absent until a current consumer justifies their contracts. Settings-layer arrays still replace wholesale, and pi-ai provider routes remain composition decisions. The [credential-boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md) owns the surviving storage and request-generation safety decisions. diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 99fd90013a..29835b9fb3 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-29-request-level-llm-config-credentials.md) | 中文 -> 范围:`ctx.settings` 的第一批生产消费方(两个 LLM 适配器插件)、新增的 `packages/credentials/` 能力族,以及 `packages/util/atomic-write` 的抽取。后续的 wire 面(`settings.*`/`credentials.*` RPC、secret 角色脱敏、web 设置表单)是单独的 PR,不在本 note 已交付范围内。 +> 范围:`ctx.settings` 的第一批生产消费方(两个 LLM 适配器插件)与 `packages/credentials/` 能力族。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了超前加入的凭据变更、atomic-write 抽取与 settings 驱动的路由生命周期;本 note 负责保留至今的请求解析理由。 ## 问题 @@ -12,18 +12,18 @@ Status: implemented ## 决策 -**按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(`DUPLICATE_ADAPTER` 顺序问题、`NO_ADAPTER` 窗口、延迟激活状态机),并把密钥缺失变成*请求时*可行动的失败(`MISSING_CREDENTIAL` 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——`ctx.llm` 注册表在 `registerAdapter` 时快照的重试策略(外加 pi-ai 的路由*集合*)——在其变化时于一个同步区段内原地重新注册同一适配器实例。 +**按请求解析,而非重建 fiber。**适配器接收 options thunk 与按流调用的凭据解析器,不再重建其 fiber。连接、凭据与请求传输事实在操作期间读取,进行中的流则保持其起始事实。密钥缺失会在请求时以 `MISSING_CREDENTIAL` 失败,同时路由保持注册。提供方路由及其重试策略由组合固定,不触发注册替换。 -**机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。 +**机密是引用,值藏在 `ctx.credentials` 背后。**配置可以携带 `apiKeyEnv: DEEPSEEK_API_KEY`;只读凭据 seam 按操作解析它。`credentials-local` 先检查活跃进程环境,再按需解析 `$DSH_HOME/.env`,既不缓存,也不提供变更接口。适配器内的解析顺序为:非空的字面 `apiKey` 优先,然后是 seam,最后仅在未挂载 seam 时读取点名的原始环境变量。 -**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。 +**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),采用其插件 `Config` schema,并以 `cordis.yml` 配置项为组合 `base`。`resolveAdapterOptions` 与 `resolveProfiles` 仍是显式校验步骤;错误的存活快照会保留最后可用的请求事实,错误的 entry 配置则会加载失败。pi-ai 的 `providers` 是以组合所拥有路由为键的非空字典;用户层可以覆盖这些路由的请求事实,但不能新增或移除路由。 ## 曾考虑的替代方案 - **由桥接插件(`dsh-llm-models`)持有统一的 `models` 字典**——有了按插件划分的 namespace,就没有什么可桥接的了;它所需的适配器映射规则纯属凭空发明的间接层。 - **把机密放进 settings.yaml 并靠 `role('secret')` 脱敏**——删除问题本身(引用)胜过缓解问题(脱敏 + 回填 + 同步告警);编码 agent 同类产品在这一点上口径一致。 -- **注册表级的实时重试策略**——让 `providerRetryPolicy` 每次调用都重读,会静默改变所有注册都依赖的 `ctx.llm` 捕获契约;原地重新注册路由既保住该契约,又保持可观察。 +- **注册表级的实时重试策略**:让 `providerRetryPolicy` 每次调用都重读,会静默改变所有注册都依赖的 `ctx.llm` 捕获契约;因此,重试策略与组合所拥有的路由一同保持固定。 ## 后果 -上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏;settings 层的数组仍整体替换(deepseek 的 `models` 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。对该 seam 的评审随后改造了存储的所在位置与谁可以读取它,让一个请求解析出一个配置世代,并使路由替换成为原子操作([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md))。 +无密钥启动仍然有效:第一次请求会失败并点名该引用,而从外部提供的环境变量或 dotenv 值无需重启即可作用于下一次请求。demo 默认挂载 `settings-local` 与只读的 `credentials-local` 提供方,不内联任何 `!!js` 密钥接线。在当前消费方为其契约提供依据之前,凭据管理 RPC/UI 与注册变更均不存在。settings 层的数组仍整体替换,pi-ai 提供方路由也仍由组合决定。[凭据边界 note](2026-07-30-credential-boundaries-and-atomic-registration.md)负责保留至今的存储与请求世代安全决策。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index 98f2b0cb0d..f7fde2b4e7 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4 -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 3eb3b022064124aad2a389abba3063af4e2110fa +2026-07-30-credential-boundaries-and-atomic-registration.md: 09beda90d4789f951f663a3f9df794d74db2c11e +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 84f3826ba8a8b89fd5dd616164ac573151b8203c diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md index 6fe5f554ac..09beda90d4 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.md) -> Scope: the third review round over the [request-level LLM configuration seam](2026-07-29-request-level-llm-config-credentials.md) — where a stored credential lives and who can read it, how one request's facts stay one generation, and how a route set changes without a window. Companion to the [settings write-path note](2026-07-30-settings-write-path-integrity.md), whose provider fixes this round applies to `credentials-local` and whose writer lock it promotes into `dsh-atomic-write`. +> Scope: storage and request-boundary corrections to the [request-level LLM configuration seam](2026-07-29-request-level-llm-config-credentials.md). The later [read-only credentials and static routes](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md) decision removes credential writes, the shared atomic writer, and mutable registration; this note owns the surviving secret boundary and whole-request generation rules. ## Problem @@ -20,17 +20,17 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept **One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. -**Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change. +**Provider routes are composition-owned.** `registerAdapter` binds one non-empty route set to its calling fiber and returns a disposer. Settings cannot create or remove routes or change their captured retry policy, so the registry needs no replacement lifecycle and a bad settings snapshot leaves the composition registration untouched. -**Contained publication for committed credential writes.** `Credentials.notifyUpdated` fans `credentials/updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown. +**Credential resolution has no publication lifecycle.** The seam is read-only and consumers resolve for each operation, so external changes need no cache invalidation or event. `installSettingsSection` only switches the consumer's source thunk between the live scope and composition entry; committed values are read directly through that thunk. ## Alternatives considered - **A sandbox read-denial naming `$DSH_HOME/.env`** — implemented as a `readDenyPaths` policy field (a trailing SBPL `deny file-read* file-write*`, a `/dev/null` bwrap bind) and withdrawn on its own evidence. bwrap must create that bind's mount point inside a tree its profile has already made read-only, so it refuses the entire confinement whenever the parent directory is absent — every host that has not stored a credential yet, including a fresh install; Landlock cannot subtract from its own `/` read grant, so every confined call would report `partial` for a file it never hid. A protection that breaks confinement where it works and misreports it where it does not is worse than a documented absence. Denying the whole harness home was rejected earlier for a separate reason: it also covers `sessions/`, and `DSH_SESSION_JSONL` is a documented model-visible capability. - **Removing `DSH_HOME` from the model's bash environment** — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. There is no boundary here for it to complement; hiding the pointer would only make the absence harder to see. - **Shipping the OS-keychain provider in this round** — it is the only design where the model's processes genuinely cannot read the secret, and it is a sibling package with three platform backends. Sizing it against the rest of this review round would have delayed every other fix; it is recorded as the deferred answer, not as a maybe. -- **A `replaceRegistration(previous, next)` service method** — the review's shape, but it makes the caller carry the previous handle and lets it pass a mismatched one. Hanging `replace` on the registration handle makes ownership structural: only the registration that holds routes can replace them. +- **A mutable adapter-registration handle** — it can make replacement ownership structural, but current provider routes are known at composition and no shipped consumer needs mutation. Static registration removes the lifecycle instead. ## Consequences -`update()`-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and `describe()` reports `writable: false` for multi-line entries it will not rewrite. `LlmAdapter` registrants keep working unchanged (the handle is still callable as the disposer), and `DeepSeekConnectionOptions` gained credential fields, so a programmatic constructor of the adapter must supply `apiKeyEnv`. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution). +The local provider performs a direct environment-then-dotenv read for each resolution; mutation, description, writer locking, and change events are absent. `LlmAdapter` registrants receive an ordinary disposer, and `DeepSeekConnectionOptions` carries credential facts with its endpoint so one rejected settings generation cannot contribute only a key. An OS-keychain provider remains the path to isolating secrets from same-user model tools. diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index 3eb3b02206..84f3826ba8 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-30-credential-boundaries-and-atomic-registration.md) | 中文 -> 范围:对[请求级 LLM(大语言模型)配置 seam](2026-07-29-request-level-llm-config-credentials.md)的第三轮评审——存下来的凭据落在哪里、谁能读到它,一次请求的事实如何保持为同一代,以及一组路由如何在不留空窗的前提下更换。本 note 与 [settings 写路径 note](2026-07-30-settings-write-path-integrity.md) 配套:本轮把那篇 note 的提供方修复套用到 `credentials-local`,并把其中的写锁提升进 `dsh-atomic-write`。 +> 范围:对[请求级 LLM(大语言模型)配置 seam](2026-07-29-request-level-llm-config-credentials.md)的存储与请求边界修正。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了凭据写入、共享原子写入器与可变注册;本 note 负责保留至今的机密边界与整次请求同代规则。 ## 问题 @@ -24,17 +24,17 @@ Status: implemented **一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 -**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。 +**提供方路由归组合所有。**`registerAdapter` 把一组非空路由绑定到调用方 fiber,并返回释放器。settings 无法创建或移除路由,也无法更改注册时捕获的重试策略,因此注册表无需替换生命周期,错误的 settings 快照也不会影响组合注册。 -**已提交的凭据写入采用收容式发布。**`Credentials.notifyUpdated` 逐个监听器扇出 `credentials/updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。 +**凭据解析没有发布生命周期。**该 seam 只读,消费方每个操作都会解析,因此外部变更无需缓存失效或事件。`installSettingsSection` 只在存活 scope 与组合配置项之间切换消费方的来源 thunk;已提交值直接通过该 thunk 读取。 ## 曾考虑的替代方案 - **用沙箱点名拒读 `$DSH_HOME/.env`**——已按 `readDenyPaths` 策略字段实现过(末尾一条 SBPL `deny file-read* file-write*`、一条 `/dev/null` 的 bwrap bind),又被它自己的证据推翻。bwrap 必须在自己 profile 已经置为只读的目录树内部创建该 bind 的挂载点,因此只要父目录不存在,它就会拒绝整次约束——那是每一台还没有存过凭据的主机,包括全新安装;Landlock 无法从它自己对 `/` 的读取授权中减去任何东西,于是每一次受限调用都会为一个它其实从未藏起的文件报 `partial`。一项在生效之处破坏约束、在不生效之处误报的保护,比一条写明的「没有保护」更糟。至于拒掉整个 harness home,早先另有理由被否:它同时覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。 - **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。这里并不存在一条需要它来补强的边界,藏起指针只会让这份缺席更难被看见。 - **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密,而它是一个带三种平台后端的兄弟包(package)。把它与本轮评审的其余工作放在一起评估体量,会拖慢其他每一项修复;它被记录为那个延后的答案,而不是一个「也许」。 -- **做成 `replaceRegistration(previous, next)` 服务方法**——这是评审给出的形状,但它要求调用方自行携带上一个句柄,也允许它传入一个不匹配的句柄。把 `replace` 挂在注册句柄上,让归属关系变成结构性的:只有持有路由的那一项注册才能替换它们。 +- **可变的适配器注册句柄**:它可以使替换所有权成为结构关系,但当前提供方路由在组合时便已知,并且没有已交付的消费方需要变更能力。静态注册直接移除了这项生命周期。 ## 后果 -`update()` 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,`describe()` 对它不会改写的多行条目报告 `writable: false`。`LlmAdapter` 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),`DeepSeekConnectionOptions` 则新增了凭据字段,因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项:OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。 +本地提供方每次解析都会依次直接读取环境与 dotenv;修改、描述、写入锁和变更事件均不存在。`LlmAdapter` 注册方收到普通释放器;`DeepSeekConnectionOptions` 将凭据事实与端点一同携带,因此一代被拒绝的 settings 不可能只贡献密钥。OS 钥匙串提供方仍是将机密与同一用户身份下的模型工具隔离的实现路径。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml new file mode 100644 index 0000000000..0e69e9aba4 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.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/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md +2026-07-31-read-only-credentials-and-static-llm-routes.md: 2ebbea28c2dadeb9482c483247249ed4054749e4 +2026-07-31-read-only-credentials-and-static-llm-routes.zh.md: ad21d93bf73ca0063eb97da8e610aa2814ee1726 diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md new file mode 100644 index 0000000000..2ebbea28c2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md @@ -0,0 +1,35 @@ +# Agent Note: read-only credentials and static LLM routes + +Status: implemented + +English | [中文](2026-07-31-read-only-credentials-and-static-llm-routes.zh.md) + +## Problem + +The first request-level LLM configuration design shipped future configuration-UI capabilities before that UI existed. The credential seam exposed description, mutation, and change events; its local provider therefore needed a watcher, cache, operation queue, dotenv editor, writer lock, and a new shared atomic-write package. No production caller used those operations. Mutable adapter registrations and a dormant pi-ai mount similarly existed so settings could create routes even though provider ownership is a composition decision. + +That speculative closure accounted for much of the feature's runtime and test growth, widened public contracts, and introduced lifecycle and concurrency failure modes unrelated to the two current consumers, which only need to resolve a named key for a request. + +## Decision + +`ctx.credentials` exposes only branded `CredentialRef` construction and `resolve(ref): Promise`. `credentials-local` reads the named process environment value, then parses its dotenv file on demand. It owns no mutation, description, event, watcher, cache, editor, or writer lifecycle; externally changing either source is visible to the next resolution. + +LLM provider routes and their retry policies are composition-owned. `registerAdapter()` returns a disposer rather than a mutable registration handle. DeepSeek always owns its one route, and pi-ai requires a non-empty configured route map; settings may change request-level facts for those existing routes but cannot create, remove, or retune registrations. The shared CLI composition therefore does not mount an empty pi-ai adapter. + +The optional-settings helper only switches a consumer's source thunk between its composition entry and a live settings scope. Consumers read committed values through that thunk, so the helper needs no update watcher, derived-state callback, or teardown-state mirror. `settings-local` keeps its write protocol private instead of publishing a utility for a second writer that no longer exists. + +## Alternatives considered + +**Keep the credential writer for the planned web surface.** A future UI may need mutation and redacted description, but its exact RPC, ownership, and security contract is not shipped. Reintroducing the smallest closure with that consumer is cheaper than maintaining a generic write lifecycle meanwhile. + +**Cache the dotenv file and watch for invalidation.** Per-resolution file I/O is small beside a model request and makes external rotation current without watcher readiness, debounce, missed-event, and disposal semantics. + +**Keep mutable route registration as a generic registry feature.** Current adapters know their provider routes at composition. A mutable public handle creates a lifecycle state solely for a deferred settings-driven route feature. + +**Keep a shared atomic-write package for settings alone.** One consumer does not justify a public package, peer dependency, invariant companion, and independent test surface; the settings provider owns its private write protocol. + +## Consequences + +Credential rotation remains restart-free when an operator or external secret manager changes the environment or dotenv document, but the harness offers no credential-management API or UI contract. pi-ai deployments explicitly compose at least one provider route. The remaining public seams match current production calls, and the removed watcher/editor/registration machinery no longer contributes concurrency or teardown states. + +Focused seam, provider, dynamic-settings, Loader-composition, and missing-credential snapshot tests pin the smaller closure. The earlier [request-level configuration](../architecture/2026-07-29-request-level-llm-config-credentials.md) and [credential-boundary](../architecture/2026-07-30-credential-boundaries-and-atomic-registration.md) notes retain the motivation and surviving request/security decisions while deferring to this note for the removed mutation and route-lifecycle contracts. diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md new file mode 100644 index 0000000000..ad21d93bf7 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md @@ -0,0 +1,35 @@ +# Agent Note(agent 决策记录):只读凭据与静态 LLM(大语言模型)路由 + +Status: implemented + +[English](2026-07-31-read-only-credentials-and-static-llm-routes.md) | 中文 + +## 问题 + +第一版请求级 LLM 配置设计在相应 UI 尚不存在时,便交付了面向未来配置 UI 的能力。凭据 seam 暴露描述、修改和变更事件,因此本地提供方需要 watcher、缓存、操作队列、dotenv 编辑器、写入锁,以及一个新的共享 atomic-write 包(package)。没有生产调用方使用这些操作。可变适配器注册与休眠的 pi-ai 挂载同样是为了让 settings 创建路由而存在,尽管提供方所有权属于组合决策。 + +这些超前加入的能力占据了该功能大部分运行时与测试增量,扩大了公开契约,还引入了与两个当前消费方无关的生命周期和并发失败模式;这两个消费方只需要为一次请求解析点名的密钥。 + +## 决策 + +`ctx.credentials` 只暴露品牌化 `CredentialRef` 的构造,以及 `resolve(ref): Promise`。`credentials-local` 先读取点名的进程环境值,再按需解析其 dotenv 文件。它不拥有修改、描述、事件、watcher、缓存、编辑器或写入器生命周期;从外部更改任一来源,都会在下一次解析时生效。 + +LLM 提供方路由及其重试策略归组合所有。`registerAdapter()` 返回释放器,而非可变注册句柄。DeepSeek 始终拥有自身唯一的路由,pi-ai 则要求配置一份非空路由映射;settings 可以更改这些现有路由的请求级事实,但不能创建、移除或重新调整注册。因此,共享 CLI(命令行界面)组合不会挂载空的 pi-ai 适配器。 + +可选 settings 辅助工具只在组合配置项与存活 settings scope 之间切换消费方的来源 thunk。消费方经该 thunk 读取已提交值,因此辅助工具不需要更新 watcher、派生状态回调或拆卸状态镜像。`settings-local` 将自身的写入协议保留为私有实现,不再为一个已不存在的第二写入方公开工具。 + +## 曾考虑的替代方案 + +**为计划中的 web surface 保留凭据写入器。**未来的 UI 可能需要变更与脱敏后的描述,但其确切 RPC、所有权和安全契约尚未交付。届时随消费方重新引入满足需求的最小能力闭包,比在此期间维护通用写入生命周期成本更低。 + +**缓存 dotenv 文件并通过 watcher 触发失效。**相比一次模型请求,每次解析执行的文件 I/O 很小;直接读取可以让外部轮换始终生效,而无需引入 watcher 就绪、防抖、事件漏失与资源释放语义。 + +**保留可变路由注册,将其作为通用注册表功能。**当前适配器在组合时便已知自身的提供方路由。可变公开句柄只为一项延后的 settings 驱动路由功能创建了生命周期状态。 + +**只为 settings 保留共享 atomic-write 包。**一个消费方不足以证明公开包、对等依赖(peer dependency)、不变量配套实现与独立测试面的必要性;settings 提供方拥有自身的私有写入协议。 + +## 后果 + +当操作者或外部机密管理器更改环境或 dotenv 文档时,凭据轮换仍然无需重启,但 harness 不提供凭据管理 API 或 UI 契约。pi-ai 部署必须显式组合至少一条提供方路由。余下公开 seam 与当前生产调用相符,被移除的 watcher、编辑器与注册机制也不再引入并发或拆卸状态。 + +针对 seam、提供方、动态 settings、Loader 组合与凭据缺失快照的聚焦测试固定了这个更小的能力闭包。先前的[请求级配置](../architecture/2026-07-29-request-level-llm-config-credentials.md)与[凭据边界](../architecture/2026-07-30-credential-boundaries-and-atomic-registration.md) note 保留其动机以及仍然适用的请求与安全决策;对于已移除的变更和路由生命周期契约,则以本 note 为准。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 2e71c6c07b..547d4bfa83 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -32,8 +32,6 @@ flowchart LR cfg --> plugin_tui_settings plugin_tui_credentials["credentials
@deepseek-ai/dsh-credentials-local"] cfg --> plugin_tui_credentials - plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] - cfg --> plugin_tui_llm_pi_ai plugin_tui_session_persistence_jsonl["session-persistence-jsonl
@deepseek-ai/dsh-session-persistence-jsonl"] cfg --> plugin_tui_session_persistence_jsonl plugin_tui_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"] @@ -120,7 +118,6 @@ flowchart LR | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | | `settings` | `@deepseek-ai/dsh-settings-local` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | -| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index e885ce6ef1..85ef71b305 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -56,29 +56,20 @@ - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' -# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a -# `llm-deepseek:` or `llm-pi-ai:` section there overrides the adapter entries -# below without a restart, and is what the web Models page writes. +# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): the +# `llm-deepseek:` section overrides request-level adapter facts below without +# a restart. Provider routes and retry policy remain composition-owned. - id: settings name: '@deepseek-ai/dsh-settings-local' -# Credential store: the live process environment over `$DSH_HOME/.env` -# (owner-only file, hot-reloaded). Adapters resolve their key references -# through it at each request, so no key is inlined in this file — and nothing -# hoists that document into the process environment, which would make every -# stored key read as an unrotatable ambient override. +# Credential reader: the live process environment over `$DSH_HOME/.env`. +# Adapters resolve their key references on demand, so external rotations reach +# the next request without a watcher, cache, or mutation API. Nothing hoists +# that document into the process environment, where it would become an ambient +# override. - id: credentials name: '@deepseek-ai/dsh-credentials-local' -# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra -# models in the picker) until a `llm-pi-ai:` settings section supplies provider -# profiles — then those routes register live, keys resolving per request -# through their apiKeyEnv references, and drop again when the section empties. -# Which adapters exist is composition; which providers run is the user's -# settings document. -- id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' config: @@ -237,7 +228,7 @@ # The native DeepSeek adapter. No key or endpoint is inlined: both resolve per # request from the `llm-deepseek:` settings section over this entry, with the -# key coming from the credential store below. Thinking defaults are a surface +# key coming from the credential reader below. Thinking defaults are a surface # choice. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/apps/cli/package.json b/apps/cli/package.json index 94cf6da9c2..1485a5af12 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -66,7 +66,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 3469a737c1..515f31f470 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -123,9 +123,9 @@ export async function runTui( } installFailLoud(NAME) // The bin already loaded the invoking directory's .env, and that is the - // whole environment: $DSH_HOME/.env is credentials-local's writable store, - // and hoisting it would make every stored key read as a read-only ambient - // override on the next run — unrotatable from the TUI or the web page. + // whole environment: credentials-local reads $DSH_HOME/.env on demand, and + // hoisting it would make every stored key an ambient override whose later + // file rotations cannot take effect. // The environment is settled, so switching the workspace here cannot alter // its precedence. The cwd IS the workspace seam: the shipped config // resolves the session cwd and the HMR watch root from it, so one chdir moves diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 714a838124..945f6200cf 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -359,10 +359,10 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // SURFACE OVERLAY inserted, not one the base declares — proving a later // patch list reaches a row an earlier one inserted. The single `!!js` // expression prefers the PERSONAL variable, so the welcome can only render - // the project value while the harness home's .env — the credential store - // of `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting - // it would make every stored key read as a read-only launch override on - // the next run and hand it to every subprocess the agent starts. + // the project value while the harness home's .env — the document read by + // `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting it + // would make every stored key a launch override and hand it to every + // subprocess the agent starts. const output = await smoke({ label: 'dsh personal overlay', tempDirPrefix: 'dsh-personal-overlay-', diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 159fc308fd..0ae1cf6ea1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -410,20 +410,16 @@ Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../package ## `@deepseek-ai/dsh-credentials-local` ```ts config-catalog -/** Plugin config: file location and hot-reload behavior. */ +/** Plugin config: the optional credential document location. */ export interface Config { /** Credentials document path; defaults to `.env` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string - /** Watch the document and hot-publish external edits; defaults to true. */ - watch?: boolean - /** Watcher write-settle window in milliseconds; defaults to 100. */ - debounceMs?: number } ``` -Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts) +Source: [`packages/credentials/credentials-local/src/index.ts:17`](../packages/credentials/credentials-local/src/index.ts) ## `@deepseek-ai/dsh-fs-local` @@ -663,7 +659,7 @@ export interface DeepSeekCatalogModel { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:50`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:49`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -672,12 +668,8 @@ Requires: `llm` ```ts config-catalog /** Plugin configuration: the provider routes this instance owns. */ export interface Config { - /** - * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is - * the dormant settings-driven posture: the adapter mounts with no routes - * and registers them the moment a settings section supplies profiles. - */ - providers?: Record + /** Non-empty pi-ai provider routes, keyed by provider and fixed by composition. */ + providers: Record } /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ @@ -2343,7 +2335,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts)) - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) -- `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 249d26b416..9d48809dd0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -439,32 +439,6 @@ A command was registered or unregistered. This is an unfiltered registry notific Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src/index.ts) -## `credentials/*` - -### `credentials/updated` — emit - -Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions. - -```ts cordis-catalog -/** - * Committed change to a provider-managed credential source: a `set`, an - * `unset`, or an external edit observed in storage. Ambient - * process-environment changes are not observable and never emit. Listener - * failures are contained and logged — a sync throw and an async rejection - * alike — without changing the committed operation's outcome, except - * `INVARIANT`-coded failures, which rethrow after every listener ran; - * that rethrow reaches the emitter only from synchronous listeners, so - * invariant checks on this event must not be async functions. - * @param ref - the reference whose stored value changed. - * @mode emit - */ -'credentials/updated'(ref: CredentialRef): void -``` - -Types: [CredentialRef](../core-data-structures/credentials.md) - -Source: [`packages/credentials/credentials/src/index.ts:67`](../../packages/credentials/credentials/src/index.ts) - ## `domain/*` ### `domain/changed` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3149d6b572..b72caab0fd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -490,49 +490,21 @@ Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/comp ## `ctx.credentials` — `Credentials` (abstract seam) -Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. +Abstract read-only credential service. ```ts cordis-catalog /** - * Resolve one reference to its current value. Resolution is per call: - * consumers re-resolve at each operation and must not cache across - * operations — that per-operation read is what makes a changed credential - * reach the next operation without a restart. + * Resolve one reference to its current non-empty value. Consumers call once + * per operation and do not cache across operations. * @param ref - the reference to resolve. - * @returns the value and its source, or `undefined` while unconfigured. + * @returns the current value, or `undefined` while unconfigured. */ -abstract resolve(ref: CredentialRef): Promise - -/** - * Describe one reference for configuration surfaces without exposing the - * value. - * @param ref - the reference to describe. - * @returns configured state, supplying source, and writability. - */ -abstract describe(ref: CredentialRef): Promise - -/** - * Durably store one value in the provider-managed writable source. Rejects - * while a read-only source shadows the reference — the write would appear - * to succeed while resolution keeps returning the shadowing value — and - * rejects an empty value (use {@link unset}). - * @param ref - the reference to store. - * @param value - the non-empty secret value. - */ -abstract set(ref: CredentialRef, value: string): Promise - -/** - * Remove one reference from the provider-managed writable source; removing - * an absent reference is a no-op. Rejects while a read-only source shadows - * the reference, like {@link set}. - * @param ref - the reference to remove. - */ -abstract unset(ref: CredentialRef): Promise +abstract resolve(ref: CredentialRef): Promise ``` -Types: [CredentialInfo](../core-data-structures/credentials.md) · [CredentialRef](../core-data-structures/credentials.md) · [ResolvedCredential](../core-data-structures/credentials.md) +Types: [CredentialRef](../core-data-structures/credentials.md) -Source: [`packages/credentials/credentials/src/index.ts:77`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts:34`](../../packages/credentials/credentials/src/index.ts) ## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam) @@ -790,9 +762,9 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surf * Disposed with the fiber. * @param providers - every provider route this adapter should serve. * @param adapter - the adapter that streams calls for those providers. - * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}. + * @returns the disposer that unregisters all routes. */ -registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle +registerAdapter(providers: string[], adapter: LlmAdapter): () => void /** * Describe provider routes with a registered adapter. @@ -864,9 +836,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:215`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:191`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index e998f1f4a4..9e32dcc915 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 09b437a8483134230d4b941c20940c5655bc53f0 -core.zh.md: 2025707db397203dbaec52c59172f83367f2033e +core.md: fa313fee4f29c79ae0713b156ed248cca7285e21 +core.zh.md: be604b24474f2308ddb31d50ae88b73e6aaa4084 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 09b437a848..fa313fee4f 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -25,7 +25,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | -| [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | +| [credentials.md](credentials.md) | the read-only credential seam: `CredentialRef` references and per-operation value resolution | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | | [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | @@ -183,33 +183,7 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) Provider and model discovery uses small provider-neutral descriptors. A model catalog is advisory: routing still keys on a registered provider, and an adapter may accept unlisted model ids. -Registering an adapter returns a handle: the disposer, plus the atomic route replacement a plugin whose route set is user-configurable needs. - -```ts type-equiv -/** - * What {@link LlmService.registerAdapter} returns: the disposer, plus an - * atomic route replacement for the same adapter instance. - */ -interface AdapterRegistrationHandle { - /** Release every route this registration currently holds. */ - (): void - /** - * Replace this registration's routes with `providers`, keeping the same - * adapter instance. The candidate set is validated in full first — a - * conflict with another adapter, an invalid name, or bad provider metadata - * throws and leaves the current routes untouched — and the swap itself is - * one synchronous section, so no request can observe a gap. An empty array - * is legal here (a settings section that emptied holds zero routes while - * staying registered), unlike an empty initial registration. - * - * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration - * has been released: its routes are gone and its disposer has already run, - * so anything registered afterwards would have no owner left to release it. - * @param providers - the complete next route set for this registration. - */ - replace(providers: string[]): void -} -``` +Registering an adapter binds one non-empty provider-route set to the calling fiber and returns its disposer. Routes are composition-owned rather than mutable user settings. ```ts type-equiv /** Display metadata for one registered provider route. */ diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 2025707db3..be604b2447 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -25,7 +25,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` | | [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | -| [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 | +| [credentials.md](credentials.md) | 只读凭据 seam:`CredentialRef` 引用与按操作解析值 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | | [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | @@ -189,33 +189,7 @@ interface MessageSourceMap { 提供方与模型发现使用小型、提供方无关的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。 -注册适配器会返回一个句柄:既是释放器,也带有原子的路由替换——路由集合由用户配置决定的插件正需要它。 - -```ts type-equiv -/** - * What {@link LlmService.registerAdapter} returns: the disposer, plus an - * atomic route replacement for the same adapter instance. - */ -interface AdapterRegistrationHandle { - /** Release every route this registration currently holds. */ - (): void - /** - * Replace this registration's routes with `providers`, keeping the same - * adapter instance. The candidate set is validated in full first — a - * conflict with another adapter, an invalid name, or bad provider metadata - * throws and leaves the current routes untouched — and the swap itself is - * one synchronous section, so no request can observe a gap. An empty array - * is legal here (a settings section that emptied holds zero routes while - * staying registered), unlike an empty initial registration. - * - * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration - * has been released: its routes are gone and its disposer has already run, - * so anything registered afterwards would have no owner left to release it. - * @param providers - the complete next route set for this registration. - */ - replace(providers: string[]): void -} -``` +注册适配器会把一组非空提供方路由绑定到调用方 fiber,并返回相应的释放器。路由归组合所有,不属于可变的用户设置。 ```ts type-equiv /** Display metadata for one registered provider route. */ diff --git a/docs/core-data-structures/credentials.i18n.yaml b/docs/core-data-structures/credentials.i18n.yaml index 23bb940afe..7e494a8966 100644 --- a/docs/core-data-structures/credentials.i18n.yaml +++ b/docs/core-data-structures/credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/credentials.md -credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951 -credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca +credentials.md: 90e39a013f6b557ca5d4f07facebda390b908305 +credentials.zh.md: 92986f484bcc271538d02169038bbe71ebd5e4a6 diff --git a/docs/core-data-structures/credentials.md b/docs/core-data-structures/credentials.md index 3f6fcd127d..90e39a013f 100644 --- a/docs/core-data-structures/credentials.md +++ b/docs/core-data-structures/credentials.md @@ -2,7 +2,7 @@ English | [中文](credentials.zh.md) -The credential seam of [dsh-credentials](../../packages/credentials/credentials) keeps secrets out of configuration: settings sections and `cordis.yml` entries carry *references* (environment-variable names), providers such as [dsh-credentials-local](../../packages/credentials/credentials-local) own the values, and consumers resolve a reference once per operation — the LLM adapters resolve once per model request, so a rotated credential reaches the very next request without any restart. One seam-wide rule binds every provider: an empty stored value is absent everywhere. +The [dsh-credentials](../../packages/credentials/credentials) seam lets configuration name secrets by reference rather than carry their values. Providers such as [dsh-credentials-local](../../packages/credentials/credentials-local) resolve the current non-empty value, and consumers resolve once per operation so an external rotation reaches the next operation without a restart. Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) @@ -17,34 +17,4 @@ type CredentialRef = Branded<'CredentialRef'> ## Resolution -`resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism. - -```ts type-equiv -/** One resolved credential value and the source layer that supplied it. */ -interface ResolvedCredential { - /** The non-empty secret value. */ - value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ - source: string -} -``` - -## Description - -`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider reports a reference supplied by the live process environment as `writable: false` — a write would appear to succeed while resolution kept returning the shadowing value, so the seam rejects it and the UI can render the reference read-only up front. - -```ts type-equiv -/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ -interface CredentialInfo { - /** Whether {@link Credentials.resolve} would currently return a value. */ - configured: boolean - /** Source layer currently supplying the value; absent while unconfigured. */ - source?: string - /** Whether {@link Credentials.set} would currently succeed for this reference. */ - writable: boolean -} -``` - -## Change commits - -`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge. +`ctx.credentials.resolve(ref)` returns the provider's current non-empty secret string, or `undefined` while unconfigured. Consumers do not cache across operations. The seam deliberately exposes no mutation, source-description, enumeration, or change-event contract; the generated [service catalog](../cordis-catalog/services.md) owns the method signature. diff --git a/docs/core-data-structures/credentials.zh.md b/docs/core-data-structures/credentials.zh.md index b5d2d9e164..92986f484b 100644 --- a/docs/core-data-structures/credentials.zh.md +++ b/docs/core-data-structures/credentials.zh.md @@ -2,7 +2,7 @@ [English](credentials.md) | 中文 -[dsh-credentials](../../packages/credentials/credentials) 的凭据 seam 把机密挡在配置之外:settings 分节与 `cordis.yml` 条目携带的是*引用*(环境变量名),值归 [dsh-credentials-local](../../packages/credentials/credentials-local) 这类 provider 所有,消费方每个操作解析一次引用——LLM 适配器每次模型请求解析一次,因此轮换后的凭据无需任何重启即可作用于紧随其后的下一次请求。一条 seam 级规则约束每个 provider:空的存储值在任何地方都视为不存在。 +[dsh-credentials](../../packages/credentials/credentials) seam 允许配置以引用点名机密,而非携带机密值。[dsh-credentials-local](../../packages/credentials/credentials-local) 这类提供方解析当前非空值,消费方每个操作解析一次,因此从外部轮换的值无需重启即可作用于下一次操作。 Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) @@ -17,34 +17,4 @@ type CredentialRef = Branded<'CredentialRef'> ## 解析 -`resolve(ref)` 返回值,连同供出该值、由 provider 定义的来源层;未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这次按操作进行的读取正是热更新机制。 - -```ts type-equiv -/** One resolved credential value and the source layer that supplied it. */ -interface ResolvedCredential { - /** The non-empty secret value. */ - value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ - source: string -} -``` - -## 描述 - -`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地 provider 把由活跃进程环境供值的引用报告为 `writable: false`——那样的写入会表面成功而解析持续返回遮蔽值,因此 seam 直接拒绝,界面也得以提前把该引用渲染为只读。 - -```ts type-equiv -/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ -interface CredentialInfo { - /** Whether {@link Credentials.resolve} would currently return a value. */ - configured: boolean - /** Source layer currently supplying the value; absent while unconfigured. */ - source?: string - /** Whether {@link Credentials.set} would currently succeed for this reference. */ - writable: boolean -} -``` - -## 变更提交 - -`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 +`ctx.credentials.resolve(ref)` 返回提供方当前的非空机密字符串,未配置时返回 `undefined`。消费方不跨操作缓存。该 seam 刻意不暴露变更、来源描述、枚举或变更事件契约;方法签名由生成的[服务目录](../cordis-catalog/services.md)负责。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 950544938a..f0dbda5137 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -26,7 +26,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | -| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | diff --git a/docs/module-graph.md b/docs/module-graph.md index e0d102d237..22e361d608 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -8,7 +8,6 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri ```mermaid flowchart TD subgraph group_util["packages/util"] - pkg_atomic_write["atomic-write"] pkg_brand["brand"] pkg_native_command["native-command"] pkg_paths["paths"] @@ -266,7 +265,6 @@ flowchart TD subgraph group_workspace["packages/workspace"] pkg_workspace["workspace"] end - pkg_atomic_write --> pkg_invariants pkg_brand --> pkg_invariants pkg_native_command --> pkg_invariants pkg_paths --> pkg_invariants @@ -383,7 +381,6 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants - pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths @@ -402,7 +399,6 @@ flowchart TD pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm - pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths pkg_settings_local --> pkg_settings @@ -1016,7 +1012,6 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | -| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | @@ -1070,12 +1065,12 @@ flowchart TD | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | +| [`settings-local`](../packages/settings/settings-local) | `settings` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index a09e0281cc..07de430205 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -191,12 +191,11 @@ describe('headless stream-json snapshots', () => { prepare: (cwd) => { runCwd = cwd }, }) - // The guidance leads with the credential store — the path that keeps the - // secret out of configuration files — and offers a literal key last. + // The guidance names the external credential sources and the literal + // configuration escape hatch without promising an unshipped writer UI. expect(result.stderr).toBe( 'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek";' - + ' store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),' - + ' export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal' + + ' provide DEEPSEEK_API_KEY through the credential provider or launching environment, or set a literal' + ' "apiKey" in the llm-deepseek settings section\n', ) const normalized = normalizeHeadlessStream(result.stdout, runCwd) diff --git a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl index c48a42f62e..77e7ff6462 100644 --- a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl @@ -4,5 +4,5 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}} -{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; provide DEEPSEEK_API_KEY through the credential provider or launching environment, or set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}} +{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; provide DEEPSEEK_API_KEY through the credential provider or launching environment, or set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 1c9473b245..31b1793f13 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -266,23 +266,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'credentials', - summary: 'Abstract credential service.', + summary: 'Abstract read-only credential service.', methods: [ { - signature: 'abstract resolve(ref: CredentialRef): Promise', - jsDoc: '/**\n * Resolve one reference to its current value. Resolution is per call:\n * consumers re-resolve at each operation and must not cache across\n * operations — that per-operation read is what makes a changed credential\n * reach the next operation without a restart.\n * @param ref - the reference to resolve.\n * @returns the value and its source, or `undefined` while unconfigured.\n */', - }, - { - signature: 'abstract describe(ref: CredentialRef): Promise', - jsDoc: '/**\n * Describe one reference for configuration surfaces without exposing the\n * value.\n * @param ref - the reference to describe.\n * @returns configured state, supplying source, and writability.\n */', - }, - { - signature: 'abstract set(ref: CredentialRef, value: string): Promise', - jsDoc: '/**\n * Durably store one value in the provider-managed writable source. Rejects\n * while a read-only source shadows the reference — the write would appear\n * to succeed while resolution keeps returning the shadowing value — and\n * rejects an empty value (use {@link unset}).\n * @param ref - the reference to store.\n * @param value - the non-empty secret value.\n */', - }, - { - signature: 'abstract unset(ref: CredentialRef): Promise', - jsDoc: '/**\n * Remove one reference from the provider-managed writable source; removing\n * an absent reference is a no-op. Rejects while a read-only source shadows\n * the reference, like {@link set}.\n * @param ref - the reference to remove.\n */', + signature: 'abstract resolve(ref: CredentialRef): Promise', + jsDoc: '/**\n * Resolve one reference to its current non-empty value. Consumers call once\n * per operation and do not cache across operations.\n * @param ref - the reference to resolve.\n * @returns the current value, or `undefined` while unconfigured.\n */', }, ], }, @@ -405,8 +393,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.', methods: [ { - signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle', - jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.\n */', + signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): () => void', + jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer that unregisters all routes.\n */', }, { signature: 'listProviders(): LlmProviderInfo[]', @@ -1293,13 +1281,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', summary: 'A command was registered or unregistered.', }, - { - name: 'credentials/updated', - mode: 'emit', - signature: '\'credentials/updated\'(ref: CredentialRef): void', - jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit. Listener\n * failures are contained and logged — a sync throw and an async rejection\n * alike — without changing the committed operation\'s outcome, except\n * `INVARIANT`-coded failures, which rethrow after every listener ran;\n * that rethrow reaches the emitter only from synchronous listeners, so\n * invariant checks on this event must not be async functions.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */', - summary: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage.', - }, { name: 'domain/changed', mode: 'emit', @@ -1521,10 +1502,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ - { - name: 'AdapterRegistrationHandle', - declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', - }, { name: 'Agent', declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', @@ -1753,10 +1730,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}', }, - { - name: 'CredentialInfo', - declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n writable: boolean;\n}', - }, { name: 'CredentialRef', declaration: 'export type CredentialRef = Branded<\'CredentialRef\'>;', @@ -2189,10 +2162,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResolvedAlwaysRetryPolicy', declaration: 'export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'always\';\n}', }, - { - name: 'ResolvedCredential', - declaration: 'export interface ResolvedCredential {\n value: string;\n source: string;\n}', - }, { name: 'ResolvedNormalRetryPolicy', declaration: 'export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'normal\';\n readonly maxRetries: number;\n readonly retryableCodes: readonly string[];\n}', diff --git a/packages/credentials/README.i18n.yaml b/packages/credentials/README.i18n.yaml index e8b35ba48e..84df37baa8 100644 --- a/packages/credentials/README.i18n.yaml +++ b/packages/credentials/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/README.md -README.md: 1d450cbeef84750fa57ca0151563c496aed0ce12 -README.zh.md: 843230c3cebf35f234d3ad812165b16ea734678b +README.md: e7dc38db9be95bdacabc19538c06d87723e9c922 +README.zh.md: 74bf5d9b53e6861271b4810474c6eddd4388da51 diff --git a/packages/credentials/README.md b/packages/credentials/README.md index 1d450cbeef..e7dc38db9b 100644 --- a/packages/credentials/README.md +++ b/packages/credentials/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -The credential capability seam, as three-package shape dictates (interface / implementation / consumers): +The credential capability keeps secret values behind provider-owned references: | Package | Role | |---|---| -| [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references, per-operation `resolve`, UI-safe `describe`, fail-loud `set`/`unset`, the `credentials/updated` commit event | -| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.env` (writable, byte-preserving line edits, hot-reloaded) | +| [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references and per-operation `resolve` | +| [`credentials-local/`](credentials-local/README.md) | Read-only provider: the live process environment layered over an on-demand `$DSH_HOME/.env` read | -Configuration files carry *references* to secrets (`apiKeyEnv: DEEPSEEK_API_KEY`), never the secrets: the settings document stays safe to sync and render, and rotating a value touches no configuration. The LLM adapters are the first consumers — they resolve their reference once per model request, which is what makes a key stored moments ago reach the very next request without restarting anything. +Configuration can carry a reference such as `apiKeyEnv: DEEPSEEK_API_KEY` instead of the secret itself. LLM adapters resolve that reference for each model request, so an externally rotated environment or dotenv value reaches the next request without restarting the harness. -The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers. +The seam can also support keyring-, helper-command-, and KMS-backed providers when a shipped consumer needs one. diff --git a/packages/credentials/README.zh.md b/packages/credentials/README.zh.md index 843230c3ce..74bf5d9b53 100644 --- a/packages/credentials/README.zh.md +++ b/packages/credentials/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -凭据能力 seam,按三包形态的要求组织(接口/实现/消费方): +凭据能力把机密值留在提供方拥有的引用背后: -| 包 | 角色 | +| 包(package) | 角色 | |---|---| -| [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用、按操作 `resolve`、对 UI 安全的 `describe`、响亮失败的 `set`/`unset`,以及 `credentials/updated` 提交事件 | -| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.env`(可写、保字节行级编辑、热重载)之上 | +| [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用与按操作 `resolve` | +| [`credentials-local/`](credentials-local/README.md) | 只读提供方:活跃进程环境叠加按需读取的 `$DSH_HOME/.env` | -配置文件携带的是对机密的*引用*(`apiKeyEnv: DEEPSEEK_API_KEY`),绝不携带机密本身:设置文档可以放心同步与渲染,轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。 +配置可以携带 `apiKeyEnv: DEEPSEEK_API_KEY` 这样的引用,而非机密本身。LLM(大语言模型)适配器每次模型请求都会解析该引用,因此从外部轮换的环境变量或 dotenv 值无需重启 harness 即可作用于下一次请求。 -seam 形状为 keyring、辅助命令与 KMS 后端的 provider 留有余地。 +已交付的消费方需要时,该 seam 也可以支持由 keyring、辅助命令或 KMS 支撑的提供方。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 89a8576683..d06f03448a 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md -README.md: 126140b10719dc6f7bc458a118ba1feb1f440270 -README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d +README.md: dcfaea036595c68eb07b104d9075b67d17dad2ac +README.zh.md: 635d41a8e156b71d749419e90e408c184e9adbbc diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 126140b107..dcfaea0365 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -2,14 +2,14 @@ English | [中文](README.zh.md) -File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence. +Read-only [credentials](../credentials/README.md) provider with two externally managed sources: -| Layer | Source id | Writable | Wins | -|---|---|---|---| -| Live process environment | `env` | no | always | -| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise | +| Layer | Wins | +|---|---| +| Live process environment | Always, when the named value is non-empty | +| `$DSH_HOME/.env` document | Otherwise | -The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back. +The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, or a prepared shell) is operator intent for that process. The provider never writes either source. ## Config @@ -17,28 +17,20 @@ The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, |---|---|---| | `path` | `/.env` | Credentials document location. | | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. | -| `watch` | `true` | Hot-publish external edits. | -| `debounceMs` | `100` | Watcher write-settle window. | -## The document +## Resolution -dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. +Each `resolve(ref)` reads `process.env[ref]` first. If it is absent or empty, the provider reads the dotenv document and parses it with `dotenv`; a missing file, missing key, or empty value resolves to `undefined`, while any other file error rejects the operation. Nothing is watched or cached, so an external edit is visible to the next resolution without a provider lifecycle or mutation API. -Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule. - -## Hot reload - -External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address. +The provider accepts dotenv's parsing semantics, including last-assignment precedence. It does not create the document or control its permissions; the operator or external credential-management surface owns both. ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns, and no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given. - -That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. +The harness does not expose the resolved document path to the model or hoist the file into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)). This is discretion, not isolation: tools run as the same OS user and can read any file that user's permissions allow. A deployment that must keep provider keys away from its own agent needs a provider backed by a store those tool processes cannot read. ## Model Experience -Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface. +Indirectly, through the consuming LLM adapters: resolved values authorize their provider requests, and the adapter owns every model-visible surface. #### KV Cache effect @@ -46,9 +38,6 @@ No direct invalidation; credentials never enter a request prefix. ## Known Limitations and Deferred Work -- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly. -- **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check. -- **A same-UID process can read the document** — see [Security boundary](#security-boundary): only a confining sandbox mode denies it, and an OS-keychain provider is deferred. -- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format. -- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there. -- **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot. +- **Mutation is external** — edit the dotenv document, launching environment, or upstream secret store; this provider intentionally has no write API. +- **Every file fallback performs I/O** — the implementation favors a small always-current read path over a watcher, cache, and invalidation lifecycle. +- **A same-UID process can read the document** — file permissions do not isolate a secret from model-invoked tools running as the same user. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index c22575115a..635d41a8e1 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -2,14 +2,14 @@ [English](README.md) | 中文 -文件型[凭据](../credentials/README.md) provider:两层来源,一条诚实的优先级。 +只读[凭据](../credentials/README.md)提供方,包含两个由外部管理的来源: -| 层 | 来源 id | 可写 | 优先 | -|---|---|---|---| -| 活跃进程环境 | `env` | 否 | 恒定优先 | -| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset`) | 其余情况 | +| 层 | 优先级 | +|---|---| +| 活跃进程环境 | 点名的值非空时始终优先 | +| `$DSH_HOME/.env` 文档 | 其余情况 | -环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false`,`set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。 +环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密或预先设置好环境的 shell)代表操作者对该进程的意图。提供方不会写入任何一个来源。 ## 配置 @@ -17,28 +17,20 @@ |---|---|---| | `path` | `/.env` | 凭据文档位置。 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | `path` 缺省时使用的 harness home。 | -| `watch` | `true` | 热发布外部编辑。 | -| `debounceMs` | `100` | watcher 写入稳定窗口。 | -## 文档本身 +## 解析 -dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。 +每次调用 `resolve(ref)` 时,提供方先读取 `process.env[ref]`。若该值不存在或为空,提供方再读取 dotenv 文档并以 `dotenv` 解析;文件不存在、键不存在或值为空时返回 `undefined`,其他文件错误则使该操作失败。实现不使用 watcher 或缓存,因此外部编辑无需经过提供方生命周期或变更 API,即可在下一次解析时生效。 -值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。 - -## 热重载 - -外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。 +提供方接受 dotenv 的解析语义,包括最后一次赋值优先。它既不创建文档,也不控制其权限;两者均归操作者或外部凭据管理接口所有。 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致,也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。 - -这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。 +harness 不会向模型暴露解析后的文档路径,也不会把文件载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config))。这是审慎,不是隔离:工具以同一 OS 用户身份运行,可以读取该用户权限允许的任何文件。必须让提供方密钥远离自身 agent(智能体)的部署,需要采用由这些工具进程无法读取的存储支撑的提供方。 ## Model Experience -经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 +经由消费它的 LLM(大语言模型)适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 #### KV Cache effect @@ -46,9 +38,6 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 ## Known Limitations and Deferred Work -- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。 -- **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。 -- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有受限沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。 -- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。 -- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。 -- **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。 +- **修改由外部完成**:请编辑 dotenv 文档、启动环境或上游机密存储;该提供方刻意不提供写入 API。 +- **每次回退到文件都会执行 I/O**:实现选择小而始终读取当前值的路径,不引入 watcher、缓存和失效生命周期。 +- **同一 OS 用户的进程可以读取该文档**:文件权限无法将机密与以同一用户身份运行的模型调用工具隔离。 diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 0b8924d7f2..a59c408154 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -27,19 +27,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { - "chokidar": "^4.0.3", "dotenv": "^17.2.0", "schemastery": "^3.18.0" }, "devDependencies": { - "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index c62d4411fa..5ab141428e 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -1,463 +1,72 @@ /** - * File-backed credentials provider layering the live process environment over - * a `$DSH_HOME/.env` document. The environment is authoritative and read-only - * (a launch-time override must win, and must be visibly read-only rather than - * silently shadow writes); the file is the provider-managed writable source: - * every write re-reads the document under a cross-process writer lock before - * rewriting only its own line — preserving every other byte, physical line - * endings and quoted multi-line values included — external edits hot-publish - * through the seam, and each reload replaces the snapshot wholesale so a - * deleted entry never lingers in memory. + * Read-only credential provider layering the live process environment over a + * `$DSH_HOME/.env` document read on demand. * @module @deepseek-ai/dsh-credentials-local */ -import { Context, Service } from 'cordis' +import { Context } from 'cordis' import z from 'schemastery' -import { watch as chokidarWatch } from 'chokidar' -import { mkdir, readFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' import { parse } from 'dotenv' -import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials' -import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' +import { Credentials } from '@deepseek-ai/dsh-credentials' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' -/** Plugin config: file location and hot-reload behavior. */ +/** Plugin config: the optional credential document location. */ export interface Config { /** Credentials document path; defaults to `.env` under the harness home. */ path?: string /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */ dshHome?: string - /** Watch the document and hot-publish external edits; defaults to true. */ - watch?: boolean - /** Watcher write-settle window in milliseconds; defaults to 100. */ - debounceMs?: number } -/** Fully resolved provider parameters; defaulting happens here, never inline. */ +/** Fully resolved provider parameters. */ interface ResolvedSpec { filename: string - watch: boolean - debounceMs: number } /** - * Resolve the runtime spec from plugin config: an explicit `path` wins, - * otherwise the document lives at `/.env`. + * Resolve the runtime spec from plugin config. * @param config - raw plugin config. - * @returns the resolved file location and watch behavior. + * @returns the absolute credential document path. */ export function resolveSpec(config: Config): ResolvedSpec { - return { - filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')), - watch: config.watch ?? true, - debounceMs: config.debounceMs ?? 100, - } + return { filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')) } } -/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ +/** Whether a filesystem error means absence; every non-ENOENT failure surfaces. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -/** Values that survive a dotenv round-trip without quoting. */ -const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/ - -/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */ -function hasControlCharacters(value: string): boolean { - for (const char of value) { - if (char.charCodeAt(0) < 0x20) return true - } - return false -} - -/** - * Render one `KEY=value` line in the narrowest style dotenv reads back - * verbatim: bare, then single quotes (fully literal), then double quotes - * (safe only without backslashes, which double-quote reading expands). - * A value no style can represent fails loud instead of corrupting silently. - */ -function renderLine(ref: CredentialRef, value: string): string { - if (BARE_VALUE.test(value)) return `${ref}=${value}` - if (hasControlCharacters(value)) { - throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`) - } - if (!value.includes('\'')) return `${ref}='${value}'` - if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"` - throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`) -} - -/** Split text into physical lines with their terminators attached. */ -function physicalLines(text: string): string[] { - return text.length === 0 ? [] : text.split(/(?<=\n)/) -} - -/** One physical line's content without its terminator. */ -function lineContent(line: string): string { - if (line.endsWith('\r\n')) return line.slice(0, -2) - if (line.endsWith('\n')) return line.slice(0, -1) - return line -} - -/** One physical line's terminator (empty on a final unterminated line). */ -function lineTerminator(line: string): string { - return line.slice(lineContent(line).length) -} - -/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */ -const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/ - -/** Quote characters dotenv reads across physical lines. */ -const MULTILINE_QUOTES = ['\'', '"', '`'] - -/** - * The quote character an assignment's value part opens without closing on its - * own line — the following physical lines are that value's continuation, not - * assignments — or `undefined` for a single-line value. - */ -function opensMultiline(valuePart: string): string | undefined { - const trimmed = valuePart.trimStart() - const quote = trimmed[0] - if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined - const rest = trimmed.slice(1) - const body = quote === '"' ? rest.replaceAll('\\"', '') : rest - return body.includes(quote) ? undefined : quote -} - -/** Whether a continuation line closes the given quote. */ -function closesQuote(content: string, quote: string): boolean { - const body = quote === '"' ? content.replaceAll('\\"', '') : content - return body.includes(quote) -} - -/** - * Replace, insert, or delete one reference's assignment while preserving - * every other byte: untouched lines keep their exact content and terminators - * (CRLF included), and the physical lines inside another key's quoted - * multi-line value are never mistaken for assignments. The first matching - * assignment is rewritten in place with its own line ending; later duplicates - * drop (dotenv reads the last one, so a surviving duplicate would override - * the edit); an insert appends in the document's dominant ending style. - */ -function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string { - const lines = physicalLines(text ?? '') - const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n' - const out: string[] = [] - let placed = false - let pendingQuote: string | undefined - for (const line of lines) { - const content = lineContent(line) - if (pendingQuote !== undefined) { - // Inside a quoted multi-line value: never an assignment, always kept. - if (closesQuote(content, pendingQuote)) pendingQuote = undefined - out.push(line) - continue - } - const match = ASSIGNMENT.exec(content) - if (match === null) { - out.push(line) - continue - } - const [, key, valuePart] = match - if (key !== ref) { - /* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */ - pendingQuote = opensMultiline(valuePart ?? '') - out.push(line) - continue - } - // The write path refuses multi-line targets before rendering, so the - // matched assignment is single-line and drops or rewrites wholesale. - if (rendered !== undefined && !placed) { - out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`) - placed = true - } - } - if (rendered !== undefined && !placed) { - const last = out[out.length - 1] - if (last !== undefined && lineTerminator(last) === '') { - out[out.length - 1] = `${last}${dominant}` - } - out.push(`${rendered}${dominant}`) - } - return out.join('') -} - /** File-backed credentials provider (`$DSH_HOME/.env`). */ export class CredentialsLocal extends Credentials { - /* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with - settings-local (prefer symmetry for parallel values); extracting the shared - shape would couple the two providers' teardown semantics across packages. */ static Config: z = z.object({ path: z.string(), dshHome: z.string(), - watch: z.boolean().default(true), - debounceMs: z.number().min(0).default(100), }) private readonly spec: ResolvedSpec - /** - * Raw text of the last read or persisted document; `undefined` while the - * file is absent. Watcher events whose content equals this cache are no-ops, - * which is also the self-write suppression. - */ - private text: string | undefined - /** Parsed document snapshot; replaced wholesale on every reload. */ - private values = new Map() - /** - * Single exclusive operation chain: watcher reloads and line edits run one - * at a time in queue order (settled tail), so an edit can never render from - * text a concurrent reload is busy replacing. - */ - private operations: Promise = Promise.resolve() - /** Set at dispose: refuse new writes and let in-flight work no-op. */ - private closed = false - - /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */ - private isClosed(): boolean { - return this.closed - } - /* jscpd:ignore-end */ constructor(ctx: Context, public config: Config) { super(ctx) - // Programmatic construction may bypass Schemastery normalization; resolve - // the same defaults in one explicit step either way. this.spec = resolveSpec(config) } - async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { - yield async () => { - // Drain: refuse new operations, then settle the queued ones so disposal - // completes only once storage is quiescent. - this.closed = true - await this.operations - } - await this.loadInitial() - if (!this.spec.watch) return - /* jscpd:ignore-start -- same watcher discipline as settings-local by design: - the serialized-refresh and quiesce-on-dispose shape is the reviewed - lifecycle contract, not accidental repetition. */ - const watcher = chokidarWatch(this.spec.filename, { - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: this.spec.debounceMs, - pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)), - }, - }) - watcher.on('all', () => { - if (this.closed) return - this.queueRefresh() - }) - watcher.on('ready', () => { - // The initial load raced the watcher's own setup: a change written - // between that read and the watcher becoming active never fires an - // event. One reconcile at ready closes the gap. - if (this.closed) return - this.queueRefresh() - }) - watcher.on('error', (error) => { - this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) - this.ctx.logger.warn(error) - }) - yield async () => { - // Quiesce: stop accepting events, close the watcher, then wait out any - // queued or in-flight operation so nothing publishes after disposal. - this.closed = true - await watcher.close() - await this.operations - } - /* jscpd:ignore-end */ - } + override async resolve(ref: CredentialRef): Promise { + const ambient = process.env[ref] + if (ambient !== undefined && ambient.length > 0) return ambient - override resolve(ref: CredentialRef): Promise { - const env = process.env[ref] - if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' }) - const stored = this.values.get(ref) - if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' }) - return Promise.resolve(undefined) - } - - override describe(ref: CredentialRef): Promise { - const env = process.env[ref] - if (env !== undefined && env.length > 0) { - return Promise.resolve({ configured: true, source: 'env', writable: false }) - } - const stored = this.values.get(ref) - if (stored !== undefined && stored.length > 0) { - // A quoted multi-line value resolves fine but the line editor refuses to - // rewrite it, so writability must say what set() would actually do. - return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') }) - } - return Promise.resolve({ configured: false, writable: true }) - } - - override async set(ref: CredentialRef, value: string): Promise { - if (value.length === 0) { - throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`) - } - await this.write(ref, value) - } - - override async unset(ref: CredentialRef): Promise { - await this.write(ref, undefined) - } - - /* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same - reviewed contract as settings-local, deliberately mirrored (prefer symmetry - for parallel values); the two providers own different documents and - failure policies, so extracting the shape would couple their teardown - semantics across packages for a handful of lines. */ - /** Queue one exclusive document operation behind every earlier one. */ - private enqueue(operation: () => Promise): Promise { - const task = this.operations.then(operation) - this.operations = task.then(() => undefined, () => undefined) - return task - } - - /** Queue a reload; only an invariant violation escaping the fan-out can reject it. */ - private queueRefresh(): void { - void this.enqueue(() => this.refresh()).catch((error: unknown) => { - // Only an invariant violation escaping the update fan-out can reject a - // refresh; keep the operation queue alive and surface it as an error so - // one poisoned commit cannot silently end hot reloading forever. - this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename) - this.ctx.logger.error(error) - }) - } - /* jscpd:ignore-end */ - - /** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */ - private async write(ref: CredentialRef, value: string | undefined): Promise { - const verb = value === undefined ? 'unset' : 'set' - if (this.isClosed()) { - throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`) - } - this.assertUnshadowed(ref, verb) - return this.enqueue(async () => { - if (this.isClosed()) { - throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`) - } - // Re-judged at run time: the environment may have changed while queued. - this.assertUnshadowed(ref, verb) - // The writer lock's exclusive create needs the parent to exist; 0700 - // because the harness home holds user-private data. - await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) - await withFileLock(this.spec.filename, async () => { - // Read-modify-write: fold in any on-disk state this process has not - // observed yet — an external edit still inside the watcher debounce - // window, a change the watcher missed, or another process's write — - // so the line edit below can never resurrect a stale document. - await this.reconcileFromDisk() - const existing = this.values.get(ref) - if (value === undefined && existing === undefined) return - if (existing !== undefined && existing.includes('\n')) { - throw new Error( - `credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`, - ) - } - const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value)) - // 0600: a document holding secrets is never world-readable. - await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 }) - this.text = nextText - if (value === undefined) this.values.delete(ref) - else this.values.set(ref, value) - // After the commit: a broken observer must never make the durable - // write look failed (an INVARIANT failure still rethrows). - this.notifyUpdated(ref) - }, { - onStaleBreak: (lockPath) => { - this.ctx.logger.warn('credentials-local: breaking a stale writer lock at %s', lockPath) - }, - }) - }) - } - - /** Reject a write the live environment would shadow into apparent no-effect. */ - private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void { - const env = process.env[ref] - if (env !== undefined && env.length > 0) { - throw new Error( - `credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be` - + ' shadowed; change the launching environment instead', - ) - } - } - - /** Boot read: an absent file is an empty store; any other failure is loud. */ - private async loadInitial(): Promise { let text: string try { text = await readFile(this.spec.filename, 'utf8') } catch (error) { - if (!isENOENT(error)) throw error - return + if (isENOENT(error)) return undefined + throw error } - this.text = text - this.values = new Map(Object.entries(parse(text))) - } - - /* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and - reconcile policy: warn-and-keep on a reload, throw on a write, invariant - failures propagate. */ - /** - * Re-read the document after a watcher event. Unchanged content (including - * this provider's own writes) is a no-op; an unreadable document keeps the - * last good snapshot and warns — a live hot-reload must never take the - * process down. An invariant violation escaping the fan-out is not a reload - * failure and propagates to the queue's error surface. - */ - private async refresh(): Promise { - if (this.closed) return - try { - await this.reconcileFromDisk() - } catch (error) { - if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error - this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename) - this.ctx.logger.warn(error) - } - } - - /** - * Compare the on-disk text against the cache and publish any difference - * into the seam. Absence publishes the empty store; an unreadable file - * throws, so each caller picks its policy — a reload warns and keeps the - * last good snapshot, a write fails loud. dotenv parsing is lenient by - * design and cannot fail. - */ - private async reconcileFromDisk(): Promise { - let text: string | undefined - try { - text = await readFile(this.spec.filename, 'utf8') - } catch (error) { - if (!isENOENT(error)) throw error - text = undefined - } - if (text === this.text || this.isClosed()) return - const next = text === undefined ? new Map() : new Map(Object.entries(parse(text))) - const changed = this.changedRefs(this.values, next) - this.text = text - this.values = next - for (const ref of changed) this.notifyUpdated(ref) - } - /* jscpd:ignore-end */ - - /** Seam-addressable entries whose effective (non-empty) value changed. */ - private changedRefs(prev: Map, next: Map): CredentialRef[] { - const changed: CredentialRef[] = [] - for (const key of new Set([...prev.keys(), ...next.keys()])) { - const before = prev.get(key) - const after = next.get(key) - const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined - const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined - if (effectiveBefore === effectiveAfter) continue - try { - changed.push(credentialRef(key)) - } catch (_unaddressableKey) { - // A key that is not a POSIX identifier is preserved file content the - // seam cannot address, so no observer could ever see it change. - } - } - return changed + const stored = parse(text)[ref] + return stored === undefined || stored.length === 0 ? undefined : stored } } diff --git a/packages/credentials/credentials-local/src/invariant.ts b/packages/credentials/credentials-local/src/invariant.ts index 9ec75ed21d..024be1c4ea 100644 --- a/packages/credentials/credentials-local/src/invariant.ts +++ b/packages/credentials/credentials-local/src/invariant.ts @@ -15,8 +15,7 @@ export const name = 'credentials-local-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the seam companion (`dsh-credentials/invariant`) owns the - * `credentials/updated` lifecycle contract; this provider's file/environment layering is + * No runtime invariant: this provider's file/environment layering is * asynchronous I/O pinned by its unit suite. */ const install: InvariantInstaller = () => {} diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts deleted file mode 100644 index baefbd52c5..0000000000 --- a/packages/credentials/credentials-local/tests/drain.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { CredentialsLocal } from '../src/index.ts' - -// The atomic write is the gated asynchronous hold point inside a queued -// write; gating it makes the dispose-versus-queued-write race fully -// deterministic. The lock helper passes through so the gated operation still -// runs inside its real acquire/release cycle. -vi.mock('@deepseek-ai/dsh-atomic-write', async (importOriginal) => { - const actual = await importOriginal() - let gate: Promise = Promise.resolve() - return { - ...actual, - writeFileAtomic: vi.fn(() => gate), - __setGate: (next: Promise) => { - gate = next - }, - } -}) - -async function setGate(next: Promise): Promise { - const mocked = await import('@deepseek-ai/dsh-atomic-write') as unknown as { __setGate: (next: Promise) => void } - mocked.__setGate(next) -} - -const KEY = credentialRef('DSH_CRED_DRAIN_A') -const OTHER = credentialRef('DSH_CRED_DRAIN_B') - -const cleanups: Array<() => Promise> = [] - -afterEach(async () => { - await setGate(Promise.resolve()) - while (cleanups.length > 0) await cleanups.pop()!() -}) - -describe('write-drain teardown', () => { - it('lets the in-flight write land and fails the queued one after disposal', async () => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-')) - cleanups.push(() => rm(dir, { recursive: true, force: true })) - const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) - await fiber - const service = ctx.credentials - - let release!: () => void - await setGate(new Promise((resolveGate) => { - release = resolveGate - })) - const first = service.set(KEY, 'one') - // Let the first task pass its liveness checks and park on the gate, so it - // is genuinely in-flight when disposal begins. - await new Promise(resolvePause => setTimeout(resolvePause, 5)) - // Attach the rejection handler up front: the queued write fails while the - // drain is still awaited, before any later `await expect` could run. - const secondRejects = expect(service.set(OTHER, 'two')).rejects.toThrow(/disposed before the queued/) - const disposal = fiber.dispose() - // Give the drain disposer its first turn (set closed) before opening the gate. - await new Promise(resolvePause => setTimeout(resolvePause, 10)) - release() - await disposal - - await expect(first).resolves.toBeUndefined() - await secondRejects - expect(await service.resolve(KEY)).toEqual({ value: 'one', source: 'file' }) - expect(await service.resolve(OTHER)).toBeUndefined() - }) -}) diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 4ebaed1a0c..f4f18b101c 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -1,15 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { credentialRef } from '@deepseek-ai/dsh-credentials' -import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal, resolveSpec } from '../src/index.ts' const KEY = credentialRef('DSH_CRED_TEST') const OTHER = credentialRef('DSH_CRED_OTHER') - const cleanups: Array<() => Promise> = [] afterEach(async () => { @@ -23,222 +21,69 @@ async function tempDir(): Promise { return dir } -async function boot(config: ConstructorParameters[1]): Promise { +async function boot(path: string): Promise { const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, config) - cleanups.push(async () => { - await fiber.dispose() - }) - await fiber + await ctx.plugin(CredentialsLocal, { path }) + cleanups.push(async () => { await ctx.fiber.dispose() }) return ctx } -function updates(ctx: Context): CredentialRef[] { - const seen: CredentialRef[] = [] - ctx.on('credentials/updated', (ref) => { - seen.push(ref) - }) - return seen -} - describe('resolveSpec', () => { - it('defaults to .env under the harness home with watching on', () => { - const spec = resolveSpec({ dshHome: '/custom/home' }) - expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 }) + it('defaults to .env under the harness home', () => { + expect(resolveSpec({ dshHome: '/custom/home' })) + .toEqual({ filename: resolve('/custom/home/.env') }) }) it('lets an explicit path win over the home', () => { - const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 }) - expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 }) + expect(resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored' })) + .toEqual({ filename: resolve('/etc/dsh/creds.env') }) }) }) -describe('layering and reads', () => { - it('treats an absent file as an empty writable store', async () => { +describe('read-only resolution', () => { + it('treats an absent file as unconfigured', async () => { const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) + const ctx = await boot(join(dir, '.env')) expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) }) - it('serves file entries, including export-prefixed and quoted values', async () => { + it('parses export-prefixed, quoted, and multiline dotenv values', async () => { const dir = await tempDir() const path = join(dir, '.env') - await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n') - const ctx = await boot({ path, watch: false }) - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' }) - expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' }) - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true }) + await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="line one\nline two"\n') + const ctx = await boot(path) + expect(await ctx.credentials.resolve(KEY)).toBe('plain') + expect(await ctx.credentials.resolve(OTHER)).toBe('line one\nline two') }) - it('lets a non-empty process environment win read-only over the file', async () => { + it('reads the live environment first on every call', async () => { const dir = await tempDir() const path = join(dir, '.env') await writeFile(path, 'DSH_CRED_TEST=from-file\n') - const ctx = await boot({ path, watch: false }) + const ctx = await boot(path) vi.stubEnv('DSH_CRED_TEST', 'from-env') - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' }) - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false }) + expect(await ctx.credentials.resolve(KEY)).toBe('from-env') + vi.stubEnv('DSH_CRED_TEST', '') + expect(await ctx.credentials.resolve(KEY)).toBe('from-file') }) - it('treats empty values as absent in both layers', async () => { + it('re-reads the file on every call and treats empty values as absent', async () => { const dir = await tempDir() const path = join(dir, '.env') + await writeFile(path, 'DSH_CRED_TEST=first\n') + const ctx = await boot(path) + expect(await ctx.credentials.resolve(KEY)).toBe('first') + await writeFile(path, 'DSH_CRED_TEST=second\n') + expect(await ctx.credentials.resolve(KEY)).toBe('second') await writeFile(path, 'DSH_CRED_TEST=\n') - const ctx = await boot({ path, watch: false }) - vi.stubEnv('DSH_CRED_TEST', '') expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true }) }) - it('fails boot loud when the document exists but cannot be read', async () => { + it('surfaces non-absence read failures at resolution time', async () => { const dir = await tempDir() const path = join(dir, 'occupied') await mkdir(path) - const ctx = new Context() - await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow() - }) -}) - -describe('line-editing writes', () => { - it('appends a missing key to a fresh 0600 document and emits the commit', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - const seen = updates(ctx) - await ctx.credentials.set(KEY, 'sk-fresh') - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n') - expect((await stat(path)).mode & 0o777).toBe(0o600) - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' }) - expect(seen).toEqual([KEY]) - }) - - it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older') - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(KEY, 'new value!') - expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n') - }) - - it('quotes hostile values so they round-trip through a fresh provider', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - const singleQuoted = 'with "quote", back\\slash and space' - const doubleQuoted = "it's got an apostrophe" - await ctx.credentials.set(KEY, singleQuoted) - await ctx.credentials.set(OTHER, doubleQuoted) - const reread = await boot({ path, watch: false }) - expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' }) - expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' }) - }) - - it('fails loud on values no .env quoting style reads back verbatim', async () => { - const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) - await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/) - await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) - }) - - it('unsets only the owning line and keeps an absent unset silent', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n') - const ctx = await boot({ path, watch: false }) - const seen = updates(ctx) - await ctx.credentials.unset(KEY) - expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n') - await ctx.credentials.unset(KEY) - expect(seen).toEqual([KEY]) - }) - - it('rejects empty values, shadowed writes, and multi-line entries', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n') - const ctx = await boot({ path, watch: false }) - - await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/) - await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/) - await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/) - - vi.stubEnv('DSH_CRED_TEST', 'shadowing') - await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/) - await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/) - }) - - it('leaves an empty document after unsetting the only entry', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_TEST=only\n') - const ctx = await boot({ path, watch: false }) - await ctx.credentials.unset(KEY) - expect(await readFile(path, 'utf8')).toBe('') - }) - - it('chains past a rejected write so one bad value cannot poison the queue', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/) - const good = ctx.credentials.set(OTHER, 'lands') - await bad - await good - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n') - }) - - it('serializes concurrent writes so both land in the one document', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - await Promise.all([ - ctx.credentials.set(KEY, 'one'), - ctx.credentials.set(OTHER, 'two'), - ]) - expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n') - }) - - it('refuses writes after disposal', async () => { - const dir = await tempDir() - const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) - await fiber - // Capture the handle first: disposal also removes the ctx.credentials service. - const service = ctx.credentials - await fiber.dispose() - await expect(service.set(KEY, 'late')).rejects.toThrow(/disposed/) - }) -}) - -describe('real hot reload', () => { - it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - // Watching starts on an existing document: creation racing watcher setup - // is a chokidar readiness gap, not the reload contract under test. - await writeFile(path, 'DSH_CRED_TEST=boot\n') - const ctx = await boot({ path, debounceMs: 10 }) - const seen = updates(ctx) - - await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n') - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' }) - }) - - // Wholesale replacement: an entry deleted on disk never lingers in memory. - await writeFile(path, 'DSH_CRED_TEST=live\n') - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(OTHER)).toBeUndefined() - }) - - const before = seen.length - await ctx.credentials.set(KEY, 'self-written') - await new Promise(resolvePause => setTimeout(resolvePause, 200)) - // Exactly the committed write's own event: the watcher echo of our own - // content is recognized by the text cache and publishes nothing extra. - expect(seen.length).toBe(before + 1) - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'self-written', source: 'file' }) + const ctx = await boot(path) + await expect(ctx.credentials.resolve(KEY)).rejects.toThrow() }) }) diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts deleted file mode 100644 index 7583cf0813..0000000000 --- a/packages/credentials/credentials-local/tests/review-fixes.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -// Third-review behaviors: read-modify-write under the writer lock (external -// edits survive an API write), the contained credentials/updated fan-out (a -// broken observer never fails a committed write), and the physical-line -// editor's multi-line and CRLF discipline. -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { CredentialsLocal } from '../src/index.ts' - -const ALPHA = credentialRef('DSH_REVIEW_ALPHA') -const BETA = credentialRef('DSH_REVIEW_BETA') -const INNER = credentialRef('DSH_REVIEW_INNER') - -const cleanups: Array<() => Promise> = [] - -afterEach(async () => { - while (cleanups.length > 0) await cleanups.pop()!() -}) - -async function tempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-review-')) - cleanups.push(() => rm(dir, { recursive: true, force: true })) - return dir -} - -async function boot(config: ConstructorParameters[1]): Promise { - const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, config) - cleanups.push(async () => { await fiber.dispose() }) - await fiber - return ctx -} - -describe('read-modify-write', () => { - it('folds an unobserved external edit into a write instead of overwriting it', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - const seen: string[] = [] - ctx.on('credentials/updated', (ref) => { seen.push(ref) }) - await ctx.credentials.set(ALPHA, 'one') - // The external edit has landed on disk but no watcher reported it (watch - // is off — the same blind spot as a debounce window or a missed event). - await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`) - await ctx.credentials.set(ALPHA, 'two') - const text = await readFile(path, 'utf8') - expect(text).toContain(`${BETA}=external`) - expect(text).toContain(`${ALPHA}=two`) - // The fold published the unobserved entry before the write's own commit. - expect(seen).toEqual([ALPHA, BETA, ALPHA]) - expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' }) - }) - - it('keeps both refs when two providers write the same document concurrently', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const first = await boot({ path, watch: false }) - const second = await boot({ path, watch: false }) - await Promise.all([ - (async () => { for (const value of ['1', '2', '3'] as const) await first.credentials.set(ALPHA, value) })(), - (async () => { for (const value of ['1', '2', '3'] as const) await second.credentials.set(BETA, value) })(), - ]) - const third = await boot({ path, watch: false }) - expect(await third.credentials.resolve(ALPHA)).toEqual({ value: '3', source: 'file' }) - expect(await third.credentials.resolve(BETA)).toEqual({ value: '3', source: 'file' }) - }) - - it('breaks a stale writer lock with a warning and writes through', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - await writeFile(`${path}.lock`, 'crashed-holder\n') - const past = (Date.now() - 60_000) / 1000 - await utimes(`${path}.lock`, past, past) - await ctx.credentials.set(ALPHA, 'nine') - expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=nine`) - }) - - it('creates the credentials directory owner-only', async () => { - const dir = await tempDir() - const home = join(dir, 'home') - const ctx = await boot({ path: join(home, '.env'), watch: false }) - await ctx.credentials.set(ALPHA, 'one') - expect((await stat(home)).mode & 0o777).toBe(0o700) - }) -}) - -describe('contained update fan-out', () => { - it('does not fail a committed set when a listener throws, and later listeners still run', async () => { - const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) - ctx.on('credentials/updated', () => { - throw new Error('observer boom') - }) - const second = vi.fn() - ctx.on('credentials/updated', second) - await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined() - expect(second).toHaveBeenCalledWith(ALPHA) - expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' }) - }) - - it('contains an async listener rejection', async () => { - const dir = await tempDir() - const ctx = await boot({ path: join(dir, '.env'), watch: false }) - // An unknown-returning function keeps the typed surface legal while the - // runtime value is still the rejected promise the containment must handle. - const boom = (): unknown => Promise.reject(new Error('async observer boom')) - ctx.on('credentials/updated', boom) - await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined() - await new Promise(resolve => setTimeout(resolve, 10)) - }) - - it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, watch: false }) - ctx.on('credentials/updated', () => { - throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) - }) - const second = vi.fn() - ctx.on('credentials/updated', second) - await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/) - // Harness-fatal by design — but the write itself committed first. - expect(second).toHaveBeenCalledWith(ALPHA) - expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`) - expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' }) - }) -}) - -describe('physical-line editor', () => { - it('never mistakes a quoted multi-line continuation for an assignment', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n` - await writeFile(path, wrapped) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - // The wrapped value survives byte-for-byte; only ALPHA's line changed. - const afterAlpha = await readFile(path, 'utf8') - expect(afterAlpha).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n`) - // Setting the inner-looking ref appends a real assignment; the - // continuation line inside the quoted value stays untouched. - await ctx.credentials.set(INNER, 'real') - const afterInner = await readFile(path, 'utf8') - expect(afterInner).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n${INNER}=real\n`) - expect(await ctx.credentials.resolve(INNER)).toEqual({ value: 'real', source: 'file' }) - }) - - it('preserves CRLF line endings on untouched and edited lines', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`) - await ctx.credentials.set(INNER, 'new') - expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`) - }) - - it('terminates a final unterminated line before appending', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}=a`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(BETA, 'b') - expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`) - }) - - it('rewrites a final unterminated assignment in the dominant ending style', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}=a`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'b') - expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`) - }) - - it('tracks a single-quoted multi-line value through its continuation', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`) - const ctx = await boot({ path, watch: false }) - await ctx.credentials.set(ALPHA, 'x') - expect(await readFile(path, 'utf8')) - .toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`) - }) - - it('reports a multi-line entry as unwritable and refuses to edit it', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${ALPHA}="line1\nline2"\n`) - const ctx = await boot({ path, watch: false }) - expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false }) - await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/) - await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/) - // Resolution still serves the multi-line value. - expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' }) - }) -}) diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts deleted file mode 100644 index 6ff53252cf..0000000000 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { CredentialsLocal } from '../src/index.ts' - -// chokidar is the nondeterministic OS boundary: faking it lets these tests -// drive the event pipeline (error events, races with unreadable files) -// deterministically. Real end-to-end watching stays covered by local.spec.ts. -vi.mock('chokidar', async () => { - const { EventEmitter } = await import('node:events') - class FakeWatcher extends EventEmitter { - close = vi.fn(() => Promise.resolve()) - } - const instances: Array<{ path: string; options: unknown; watcher: InstanceType }> = [] - return { - watch: vi.fn((path: string, options: unknown) => { - const watcher = new FakeWatcher() - instances.push({ path, options, watcher }) - return watcher - }), - __instances: instances, - } -}) - -interface FakeChokidar { - __instances: Array<{ - path: string - options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } } - watcher: import('node:events').EventEmitter - }> -} - -async function fakeInstances(): Promise { - const chokidar = await import('chokidar') as unknown as FakeChokidar - return chokidar.__instances -} - -const KEY = credentialRef('DSH_CRED_PIPE') - -const cleanups: Array<() => Promise> = [] - -afterEach(async () => { - while (cleanups.length > 0) await cleanups.pop()!() - ;(await fakeInstances()).length = 0 -}) - -async function tempDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-watch-')) - cleanups.push(() => rm(dir, { recursive: true, force: true })) - return dir -} - -async function boot(config: ConstructorParameters[1]): Promise { - const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, config) - cleanups.push(async () => { - await fiber.dispose() - }) - await fiber - return ctx -} - -describe('watcher pipeline', () => { - it('clamps the write-settle poll interval for a zero debounce', async () => { - const dir = await tempDir() - await boot({ path: join(dir, '.env'), debounceMs: 0 }) - const [instance] = await fakeInstances() - expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 }) - }) - - it('survives a watcher error and keeps publishing later edits', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, debounceMs: 5 }) - const [instance] = await fakeInstances() - - instance!.watcher.emit('error', new Error('watch backend failure')) - expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - - await writeFile(path, 'DSH_CRED_PIPE=arrived\n') - instance!.watcher.emit('all', 'change', path) - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' }) - }) - }) - - it('keeps the last good snapshot when the file turns unreadable at runtime', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=good\n') - const ctx = await boot({ path, debounceMs: 5 }) - - await chmod(path, 0o000) - cleanups.push(() => chmod(path, 0o600)) - const [instance] = await fakeInstances() - instance!.watcher.emit('all', 'change', path) - // The warn-and-keep path is asynchronous; give the serialized refresh a turn. - await new Promise(resolve => setTimeout(resolve, 50)) - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' }) - }) - - it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, debounceMs: 5 }) - let arm = true - ctx.on('credentials/updated', () => { - if (!arm) return - throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' }) - }) - const [instance] = await fakeInstances() - - await writeFile(path, 'DSH_CRED_PIPE=first\n') - instance!.watcher.emit('all', 'change', path) - // The snapshot commits before the fan-out, so the value lands even though - // the listener threw out of the refresh. - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' }) - }) - - arm = false - await writeFile(path, 'DSH_CRED_PIPE=second\n') - instance!.watcher.emit('all', 'change', path) - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' }) - }) - }) - - it('quiesces the refresh pipeline before dispose completes', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=initial\n') - const ctx = new Context() - const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 }) - await fiber - let disposed = false - let postDisposeCommits = 0 - ctx.on('credentials/updated', () => { - if (disposed) postDisposeCommits += 1 - }) - - await writeFile(path, 'DSH_CRED_PIPE=changed\n') - const [instance] = await fakeInstances() - // Two queued refreshes: dispose interrupts one mid-flight and the other - // before it starts, so both closed guards must hold. - instance!.watcher.emit('all', 'change', path) - instance!.watcher.emit('all', 'change', path) - await fiber.dispose() - disposed = true - instance!.watcher.emit('all', 'change', path) - instance!.watcher.emit('ready') - await new Promise(resolve => setTimeout(resolve, 100)) - expect(postDisposeCommits).toBe(0) - }) - - it('empties the snapshot when the document is deleted and emits the removals', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'DSH_CRED_PIPE=doomed\n') - const ctx = await boot({ path, debounceMs: 5 }) - const seen: string[] = [] - ctx.on('credentials/updated', (ref) => { - seen.push(ref) - }) - - await rm(path) - const [instance] = await fakeInstances() - instance!.watcher.emit('all', 'unlink', path) - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - }) - expect(seen).toEqual([KEY]) - }) - - it('publishes only seam-addressable keys and preserves the rest untouched', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n') - const ctx = await boot({ path, debounceMs: 5 }) - const seen: string[] = [] - ctx.on('credentials/updated', (ref) => { - seen.push(ref) - }) - - await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n') - const [instance] = await fakeInstances() - instance!.watcher.emit('all', 'change', path) - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' }) - }) - // The dash-named key is preserved file content the seam cannot address: - // its change publishes nothing and breaks nothing. - expect(seen).toEqual([KEY]) - }) - - it('treats an event for a still-absent file as a no-op', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - const ctx = await boot({ path, debounceMs: 5 }) - const [instance] = await fakeInstances() - instance!.watcher.emit('all', 'add', path) - await new Promise(resolve => setTimeout(resolve, 50)) - expect(await ctx.credentials.resolve(KEY)).toBeUndefined() - }) - - it('reconciles at watcher ready so a change during setup is not missed', async () => { - const dir = await tempDir() - const path = join(dir, '.env') - await writeFile(path, `${KEY}=a\n`) - const ctx = await boot({ path, debounceMs: 5 }) - // Written after the initial load but before the watcher became active: - // no 'all' event will ever fire for it. - await writeFile(path, `${KEY}=written-before-ready\n`) - const [instance] = await fakeInstances() - instance!.watcher.emit('ready') - await vi.waitFor(async () => { - expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'written-before-ready', source: 'file' }) - }) - }) -}) diff --git a/packages/credentials/credentials-local/tsconfig.json b/packages/credentials/credentials-local/tsconfig.json index 3acfbdeffe..57e58c3830 100644 --- a/packages/credentials/credentials-local/tsconfig.json +++ b/packages/credentials/credentials-local/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../util/atomic-write" - }, { "path": "../../util/paths" }, diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml index 10fe5f0ffe..40c2f2c67e 100644 --- a/packages/credentials/credentials/README.i18n.yaml +++ b/packages/credentials/credentials/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/credentials/README.md -README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc -README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6 +README.md: 1070f21c11a7ce87a7d901a235edbd3d0b0a8da1 +README.zh.md: 0787a6b93daeeb7a49b2230fba309e2bd7a508d9 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index 1c18c47623..1070f21c11 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -2,13 +2,7 @@ English | [中文](README.zh.md) -Abstract credential seam (`ctx.credentials`). One doctrine, three consequences: - -**Configuration carries references to secrets, never the secrets.** A settings section or `cordis.yml` entry says `apiKeyEnv: DEEPSEEK_API_KEY`; the value behind that reference lives with a credential provider. So the settings document stays safe to sync and to render in a configuration UI, `describe()` can answer "is this configured, where from, can I write it" without ever holding a value, and rotating a secret touches no configuration file. - -**Consumers resolve per operation.** `resolve(ref)` is called at the start of each operation (the LLM adapters resolve once per model request) and never cached across operations — that read is what makes a changed credential reach the very next request without restarting any plugin. - -**An empty stored value is absent.** Everywhere: `resolve` skips it, `describe` reports it unconfigured. A blank can never masquerade as a configured secret. +Abstract read-only credential seam (`ctx.credentials`). Configuration carries a branded reference such as `DEEPSEEK_API_KEY`; the provider owns the value, and the consumer resolves it only when starting an operation. ## Surface @@ -18,20 +12,15 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials' declare const ctx: Context -const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded -const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined -const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value -await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref -await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule +const ref = credentialRef('DEEPSEEK_API_KEY') +const value = await ctx.credentials.resolve(ref) // string | undefined ``` -`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. - -The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front. +`credentialRef()` accepts POSIX-style environment-variable names and brands them so references do not mix with unrelated cross-package strings. `resolve(ref)` returns the current non-empty value or `undefined`. Consumers resolve once per operation and do not cache across operations; mutation, source metadata, enumeration, and change events stay out of the seam until a current consumer requires them. ## Providers -[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets. +[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. Other providers may resolve the same reference vocabulary from a keyring, helper command, or KMS without changing consumers. ## Model Experience @@ -43,6 +32,5 @@ No direct invalidation; credentials never enter a request prefix. ## Known Limitations and Deferred Work -- **No enumeration** — the seam answers questions about references it is given; configuration surfaces learn the references from settings schemas, so a `list()` has no current consumer. -- **References are environment-variable-shaped** — one flat POSIX-identifier namespace until a provider needs richer addressing. -- **Process-environment changes are invisible** — no event can fire for them; a UI only re-reads `describe()` on its own navigation. +- **No mutation, description, or enumeration** — the seam only resolves references already named by consumer configuration; a credential-management UI requires its own justified contract. +- **References are environment-variable-shaped** — one flat POSIX-identifier namespace remains sufficient for current consumers. diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index 751fb7c1e8..0787a6b93d 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -2,13 +2,7 @@ [English](README.md) | 中文 -抽象凭据 seam(`ctx.credentials`)。一条准则,三个推论: - -**配置只携带对机密的引用,绝不携带机密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答「配置了吗、来自哪层、能否写入」;轮换机密不触碰任何配置文件。 - -**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM 适配器每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。 - -**空的存储值等于不存在。**处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的机密。 +抽象的只读凭据 seam(`ctx.credentials`)。配置携带 `DEEPSEEK_API_KEY` 这样的品牌化引用;值归提供方所有,消费方只在操作开始时解析它。 ## 接口面 @@ -18,24 +12,19 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials' declare const ctx: Context -const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded -const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined -const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value -await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref -await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule +const ref = credentialRef('DEEPSEEK_API_KEY') +const value = await ctx.credentials.resolve(ref) // string | undefined ``` -`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 - -`set`/`unset` 的遮蔽规则是刻意的响亮失败:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 +`credentialRef()` 接受 POSIX 风格的环境变量名并为其添加品牌类型,使引用不会与其他跨包(package)字符串混用。`resolve(ref)` 返回当前非空值,未配置时返回 `undefined`。消费方每个操作解析一次,不跨操作缓存;在当前消费方需要之前,seam 不引入修改、来源元数据、枚举或变更事件。 ## Providers -[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。 +[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。其他提供方可以从 keyring、辅助命令或 KMS 解析相同的引用词汇,而无需改动消费方。 ## Model Experience -经由消费它的 LLM 适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 +经由消费它的 LLM(大语言模型)适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。 #### KV Cache effect @@ -43,6 +32,5 @@ await ctx.credentials.unset(ref) // no-op when absent; s ## Known Limitations and Deferred Work -- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费方。 -- **引用限定为环境变量形状**——在有 provider 需要更丰富寻址前,保持单一扁平的 POSIX 标识符命名空间。 -- **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`。 +- **不提供修改、描述或枚举**:seam 只解析消费方配置已经点名的引用;凭据管理 UI 需要自身有明确依据的契约。 +- **引用限定为环境变量形状**:单一扁平的 POSIX 标识符命名空间足以满足当前消费方。 diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index b640b42881..51cdd12279 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -1,10 +1,6 @@ /** - * Credential seam (`ctx.credentials`). Settings and composition files carry - * *references* to secrets — environment-variable names — while providers own - * the actual values and their storage. Consumers resolve a reference once per - * operation, so a changed credential reaches the next operation without any - * plugin restart, and configuration surfaces describe a reference without - * ever seeing its value. + * Read-only credential seam (`ctx.credentials`). Configuration carries + * branded references to secrets; providers resolve their current values. * @module @deepseek-ai/dsh-credentials */ @@ -28,135 +24,25 @@ export function credentialRef(value: string): CredentialRef { return value as CredentialRef } -/** One resolved credential value and the source layer that supplied it. */ -export interface ResolvedCredential { - /** The non-empty secret value. */ - value: string - /** Provider-defined source layer id (the local provider uses `env` and `file`). */ - source: string -} - -/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ -export interface CredentialInfo { - /** Whether {@link Credentials.resolve} would currently return a value. */ - configured: boolean - /** Source layer currently supplying the value; absent while unconfigured. */ - source?: string - /** Whether {@link Credentials.set} would currently succeed for this reference. */ - writable: boolean -} - declare module 'cordis' { interface Context { credentials: Credentials } - - interface Events { - /** - * Committed change to a provider-managed credential source: a `set`, an - * `unset`, or an external edit observed in storage. Ambient - * process-environment changes are not observable and never emit. Listener - * failures are contained and logged — a sync throw and an async rejection - * alike — without changing the committed operation's outcome, except - * `INVARIANT`-coded failures, which rethrow after every listener ran; - * that rethrow reaches the emitter only from synchronous listeners, so - * invariant checks on this event must not be async functions. - * @param ref - the reference whose stored value changed. - * @mode emit - */ - 'credentials/updated'(ref: CredentialRef): void - } } -/** - * Abstract credential service. Providers implement the four operations over - * their source layers; one seam-wide rule binds them all: an empty stored - * value is absent everywhere — `resolve` skips it, `describe` reports it - * unconfigured — so a blank never masquerades as a configured secret. - */ +/** Abstract read-only credential service. */ export abstract class Credentials extends Service { constructor(ctx: Context) { super(ctx, 'credentials') } /** - * Resolve one reference to its current value. Resolution is per call: - * consumers re-resolve at each operation and must not cache across - * operations — that per-operation read is what makes a changed credential - * reach the next operation without a restart. + * Resolve one reference to its current non-empty value. Consumers call once + * per operation and do not cache across operations. * @param ref - the reference to resolve. - * @returns the value and its source, or `undefined` while unconfigured. + * @returns the current value, or `undefined` while unconfigured. */ - abstract resolve(ref: CredentialRef): Promise - - /** - * Describe one reference for configuration surfaces without exposing the - * value. - * @param ref - the reference to describe. - * @returns configured state, supplying source, and writability. - */ - abstract describe(ref: CredentialRef): Promise - - /** - * Durably store one value in the provider-managed writable source. Rejects - * while a read-only source shadows the reference — the write would appear - * to succeed while resolution keeps returning the shadowing value — and - * rejects an empty value (use {@link unset}). - * @param ref - the reference to store. - * @param value - the non-empty secret value. - */ - abstract set(ref: CredentialRef, value: string): Promise - - /** - * Remove one reference from the provider-managed writable source; removing - * an absent reference is a no-op. Rejects while a read-only source shadows - * the reference, like {@link set}. - * @param ref - the reference to remove. - */ - abstract unset(ref: CredentialRef): Promise - - /* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit - fan-out: the contained-dispatch shape is the reviewed listener-lifecycle - contract, and extracting it would couple the two seams' event semantics. */ - /** - * Fan `credentials/updated` out with contained listener failures: every - * listener runs, and a sync throw or async rejection is logged without - * changing the committed operation's outcome — except `INVARIANT`-coded - * failures, which rethrow after every listener ran (the rethrow reaches the - * caller only from synchronous listeners, so invariant checks on this event - * must not be async functions). Providers call this only after the write or - * reload actually committed, so a broken observer can never make a durable - * change look failed. - * @param ref - the reference whose stored value changed. - */ - protected notifyUpdated(ref: CredentialRef): void { - let invariantFailure: unknown - const args = ['credentials/updated', ref] - for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { - try { - const returned = listener(ref) - if (returned != null && typeof (returned as PromiseLike).then === 'function') { - void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { - this.warnListenerFailure(ref, error) - }) - } - } catch (error) { - if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { - invariantFailure ??= error - continue - } - this.warnListenerFailure(ref, error) - } - } - if (invariantFailure !== undefined) throw invariantFailure as Error - } - /* jscpd:ignore-end */ - - /** Contained-listener diagnostic shared by the sync and async failure paths. */ - private warnListenerFailure(ref: CredentialRef, error: unknown): void { - this.ctx.logger.warn('credentials: a credentials/updated listener for "%s" failed', ref) - this.ctx.logger.warn(error) - } + abstract resolve(ref: CredentialRef): Promise } export default Credentials diff --git a/packages/credentials/credentials/src/invariant.ts b/packages/credentials/credentials/src/invariant.ts index 23c2dda45b..ae8de8976c 100644 --- a/packages/credentials/credentials/src/invariant.ts +++ b/packages/credentials/credentials/src/invariant.ts @@ -4,7 +4,7 @@ */ import type { Context } from 'cordis' -import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-credentials' @@ -14,20 +14,11 @@ export const name = 'credentials-invariant' export const inject = ['invariants'] /** - * Install the commit-event lifecycle contract: `credentials/updated` names a - * committed provider-source change, so it can only fire while a credentials - * service is live — an emission after disposal means a provider leaked work - * past its teardown quiescence. The value relation itself (`describe` - * agreeing with `resolve`) is asynchronous provider I/O and stays pinned by - * each provider's own suite. + * No runtime invariant: this read-only seam exposes no event sequence or + * mutable data relation; provider resolution crosses an asynchronous I/O + * boundary and stays pinned by each provider's own suite. */ -const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => { - ctx.on('credentials/updated', (ref) => { - if (ctx.get('credentials') === undefined) { - fail(`credentials/updated for "${ref}" emitted without a live credentials service`) - } - }) -} +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. diff --git a/packages/credentials/credentials/tests/credentials.spec.ts b/packages/credentials/credentials/tests/credentials.spec.ts index 9b4cf7b1e8..0b32b59fbe 100644 --- a/packages/credentials/credentials/tests/credentials.spec.ts +++ b/packages/credentials/credentials/tests/credentials.spec.ts @@ -1,17 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { credentialRef } from '../src/index.ts' -import type { CredentialRef } from '../src/index.ts' import { MemoryCredentials } from './memory.ts' const REF = credentialRef('DEEPSEEK_API_KEY') -async function boot(seed: Record = {}): Promise { - const ctx = new Context() - await ctx.plugin(MemoryCredentials, seed) - return ctx -} - describe('credentialRef', () => { it('brands POSIX shell identifiers', () => { expect(credentialRef('DEEPSEEK_API_KEY')).toBe('DEEPSEEK_API_KEY') @@ -26,39 +19,17 @@ describe('credentialRef', () => { }) }) -describe('the credentials seam through the memory provider', () => { - it('mounts as ctx.credentials and resolves a seeded reference with its source', async () => { - const ctx = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' }) - expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-seeded', source: 'memory' }) - expect(await ctx.credentials.describe(REF)).toEqual({ configured: true, source: 'memory', writable: true }) +describe('the credentials seam', () => { + it('mounts as ctx.credentials and resolves non-empty values', async () => { + const ctx = new Context() + await ctx.plugin(MemoryCredentials, { DEEPSEEK_API_KEY: 'sk-seeded' }) + expect(await ctx.credentials.resolve(REF)).toBe('sk-seeded') }) - it('treats an empty stored value as absent everywhere', async () => { - const ctx = await boot({ DEEPSEEK_API_KEY: '' }) + it('treats an empty provider value as absent', async () => { + const ctx = new Context() + await ctx.plugin(MemoryCredentials, { DEEPSEEK_API_KEY: '' }) expect(await ctx.credentials.resolve(REF)).toBeUndefined() - expect(await ctx.credentials.describe(REF)).toEqual({ configured: false, writable: true }) - }) - - it('stores through set, removes through unset, and emits the committed change', async () => { - const ctx = await boot() - const events: CredentialRef[] = [] - ctx.on('credentials/updated', ref => void events.push(ref)) - - await ctx.credentials.set(REF, 'sk-live') - expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-live', source: 'memory' }) - await ctx.credentials.unset(REF) - expect(await ctx.credentials.resolve(REF)).toBeUndefined() - expect(events).toEqual([REF, REF]) - }) - - it('rejects an empty set and keeps an absent unset silent', async () => { - const ctx = await boot() - const events: CredentialRef[] = [] - ctx.on('credentials/updated', ref => void events.push(ref)) - - await expect(ctx.credentials.set(REF, '')).rejects.toThrow(/empty value/) - await ctx.credentials.unset(REF) - expect(events).toEqual([]) }) it('removes the service with its fiber', async () => { diff --git a/packages/credentials/credentials/tests/invariant.spec.ts b/packages/credentials/credentials/tests/invariant.spec.ts index dccde4843f..544b26c707 100644 --- a/packages/credentials/credentials/tests/invariant.spec.ts +++ b/packages/credentials/credentials/tests/invariant.spec.ts @@ -1,30 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import InvariantService from '@deepseek-ai/dsh-invariants' -import { credentialRef } from '../src/index.ts' import * as CredentialsInvariant from '../src/invariant.ts' -import { MemoryCredentials } from './memory.ts' - -const REF = credentialRef('DEEPSEEK_API_KEY') describe('credentials invariant companion', () => { - it('accepts a committed change emitted by a live service', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService) - await ctx.plugin(CredentialsInvariant) - await ctx.plugin(MemoryCredentials) - - await expect(ctx.credentials.set(REF, 'sk-live')).resolves.toBeUndefined() - }) - - it('fails an update event emitted without a live service', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService) - await ctx.plugin(CredentialsInvariant) - - expect(() => { ctx.emit('credentials/updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/) - }) - it('reserves the package name against duplicate registration', async () => { const ctx = new Context() await ctx.plugin(InvariantService) diff --git a/packages/credentials/credentials/tests/memory.ts b/packages/credentials/credentials/tests/memory.ts index dc1ed77a06..615bae351e 100644 --- a/packages/credentials/credentials/tests/memory.ts +++ b/packages/credentials/credentials/tests/memory.ts @@ -1,11 +1,8 @@ import type { Context } from 'cordis' import { Credentials } from '../src/index.ts' -import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/index.ts' +import type { CredentialRef } from '../src/index.ts' -/** - * In-memory credentials provider for interface and consumer tests: one - * always-writable `memory` source seeded from plugin config. - */ +/** In-memory read-only credentials provider for seam tests. */ export class MemoryCredentials extends Credentials { private readonly store = new Map() @@ -14,36 +11,8 @@ export class MemoryCredentials extends Credentials { for (const [key, value] of Object.entries(seed)) this.store.set(key, value) } - override resolve(ref: CredentialRef): Promise { + override resolve(ref: CredentialRef): Promise { const value = this.store.get(ref) - return Promise.resolve(value === undefined || value.length === 0 - ? undefined - : { value, source: 'memory' }) - } - - override describe(ref: CredentialRef): Promise { - const value = this.store.get(ref) - const configured = value !== undefined && value.length > 0 - return Promise.resolve({ - configured, - ...configured ? { source: 'memory' } : {}, - writable: true, - }) - } - - override set(ref: CredentialRef, value: string): Promise { - if (value.length === 0) { - return Promise.reject(new Error('memory credentials: an empty value cannot be stored; use unset')) - } - this.store.set(ref, value) - this.ctx.emit('credentials/updated', ref) - return Promise.resolve() - } - - override unset(ref: CredentialRef): Promise { - if (this.store.delete(ref)) { - this.ctx.emit('credentials/updated', ref) - } - return Promise.resolve() + return Promise.resolve(value === undefined || value.length === 0 ? undefined : value) } } diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index e02f994fef..402bc1e164 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: ab44b61e300ca65cc4dd3507ad7262cd08edcfce -README.zh.md: 4ecaf361fdb396f9f8079476240b5e9353a73f5e +README.md: 8532dab4731e25b4af777217ad7c5ffad521d924 +README.zh.md: 922fb50ae063ae0a1f90db3b915dc55096448a5c diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index ab44b61e30..8532dab473 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -47,12 +47,12 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und ## Dynamic configuration (settings + credentials) -Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: +Request 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 take effect on the next operation, while an in-flight stream keeps the facts it started with. The `deepseek` route and its retry policy remain fixed by the plugin composition. Two optional seams feed the request facts: -- **`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.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`. Without a mounted settings service the entry config alone drives the adapter. 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 request 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 rejected settings snapshot contributes neither its endpoint nor its key. A request with no key anywhere fails with `MISSING_CREDENTIAL`; after the operator supplies the named environment or dotenv value, the next request resolves it without a restart. -The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek')` always reports the current policy. +`ctx.llm.providerRetryPolicy('deepseek')` reports the policy captured from the composition entry at registration. ## App attribution @@ -72,7 +72,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` ## Testing -Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, retry-policy re-registration), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. +Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, and composition-fixed retry policy), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. ## Model Experience @@ -107,7 +107,7 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. -- **`Config.apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. +- **`Config.apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). - **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 4ecaf361fd..922fb50ae0 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -47,12 +47,12 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 动态配置(settings + credentials) -连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: +请求事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次操作生效,进行中的流则保持其起始事实。`deepseek` 路由及其重试策略始终由插件组合固定。两个可选 seam 为请求事实供值: -- **`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.settings`**:插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`。未挂载 settings 服务时,仅由 entry 配置驱动适配器。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用的请求事实并记录失败;entry 配置本身仍会使插件加载失败。 +- **`ctx.credentials`**:API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后仅在未挂载 seam 时读取原始环境变量。由于凭据事实与连接事实同行,被拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;操作者为点名的环境变量或 dotenv 值供值后,下一次请求无需重启即可解析它。 -唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek')` 始终报告当前策略。 +`ctx.llm.providerRetryPolicy('deepseek')` 报告注册时从组合配置项捕获的策略。 ## 应用归因 @@ -72,7 +72,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 测试 -单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 +单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照,以及由组合固定的重试策略),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 ## 模型体验 @@ -107,7 +107,7 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 - **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 -- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 +- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 - **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。 diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 6623351774..0801f36747 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -6,8 +6,7 @@ * key through the optional credential seam (`ctx.credentials`), so a changed * base URL, catalog, or key reaches the very next request without restarting * anything, while an in-flight stream keeps the facts it started with. The - * one registration-captured fact — the retry policy — re-registers the route - * in place when it changes. + * registration-captured facts stay composition-fixed. * @module @deepseek-ai/dsh-llm-deepseek */ @@ -16,7 +15,7 @@ import z from 'schemastery' import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' @@ -167,12 +166,13 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { } export function apply(ctx: Context, config: Config): void { + const compositionOptions = resolveAdapterOptions(config) let current: () => Config = () => config - let lastRaw: Config | undefined - let lastGood: ResolvedDeepSeekOptions | undefined + let lastRaw: Config = config + let lastGood = compositionOptions const options = (): ResolvedDeepSeekOptions => { const raw = current() - if (raw === lastRaw && lastGood !== undefined) return lastGood + if (raw === lastRaw) return lastGood try { const next = resolveAdapterOptions(raw) lastRaw = raw @@ -182,14 +182,12 @@ export function apply(ctx: Context, config: Config): void { // Static composition resolves before anything registers, so this branch // only sees a live settings snapshot failing a beyond-schema bound: // keep serving the last good facts and say so once per bad snapshot. - if (lastGood === undefined) throw error lastRaw = raw ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section') ctx.logger.error(error) return lastGood } } - options() const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { // Every credential fact comes from the caller's snapshot, so a rejected @@ -199,7 +197,7 @@ 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 hit } else { // Without the seam, keep the historical ambient fallback so a plain // cordis.yml composition works from the environment alone. @@ -207,33 +205,18 @@ export function apply(ctx: Context, config: Config): void { if (ambient !== undefined && ambient.length > 0) return ambient } throw new LlmError( - `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` - + ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a` - + ' last resort — set a literal "apiKey" in the llm-deepseek settings section', + `llm-deepseek: no API key for provider route "${PROVIDER}"; provide ${ref} through the credential` + + ' provider or launching environment, or set a literal "apiKey" in the llm-deepseek settings section', 'MISSING_CREDENTIAL', ) } const adapter = new DeepSeekAdapter({ options, resolveApiKey }) - // Route effects bind to this apply fiber via the stable `ctx` reference, - // even when a swap runs inside the scoped settings callback below. - let disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter) - let registeredPolicy = options().retryPolicy - const ensureRegistrationFacts = (): void => { - const policy = options().retryPolicy - if (deepEqualJson(policy, registeredPolicy)) return - // The registry captures the retry policy at registration, so it is the one - // fact per-request resolution cannot refresh: swap the registration in one - // synchronous section (same adapter instance, no NO_ADAPTER window). - disposeRoute() - disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter) - registeredPolicy = policy - } + ctx.llm.registerAdapter([PROVIDER], adapter) installSettingsSection(ctx, NS, Config, config, { setSource: (source) => { current = source }, - onChange: ensureRegistrationFacts, }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index e59af1185d..a1c050414b 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -69,7 +69,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmService) - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env') }) await ctx.plugin(LlmDeepSeek, {}) const result = await assemble(ctx, { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index aec9229e25..811b9da1c6 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -817,10 +817,9 @@ describe('plugin registration and config', () => { await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) - // The guidance leads with the credential store — the path that keeps the - // secret out of configuration files — and mentions a literal key last. + // The guidance names real external sources and the literal escape hatch. await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s) + .rejects.toThrow(/provide DEEPSEEK_API_KEY through the credential provider.*"apiKey"/s) }) it('reads the ambient variable when no credentials seam is mounted', async () => { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 3cd430ec14..9a89734c60 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -4,7 +4,6 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' -import { credentialRef } from '@deepseek-ai/dsh-credentials' import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { SettingsLocal } from '@deepseek-ai/dsh-settings-local' @@ -13,7 +12,6 @@ import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const NS = settingsNamespace('llm-deepseek') -const KEY_REF = credentialRef('DEEPSEEK_API_KEY') const cleanups: Array<() => Promise> = [] @@ -36,9 +34,8 @@ interface Harness { /** * Real dynamic composition: llm + settings-local + credentials-local + - * llm-deepseek over one temp harness home. `watch: false` keeps every change - * flowing through the in-process write path, which is deterministic; external - * file watching is the providers' own covered concern. + * llm-deepseek over one temp harness home. Settings updates use their owning + * write path; credentials are edited externally and read on demand. */ async function boot(dir: string, config: object): Promise { const ctx = new Context() @@ -48,7 +45,7 @@ async function boot(dir: string, config: object): Promise { await ctx.plugin(LlmService) const settingsFiber = ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) await settingsFiber - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env') }) await ctx.plugin(LlmDeepSeek, config) return { ctx, settingsFiber } } @@ -70,7 +67,7 @@ describe('request-level dynamic configuration', () => { expect(serverA.headers[0]?.authorization).toBe('Bearer first-key') await ctx.settings.update(NS, { baseURL: serverB.url }) - await ctx.credentials.set(KEY_REF, 'second-key') + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=second-key\n') await prompt(ctx) // No restart, no re-registration: the next request resolved both facts. @@ -97,7 +94,7 @@ describe('request-level dynamic configuration', () => { const { ctx } = await boot(dir, { baseURL: server.url }) await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) - await ctx.credentials.set(KEY_REF, 'sk-arrived') + await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=sk-arrived\n') await prompt(ctx) expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') }) @@ -113,12 +110,19 @@ describe('request-level dynamic configuration', () => { ]) }) - it('re-registers the route in place when the captured retry policy changes', async () => { + it('keeps the registration retry policy composition-fixed', async () => { const dir = await home() - const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) + const { ctx } = await boot(dir, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, + }) await ctx.settings.update(NS, { - retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, + retryPolicy: { mode: 'normal', maxRetries: 0 }, }) expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({ mode: 'always', diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 402f94441d..8395d007af 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -17,7 +17,6 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import LlmService from '@deepseek-ai/dsh-llm' -import { credentialRef } from '@deepseek-ai/dsh-credentials' import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import SettingsLocal from '@deepseek-ai/dsh-settings-local' @@ -26,7 +25,6 @@ import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const NS = settingsNamespace('llm-deepseek') -const KEY_REF = credentialRef('DEEPSEEK_API_KEY') let root: string | undefined let context: Context | undefined @@ -69,7 +67,6 @@ async function loadComposition( " name: '@deepseek-ai/dsh-credentials-local'", ' config:', ` path: ${JSON.stringify(envPath)}`, - ' debounceMs: 10', ] : [], '- id: llm-deepseek', @@ -117,22 +114,19 @@ describe('llm-deepseek real dynamic composition', () => { await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key') - // External edits, exactly as a user or the web UI would leave them on disk. + // External edits, exactly as a user would leave them on disk. await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`) await vi.waitFor(() => { expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) }, { timeout: 5000 }) await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n') - await vi.waitFor(async () => { - expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' }) - }, { timeout: 5000 }) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(serverA.requests).toHaveLength(1) expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key') }) - it('keeps a stored key writable and rotatable across a real restart', async () => { + it('reads a stored key and an external rotation across a real restart', async () => { // No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist // $DSH_HOME/.env into process.env, so a stored key must stay file-sourced. vi.stubEnv('DEEPSEEK_API_KEY', '') @@ -140,23 +134,15 @@ describe('llm-deepseek real dynamic composition', () => { const second = await mockServer([{ kind: 'sse', events: textEvents }]) const boot = await loadComposition({ withDynamic: true, baseURL: first.url }) const home = root! - await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui') - expect(await boot.ctx.get('credentials')!.describe(KEY_REF)) - .toEqual({ configured: true, source: 'file', writable: true }) + await writeFile(boot.envPath, 'DEEPSEEK_API_KEY=stored-directly\n') await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui') + expect(first.headers[0]?.authorization).toBe('Bearer stored-directly') await boot.ctx.fiber.dispose() context = undefined // Restart over the same harness home. const restarted = await loadComposition({ withDynamic: true, baseURL: second.url, reuseRoot: home }) - const credentials = restarted.ctx.get('credentials')! - // The stored key is still the provider's own writable file entry — not a - // read-only launch override, which is what hoisting it would have made it. - expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' }) - expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true }) - // Rotation still works after the restart, and the next request uses it. - await credentials.set(KEY_REF, 'rotated-after-restart') + await writeFile(restarted.envPath, 'DEEPSEEK_API_KEY=rotated-after-restart\n') await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart') }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 25e825eead..9af6c5b499 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: 0099c9acd39cd2d471936505726d68423f351c76 -README.zh.md: 7cb4f5fcbc1c7a67b77d690031cc7d553569433f +README.md: e8b7adf122946fc22f231fafb521866cbacdc652 +README.zh.md: 5bbf034267f6e276bc6552c5e731f6369cd97aee diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 0099c9acd3..e8b7adf122 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -35,13 +35,13 @@ Configure credentials and deployment-specific transport settings per provider, k X-Deployment: production ``` -Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. +Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. Composition must provide at least one route. Registration with `ctx.llm` is all-or-nothing: a collision with any route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. ## Dynamic configuration (settings + credentials) -The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. +The adapter reads its profiles through a thunk **once per operation** instead of freezing request facts 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`. The user layer can override request-level fields of a composition route, such as its endpoint, credential reference, headers, or transport controls, effective on the next operation. Provider routes and retry policies remain composition-fixed; a settings snapshot that changes either is rejected as one generation. Without a mounted settings service the entry config alone drives the adapter. -Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. +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. A live settings snapshot that changes registration facts, names an unknown provider, or fails another resolver bound keeps the last good profiles and logs the failure; the entry config itself fails plugin load. The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. @@ -77,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata ## Testing -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. `tests/loader-composition.spec.ts` boots the dormant posture from a test-only `cordis.yml` through the actual Loader and registers its route from an on-disk `settings.yaml` edit. Real-API coverage remains key-gated under `pnpm run test:e2e`. +Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: endpoint and `apiKeyEnv` changes reach later requests while routes and retry policy stay composition-fixed. `tests/loader-composition.spec.ts` boots that chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage remains key-gated under `pnpm run test:e2e`. ## Model Experience @@ -111,8 +111,8 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work -- **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. -- **`apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface. +- **Settings cannot add or remove routes** — provider ownership and retry policy are composition facts; the user layer can only change request-level fields of existing routes. +- **`apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields. - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 7cb4f5fcbc..5bbf034267 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -35,13 +35,13 @@ X-Deployment: production ``` -每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 +每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。组合必须提供至少一条路由。向 `ctx.llm` 注册要么全部成功,要么全部不生效:如果与另一适配器已拥有的任何路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 ## 动态配置(settings + credentials) -适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 +适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结请求事实。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`。用户层可以覆盖组合路由的请求级字段,例如端点、凭据引用、标头或传输控制项,并在下一次操作生效。提供方路由与重试策略始终由组合固定;settings 快照若更改任一项,就会整代被拒绝。未挂载 settings 服务时,仅由 entry 配置驱动适配器。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile(仅限这一种情况),才交给 pi-ai 的环境发现。存活 settings 快照若更改注册事实、点名未知提供方或违反其他 resolver 约束,则保留最后可用 profile 并记录失败;entry 配置本身会使插件加载失败。 适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 @@ -77,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK ## 测试 -单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起休眠姿态,并从磁盘上的一次 `settings.yaml` 编辑注册出它的路由。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 +单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:端点与 `apiKeyEnv` 变更会作用于后续请求,而路由与重试策略始终由组合固定。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起该链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 ## 模型体验 @@ -111,8 +111,8 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 -- **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 -- **`apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。 +- **settings 无法新增或移除路由**:提供方所有权与重试策略属于组合事实;用户层只能更改现有路由的请求级字段。 +- **`apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。 - **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 053d6d56e6..a298873b28 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -1,7 +1,7 @@ /** * Configuration schema and provider-profile validation for the pi-ai adapter. * Profiles are a dict keyed by provider route, so the composition base and a - * user-settings layer merge per provider and the route set is structural. + * user-settings layer merge per provider. * * @module dsh-llm-pi-ai/config */ @@ -60,12 +60,8 @@ export interface ResolvedPiAiProviderProfile extends Omit + /** Non-empty pi-ai provider routes, keyed by provider and fixed by composition. */ + providers: Record } const thinkingBudgets = z.object({ @@ -92,24 +88,24 @@ const profile = z.object({ /** Runtime schema for {@link Config}. */ export const Config: z = z.object({ - providers: z.dict(profile).default({}), + providers: z.dict(profile).required(), }) /** * Validate profiles against the installed pi-ai catalog and return a detached * route-keyed map suitable for per-request reads. This is the one explicit - * resolve step, so an omitted dict resolves to the empty (dormant) route set - * here rather than through a hidden fallback. + * resolve step; a composition must name at least one route. * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ export function resolveProfiles( - providers: Readonly> | undefined, + providers: Readonly>, ): Map { if (Array.isArray(providers)) { throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles') } - const entries = Object.entries(providers ?? {}) + const entries = Object.entries(providers) + if (entries.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') const supported = new Set(getBuiltinProviders()) const resolved = new Map() for (const [provider, source] of entries) { diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 4c610cae21..46a4bf01f1 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -3,10 +3,9 @@ * provider routes; requests select a profile by provider and resolve the * model dynamically from pi-ai's installed catalog. Profile facts resolve per * request over the optional `llm-pi-ai` user-settings section and the - * optional credential seam, so a changed key, endpoint, or knob reaches the - * next request without a restart; a changed *route set* (or a route's - * registration-captured retry policy) re-registers the same adapter instance - * in place. + * optional credential seam, so a changed key, endpoint, or request knob + * reaches the next request without a restart. Provider routes and retry + * policies stay composition-fixed. * * ```yaml * - id: llm @@ -30,7 +29,6 @@ import type { Context } from 'cordis' import { LlmError } from '@deepseek-ai/dsh-llm' -import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { Config, resolveProfiles } from './config.ts' @@ -59,14 +57,19 @@ function registrationFacts(profiles: ReadonlyMap Config = () => config - let lastRaw: Config | undefined - let lastGood: ReadonlyMap | undefined + let lastRaw: Config = config + let lastGood: ReadonlyMap = compositionProfiles const profiles = (): ReadonlyMap => { const raw = current() - if (raw === lastRaw && lastGood !== undefined) return lastGood + if (raw === lastRaw) return lastGood try { const next = resolveProfiles(raw.providers) + if (!deepEqualJson(registrationFacts(next), compositionFacts)) { + throw new Error('llm-pi-ai: provider routes and retry policies are composition-fixed') + } lastRaw = raw lastGood = next return next @@ -74,14 +77,12 @@ export function apply(ctx: Context, config: Config): void { // Static composition resolves before anything registers, so this branch // only sees a live settings snapshot failing catalog or bound checks: // keep serving the last good profiles and say so once per bad snapshot. - if (lastGood === undefined) throw error lastRaw = raw ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section') ctx.logger.error(error) return lastGood } } - profiles() const resolveApiKey = async ( provider: string, @@ -97,55 +98,25 @@ export function apply(ctx: Context, config: Config): void { if (ref === undefined) return undefined const credentials = ctx.get('credentials') const hit = credentials !== undefined - ? (await credentials.resolve(ref))?.value + ? await credentials.resolve(ref) // Without the seam, read exactly the named variable so a plain // cordis.yml composition works from the environment alone. : process.env[ref] if (hit !== undefined && hit.length > 0) return hit throw new LlmError( `llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not` - + ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,` + + ` set — provide ${ref} through the credential provider or launching environment,` + ' and remove apiKeyEnv only if this provider should authenticate from pi-ai\'s own environment discovery', 'MISSING_CREDENTIAL', ) } const adapter = new PiAiAdapter({ profiles, resolveApiKey }) - // Route effects bind to this apply fiber via the stable `ctx` reference, - // even when a swap runs inside the scoped settings callback below. A bare - // mount (zero routes) is the dormant posture: nothing registers until a - // settings section supplies profiles, and routes drop when it empties. - let registration: AdapterRegistrationHandle | undefined - let registeredFacts: unknown - const ensureRegistrationFacts = (): void => { - const facts = registrationFacts(profiles()) - if (deepEqualJson(facts, registeredFacts)) return - // The registry captures the route set and each route's retry policy at - // registration, so a change to either must re-register. The swap is - // atomic (same adapter instance, validated before anything moves): a - // conflicting route leaves the previous routes serving requests, and - // `registeredFacts` only advances once the registry actually holds the - // new set — so returning to a working configuration always re-applies. - const routes = [...profiles().keys()] - if (registration === undefined) { - // Dormant bare mount: nothing is registered until a section supplies - // profiles, and an empty section keeps it that way. - if (routes.length === 0) { - registeredFacts = facts - return - } - registration = ctx.llm.registerAdapter(routes, adapter) - } else { - registration.replace(routes) - } - registeredFacts = facts - } - ensureRegistrationFacts() + ctx.llm.registerAdapter([...compositionProfiles.keys()], adapter) installSettingsSection(ctx, NS, Config, config, { setSource: (source) => { current = source }, - onChange: ensureRegistrationFacts, }) } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a0826b3571..8edfaceb1a 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -401,9 +401,7 @@ describe('provider profile lifecycle', () => { }) it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => { - // Empty and omitted dicts are the dormant zero-route posture, not errors. - expect(resolveProfiles({}).size).toBe(0) - expect(resolveProfiles(undefined).size).toBe(0) + expect(() => resolveProfiles({})).toThrow(/at least one profile/) expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/) expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/) // The pre-release array shape and its per-profile provider field fail diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 598d2aa2a9..a9735e1dcf 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -3,8 +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, { LlmAdapter } from '@deepseek-ai/dsh-llm' -import { credentialRef } from '@deepseek-ai/dsh-credentials' +import LlmService from '@deepseek-ai/dsh-llm' import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { SettingsLocal } from '@deepseek-ai/dsh-settings-local' @@ -13,15 +12,6 @@ import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const NS = settingsNamespace('llm-pi-ai') - -/** Minimal foreign adapter: only needs to own a route the pi-ai plugin then wants. */ -class StubAdapter extends LlmAdapter { - - override async * stream(): AsyncIterable { - throw new Error('stub adapter must never stream') - } -} - const cleanups: Array<() => Promise> = [] afterEach(async () => { @@ -36,154 +26,75 @@ async function home(): Promise { return dir } -/** Real dynamic composition mirroring the deepseek twin's harness. */ +/** Real dynamic composition mirroring the DeepSeek twin's harness. */ async function boot(dir: string, config: LlmPiAi.Config): Promise { const ctx = new Context() - cleanups.push(async () => { - await ctx.fiber.dispose() - }) + cleanups.push(async () => { await ctx.fiber.dispose() }) await ctx.plugin(LlmService) await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) - await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env') }) await ctx.plugin(LlmPiAi, config) return ctx } describe('request-level dynamic profiles', () => { - it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => { - vi.stubEnv('PI_DYNAMIC_KEY', '') - const dir = await home() - await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n') - const server = await mockServer([{ events: textEvents }]) - // The exact product posture: `- id: llm-pi-ai` with no config at all. - const ctx = await boot(dir, {}) - - expect(ctx.llm.listProviders()).toEqual([]) - await ctx.settings.update(NS, { - providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, - }) - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) - await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0) - - const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) - expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) - expect(server.headers[0]?.authorization).toBe('Bearer pk-from-settings') - - // Emptying the user layer returns the adapter to its dormant state. - await ctx.settings.replace(NS, {}) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - it('adds a provider route from settings and drops it when the user layer resets', async () => { - const dir = await home() - const server = await mockServer([{ events: textEvents }]) - const ctx = await boot(dir, { - providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } }, - }) - - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) - await ctx.settings.update(NS, { - providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } }, - }) - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek']) - - const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) - expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) - expect(server.headers[0]?.authorization).toBe('Bearer live-key') - - // Reset the user layer: the settings-born route unregisters, the - // composition route stays. - await ctx.settings.replace(NS, {}) - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) - await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ code: 'NO_ADAPTER' }) - }) - - it('rotates the per-request credential referenced by apiKeyEnv', async () => { + it('uses the next endpoint and credential while keeping the route fixed', async () => { vi.stubEnv('PI_DYNAMIC_KEY', '') const dir = await home() await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n') - const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const serverA = await mockServer([{ events: textEvents }]) + const serverB = await mockServer([{ events: textEvents }]) const ctx = await boot(dir, { - providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } }, + providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: serverA.url } }, }) await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[0]?.authorization).toBe('Bearer pk-one') + expect(serverA.headers[0]?.authorization).toBe('Bearer pk-one') - await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two') + await ctx.settings.update(NS, { providers: { deepseek: { baseURL: serverB.url } } }) + await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-two\n') await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[1]?.authorization).toBe('Bearer pk-two') + expect(serverA.requests).toHaveLength(1) + expect(serverB.headers[0]?.authorization).toBe('Bearer pk-two') + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) }) - it('re-registers routes in place when a captured retry policy changes', async () => { + it('rejects settings-born routes and keeps the composition profile serving', async () => { const dir = await home() - const ctx = await boot(dir, { providers: { openai: {} } }) + const server = await mockServer([{ events: textEvents }]) + const ctx = await boot(dir, { + providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } }, + }) await ctx.settings.update(NS, { + providers: { anthropic: { apiKey: 'other' } }, + }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) + await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) + expect(server.paths).toEqual(['/v1/responses']) + }) + + it('keeps the registration retry policy composition-fixed', async () => { + const dir = await home() + const ctx = await boot(dir, { providers: { openai: { - retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } }, + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, }, }, }) + + await ctx.settings.update(NS, { + providers: { openai: { retryPolicy: { mode: 'normal', maxRetries: 0 } } }, + }) expect(ctx.llm.providerRetryPolicy('openai')).toEqual({ mode: 'always', initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2, }) - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) - }) - - it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => { - const dir = await home() - const ctx = await boot(dir, { providers: { openai: {} } }) - - // Schema-valid but catalog-invalid: the resolver rejects it and the - // last good route set keeps serving. - await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } }) - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) - }) - - it('keeps serving its routes when a settings-born route collides with another adapter', async () => { - const dir = await home() - const server = await mockServer([{ events: textEvents }, { events: textEvents }]) - const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } }) - // Another adapter owns `anthropic`; the registry must refuse to hand it over. - ctx.llm.registerAdapter(['anthropic'], new StubAdapter()) - - await ctx.settings.update(NS, { - providers: { - openai: { apiKey: 'pk', baseURL: `${server.url}/v1` }, - anthropic: { apiKey: 'other' }, - }, - }) - - // The conflicting swap was refused whole: the previous route set still - // owns openai (an eager dispose would have dropped it), and anthropic - // still belongs to its original adapter. - expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai']) - const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) - expect(result.finish.kind).toBe('error') - expect(server.paths).toEqual(['/v1/responses']) - - // Reverting to the working configuration re-applies, even though its - // facts equal the ones the registry already holds. - await ctx.settings.replace(NS, {}) - expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai']) - await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) - expect(server.paths).toEqual(['/v1/responses', '/v1/responses']) - }) - - it('ignores a settings document that merely reorders its provider keys', async () => { - const dir = await home() - const ctx = await boot(dir, { providers: { openai: {}, anthropic: {} } }) - const before = ctx.llm.listProviders().map(provider => provider.id) - - // Same routes, different YAML key order: nothing about the registration - // changed, so no swap should happen at all. - await ctx.settings.update(NS, { providers: { anthropic: {}, openai: {} } }) - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(before) }) }) diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index 460e78b7c2..0da44cc13e 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -1,11 +1,6 @@ /** - * Real-composition guard for the dormant pi-ai posture: LlmService, - * settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a - * test-only cordis.yml through the actual Loader + Include path, an external - * edit of settings.yaml registers the route live, and the next request - * carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot - * catch Loader export-shape failures, which is why the twin adapter has the - * same guard. + * Real-composition guard for a configured pi-ai route through Loader + Include. + * Settings may change request facts, while the route stays composition-owned. */ import { mkdtemp, rm, writeFile } from 'node:fs/promises' @@ -18,6 +13,7 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' import SettingsLocal from '@deepseek-ai/dsh-settings-local' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { assemble } from './assemble.ts' @@ -25,6 +21,7 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts' let root: string | undefined let context: Context | undefined +const NS = settingsNamespace('llm-pi-ai') afterEach(async () => { await context?.fiber.dispose() @@ -35,12 +32,12 @@ afterEach(async () => { vi.unstubAllEnvs() }) -/** Boot the dormant composition: a bare `llm-pi-ai` row with no config at all. */ -async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }> { +async function loadComposition(baseURL: string): Promise<{ ctx: Context; settingsPath: string; envPath: string }> { root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-')) const settingsPath = join(root, 'settings.yaml') + const envPath = join(root, '.env') await writeFile(settingsPath, '# personal settings\n') - await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n') + await writeFile(envPath, 'PI_COMPOSITION_KEY=key-from-store\n') const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ @@ -54,10 +51,14 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } '- id: credentials', " name: '@deepseek-ai/dsh-credentials-local'", ' config:', - ` path: ${JSON.stringify(join(root, '.env'))}`, - ' debounceMs: 10', + ` path: ${JSON.stringify(envPath)}`, '- id: llm-pi-ai', " name: '@deepseek-ai/dsh-llm-pi-ai'", + ' config:', + ' providers:', + ' deepseek:', + ' apiKeyEnv: PI_COMPOSITION_KEY', + ` baseURL: ${baseURL}`, '', ].join('\n')) @@ -84,33 +85,34 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } config: { path: pathToFileURL(configPath).href }, }) await ctx.loader.await() - return { ctx, settingsPath } + return { ctx, settingsPath, envPath } } -describe('llm-pi-ai real dormant composition', () => { - it('boots with zero routes and registers one the moment settings supply a profile', async () => { +describe('llm-pi-ai real composition', () => { + it('keeps its route while external settings and credential edits reach the next request', async () => { vi.stubEnv('PI_COMPOSITION_KEY', '') - const server = await mockServer([{ events: textEvents }]) - const { ctx, settingsPath } = await loadComposition() + const serverA = await mockServer([{ events: textEvents }]) + const serverB = await mockServer([{ events: textEvents }]) + const { ctx, settingsPath, envPath } = await loadComposition(serverA.url) - // The shipped posture: the adapter exists, no route does. - expect(ctx.llm.listProviders()).toEqual([]) + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(serverA.headers[0]?.authorization).toBe('Bearer key-from-store') - // Exactly what the web Models page leaves on disk. await writeFile(settingsPath, [ 'llm-pi-ai:', ' providers:', ' deepseek:', - ' apiKeyEnv: PI_COMPOSITION_KEY', - ` baseURL: ${server.url}`, + ` baseURL: ${serverB.url}`, '', ].join('\n')) + await writeFile(envPath, 'PI_COMPOSITION_KEY=rotated-key\n') await vi.waitFor(() => { - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + expect((ctx.get('settings')!.get(NS) as { providers?: { deepseek?: { baseURL?: string } } }) + .providers?.deepseek?.baseURL).toBe(serverB.url) }, { timeout: 5000 }) - const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) - expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) - expect(server.headers[0]?.authorization).toBe('Bearer key-from-store') + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key') }) }) diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index 8a0ecf3434..eef06331da 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/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-retry/README.md -README.md: 7a86652a794e70c4dfd00ab7427730387e3ec949 -README.zh.md: c66c04806597c11b5c64dcb01443bb84f489e0f5 +README.md: 233ccb0b744f7ec52379f823286c1c0638d91ee2 +README.zh.md: 86260785128f7089be7bcbae08b7e858427a68ae diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 7a86652a79..233ccb0b74 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -8,7 +8,7 @@ Each provider adapter owns an optional nested `retryPolicy`, captured when its r Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. -Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. After the wait, the listener returns `{ kind: 'retry' }`, and the loop closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. +Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a later registration with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. After the wait, the listener returns `{ kind: 'retry' }`, and the loop closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index c66c048065..8626078512 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -8,7 +8,7 @@ 两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。 -等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含提供方、mode、已解析策略的规范 key、失败和计划延迟。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`,循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。 +等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含提供方、mode、已解析策略的规范 key、失败和计划延迟。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,后续注册若采用不同的限制、code 成员关系或退避,就会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`,循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。 单独发布的 `./invariant` 配套模块会检查每个重试记录是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。 diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index d7740dcebf..48b6febe4e 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: 5b0c1b2dcafeefaad25f1714e4a1783430370118 -README.zh.md: 5f5c8142ec829e8ca8cfd40e6caa341ae0a33c7d +README.md: 095a5dc48ad1e762aabf71db0216fa50aceb6211 +README.zh.md: baeb0214501e9c2a13a0a272a37a6ead75f6c8d6 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 5b0c1b2dca..095a5dc48a 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -10,7 +10,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa ### Public API -- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration. +- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for a non-empty, composition-owned route set. Registration is all-or-nothing, is disposed with the calling fiber, and returns an explicit disposer. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. @@ -19,7 +19,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` Resolve a config and capture its current adapter registration as one cancellable, one-shot call. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. -`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. +`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route later changes ownership. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 5f5c8142ec..baeb021450 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -10,7 +10,7 @@ ### 公开 API -- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。 +- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为一组非空且由组合拥有的路由注册一个适配器实例。注册要么全部成功,要么全部不生效,会随调用 fiber 一起 dispose(资源释放),并返回显式释放器。 - `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。 - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 @@ -19,7 +19,7 @@ - `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` 解析配置并将其当前适配器注册捕获为一次可取消、一次性调用。 - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` 将一次模型调用流式输出为原始分片(token 级增量)。消费方使用 `BlockAssembler` 将分片组装为块/消息。 -`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`。 +`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使路由后来更换所有者也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`。 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 3759f9f8df..1f29cfc607 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -184,30 +184,6 @@ export abstract class LlmAdapter { abstract stream(options: GenerateOptions): AsyncIterable } -/** - * What {@link LlmService.registerAdapter} returns: the disposer, plus an - * atomic route replacement for the same adapter instance. - */ -export interface AdapterRegistrationHandle { - /** Release every route this registration currently holds. */ - (): void - /** - * Replace this registration's routes with `providers`, keeping the same - * adapter instance. The candidate set is validated in full first — a - * conflict with another adapter, an invalid name, or bad provider metadata - * throws and leaves the current routes untouched — and the swap itself is - * one synchronous section, so no request can observe a gap. An empty array - * is legal here (a settings section that emptied holds zero routes while - * staying registered), unlike an empty initial registration. - * - * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration - * has been released: its routes are gone and its disposer has already run, - * so anything registered afterwards would have no owner left to release it. - * @param providers - the complete next route set for this registration. - */ - replace(providers: string[]): void -} - /** * The abstract `llm` service: an adapter registry plus a streaming model-call * surface, interceptable via the `llm/stream` waterfall. @@ -225,79 +201,39 @@ export class LlmService extends Service { * Disposed with the fiber. * @param providers - every provider route this adapter should serve. * @param adapter - the adapter that streams calls for those providers. - * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}. + * @returns the disposer that unregisters all routes. */ - registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle { - // The routes this registration currently holds; `replace` rewrites it, and - // the disposer releases whatever it holds at disposal time. - const owned = new Set() - // The disposer has run: `owned` being empty cannot say so on its own, - // because `replace([])` legally leaves a live registration holding none. - let released = false + registerAdapter(providers: string[], adapter: LlmAdapter): () => void { const dispose = this.ctx.effect(function* (this: LlmService) { if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER') - this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned)) + const unique = new Set() + const registrations: AdapterRegistration[] = [] + for (const provider of providers) { + if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER') + if (unique.has(provider) || this.adapters.has(provider)) { + throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER') + } + const info = adapter.providerInfo(provider) + if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) { + throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER') + } + unique.add(provider) + const retryPolicy = adapter.providerRetryPolicy(provider) + ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) + registrations.push({ + adapter, + provider: { id: info.id, name: info.name }, + retryPolicy, + }) + } + for (const registration of registrations) this.adapters.set(registration.provider.id, registration) yield () => { - released = true - for (const provider of owned) this.adapters.delete(provider) - owned.clear() + for (const provider of providers) this.adapters.delete(provider) } }.bind(this), 'llm.registerAdapter()') // ctx.effect's disposer returns Promise; our disposer API is // synchronous fire-and-forget — discard the (always-resolved) promise. - const handle = (() => void dispose()) as AdapterRegistrationHandle - handle.replace = (next: string[]): void => { - // Registering here would leak: the effect's disposer already ran, so - // nothing remains to release whatever this call would put in the map. - if (released) { - throw new LlmError('a disposed adapter registration cannot replace its routes', 'REGISTRATION_DISPOSED') - } - this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned)) - } - return handle - } - - /** - * Validate one candidate route set for `adapter`, treating routes this - * registration already holds as available. Nothing is mutated: a rejected - * candidate leaves the registry exactly as it was. - */ - private prepareRoutes(providers: string[], adapter: LlmAdapter, owned: ReadonlySet): AdapterRegistration[] { - const unique = new Set() - const registrations: AdapterRegistration[] = [] - for (const provider of providers) { - if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER') - if (unique.has(provider) || (this.adapters.has(provider) && !owned.has(provider))) { - throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER') - } - const info = adapter.providerInfo(provider) - if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) { - throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER') - } - unique.add(provider) - const retryPolicy = adapter.providerRetryPolicy(provider) - ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) - registrations.push({ - adapter, - provider: { id: info.id, name: info.name }, - retryPolicy, - }) - } - return registrations - } - - /** - * Swap this registration's routes for the prepared ones in one synchronous - * section, so no observer can see the registry between the release and the - * re-registration. - */ - private commitRoutes(owned: Set, registrations: readonly AdapterRegistration[]): void { - for (const provider of owned) this.adapters.delete(provider) - owned.clear() - for (const registration of registrations) { - this.adapters.set(registration.provider.id, registration) - owned.add(registration.provider.id) - } + return () => void dispose() } /** diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index f723c7f474..a5d755bdcf 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -217,7 +217,7 @@ describe('LlmService', () => { ) }) - it('keeps the serving registration policy on an in-flight call after route replacement', async () => { + it('keeps the serving registration policy on an in-flight call after route re-registration', async () => { const oldPolicy = resolveRetryPolicy({ mode: 'always' }, 'old retryPolicy') const newPolicy = resolveRetryPolicy({ mode: 'normal', maxRetries: 0 }, 'new retryPolicy') const entered = Promise.withResolvers() @@ -1382,31 +1382,4 @@ describe('LlmService', () => { expect(ctx.llm.listProviders()).toEqual([]) }) - it('refuses to replace routes on a registration that was already released', async () => { - // The leak this prevents: the effect's disposer has run, so a route added - // afterwards would sit in the registry with nothing left to release it. - const ctx = new Context() - await ctx.plugin(LlmService) - - const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT)) - handle() - expect(() => { handle.replace(['leaked']) }) - .toThrow(/disposed adapter registration cannot replace its routes/) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - it('still allows an empty route set on a live registration', async () => { - // `replace([])` is the settings-section-emptied case: legal, and it must - // not be mistaken for disposal by the guard above. - const ctx = new Context() - await ctx.plugin(LlmService) - - const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT)) - handle.replace([]) - expect(ctx.llm.listProviders()).toEqual([]) - handle.replace(['m2']) - expect(ctx.llm.listProviders()).toEqual([{ id: 'm2', name: 'm2' }]) - handle() - expect(ctx.llm.listProviders()).toEqual([]) - }) }) diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index 0040b65507..aefb1ccd33 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -27,7 +27,6 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-atomic-write": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", @@ -39,7 +38,6 @@ "yaml": "^2.9.0" }, "devDependencies": { - "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 8043e6db45..8f41f17171 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -10,10 +10,10 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' -import { mkdir, readFile } from 'node:fs/promises' +import { randomBytes } from 'node:crypto' +import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' -import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings' @@ -96,6 +96,17 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +/** Whether an exclusive create failed because the path already exists. */ +function isEEXIST(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + +/** Writer-lock retry constants for the private settings document protocol. */ +const LOCK_RETRY_INITIAL_MS = 20 +const LOCK_RETRY_MAX_MS = 200 +const LOCK_TIMEOUT_MS = 2_000 +const LOCK_STALE_MS = 5_000 + /** File-backed settings provider (`settings.yaml`/`.json`). */ export class SettingsLocal extends Settings { static Config: z = z.object({ @@ -180,11 +191,8 @@ export class SettingsLocal extends Settings { } private async persistSection(ns: SettingsNamespace, section: Record): Promise { - // The writer lock's exclusive create needs the parent to exist before - // writeFileAtomic gets its own chance to create it. - // 0700: the harness home holds user-private documents. await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 }) - await withFileLock(this.spec.filename, async () => { + await this.withWriterLock(async () => { // Read-modify-write: fold in any on-disk state this process has not // observed yet — an external edit still inside the watcher debounce // window, a change the watcher missed, or another process's write — so @@ -195,16 +203,64 @@ export class SettingsLocal extends Settings { const output = this.spec.format === 'yaml' ? this.renderYaml(ns, section) : this.renderJson(ns, section) - // 0600: a document that may hold personal values is never world-readable. - await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 }) + const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp` + // TODO(settings-atomic-durability): Use a replacement that fsyncs the file + // and parent directory and preserves owner-only permissions on Windows. + try { + await writeFile(temp, output, { mode: 0o600, flag: 'wx' }) + await rename(temp, this.spec.filename) + } catch (error) { + await rm(temp, { force: true }) + throw error + } this.text = output - }, { - onStaleBreak: (lockPath) => { - this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath) - }, }) } + /** Hold the private cross-process writer lock around one read-render-rename cycle. */ + private async withWriterLock(operation: () => Promise): Promise { + const lockPath = `${this.spec.filename}.lock` + const deadline = Date.now() + LOCK_TIMEOUT_MS + let delay = LOCK_RETRY_INITIAL_MS + for (;;) { + try { + await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) + break + } catch (error) { + if (!isEEXIST(error)) throw error + } + const ageMs = await this.lockAgeMs(lockPath) + if (ageMs === undefined) continue + if (ageMs > LOCK_STALE_MS) { + // TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe + // acquisition and release so a slow writer cannot remove a successor's lock. + this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath) + await rm(lockPath, { force: true }) + continue + } + if (Date.now() >= deadline) { + throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`) + } + await new Promise(resolvePause => setTimeout(resolvePause, delay)) + delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS) + } + try { + return await operation() + } finally { + await rm(lockPath, { force: true }) + } + } + + /** Age of the writer lock, or `undefined` when it vanished after a failed create. */ + private async lockAgeMs(lockPath: string): Promise { + try { + return Date.now() - (await stat(lockPath)).mtimeMs + } catch (error) { + if (!isENOENT(error)) throw error + return undefined + } + } + override async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { // The base init loads and publishes; a parse failure there is a boot // failure: an existing-but-invalid document must fail loud, never be diff --git a/packages/settings/settings-local/src/invariant.ts b/packages/settings/settings-local/src/invariant.ts index b59b798298..3bd07a523c 100644 --- a/packages/settings/settings-local/src/invariant.ts +++ b/packages/settings/settings-local/src/invariant.ts @@ -16,7 +16,7 @@ export const inject = ['invariants'] /** * No runtime invariant: this provider's contracts are file round-trip, - * watcher timing, and atomic-write behavior — IO effects proven by package + * watcher timing, and atomic replacement behavior — IO effects proven by package * tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`. */ const install: InvariantInstaller = () => {} diff --git a/packages/settings/settings-local/tsconfig.json b/packages/settings/settings-local/tsconfig.json index cf5b68fc11..67a746c982 100644 --- a/packages/settings/settings-local/tsconfig.json +++ b/packages/settings/settings-local/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../util/atomic-write" - }, { "path": "../../util/paths" }, diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 241bc41bfa..8a689979c1 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -546,34 +546,14 @@ export abstract class Settings extends Service { } } -/** - * Value mirror of the `FiberState` members {@link isUnloading} compares - * against: a const enum has no runtime object to import, and the value is - * needed at runtime (same rationale as the CLI boot driver's mirror). - */ -const FIBER_DISPOSED = 4 -const FIBER_UNLOADING = 5 - -/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */ -function isUnloading(ctx: Context): boolean { - const state: number = ctx.fiber.state - return state === FIBER_UNLOADING || state === FIBER_DISPOSED -} - /** Hooks a consumer hands to {@link installSettingsSection}. */ export interface SettingsSectionHooks { /** * Receive the active configuration source: the resolved settings scope - * while one is attached, the composition entry otherwise. Called before - * the matching `onChange` at attach and at detach. + * while one is attached, the composition entry otherwise. * @param current - thunk returning the currently authoritative value. */ setSource(current: () => T): void - /** - * Re-judge anything derived from the source — registration-level facts, - * memoized resolutions — after an attach, a detach, or a committed change. - */ - onChange(): void } /** @@ -581,8 +561,10 @@ export interface SettingsSectionHooks { * service exists, register `ns` with the consumer's composition entry as the * `base` layer and point the source thunk at the resolved scope; when the * service goes away (disposal, provider reload), fall back to the entry so - * the consumer keeps working exactly as composed. The registration rides the - * scoped fiber, so no settings service ever mounted means none of this runs. + * the consumer keeps working exactly as composed. The returned source is live: + * callers read committed changes from `scope.get()` without a change callback. + * The registration rides the scoped fiber, so no settings service ever mounted + * means none of this runs. * @param ctx - consumer plugin context owning the wiring. * @param ns - the consumer-owned settings namespace. * @param schema - schema resolving the namespace (typically the plugin Config). @@ -600,24 +582,7 @@ export function installSettingsSection( const scope = sctx.settings.register(ns, schema, { base: entry }) hooks.setSource(() => scope.get()) sctx.effect(() => () => { - // This disposer runs for two different reasons. A settings provider - // detaching leaves the consumer running, so it must fall back to its - // composition entry and re-judge what it derived. The consumer's own - // unload runs it too — and there `onChange` would re-register routes - // and touch resources the teardown is releasing, so the fallback is - // pointless and the notification actively harmful. - if (isUnloading(ctx)) return hooks.setSource(() => entry) - hooks.onChange() - }) - hooks.onChange() - scope.watch(() => { - // A stored change landing while the consumer unloads reaches the watcher - // before the registration is released, and `onChange` is exactly as - // harmful here as in the disposer above: it re-registers routes against - // a fiber whose resources are being let go. - if (isUnloading(ctx)) return - hooks.onChange() }) }) } diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 9e3a8dd7e5..e6da741512 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -662,98 +662,23 @@ describe('installSettingsSection', () => { const ctx = new Context() const entry = { theme: 'entry' } let current: () => { theme: string } = () => entry - let changes = 0 installSettingsSection(ctx, settingsNamespace('helper-ns'), HelperSchema, entry, { setSource: (source) => { current = source }, - onChange: () => { - changes += 1 - }, }) // No settings service mounted: nothing ran, the entry stays authoritative. expect(current()).toEqual({ theme: 'entry' }) - expect(changes).toBe(0) const fiber = ctx.plugin(MemorySettings, { doc: { 'helper-ns': { theme: 'user' } } }) await fiber await vi.waitFor(() => { expect(current()).toEqual({ theme: 'user' }) }) - expect(changes).toBe(1) - await ctx.settings.update(settingsNamespace('helper-ns'), { theme: 'live' }) - await vi.waitFor(() => { - expect(changes).toBe(2) - }) expect(current()).toEqual({ theme: 'live' }) await fiber.dispose() - await vi.waitFor(() => { - expect(changes).toBe(3) - }) expect(current()).toEqual({ theme: 'entry' }) }) - - it('stays silent when the consumer itself unloads', async () => { - const { ctx } = await boot({ doc: { 'helper-ns': { theme: 'user' } } }) - const entry = { theme: 'entry' } - let current: () => { theme: string } = () => entry - const changes: string[] = [] - const consumer = ctx.plugin({ - inject: ['settings'], - apply: (child: Context) => { - installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, { - setSource: (source) => { - current = source - }, - onChange: () => { - changes.push(current().theme) - }, - }) - }, - }) - await consumer - await vi.waitFor(() => { - expect(changes).toEqual(['user']) - }) - - // The consumer's own teardown must not re-derive anything: an onChange - // here would re-register routes and touch resources being released. - await consumer.dispose() - await new Promise(resolve => setTimeout(resolve, 20)) - expect(changes).toEqual(['user']) - }) - - it('stays silent for a stored change that lands while the consumer unloads', async () => { - // The watcher outlives the start of teardown by the width of the unload, - // so a document change arriving in that window reaches it. Notifying then - // is exactly as harmful as notifying from the disposer. - const { ctx, provider } = await boot({ doc: { 'helper-ns': { theme: 'user' } } }) - const entry = { theme: 'entry' } - let current: () => { theme: string } = () => entry - const changes: string[] = [] - const consumer = ctx.plugin({ - inject: ['settings'], - apply: (child: Context) => { - installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, { - setSource: (source) => { - current = source - }, - onChange: () => { - changes.push(current().theme) - }, - }) - }, - }) - await consumer - await vi.waitFor(() => { - expect(changes).toEqual(['user']) - }) - - const unloading = consumer.dispose() - provider.pushExternal({ 'helper-ns': { theme: 'racing' } }) - await unloading - expect(changes).toEqual(['user']) - }) }) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index c670928ba7..b0eda21b88 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: 1beffd6fbff2b84202683b010cd104f7c84297c7 -README.zh.md: d9ce9774b9b492a98556bbd9aa4564b711dbe40e +README.md: 1c060e8a518141ae2a25a3b2bb7a7597a18052ae +README.zh.md: a7af0c4cade5049b282cbf8136ffef07e716ad56 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 1beffd6fbf..1c060e8a51 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -27,7 +27,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files: -- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. +- **`.env`** — the credential document read on demand by [`dsh-credentials-local`](../../credentials/credentials-local/README.md) alone. No surface hoists it into `process.env`: doing so would turn every stored key into an ambient launch override and hide later file rotations. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. - **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index d9ce9774b9..a7af0c4cad 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -27,7 +27,7 @@ 开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件: -- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 +- **`.env`**:只由 [`dsh-credentials-local`](../../credentials/credentials-local/README.md) 按需读取的凭据文档。没有任何表层会把它提升进 `process.env`:那样做会把每个已存密钥变成环境中的启动时覆盖,并使此后在文件中轮换的值不可见。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据提供方的组合仍然只从这两者解析密钥。 - **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。 diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index add6070a27..8bd1ff35b2 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/README.md -README.md: 46904aba70c7cf0f98bb75cce79d97bb12b950a9 -README.zh.md: 59a2dcf7926c12d7005446393cadfd8b0be88f77 +README.md: 605c3dd0beebc16109e8e6bc944ea722a60975c0 +README.zh.md: 5c66ded33a36079f80965cf466449843e07511f0 diff --git a/packages/util/README.md b/packages/util/README.md index 46904aba70..605c3dd0be 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -10,7 +10,6 @@ Zero-dependency primitives shared across the other groups. A package lands here | `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) | | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | | `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | -| `atomic-write/` | Atomic file replacement — `writeFileAtomic` (exclusive-create temp + rename carrying the caller-stated mode); shared by the settings and credentials stores | | `native-command/` | No-shell `execFile` runner for host-native OS integrations — utf8 capture, abort propagation, Windows hide (no harness deps); command choice stays in each caller | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 59a2dcf792..5c66ded33a 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -10,7 +10,6 @@ | `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) | | `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 | | `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 | -| `atomic-write/` | 原子文件替换:`writeFileAtomic`(独占创建临时文件 + 携带调用方所声明 mode 的 rename);由设置与凭据存储共用 | | `native-command/` | 宿主原生 OS 集成的免 shell `execFile` 运行器——utf8 捕获、abort 传播、Windows 窗口隐藏(无 harness 依赖);命令选择保留在各调用方 | `dsh-brand` 是规范示例:它只负责 `Branded` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。 diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml deleted file mode 100644 index ffa4d7ccbb..0000000000 --- a/packages/util/atomic-write/README.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 packages/util/atomic-write/README.md -README.md: be9f896eb24e28aedc2c04858da8b8da9da548dc -README.zh.md: 19a067dc84f12d334e5c31dda58e7cf78dac51f9 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md deleted file mode 100644 index be9f896eb2..0000000000 --- a/packages/util/atomic-write/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# dsh-atomic-write - -English | [中文](README.zh.md) - -Zero-dependency atomic file replacement shared by file-backed stores that must never leave partial, symlink-hijacked, or wider-than-intended content on disk — the user-settings document (`dsh-settings-local`) and the credentials store (`dsh-credentials-local`). - -## Surface - -```ts -import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' - -declare const text: string -declare const render: (previous: string) => string - -await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) - -// Read-modify-write against the same file from several processes. -await withFileLock('/home/u/.dsh/settings.yaml', async () => { - await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 }) -}) -``` - -`writeFileAtomic` commits one already-rendered string. The contract, in the order failures would exploit it: - -- **Exclusive-create temp** (`wx`, random suffix): the open refuses to follow a symlink planted at a guessable temp path. -- **The fresh inode carries `mode` through the rename**: replacing a wider-permission file narrows it without a chmod race. `mode` is required so the permission decision stays visible at every call site (subject to the process umask, like every fresh inode). -- **`rename` replaces a symlinked target itself**, never writing through to its referent. -- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic. -- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content. - -`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A lock older than the stale age is treated as a crashed holder and broken — see [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) for what that costs. - -## Model Experience - -None, as this is a pure filesystem primitive; nothing here reaches a model request. - -#### KV Cache effect - -None; nothing here enters a request prefix. - -## Known Limitations and Deferred Work - -- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy. -- **String content only** — no `Buffer` or stream form until a consumer needs one. -- **The lock takes over by age, not by ownership** (`TODO(settings-lock-ownership)`) — a holder slower than the stale age has its lock broken by a waiter, and release unlinks the path unconditionally, so a slow writer can remove a successor's lock. Two writers can then overlap and one cycle's result be lost. The stale age is set well above any write this repo performs, so the exposure is a paused or swapped-out process; ownership-safe acquisition and release is the fix. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md deleted file mode 100644 index 19a067dc84..0000000000 --- a/packages/util/atomic-write/README.zh.md +++ /dev/null @@ -1,45 +0,0 @@ -# dsh-atomic-write - -[English](README.md) | 中文 - -零依赖的原子文件替换,供绝不允许在磁盘上留下不完整、被符号链接劫持或权限过宽内容的文件型存储共用:用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。 - -## 接口面 - -```ts -import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' - -declare const text: string -declare const render: (previous: string) => string - -await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 }) - -// Read-modify-write against the same file from several processes. -await withFileLock('/home/u/.dsh/settings.yaml', async () => { - await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 }) -}) -``` - -`writeFileAtomic` 提交一份已经渲染好的字符串。契约按故障利用它的先后顺序列出: - -- **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。 -- **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。 -- **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。 -- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。 -- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。 - -`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。超过陈旧时限的锁被视为持有者已崩溃并被打破——其代价见[Known Limitations and Deferred Work](#known-limitations-and-deferred-work)。 - -## Model Experience - -无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。 - -#### KV Cache effect - -无;此处没有任何内容会进入请求前缀。 - -## Known Limitations and Deferred Work - -- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。 -- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。 -- **锁按时长而非归属接管**(`TODO(settings-lock-ownership)`)——持有者若慢于陈旧时限,其锁会被等待方打破,而释放又无条件删除该路径,因此慢写入方可能删掉后继者的锁。两个写入方随之重叠,一轮循环的结果可能丢失。陈旧时限远高于本仓库的任何一次写入,因此暴露面是被暂停或被换出的进程;修法是按归属安全地获取与释放。 diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json deleted file mode 100644 index 147ecb1e05..0000000000 --- a/packages/util/atomic-write/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-atomic-write", - "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts deleted file mode 100644 index 07d0276033..0000000000 --- a/packages/util/atomic-write/src/index.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Zero-dependency atomic file replacement and writer coordination. - * `writeFileAtomic` writes a random-suffix sibling with exclusive create and - * the caller's permission bits, then renames it over the target, so readers - * observe either the old or the new complete content and a replaced file ends - * up with exactly the stated mode. `withFileLock` serializes cross-process - * writers of one file through a `wx`-created `.lock` sibling, so a - * read-modify-write cycle can never resurrect a state another writer just - * replaced; readers stay lock-free because the rename commit is atomic. - * @module @deepseek-ai/dsh-atomic-write - */ - -import { randomBytes } from 'node:crypto' -import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises' -import { dirname } from 'node:path' - -/** - * Filesystem options for {@link writeFileAtomic}; `mode` is required so the - * permission decision stays visible at every call site. - */ -export interface WriteFileAtomicOptions { - /** - * Permission bits stamped on the fresh temp inode and carried through the - * rename (subject to the process umask, like every fresh inode). - */ - mode: number - /** - * Permission bits for parent directories this call creates (subject to the - * umask; existing directories keep their mode). Omission uses the mkdir - * default — pass `0o700` when the tree holds user-private data. - */ - dirMode?: number -} - -/** - * Replace `filename` with `content` in one atomic step, creating parent - * directories. The content is first written to a random-suffix sibling opened - * with exclusive create (`wx`): the open refuses to follow a symlink planted - * at the temp path, and the fresh inode carries `options.mode` through the - * rename, so replacing a wider-permission file narrows it without a chmod - * race. The rename also replaces a symlinked target itself instead of writing - * through to its referent, and the same-directory sibling keeps the rename on - * one filesystem. On any failure the temp file is removed and the failure - * rethrown. Crash durability (fsync) is out of scope. - * @param filename - final path receiving the content. - * @param content - complete next file content. - * @param options - permission bits for the replacement inode. - */ -export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise { - await mkdir(dirname(filename), { - recursive: true, - ...options.dirMode === undefined ? {} : { mode: options.dirMode }, - }) - // TODO(settings-atomic-durability): Use a replacement that fsyncs the file - // and parent directory and preserves owner-only permissions on Windows. - const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp` - try { - await writeFile(temp, content, { mode: options.mode, flag: 'wx' }) - await rename(temp, filename) - } catch (error) { - await rm(temp, { force: true }) - throw error - } -} - -/** Whether an exclusive create failed because the path already exists. */ -function isEEXIST(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' -} - -/** Whether a filesystem error means absence. */ -function isENOENT(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' -} - -/** - * Writer-lock protocol constants. These are robustness invariants of the - * cross-process write protocol, not deployment tunables: a holder rewrites one - * small file in milliseconds, so contention resolves well inside the retry - * deadline, and a lock older than the stale age can only belong to a crashed - * holder. - */ -const LOCK_RETRY_INITIAL_MS = 20 -const LOCK_RETRY_MAX_MS = 200 -const LOCK_TIMEOUT_MS = 2_000 -const LOCK_STALE_MS = 5_000 - -/** Options for {@link withFileLock}. */ -export interface WithFileLockOptions { - /** - * Called once each time a stale (crashed-holder) lock is broken, so the - * caller can log the takeover in its own voice. - */ - onStaleBreak?: (lockPath: string) => void -} - -/** Age of the lock file, or `undefined` when it vanished after a failed create. */ -async function lockAgeMs(lockPath: string): Promise { - try { - return Date.now() - (await stat(lockPath)).mtimeMs - } catch (error) { - if (!isENOENT(error)) throw error - return undefined - } -} - -/** - * Hold the cross-process writer lock for `filename` around one operation. The - * lock is a `wx`-created sibling (`.lock`); paired with the - * rename-based commit of {@link writeFileAtomic}, readers stay lock-free and - * only writers contend. Contention backs off exponentially; a lock older than - * the stale age is a crashed holder and is broken (see - * {@link WithFileLockOptions.onStaleBreak}); a live holder past the deadline - * fails the operation with a timed-out error. The parent directory must exist. - * @param filename - the file whose writers this lock serializes. - * @param operation - the read-render-commit cycle to run while holding the lock. - * @param options - stale-takeover notification hook. - * @returns the operation's result; the lock releases on both outcomes. - */ -export async function withFileLock( - filename: string, - operation: () => Promise, - options?: WithFileLockOptions, -): Promise { - const lockPath = `${filename}.lock` - const deadline = Date.now() + LOCK_TIMEOUT_MS - let delay = LOCK_RETRY_INITIAL_MS - for (;;) { - try { - await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) - break - } catch (error) { - if (!isEEXIST(error)) throw error - } - const ageMs = await lockAgeMs(lockPath) - // The holder released between the failed create and the stat: the lock is - // free right now, so retry without burning backoff or deadline. - if (ageMs === undefined) continue - if (ageMs > LOCK_STALE_MS) { - // TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe - // acquisition and release so a slow writer cannot remove a successor's lock. - options?.onStaleBreak?.(lockPath) - await rm(lockPath, { force: true }) - continue - } - if (Date.now() >= deadline) { - throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`) - } - await new Promise(resolve => setTimeout(resolve, delay)) - delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS) - } - try { - return await operation() - } finally { - await rm(lockPath, { force: true }) - } -} diff --git a/packages/util/atomic-write/src/invariant.ts b/packages/util/atomic-write/src/invariant.ts deleted file mode 100644 index 4027dd9bda..0000000000 --- a/packages/util/atomic-write/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-atomic-write`. - * @module @deepseek-ai/dsh-atomic-write/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-atomic-write' - -/** Cordis companion plugin name. */ -export const name = 'atomic-write-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this pure filesystem primitive owns no event stream or mutable runtime - * data; its replacement contract is enforced by unit tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts deleted file mode 100644 index 2bc9d3ab6a..0000000000 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { writeFileAtomic } from '../src/index.ts' - -async function scratch(): Promise { - return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) -} - -describe('writeFileAtomic', () => { - it('creates the file and its parents with exactly the stated mode', async () => { - const dir = await scratch() - const target = join(dir, 'nested', 'deep', 'doc.yaml') - await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 }) - expect(await readFile(target, 'utf8')).toBe('a: 1\n') - expect((await stat(target)).mode & 0o777).toBe(0o600) - }) - - it('replaces existing content and narrows a wider-permission file to the stated mode', async () => { - const dir = await scratch() - const target = join(dir, 'doc.yaml') - await writeFile(target, 'old', { mode: 0o644 }) - await writeFileAtomic(target, 'new', { mode: 0o600 }) - expect(await readFile(target, 'utf8')).toBe('new') - expect((await stat(target)).mode & 0o777).toBe(0o600) - }) - - it('replaces a symlinked target itself without writing through to the referent', async () => { - const dir = await scratch() - const victim = join(dir, 'victim') - await writeFile(victim, 'victim-content') - const target = join(dir, 'doc.yaml') - await symlink(victim, target) - await writeFileAtomic(target, 'replaced', { mode: 0o600 }) - expect((await lstat(target)).isSymbolicLink()).toBe(false) - expect(await readFile(target, 'utf8')).toBe('replaced') - expect(await readFile(victim, 'utf8')).toBe('victim-content') - }) - - it('leaves no temp sibling and rethrows when the rename fails', async () => { - const dir = await scratch() - const target = join(dir, 'occupied') - await mkdir(target) - await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow() - expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([]) - }) -}) diff --git a/packages/util/atomic-write/tests/invariant.spec.ts b/packages/util/atomic-write/tests/invariant.spec.ts deleted file mode 100644 index c80346762c..0000000000 --- a/packages/util/atomic-write/tests/invariant.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import InvariantService from '@deepseek-ai/dsh-invariants' -import * as AtomicWriteInvariant from '../src/invariant.ts' - -describe('atomic-write invariant companion', () => { - it('registers its explained empty runtime invariant', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService) - const fiber = await ctx.plugin(AtomicWriteInvariant) - - expect(() => { - ctx.invariants.register('@deepseek-ai/dsh-atomic-write', () => {}) - }).toThrow(/already registered/) - await fiber.dispose() - await ctx.fiber.dispose() - }) -}) diff --git a/packages/util/atomic-write/tsconfig.json b/packages/util/atomic-write/tsconfig.json deleted file mode 100644 index d970a00263..0000000000 --- a/packages/util/atomic-write/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../support/invariants" - } - ] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e0ba77304..ad035f6e18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -273,9 +273,6 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../packages/llm/llm-deepseek - '@deepseek-ai/dsh-llm-pi-ai': - specifier: workspace:^ - version: link:../../packages/llm/llm-pi-ai '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../packages/llm/llm-retry @@ -2318,9 +2315,6 @@ importers: packages/credentials/credentials-local: dependencies: - chokidar: - specifier: ^4.0.3 - version: 4.0.3 dotenv: specifier: ^17.2.0 version: 17.4.2 @@ -2328,9 +2322,6 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: - '@deepseek-ai/dsh-atomic-write': - specifier: workspace:^ - version: link:../../util/atomic-write '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../credentials @@ -4334,9 +4325,6 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: - '@deepseek-ai/dsh-atomic-write': - specifier: workspace:^ - version: link:../../util/atomic-write '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5505,15 +5493,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/util/atomic-write: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/util/brand: devDependencies: '@deepseek-ai/dsh-invariants': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b9dbb501ec..4c58a2a261 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -33,7 +33,6 @@ export const LINK_MAP: Readonly> = { MessageId: 'core.md', HookContext: 'core.md', SettleReason: 'core.md', - AdapterRegistrationHandle: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmModelReasoningInfo: 'core.md', @@ -193,8 +192,6 @@ export const LINK_MAP: Readonly> = { SettingsDescriptor: 'settings.md', SettingsUpdateSource: 'settings.md', CredentialRef: 'credentials.md', - CredentialInfo: 'credentials.md', - ResolvedCredential: 'credentials.md', AskUserQuestionAnswer: 'user-interaction.md', AskUserQuestionRequest: 'user-interaction.md', UserInteractionProvider: 'user-interaction.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3b84c91e9c..ee7a6899ca 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -31,11 +31,6 @@ "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "AdapterRegistrationHandle", - "source": "packages/llm/llm/src/index.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmProviderInfo", @@ -1378,16 +1373,6 @@ "doc": "docs/core-data-structures/credentials.md", "symbol": "CredentialRef", "source": "packages/credentials/credentials/src/index.ts" - }, - { - "doc": "docs/core-data-structures/credentials.md", - "symbol": "ResolvedCredential", - "source": "packages/credentials/credentials/src/index.ts" - }, - { - "doc": "docs/core-data-structures/credentials.md", - "symbol": "CredentialInfo", - "source": "packages/credentials/credentials/src/index.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index f5cab677be..c11c6b6d48 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -106,7 +106,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' }, 'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model surface a value authorizes.' }, 'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model surface.' }, - 'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index d255271dfa..3750979247 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -61,7 +61,6 @@ { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" }, - { "path": "./packages/util/atomic-write" }, { "path": "./packages/llm/llm" }, { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" }, From afb05b40495331bc190f02181bb2cfd83b75849b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:14:43 +0800 Subject: [PATCH 4/6] fix(settings-local): never steal writer locks --- ...30-settings-write-path-integrity.i18n.yaml | 4 +- ...026-07-30-settings-write-path-integrity.md | 7 ++-- ...-07-30-settings-write-path-integrity.zh.md | 9 ++--- .../settings/settings-local/README.i18n.yaml | 4 +- packages/settings/settings-local/README.md | 3 +- packages/settings/settings-local/README.zh.md | 3 +- packages/settings/settings-local/src/index.ts | 22 +---------- .../settings-local/tests/concurrency.spec.ts | 21 ++++------ .../settings-local/tests/lock-race.spec.ts | 38 ++++++------------- 9 files changed, 36 insertions(+), 75 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml index fa54d657a9..f0de393d84 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.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-settings-write-path-integrity.md -2026-07-30-settings-write-path-integrity.md: 07bd095162879c8e7866846cf562f6a13307e5fc -2026-07-30-settings-write-path-integrity.zh.md: 5d02177073d482b61750d7bdfbbd0866bc227a6a +2026-07-30-settings-write-path-integrity.md: f6b39ebc323e635945d2eae049c0d29e94c390b6 +2026-07-30-settings-write-path-integrity.zh.md: de60cc05751893d1875f2a68a8b357c5572940ad diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md index 07bd095162..f6b39ebc32 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md @@ -14,7 +14,7 @@ Review found the provider's write path could destroy state it never observed, an **One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active. -**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff, a 2 s acquisition deadline, and stale takeover after 5 s (a crashed holder, broken with a warning). Readers never lock — the rename commit is atomic — so contention is writer-only and resolves in milliseconds. The lock constants are protocol invariants, not config: a holder rewrites one small document, so the deadline and stale age derive from that bound, not from deployment taste. +**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. Readers never lock because rename commits atomically. A contender never removes a lock it did not create: age cannot distinguish an abandoned lock from a slow live holder, and deleting by age can also remove a successor acquired between inspection and deletion. Contention therefore rejects at the deadline, leaving an abandoned lock for explicit operator recovery. **Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is. @@ -24,7 +24,8 @@ Review found the provider's write path could destroy state it never observed, an ## Alternatives considered -- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is ~40 lines with deterministic tests (including injected `EEXIST`/`stat` races). The policy favors dependencies that delete owned code; this one would replace 40 explained lines with an opaque peer. +- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is a small exclusive-create/backoff loop with deterministic tests. The policy favors dependencies that delete owned code; this one would replace explained local behavior with an opaque peer. +- **Age-based stale-lock takeover** — age is not ownership. A slow holder may legitimately cross the threshold, and an inspector can delete a successor's newly acquired lock after the old holder releases. Failing closed preserves mutual exclusion; recovery is explicit because only the operator can establish that no writer remains. - **Revision/CAS instead of a lock** — rename cannot express compare-and-swap, so a CAS needs a version sidecar or content re-hash and a retry loop in every writer; the lock achieves the same serialization with one primitive and keeps readers free. - **Merging external edits into the in-flight write's own section** — the seam merges patches over the state visible at call time, so a same-namespace external edit racing a write still resolves last-write-wins; folding it in would need three-way merge semantics no consumer has asked for. The write publishes the external state first, so the loser is at least observed before being superseded. - **Declaring async `settings/updated` listeners unsupported** — the typed signature is `void` and lint flags misused promises, but an unlinted JS plugin can still register an async listener; a contract note cannot un-throw an unhandled rejection, so containment is the only defense that holds at runtime. @@ -32,4 +33,4 @@ Review found the provider's write path could destroy state it never observed, an ## Consequences -`update()` gained a documented failure mode (lock deadline, invalid on-disk document) and the rejection messages carry `$`-rooted paths. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up. +`update()` has documented failure modes for lock-deadline expiry and an invalid on-disk document, and rejection messages carry `$`-rooted paths. An abandoned lock blocks writes until an operator confirms that no writer owns it and removes the sidecar. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md index 5d02177073..de60cc0575 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md @@ -18,7 +18,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 **单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。 -**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行:指数退避、2 s 获取截止时间、5 s 后陈旧接管(持有者已崩溃;打破旧锁时给出告警)。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间,毫秒级即可化解。锁的各项常量是协议不变式,不是配置:持有者只是重写一份小文档,截止时间与陈旧时限都从这一上界推得,而非出自部署偏好。 +**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。读方从不加锁,因为 rename 会原子提交。竞争者绝不移除并非由自己创建的锁:锁龄无法区分遗留锁与仍存活的慢速持有者,按锁龄删除还可能移除检查与删除之间由后继者取得的新锁。因此,竞争会在期限到达时拒绝写入,把遗留锁留给操作者显式恢复。 **观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件契约现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。 @@ -28,7 +28,8 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 ## 曾考虑的替代方案 -- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁约 40 行并带确定性测试(含注入的 `EEXIST`/`stat` 竞态)。该政策偏向能删除自有代码的依赖;这个依赖只会把 40 行带解释的代码换成一个不透明的等价物。 +- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁只是一个较小的独占创建/退避循环,带确定性测试。该政策偏向能删除自有代码的依赖;这个依赖只会把解释清楚的本地行为换成一个不透明的等价物。 +- **按锁龄接管陈旧锁**——锁龄不等于所有权。慢速持有者可能合理地跨过阈值,而旧持有者释放后,检查方还可能删除后继者新取得的锁。以失败收口可以保住互斥性;恢复必须显式进行,因为只有操作者才能确认已无写入方存活。 - **用修订号/CAS 取代锁**——rename 表达不了 compare-and-swap,因此 CAS 需要一个版本伴随文件或内容重哈希,外加每个写方里的一个重试循环;锁用一个原语实现同样的串行化,还让读方完全免锁。 - **把外部编辑合并进正在进行的写入自身的分节**——seam 是在调用时刻可见的状态之上合并 patch 的,因此与写入竞态的同 namespace 外部编辑仍按后写胜出解决;要把外部编辑并进来,需要三方合并语义,而没有任何消费方提出过这种需求。写入会先发布外部状态,落败一方至少在被取代之前被观察到。 - **宣布不支持异步 `settings/updated` 监听器**——类型签名是 `void`,lint 也会标记误用的 promise,但未经 lint 的 JS 插件仍能注册异步监听器;契约里的一句说明无法收回已经抛出的 unhandled rejection,收容是唯一在运行时守得住的防线。 @@ -36,6 +37,4 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 ## 后果 -`update()` 有了成文的失败模式(锁截止时间到期、磁盘文档非法),rejection 消息携带以 `$` 为根的路径。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。 - -[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包(package)的那些 PR(Pull Request)所有,向上合并时按本模板处理。 +`update()` 对锁获取期限到达与磁盘文档非法都有成文的失败模式,rejection 消息携带以 `$` 为根的路径。遗留锁会阻塞写入,直到操作者确认没有写入方拥有它并移除伴随文件。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。 diff --git a/packages/settings/settings-local/README.i18n.yaml b/packages/settings/settings-local/README.i18n.yaml index 9638a62f96..2767afc5bf 100644 --- a/packages/settings/settings-local/README.i18n.yaml +++ b/packages/settings/settings-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md -README.md: 2c0817afd2f2fd35fda2d22cd7f7ef3772fe2257 -README.zh.md: 547abb035368f07d4478a5c3a1793cdaa6743c68 +README.md: 39c2254caac720149d2fbf04d067e6154b82a671 +README.zh.md: 9fcc1319a35d56a965eb756737175dc89518c0e5 diff --git a/packages/settings/settings-local/README.md b/packages/settings/settings-local/README.md index 2c0817afd2..39c2254caa 100644 --- a/packages/settings/settings-local/README.md +++ b/packages/settings/settings-local/README.md @@ -19,7 +19,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension - **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state. - **Every write is a read-modify-write.** A persist first re-reads the document and publishes any difference into the seam — an external edit still inside the watcher debounce window, a change the watcher missed, or another process's write — then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. If the on-disk document turned invalid, the write rejects loud instead of overwriting the user's manual edit. -- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `.lock` sibling with exponential backoff, a 2 s acquisition deadline (the write rejects), and stale-lock takeover after 5 s (a crashed holder, broken with a warning). Readers never take the lock: the rename commit is atomic, so reloads are always consistent. +- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `.lock` sibling with exponential backoff and a 2 s acquisition deadline. A contender never removes a lock it does not own; it rejects at the deadline instead. Readers never take the lock: the rename commit is atomic, so reloads are always consistent. - **Write-back is atomic, owner-only, and symlink-proof.** The render exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure. - **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments. - **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed. @@ -38,6 +38,7 @@ No direct invalidation; the consuming plugin owns any request-prefix changes. ## Known Limitations and Deferred Work - **Same-namespace conflicts stay last-write-wins** — the writer lock and read-modify-write keep concurrent writers from dropping each other's namespaces, but two writers editing one namespace still resolve to the later write; there is no per-value merge or revision check. +- **An abandoned writer lock requires operator recovery** — lock age cannot prove ownership, so writers fail closed after 2 s instead of deleting an old lock that may still protect a slow holder; remove `.lock` only after establishing that no writer owns it. - **A missed watcher event stays unseen until the next signal** — reads never re-stat the file, so a change the watcher fails to report is only folded in by the next event, the next write, or a restart. - **Comment preservation is YAML-only and map-shaped** — JSON documents re-serialize without comments (JSON has none), and comments inside a changed array (or attached inline to a changed scalar value) go with the value they described. - **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature. diff --git a/packages/settings/settings-local/README.zh.md b/packages/settings/settings-local/README.zh.md index 547abb0353..9fcc1319a3 100644 --- a/packages/settings/settings-local/README.zh.md +++ b/packages/settings/settings-local/README.zh.md @@ -19,7 +19,7 @@ - **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。 - **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入响亮拒绝,而不是覆盖用户的手工编辑。 -- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `.lock` 同级文件下运行,带指数退避、2 s 的获取期限(到期则写入拒绝)与 5 s 后的陈旧锁接管(持有者已崩溃,破锁并告警)。读取方从不取锁:rename 提交是原子的,重载因此始终一致。 +- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `.lock` 同级文件下运行,带指数退避与 2 s 的获取期限。竞争者绝不移除不归自己所有的锁,而会在期限到达时拒绝写入。读取方从不取锁:rename 提交是原子的,重载因此始终一致。 - **写回原子、仅属主可读、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。 - **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。 - **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。 @@ -38,6 +38,7 @@ ## Known Limitations and Deferred Work - **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。 +- **遗留的写锁需要操作者恢复** — 锁的存续时间无法证明所有权,因此写入方会在 2 s 后以失败收口,不会删除一把可能仍在保护慢速持有者的旧锁;只有确认没有写入方拥有 `.lock` 后才能将其移除。 - **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。 - **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化,无注释(JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。 - **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。 diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 8f41f17171..d0b8e1980c 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -11,7 +11,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { watch as chokidarWatch } from 'chokidar' import { randomBytes } from 'node:crypto' -import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { Document, parseDocument } from 'yaml' import { resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -105,7 +105,6 @@ function isEEXIST(error: unknown): boolean { const LOCK_RETRY_INITIAL_MS = 20 const LOCK_RETRY_MAX_MS = 200 const LOCK_TIMEOUT_MS = 2_000 -const LOCK_STALE_MS = 5_000 /** File-backed settings provider (`settings.yaml`/`.json`). */ export class SettingsLocal extends Settings { @@ -229,15 +228,6 @@ export class SettingsLocal extends Settings { } catch (error) { if (!isEEXIST(error)) throw error } - const ageMs = await this.lockAgeMs(lockPath) - if (ageMs === undefined) continue - if (ageMs > LOCK_STALE_MS) { - // TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe - // acquisition and release so a slow writer cannot remove a successor's lock. - this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath) - await rm(lockPath, { force: true }) - continue - } if (Date.now() >= deadline) { throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`) } @@ -251,16 +241,6 @@ export class SettingsLocal extends Settings { } } - /** Age of the writer lock, or `undefined` when it vanished after a failed create. */ - private async lockAgeMs(lockPath: string): Promise { - try { - return Date.now() - (await stat(lockPath)).mtimeMs - } catch (error) { - if (!isENOENT(error)) throw error - return undefined - } - } - override async* [Service.init](): AsyncGenerator<() => Promise | void, void, void> { // The base init loads and publishes; a parse failure there is a boot // failure: an existing-but-invalid document must fail loud, never be diff --git a/packages/settings/settings-local/tests/concurrency.spec.ts b/packages/settings/settings-local/tests/concurrency.spec.ts index ab09866819..1c6996434a 100644 --- a/packages/settings/settings-local/tests/concurrency.spec.ts +++ b/packages/settings/settings-local/tests/concurrency.spec.ts @@ -70,25 +70,20 @@ describe('writer lock', () => { expect(await readFile(path, 'utf8')).toContain('value: 7') }) - it('breaks a stale writer lock with a warning and writes through', async () => { + it('does not steal an old writer lock', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') + await writeFile(path, 'alpha:\n value: 4\n') const ctx = await boot({ path, watch: false }) const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) - await writeFile(`${path}.lock`, 'crashed-holder\n') + const lockPath = `${path}.lock` + await writeFile(lockPath, 'slow-holder\n') const past = (Date.now() - 60_000) / 1000 - await utimes(`${path}.lock`, past, past) - await scope.update({ value: 9 }) - expect(await readFile(path, 'utf8')).toContain('value: 9') - }) + await utimes(lockPath, past, past) - it('times out on a lock a live holder never releases', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) - await writeFile(`${path}.lock`, 'busy-holder\n') - await expect(scope.update({ value: 1 })).rejects.toThrow(/timed out waiting for the writer lock/) + await expect(scope.update({ value: 9 })).rejects.toThrow(/timed out waiting for the writer lock/) + expect(await readFile(path, 'utf8')).toContain('value: 4') + expect(await readFile(lockPath, 'utf8')).toBe('slow-holder\n') }, 10_000) it('surfaces a non-contention lock failure as the write error', async () => { diff --git a/packages/settings/settings-local/tests/lock-race.spec.ts b/packages/settings/settings-local/tests/lock-race.spec.ts index 09eb025654..6222041e4c 100644 --- a/packages/settings/settings-local/tests/lock-race.spec.ts +++ b/packages/settings/settings-local/tests/lock-race.spec.ts @@ -1,6 +1,6 @@ // Writer-lock races that cannot be timed from outside: a contender whose lock -// vanishes between the failed exclusive create and the stat, a stat failing -// for a reason other than absence, and a temp-file write failing mid-cycle. +// vanishes after the failed exclusive create and a temp-file write failing +// mid-cycle. // The fs/promises seam is partially mocked to inject exactly one failure at a // chosen path suffix; everything else passes through to the real filesystem. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -14,27 +14,23 @@ import { SettingsLocal } from '../src/index.ts' const state = vi.hoisted(() => ({ /** One-shot failure injections keyed by operation, matched on a path suffix. */ - failures: [] as Array<{ op: 'writeFile' | 'stat'; suffix: string; code: string }>, + failures: [] as Array<{ suffix: string; code: string }>, })) vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() - const inject = (op: 'writeFile' | 'stat', path: unknown): void => { - const index = state.failures.findIndex(f => f.op === op && String(path).endsWith(f.suffix)) + const inject = (path: unknown): void => { + const index = state.failures.findIndex(f => String(path).endsWith(f.suffix)) if (index === -1) return const [failure] = state.failures.splice(index, 1) - throw Object.assign(new Error(`${failure!.code}: injected ${op} failure`), { code: failure!.code }) + throw Object.assign(new Error(`${failure!.code}: injected writeFile failure`), { code: failure!.code }) } return { ...actual, writeFile: (async (path: unknown, ...rest: never[]) => { - inject('writeFile', path) + inject(path) return (actual.writeFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) }) as typeof actual.writeFile, - stat: (async (path: unknown, ...rest: never[]) => { - inject('stat', path) - return (actual.stat as (path: unknown, ...args: never[]) => Promise)(path, ...rest) - }) as typeof actual.stat, } }) @@ -62,36 +58,24 @@ async function boot(config: ConstructorParameters[1]): Pro } describe('writer-lock races', () => { - it('retries immediately when the contending lock vanished before the stat', async () => { + it('retries when the contending lock vanished after the failed create', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') const ctx = await boot({ path, watch: false }) const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) - // The exclusive create loses to a holder that releases before the stat: - // no lock file actually exists, so the stat sees honest absence and the - // very next attempt takes the lock. - state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' }) + // The exclusive create loses once, but no lock remains by the retry. + state.failures.push({ suffix: '.lock', code: 'EEXIST' }) await scope.update({ value: 3 }) expect(await readFile(path, 'utf8')).toContain('value: 3') }) - it('propagates a stat failure that does not mean absence', async () => { - const dir = await tempDir() - const path = join(dir, 'settings.yaml') - const ctx = await boot({ path, watch: false }) - const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) - state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' }) - state.failures.push({ op: 'stat', suffix: '.lock', code: 'EACCES' }) - await expect(scope.update({ value: 3 })).rejects.toThrow(/EACCES/) - }) - it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => { const dir = await tempDir() const path = join(dir, 'settings.yaml') await writeFile(path, 'alpha:\n value: 1\n') const ctx = await boot({ path, watch: false }) const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema) - state.failures.push({ op: 'writeFile', suffix: '.tmp', code: 'ENOSPC' }) + state.failures.push({ suffix: '.tmp', code: 'ENOSPC' }) await expect(scope.update({ value: 9 })).rejects.toThrow(/ENOSPC/) // The document is untouched and the writer lock was released on the way out. expect(await readFile(path, 'utf8')).toContain('value: 1') From a9e2489db88f3cc98a12ee2096532314ddd895ce Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:27:12 +0800 Subject: [PATCH 5/6] fix(llm): keep request generations coherent --- ...est-level-llm-config-credentials.i18n.yaml | 4 +- ...29-request-level-llm-config-credentials.md | 4 +- ...request-level-llm-config-credentials.zh.md | 4 +- ...undaries-and-atomic-registration.i18n.yaml | 4 +- ...tial-boundaries-and-atomic-registration.md | 8 +-- ...l-boundaries-and-atomic-registration.zh.md | 10 +-- ...redentials-and-static-llm-routes.i18n.yaml | 4 +- ...-only-credentials-and-static-llm-routes.md | 2 +- ...ly-credentials-and-static-llm-routes.zh.md | 2 +- docs/config-catalog.md | 14 ++-- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 8 +-- packages/llm/llm-deepseek/README.zh.md | 8 +-- packages/llm/llm-deepseek/src/adapter.ts | 51 ++++++++------- packages/llm/llm-deepseek/src/index.ts | 47 +++++++++----- .../llm/llm-deepseek/tests/adapter.spec.ts | 9 ++- .../llm-deepseek/tests/dynamic-config.spec.ts | 65 ++++++++++++------- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 12 ++-- packages/llm/llm-pi-ai/README.zh.md | 12 ++-- packages/llm/llm-pi-ai/src/adapter.ts | 23 ++++--- packages/llm/llm-pi-ai/src/config.ts | 4 +- packages/llm/llm-pi-ai/src/index.ts | 26 ++++---- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +- .../llm-pi-ai/tests/dynamic-config.spec.ts | 42 ++++++++++++ .../llm/llm-pi-ai/tests/sdk-options.spec.ts | 4 +- 26 files changed, 237 insertions(+), 142 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml index 4ec3f9de2d..530f8e95f8 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md -2026-07-29-request-level-llm-config-credentials.md: ec00b52bdbe8f00d334618f3e5347974a3928e67 -2026-07-29-request-level-llm-config-credentials.zh.md: 29835b9fb320e6b31cb49a632ff0e56d88fb3f44 +2026-07-29-request-level-llm-config-credentials.md: d84de8b74eb88720d782dfa20e193e6d180843dc +2026-07-29-request-level-llm-config-credentials.zh.md: 67b2d2e7cf952a7cb385e7c9dd67bd3a05651beb diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md index ec00b52bdb..d84de8b74e 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md @@ -12,11 +12,11 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti ## Decision -**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk and a per-stream credential resolver instead of rebuilding their fibers. Connection, credential, and request-transport facts are read for the operation, while an in-flight stream keeps the facts it started with. A missing key is a request-time `MISSING_CREDENTIAL` failure while the route remains registered. Provider routes and their retry policies are composition-fixed instead of triggering registration swaps. +**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk and a per-stream credential resolver instead of rebuilding their fibers. Connection, credential, and request-transport facts are read for the stream, while an in-flight stream keeps the facts it started with. Model catalog/capability, context, reasoning-default, provider-route, and retry-policy facts are composition-fixed. A missing key is a request-time `MISSING_CREDENTIAL` failure while the route remains registered. **Secrets are references, values live behind `ctx.credentials`.** Configuration can carry `apiKeyEnv: DEEPSEEK_API_KEY`; the read-only credential seam resolves it per operation. `credentials-local` checks the live process environment first, then parses `$DSH_HOME/.env` on demand, with no cache or mutation surface. Resolution order in the adapters is a non-empty literal `apiKey` first, then the seam, then — only without a mounted seam — the named raw environment variable. -**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and `cordis.yml` entry as the composition `base`. `resolveAdapterOptions` and `resolveProfiles` remain the explicit validation steps, and a bad live snapshot keeps the last good request facts while a bad entry config fails load. pi-ai's `providers` is a non-empty dict keyed by its composition-owned routes; the user layer may override request facts for those routes but cannot add or remove them. +**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and `cordis.yml` entry as the composition `base`. `resolveAdapterOptions` and `resolveProfiles` remain the explicit validation steps. A live snapshot that changes a fixed fact or fails another bound keeps the whole last-good generation, while a bad entry config fails load. pi-ai's `providers` is a non-empty dict keyed by its composition-owned routes; the user layer may override only live request facts for those routes. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md index 29835b9fb3..67b2d2e7cf 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -**按请求解析,而非重建 fiber。**适配器接收 options thunk 与按流调用的凭据解析器,不再重建其 fiber。连接、凭据与请求传输事实在操作期间读取,进行中的流则保持其起始事实。密钥缺失会在请求时以 `MISSING_CREDENTIAL` 失败,同时路由保持注册。提供方路由及其重试策略由组合固定,不触发注册替换。 +**按请求解析,而非重建 fiber。**适配器接收 options thunk 与按流调用的凭据解析器,不再重建其 fiber。连接、凭据与请求传输事实按流读取,进行中的流则保持其起始事实。模型 catalog/能力、上下文、推理(reasoning)默认值、提供方路由与重试策略由组合固定。密钥缺失会在请求时以 `MISSING_CREDENTIAL` 失败,同时路由保持注册。 **机密是引用,值藏在 `ctx.credentials` 背后。**配置可以携带 `apiKeyEnv: DEEPSEEK_API_KEY`;只读凭据 seam 按操作解析它。`credentials-local` 先检查活跃进程环境,再按需解析 `$DSH_HOME/.env`,既不缓存,也不提供变更接口。适配器内的解析顺序为:非空的字面 `apiKey` 优先,然后是 seam,最后仅在未挂载 seam 时读取点名的原始环境变量。 -**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),采用其插件 `Config` schema,并以 `cordis.yml` 配置项为组合 `base`。`resolveAdapterOptions` 与 `resolveProfiles` 仍是显式校验步骤;错误的存活快照会保留最后可用的请求事实,错误的 entry 配置则会加载失败。pi-ai 的 `providers` 是以组合所拥有路由为键的非空字典;用户层可以覆盖这些路由的请求事实,但不能新增或移除路由。 +**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),采用其插件 `Config` schema,并以 `cordis.yml` 配置项为组合 `base`。`resolveAdapterOptions` 与 `resolveProfiles` 仍是显式校验步骤。存活快照若更改固定事实或违反其他约束,会整代沿用最后可用设置;错误的 entry 配置则会加载失败。pi-ai 的 `providers` 是以组合所拥有路由为键的非空字典;用户层只能覆盖这些路由的实时请求事实。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index f7fde2b4e7..1c3eaa76a9 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 09beda90d4789f951f663a3f9df794d74db2c11e -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 84f3826ba8a8b89fd5dd616164ac573151b8203c +2026-07-30-credential-boundaries-and-atomic-registration.md: 2a808048bccc619a311e61340b5aeb0f3113030d +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 4c2147ea0f0d30624500aeda92fb02932a5b0960 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md index 09beda90d4..2a808048bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -1,4 +1,4 @@ -# Agent Note: credential boundaries, whole-snapshot requests, and atomic route registration +# Agent Note: credential boundaries and whole-generation LLM requests Status: implemented @@ -10,7 +10,7 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m Review found the credential path leaking across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-local defects that same review round fixed (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user. -Two request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied. +Request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. Both adapters also read their full settings snapshot during asynchronous model-capability resolution and again at stream dispatch; a change between those reads could pair one generation's reasoning/capability facts with the next generation's endpoint and key. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. ## Decision @@ -18,7 +18,7 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept **The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one. -**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error. +**One request cannot straddle settings generations.** Model catalog/capability, context, reasoning-default, provider-route, and retry-policy facts are captured from composition. Only connection, credential, and request-transport facts resolve live, once at stream dispatch. A live snapshot that changes any fixed fact is rejected whole, so its endpoint and key cannot combine with capability facts resolved before the change. DeepSeek's accepted snapshot carries the literal key and reference beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and reference. **Provider routes are composition-owned.** `registerAdapter` binds one non-empty route set to its calling fiber and returns a disposer. Settings cannot create or remove routes or change their captured retry policy, so the registry needs no replacement lifecycle and a bad settings snapshot leaves the composition registration untouched. @@ -33,4 +33,4 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept ## Consequences -The local provider performs a direct environment-then-dotenv read for each resolution; mutation, description, writer locking, and change events are absent. `LlmAdapter` registrants receive an ordinary disposer, and `DeepSeekConnectionOptions` carries credential facts with its endpoint so one rejected settings generation cannot contribute only a key. An OS-keychain provider remains the path to isolating secrets from same-user model tools. +The local provider performs a direct environment-then-dotenv read for each resolution; mutation, description, writer locking, and change events are absent. `LlmAdapter` registrants receive an ordinary disposer. Each adapter captures model/capability defaults from composition and resolves one live connection snapshot at dispatch, so a rejected settings generation cannot contribute only an endpoint or key. An OS-keychain provider remains the path to isolating secrets from same-user model tools. diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index 84f3826ba8..4c2147ea0f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -1,10 +1,10 @@ -# Agent Note: 凭据边界、按整份快照发起的请求与原子路由注册 +# Agent Note: 凭据边界与 LLM(大语言模型)请求的同代一致性 Status: implemented [English](2026-07-30-credential-boundaries-and-atomic-registration.md) | 中文 -> 范围:对[请求级 LLM(大语言模型)配置 seam](2026-07-29-request-level-llm-config-credentials.md)的存储与请求边界修正。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了凭据写入、共享原子写入器与可变注册;本 note 负责保留至今的机密边界与整次请求同代规则。 +> 范围:对[请求级 LLM 配置 seam](2026-07-29-request-level-llm-config-credentials.md)的存储与请求边界修正。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了凭据写入、共享原子写入器与可变注册;本 note 负责保留至今的机密边界与整次请求同代规则。 ## 问题 @@ -14,7 +14,7 @@ Status: implemented 在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。 -与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。 +与之并排的还有请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。两个适配器还会在异步模型能力解析期间读取一次完整 settings 快照,并在流派发时再次读取;若两次读取之间发生变更,一代的推理(reasoning)/能力事实就可能与下一代的端点和密钥拼接在一起。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。 ## 决策 @@ -22,7 +22,7 @@ Status: implemented **存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。 -**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。 +**一次请求不得跨 settings 代取值。**模型 catalog/能力、上下文、推理默认值、提供方路由与重试策略均从组合中捕获。只有连接、凭据与请求传输事实实时解析,并且只在流派发时解析一次。存活快照若更改任何固定事实,就会整代被拒绝,因此它的端点与密钥无法同变更前解析出的能力事实组合。DeepSeek 已接受的快照在端点旁一并携带字面密钥与引用,`resolveApiKey` 接收这份快照,而不再重新读取配置。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名路由与引用。 **提供方路由归组合所有。**`registerAdapter` 把一组非空路由绑定到调用方 fiber,并返回释放器。settings 无法创建或移除路由,也无法更改注册时捕获的重试策略,因此注册表无需替换生命周期,错误的 settings 快照也不会影响组合注册。 @@ -37,4 +37,4 @@ Status: implemented ## 后果 -本地提供方每次解析都会依次直接读取环境与 dotenv;修改、描述、写入锁和变更事件均不存在。`LlmAdapter` 注册方收到普通释放器;`DeepSeekConnectionOptions` 将凭据事实与端点一同携带,因此一代被拒绝的 settings 不可能只贡献密钥。OS 钥匙串提供方仍是将机密与同一用户身份下的模型工具隔离的实现路径。 +本地提供方每次解析都会依次直接读取环境与 dotenv;修改、描述、写入锁和变更事件均不存在。`LlmAdapter` 注册方收到普通释放器。每个适配器从组合中捕获模型/能力默认值,并在派发时解析一份实时连接快照,因此被拒绝的 settings 代不可能只贡献端点或密钥。OS 钥匙串提供方仍是将机密与同一用户身份下的模型工具隔离的实现路径。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml index 0e69e9aba4..5ba2c6cc6d 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.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/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md -2026-07-31-read-only-credentials-and-static-llm-routes.md: 2ebbea28c2dadeb9482c483247249ed4054749e4 -2026-07-31-read-only-credentials-and-static-llm-routes.zh.md: ad21d93bf73ca0063eb97da8e610aa2814ee1726 +2026-07-31-read-only-credentials-and-static-llm-routes.md: 3e0d4ceb612379d2ea29068605e0f7c60f1019a4 +2026-07-31-read-only-credentials-and-static-llm-routes.zh.md: eca740c6d58d67a11270dba427efbb3b70852d2c diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md index 2ebbea28c2..3e0d4ceb61 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md @@ -14,7 +14,7 @@ That speculative closure accounted for much of the feature's runtime and test gr `ctx.credentials` exposes only branded `CredentialRef` construction and `resolve(ref): Promise`. `credentials-local` reads the named process environment value, then parses its dotenv file on demand. It owns no mutation, description, event, watcher, cache, editor, or writer lifecycle; externally changing either source is visible to the next resolution. -LLM provider routes and their retry policies are composition-owned. `registerAdapter()` returns a disposer rather than a mutable registration handle. DeepSeek always owns its one route, and pi-ai requires a non-empty configured route map; settings may change request-level facts for those existing routes but cannot create, remove, or retune registrations. The shared CLI composition therefore does not mount an empty pi-ai adapter. +LLM provider routes, model/capability metadata, context limits, reasoning defaults, and retry policies are composition-owned. `registerAdapter()` returns a disposer rather than a mutable registration handle. DeepSeek always owns its one route, and pi-ai requires a non-empty configured route map; settings may change only connection, credential, and request-transport facts for those existing routes. A settings generation that changes a fixed fact is rejected whole. The shared CLI composition therefore does not mount an empty pi-ai adapter. The optional-settings helper only switches a consumer's source thunk between its composition entry and a live settings scope. Consumers read committed values through that thunk, so the helper needs no update watcher, derived-state callback, or teardown-state mirror. `settings-local` keeps its write protocol private instead of publishing a utility for a second writer that no longer exists. diff --git a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md index ad21d93bf7..eca740c6d5 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.zh.md @@ -14,7 +14,7 @@ Status: implemented `ctx.credentials` 只暴露品牌化 `CredentialRef` 的构造,以及 `resolve(ref): Promise`。`credentials-local` 先读取点名的进程环境值,再按需解析其 dotenv 文件。它不拥有修改、描述、事件、watcher、缓存、编辑器或写入器生命周期;从外部更改任一来源,都会在下一次解析时生效。 -LLM 提供方路由及其重试策略归组合所有。`registerAdapter()` 返回释放器,而非可变注册句柄。DeepSeek 始终拥有自身唯一的路由,pi-ai 则要求配置一份非空路由映射;settings 可以更改这些现有路由的请求级事实,但不能创建、移除或重新调整注册。因此,共享 CLI(命令行界面)组合不会挂载空的 pi-ai 适配器。 +LLM 提供方路由、模型/能力元数据、上下文限制、推理(reasoning)默认值与重试策略归组合所有。`registerAdapter()` 返回释放器,而非可变注册句柄。DeepSeek 始终拥有自身唯一的路由,pi-ai 则要求配置一份非空路由映射;settings 只能更改这些现有路由的连接、凭据与请求传输事实。更改固定事实的 settings 代会整代被拒绝。因此,共享 CLI(命令行界面)组合不会挂载空的 pi-ai 适配器。 可选 settings 辅助工具只在组合配置项与存活 settings scope 之间切换消费方的来源 thunk。消费方经该 thunk 读取已提交值,因此辅助工具不需要更新 watcher、派生状态回调或拆卸状态镜像。`settings-local` 将自身的写入协议保留为私有实现,不再为一个已不存在的第二写入方公开工具。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0ae1cf6ea1..7fb62dbb3b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -630,17 +630,17 @@ export interface Config { apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string - /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ + /** Composition-fixed thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' - /** Default thinking effort (default `high`); `off` disables thinking per request. */ + /** Composition-fixed default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Positive context capacity used when the selected model has no exact value. */ + /** Composition-fixed positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number - /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ + /** Composition-fixed advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } @@ -682,7 +682,7 @@ export interface PiAiProviderProfile { baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record - /** Provider-neutral pi-ai reasoning level. */ + /** Composition-fixed provider-neutral pi-ai reasoning default. */ reasoning?: ModelThinkingLevel /** Token budgets used by reasoning providers that support them. */ thinkingBudgets?: ThinkingBudgets @@ -696,7 +696,7 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } ``` diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 402bc1e164..be2571097b 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: 8532dab4731e25b4af777217ad7c5ffad521d924 -README.zh.md: 922fb50ae063ae0a1f90db3b915dc55096448a5c +README.md: 9d3014c9d4b3eb4fc2d02920c0cd4577e7977c89 +README.zh.md: d7685749376f58012c7d834047dd2eae754e27b8 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 8532dab473..9d3014c9d4 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -47,9 +47,9 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und ## Dynamic configuration (settings + credentials) -Request 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 take effect on the next operation, while an in-flight stream keeps the facts it started with. The `deepseek` route and its retry policy remain fixed by the plugin composition. Two optional seams feed the request facts: +`resolveAdapterOptions` is the explicit resolve step from raw config to validated facts. The adapter reads live connection, credential, and request-transport facts through a thunk once per stream, so base URL, key, and idle budget changes reach the next request while an in-flight stream keeps its starting facts. The provider route, model catalog, context limits, thinking policy, reasoning default, and retry policy are composition-fixed. Two optional seams feed the live facts: -- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`. Without a mounted settings service the entry config alone drives the adapter. 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 request facts and logs the failure; the entry config itself still fails plugin load. +- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`. Without a mounted settings service the entry config alone drives the adapter. A live snapshot that changes a composition-fixed fact or fails a resolver bound is rejected as a whole generation: it contributes neither its changed connection nor credential. 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 rejected settings snapshot contributes neither its endpoint nor its key. A request with no key anywhere fails with `MISSING_CREDENTIAL`; after the operator supplies the named environment or dotenv value, the next request resolves it without a restart. `ctx.llm.providerRetryPolicy('deepseek')` reports the policy captured from the composition entry at registration. @@ -72,7 +72,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` ## Testing -Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, and composition-fixed retry policy), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. +Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers, including next-request base-URL/key pickup and a change landing between capability resolution and dispatch; the latter proves a generation that changes composition facts cannot contribute a newer endpoint or key. `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document. ## Model Experience @@ -106,7 +106,7 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work -- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. +- **Settings cannot change model/capability defaults** — catalog, context limits, thinking policy, reasoning default, and retry policy belong to composition; a settings generation that changes one is rejected whole. - **`Config.apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 922fb50ae0..d768574937 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -47,9 +47,9 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 动态配置(settings + credentials) -请求事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次操作生效,进行中的流则保持其起始事实。`deepseek` 路由及其重试策略始终由插件组合固定。两个可选 seam 为请求事实供值: +`resolveAdapterOptions` 是从原始配置到已校验事实的显式 resolve 步骤。适配器经由一个 thunk 每个流读取一次实时连接、凭据与请求传输事实,因此 base URL、密钥与 idle 预算变更会作用于下一次请求,进行中的流则保持其起始事实。提供方路由、模型 catalog、上下文限制、思考策略、推理默认值与重试策略由组合固定。两个可选 seam 为实时事实供值: -- **`ctx.settings`**:插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`。未挂载 settings 服务时,仅由 entry 配置驱动适配器。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用的请求事实并记录失败;entry 配置本身仍会使插件加载失败。 +- **`ctx.settings`**:插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`。未挂载 settings 服务时,仅由 entry 配置驱动适配器。存活快照若更改由组合固定的事实或违反 resolver 约束,会整代被拒绝:其变更后的连接与凭据均不会被采用。entry 配置本身仍会使插件加载失败。 - **`ctx.credentials`**:API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后仅在未挂载 seam 时读取原始环境变量。由于凭据事实与连接事实同行,被拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;操作者为点名的环境变量或 dotenv 值供值后,下一次请求无需重启即可解析它。 `ctx.llm.providerRetryPolicy('deepseek')` 报告注册时从组合配置项捕获的策略。 @@ -72,7 +72,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 测试 -单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照,以及由组合固定的重试策略),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 +单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider,覆盖下一请求即生效的 base-URL/密钥拾取,以及落在能力解析与派发之间的变更;后者证明,更改组合事实的一代设置无法贡献更新的端点或密钥。`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。 ## 模型体验 @@ -106,7 +106,7 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 ## 已知限制与暂缓事项 -- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 +- **settings 无法更改模型/能力默认值**:catalog、上下文限制、思考策略、推理默认值与重试策略归组合所有;settings 若更改其中一项,整代设置都会被拒绝。 - **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index a4b02a3e39..adbfa23607 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -1,9 +1,9 @@ /** * `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible) * chat-completions endpoint, emitting harness StreamChunks. The adapter is - * transport-only: connection facts arrive through a thunk resolved once per - * operation and the bearer token through a per-request resolver, so the - * registering plugin owns validation, layering, and credential policy. + * transport-only: live connection facts arrive through a thunk resolved once + * per stream, composition-fixed capability/default facts arrive separately, + * and the bearer token comes from a per-request resolver. * * @module dsh-llm-deepseek/adapter */ @@ -38,10 +38,9 @@ export interface DeepSeekCatalogModel { } /** - * Validated connection facts for one operation. The plugin's - * `resolveAdapterOptions` is the one explicit resolve step producing this - * shape; the adapter trusts it and re-reads it per operation, which is what - * makes a configuration change reach the next request without re-registration. + * Validated adapter facts. The plugin's `resolveAdapterOptions` is the explicit + * resolve step producing this shape; the adapter receives one composition + * snapshot plus a per-stream current snapshot. */ export interface DeepSeekConnectionOptions { /** Endpoint base; `/chat/completions` is appended. */ @@ -54,22 +53,24 @@ export interface DeepSeekConnectionOptions { apiKey?: string /** Credential reference of this same resolution, resolved per request when no literal key exists. */ apiKeyEnv: CredentialRef - /** Request defaults applied to every call (thinking mode, effort). */ + /** Composition-fixed request defaults applied to every call (thinking mode, effort). */ defaults: RequestDefaults - /** Positive context capacity used when the selected model has no exact value. */ + /** Composition-fixed positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number - /** Advisory models exposed to discovery consumers; requests remain unrestricted. */ + /** Composition-fixed advisory models exposed to discovery consumers; requests remain unrestricted. */ models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs: number - /** Provider-owned model-request retry policy, already resolved. */ + /** Composition-fixed provider-owned model-request retry policy, already resolved. */ retryPolicy: ResolvedRetryPolicy } -/** Constructor options for {@link DeepSeekAdapter}: the two resolution seams the plugin owns. */ +/** Constructor inputs for {@link DeepSeekAdapter}: live, composition, and credential facts. */ export interface DeepSeekAdapterOptions { - /** Current validated connection facts; called once per operation. */ + /** Current validated request facts; called once per stream. */ options: () => DeepSeekConnectionOptions + /** Composition snapshot owning catalog, capability/default, context, and retry facts. */ + composition: DeepSeekConnectionOptions /** * Resolve the bearer token for the connection facts of one request. The * snapshot is passed in — never re-read — so the key can only ever come @@ -154,11 +155,11 @@ export class DeepSeekAdapter extends LlmAdapter { } override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { - return this.config.options().retryPolicy + return this.config.composition.retryPolicy } override listModels(provider: string): Promise { - return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model))) + return Promise.resolve(this.config.composition.models.map(model => modelInfo(provider, model))) } override resolveModel( @@ -166,16 +167,16 @@ export class DeepSeekAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const connection = this.config.options() - const configured = connection.models.find(entry => entry.id === model) + const composition = this.config.composition + const configured = composition.models.find(entry => entry.id === model) const contextWindow = configured?.contextWindow - ?? connection.defaultContextWindow + ?? composition.defaultContextWindow return Promise.resolve({ ...configured === undefined ? { provider, id: model, name: model } : modelInfo(provider, configured), ...contextWindow === undefined ? {} : { context: { contextWindow } }, - ...connection.defaults.thinking === 'disabled' + ...composition.defaults.thinking === 'disabled' ? { reasoning: { efforts: OFF_ONLY_REASONING_EFFORTS, @@ -185,9 +186,9 @@ export class DeepSeekAdapter extends LlmAdapter { : { reasoning: { efforts: REASONING_EFFORTS, - defaultEffort: connection.defaults.reasoningEffort === 'off' + defaultEffort: composition.defaults.reasoningEffort === 'off' ? OFF_REASONING_EFFORT - : connection.defaults.reasoningEffort === 'max' + : composition.defaults.reasoningEffort === 'max' ? MAX_REASONING_EFFORT : HIGH_REASONING_EFFORT, }, @@ -196,9 +197,9 @@ export class DeepSeekAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable { - // One resolution per stream call: connection facts and the credential - // freeze here and hold for this whole request, so an in-flight stream - // never observes a configuration change and the next call re-resolves. + // One live resolution per stream call: connection, credential, and + // transport facts freeze here and hold for the request. Model capability + // and default facts come from the composition snapshot above. // The key resolves *from this snapshot*, so an endpoint and the secret // sent to it can never come from different configuration generations. const connection = this.config.options() @@ -250,7 +251,7 @@ export class DeepSeekAdapter extends LlmAdapter { connection: DeepSeekConnectionOptions, apiKey: string, ): AsyncIterable { - const body = serializeRequest(options, connection.defaults) + const body = serializeRequest(options, this.config.composition.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 0801f36747..eb90345d5e 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -4,9 +4,9 @@ * load: the plugin layers its `cordis.yml` entry config under the optional * `llm-deepseek` user-settings section (`ctx.settings`) and resolves the API * key through the optional credential seam (`ctx.credentials`), so a changed - * base URL, catalog, or key reaches the very next request without restarting - * anything, while an in-flight stream keeps the facts it started with. The - * registration-captured facts stay composition-fixed. + * base URL, key, or request-transport control reaches the next request without + * restart. Catalog, capability/default, context, and retry facts stay fixed by + * composition. * @module @deepseek-ai/dsh-llm-deepseek */ @@ -15,7 +15,7 @@ import z from 'schemastery' import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' -import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' @@ -53,17 +53,17 @@ export interface Config { apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */ baseURL?: string - /** Deployment thinking policy; `disabled` limits every conversation request to `off`. */ + /** Composition-fixed thinking policy; `disabled` limits every conversation request to `off`. */ thinking?: 'enabled' | 'disabled' - /** Default thinking effort (default `high`); `off` disables thinking per request. */ + /** Composition-fixed default thinking effort (default `high`); `off` disables thinking per request. */ reasoningEffort?: 'off' | 'high' | 'max' - /** Positive context capacity used when the selected model has no exact value. */ + /** Composition-fixed positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number - /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ + /** Composition-fixed advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } @@ -90,10 +90,9 @@ export const Config: z = z.object({ export const PUBLIC_BASE_URL = 'https://api.deepseek.com' /** - * One resolution's complete request facts. Connection and credential facts - * are one value on purpose: a snapshot the resolver rejects keeps the whole - * previous generation, so a request can never pair a stale endpoint with a - * newer key. + * One resolution's complete adapter facts. Connection and credential facts + * stay one value, while catalog, capability/default, context, and retry facts + * must equal the composition snapshot. */ export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions @@ -165,8 +164,21 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { } } +/** Facts that must stay identical to the plugin composition for the route's lifetime. */ +function compositionFacts(options: ResolvedDeepSeekOptions): unknown { + return { + defaults: options.defaults, + ...options.defaultContextWindow === undefined + ? {} + : { defaultContextWindow: options.defaultContextWindow }, + models: options.models, + retryPolicy: options.retryPolicy, + } +} + export function apply(ctx: Context, config: Config): void { const compositionOptions = resolveAdapterOptions(config) + const fixedFacts = compositionFacts(compositionOptions) let current: () => Config = () => config let lastRaw: Config = config let lastGood = compositionOptions @@ -175,12 +187,17 @@ export function apply(ctx: Context, config: Config): void { if (raw === lastRaw) return lastGood try { const next = resolveAdapterOptions(raw) + if (!deepEqualJson(compositionFacts(next), fixedFacts)) { + throw new Error( + 'llm-deepseek: model catalog, capability defaults, context limits, and retry policy are composition-fixed', + ) + } lastRaw = raw lastGood = next return next } catch (error) { // Static composition resolves before anything registers, so this branch - // only sees a live settings snapshot failing a beyond-schema bound: + // only sees an invalid live snapshot or one that changes a fixed fact: // keep serving the last good facts and say so once per bad snapshot. lastRaw = raw ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section') @@ -211,7 +228,7 @@ export function apply(ctx: Context, config: Config): void { ) } - const adapter = new DeepSeekAdapter({ options, resolveApiKey }) + const adapter = new DeepSeekAdapter({ options, composition: compositionOptions, resolveApiKey }) ctx.llm.registerAdapter([PROVIDER], adapter) installSettingsSection(ctx, NS, Config, config, { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 811b9da1c6..77456c52d2 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -34,8 +34,10 @@ async function harness(baseURL: string, config: object = {}) { /** Direct adapter over the plugin's real resolve step, with a static key. */ function adapterOf(config: Partial & { apiKey?: string } = {}): DeepSeekAdapter { const { apiKey, ...rest } = config + const composition = resolveAdapterOptions(rest) return new DeepSeekAdapter({ - options: () => resolveAdapterOptions(rest), + options: () => composition, + composition, resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), }) } @@ -882,9 +884,10 @@ describe('plugin registration and config', () => { it('resolves connection facts and the credential exactly once per stream call', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url })) + const composition = resolveAdapterOptions({ baseURL: server.url }) + const options = vi.fn(() => composition) const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) - const adapter = new DeepSeekAdapter({ options, resolveApiKey }) + const adapter = new DeepSeekAdapter({ options, composition, resolveApiKey }) for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 9a89734c60..93db5ddce2 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -19,6 +19,7 @@ afterEach(async () => { while (cleanups.length > 0) await cleanups.pop()!() await closeMockServers() vi.unstubAllEnvs() + vi.restoreAllMocks() }) async function home(): Promise { @@ -99,15 +100,13 @@ describe('request-level dynamic configuration', () => { expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') }) - it('advertises a live settings catalog without re-registration', async () => { + it('keeps the model catalog composition-fixed', async () => { const dir = await home() const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) - await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'settings-model', name: 'From Settings' }, - ]) + await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) }) it('keeps the registration retry policy composition-fixed', async () => { @@ -133,33 +132,19 @@ describe('request-level dynamic configuration', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) }) - it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => { - const dir = await home() - const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) - - // Schema-valid but resolver-invalid: duplicate catalog ids pass the array - // schema and fail the explicit resolve step. - await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] }) - await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2) - await ctx.settings.update(NS, { models: [{ id: 'recovered' }] }) - await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'recovered', name: 'recovered' }, - ]) - }) - - it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => { + it('rejects a settings generation that combines new composition and connection facts', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() const good = await mockServer([{ kind: 'sse', events: textEvents }]) const rejected = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url }) - // One snapshot moves the endpoint AND the literal key, and fails the - // resolve step beyond the schema (duplicate catalog ids). + // One schema-valid snapshot moves the endpoint and key while also trying + // to replace the composition-owned catalog. await ctx.settings.update(NS, { apiKey: 'rejected-key', baseURL: rejected.url, - models: [{ id: 'dup' }, { id: 'dup' }], + models: [{ id: 'settings-model' }], }) await prompt(ctx) @@ -170,6 +155,42 @@ describe('request-level dynamic configuration', () => { expect(good.headers[0]?.authorization).toBe('Bearer good-key') }) + it('cannot mix earlier capability facts with a later settings connection', async () => { + const dir = await home() + const first = await mockServer([{ kind: 'sse', events: textEvents }]) + const second = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await boot(dir, { + apiKey: 'first-key', + baseURL: first.url, + thinking: 'disabled', + reasoningEffort: 'off', + }) + const resolveModel = vi.spyOn(LlmDeepSeek.DeepSeekAdapter.prototype, 'resolveModel') + resolveModel.mockImplementation(async function ( + this: LlmDeepSeek.DeepSeekAdapter, + provider, + model, + signal, + ) { + resolveModel.mockRestore() + const resolved = await this.resolveModel(provider, model, signal) + // Land a complete settings generation after capability resolution but + // before stream dispatch. Its changed composition fact rejects it whole. + await ctx.settings.update(NS, { + apiKey: 'second-key', + baseURL: second.url, + thinking: 'enabled', + reasoningEffort: 'max', + }) + return resolved + }) + + await prompt(ctx) + expect(second.requests).toHaveLength(0) + expect(first.headers[0]?.authorization).toBe('Bearer first-key') + expect(first.requests[0]).toMatchObject({ thinking: { type: 'disabled' } }) + }) + it('falls back to the composition entry when settings detach', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 9af6c5b499..108324818e 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: e8b7adf122946fc22f231fafb521866cbacdc652 -README.zh.md: 5bbf034267f6e276bc6552c5e731f6369cd97aee +README.md: 972dcaed2ca18e0bef9c7c91ba1d3f518236a756 +README.zh.md: 40d5794b6d4dac3bf31e54bd6e570407b72eaa9f diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index e8b7adf122..972dcaed2c 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -39,15 +39,15 @@ Each dict key must exist in pi-ai's installed catalog; the dict shape makes dupl ## Dynamic configuration (settings + credentials) -The adapter reads its profiles through a thunk **once per operation** instead of freezing request facts 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`. The user layer can override request-level fields of a composition route, such as its endpoint, credential reference, headers, or transport controls, effective on the next operation. Provider routes and retry policies remain composition-fixed; a settings snapshot that changes either is rejected as one generation. Without a mounted settings service the entry config alone drives the adapter. +The adapter reads live connection, credential, and request-transport facts through a thunk once per stream. 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`. The user layer can override a composition route's endpoint, credential reference, headers, budgets, cache/transport choices, and timeouts for the next request. Provider routes, installed model capabilities, reasoning defaults, and retry policies remain composition-fixed; a settings snapshot that changes a fixed fact is rejected as one generation. Without a mounted settings service the entry config alone drives the adapter. -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. A live settings snapshot that changes registration facts, names an unknown provider, or fails another resolver bound keeps the last good profiles and logs the failure; the entry config itself fails plugin load. +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. A live settings snapshot that changes a fixed fact, names an unknown provider, or fails another resolver bound keeps the last good profiles and logs the failure; none of its connection or credential facts leak into a request. The entry config itself fails plugin load. The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. -The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. +The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The composition profile's `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. `reasoning` and `retryPolicy` are composition facts; the other fields are live request facts. Each optional retry policy is captured with its provider route, and omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -77,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata ## Testing -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: endpoint and `apiKeyEnv` changes reach later requests while routes and retry policy stay composition-fixed. `tests/loader-composition.spec.ts` boots that chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage remains key-gated under `pnpm run test:e2e`. +Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: endpoint and credential changes reach later requests, while a change landing between capability resolution and dispatch cannot combine an earlier reasoning default with a newer endpoint or key. `tests/loader-composition.spec.ts` boots that chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage remains key-gated under `pnpm run test:e2e`. ## Model Experience @@ -111,7 +111,7 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work -- **Settings cannot add or remove routes** — provider ownership and retry policy are composition facts; the user layer can only change request-level fields of existing routes. +- **Settings cannot change routes or model defaults** — provider ownership, installed model capabilities, reasoning defaults, and retry policy are composition facts; the user layer can only change connection, credential, and request-transport fields of existing routes. - **`apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields. - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 5bbf034267..40d5794b6d 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -39,15 +39,15 @@ ## 动态配置(settings + credentials) -适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结请求事实。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`。用户层可以覆盖组合路由的请求级字段,例如端点、凭据引用、标头或传输控制项,并在下一次操作生效。提供方路由与重试策略始终由组合固定;settings 快照若更改任一项,就会整代被拒绝。未挂载 settings 服务时,仅由 entry 配置驱动适配器。 +适配器经由一个 thunk 每个流读取一次实时连接、凭据与请求传输事实。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`。用户层可以为下一次请求覆盖组合路由的端点、凭据引用、标头、预算、缓存/传输选项与超时。提供方路由、已安装模型的能力、推理(reasoning)默认值与重试策略始终由组合固定;settings 快照若更改固定事实,就会整代被拒绝。未挂载 settings 服务时,仅由 entry 配置驱动适配器。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile(仅限这一种情况),才交给 pi-ai 的环境发现。存活 settings 快照若更改注册事实、点名未知提供方或违反其他 resolver 约束,则保留最后可用 profile 并记录失败;entry 配置本身会使插件加载失败。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile(仅限这一种情况),才交给 pi-ai 的环境发现。存活 settings 快照若更改固定事实、点名未知提供方或违反其他 resolver 约束,则保留最后可用 profile 并记录失败;其中的连接与凭据事实一概不会泄漏进请求。entry 配置本身会使插件加载失败。 适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 -`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 +`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理能力的模型也会公开 pi-ai 的 `off` 选项。组合 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。`reasoning` 与 `retryPolicy` 属于组合事实,其他字段属于实时请求事实。每个可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -77,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK ## 测试 -单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:端点与 `apiKeyEnv` 变更会作用于后续请求,而路由与重试策略始终由组合固定。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起该链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 +单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:端点与凭据变更会作用于后续请求,而落在能力解析与派发之间的变更无法把较早一代的推理默认值与较新一代的端点或密钥拼接起来。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起该链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。 ## 模型体验 @@ -111,7 +111,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 -- **settings 无法新增或移除路由**:提供方所有权与重试策略属于组合事实;用户层只能更改现有路由的请求级字段。 +- **settings 无法更改路由或模型默认值**:提供方所有权、已安装模型的能力、推理默认值与重试策略属于组合事实;用户层只能更改现有路由的连接、凭据与请求传输字段。 - **`apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。 - **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 030592f74c..38b853f162 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -34,10 +34,12 @@ import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' -/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */ +/** Constructor inputs for {@link PiAiAdapter}: live, composition, and credential facts. */ export interface PiAiAdapterOptions { - /** Current validated profiles by provider route; called once per operation. */ + /** Current validated request profiles by provider route; called once per stream. */ profiles: () => ReadonlyMap + /** Composition snapshot owning routes, model capabilities, reasoning defaults, and retry policies. */ + compositionProfiles: ReadonlyMap /** * Resolve the credential for one already-resolved profile; called once per * stream call and frozen for that call. `undefined` defers to pi-ai's @@ -117,11 +119,11 @@ export class PiAiAdapter extends LlmAdapter { } override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { - return this.config.profiles().get(provider)?.retryPolicy + return this.config.compositionProfiles.get(provider)?.retryPolicy } override listModels(provider: string): Promise { - const profile = this.config.profiles().get(provider) + const profile = this.config.compositionProfiles.get(provider) if (profile === undefined) { return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')) } @@ -137,7 +139,7 @@ export class PiAiAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const profile = this.config.profiles().get(provider) + const profile = this.config.compositionProfiles.get(provider) if (profile === undefined) { return Promise.reject(new LlmError( `pi-ai adapter does not own provider "${provider}"`, @@ -170,17 +172,18 @@ export class PiAiAdapter extends LlmAdapter { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') } - // One resolution per stream call: the profile snapshot and the credential - // freeze here and hold for this whole request, so an in-flight stream - // never observes a configuration change and the next call re-resolves. + // One live resolution per stream call: connection, credential, and + // transport facts freeze here and hold for the request. Capability and + // default reasoning facts come from the composition snapshot. const profile = this.config.profiles().get(options.provider) - if (profile === undefined) { + const compositionProfile = this.config.compositionProfiles.get(options.provider) + if (profile === undefined || compositionProfile === undefined) { throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') } const model = resolvePiModel(profile, options.model) const reasoning = resolveReasoningLevel( model, - options.reasoningEffort ?? profile.reasoning, + options.reasoningEffort ?? compositionProfile.reasoning, ) const apiKey = await this.config.resolveApiKey(options.provider, profile) diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index a298873b28..1fd2354158 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -28,7 +28,7 @@ export interface PiAiProviderProfile { baseURL?: string /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record - /** Provider-neutral pi-ai reasoning level. */ + /** Composition-fixed provider-neutral pi-ai reasoning default. */ reasoning?: ModelThinkingLevel /** Token budgets used by reasoning providers that support them. */ thinkingBudgets?: ThinkingBudgets @@ -42,7 +42,7 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number - /** Provider-owned model-request retry policy; omission uses normal defaults. */ + /** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 46a4bf01f1..50255bdcdd 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -3,9 +3,9 @@ * provider routes; requests select a profile by provider and resolve the * model dynamically from pi-ai's installed catalog. Profile facts resolve per * request over the optional `llm-pi-ai` user-settings section and the - * optional credential seam, so a changed key, endpoint, or request knob - * reaches the next request without a restart. Provider routes and retry - * policies stay composition-fixed. + * optional credential seam, so a changed key, endpoint, or request-transport + * knob reaches the next request without a restart. Provider routes, model + * capabilities, reasoning defaults, and retry policies stay composition-fixed. * * ```yaml * - id: llm @@ -45,20 +45,24 @@ export const inject = ['llm'] const NS = settingsNamespace('llm-pi-ai') /** - * The registry captures these per route; a change here must re-register. + * Composition captures these per route; a settings change cannot alter them. * Sorted by provider so a settings document that merely reorders its keys is * not mistaken for a route change. */ -function registrationFacts(profiles: ReadonlyMap): unknown { +function compositionFacts(profiles: ReadonlyMap): unknown { return [...profiles.entries()] - .map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) + .map(([provider, profile]) => ({ + provider, + reasoning: profile.reasoning, + retryPolicy: profile.retryPolicy, + })) .sort((left, right) => left.provider.localeCompare(right.provider)) } /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { const compositionProfiles = resolveProfiles(config.providers) - const compositionFacts = registrationFacts(compositionProfiles) + const fixedFacts = compositionFacts(compositionProfiles) let current: () => Config = () => config let lastRaw: Config = config let lastGood: ReadonlyMap = compositionProfiles @@ -67,15 +71,15 @@ export function apply(ctx: Context, config: Config): void { if (raw === lastRaw) return lastGood try { const next = resolveProfiles(raw.providers) - if (!deepEqualJson(registrationFacts(next), compositionFacts)) { - throw new Error('llm-pi-ai: provider routes and retry policies are composition-fixed') + if (!deepEqualJson(compositionFacts(next), fixedFacts)) { + throw new Error('llm-pi-ai: provider routes, reasoning defaults, and retry policies are composition-fixed') } lastRaw = raw lastGood = next return next } catch (error) { // Static composition resolves before anything registers, so this branch - // only sees a live settings snapshot failing catalog or bound checks: + // only sees an invalid live snapshot or one that changes a fixed fact: // keep serving the last good profiles and say so once per bad snapshot. lastRaw = raw ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section') @@ -111,7 +115,7 @@ export function apply(ctx: Context, config: Config): void { ) } - const adapter = new PiAiAdapter({ profiles, resolveApiKey }) + const adapter = new PiAiAdapter({ profiles, compositionProfiles, resolveApiKey }) ctx.llm.registerAdapter([...compositionProfiles.keys()], adapter) installSettingsSection(ctx, NS, Config, config, { diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 8edfaceb1a..95605961b1 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -25,8 +25,10 @@ async function harness(baseURL: string, overrides: Record = {}) /** Direct adapter over the real profile resolver, with literal-key resolution. */ function adapterOf(providers: Record): PiAiAdapter { + const compositionProfiles = resolveProfiles(providers) return new PiAiAdapter({ - profiles: () => resolveProfiles(providers), + profiles: () => compositionProfiles, + compositionProfiles, resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey), }) } diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index a9735e1dcf..e4e7756cd3 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -18,6 +18,7 @@ afterEach(async () => { while (cleanups.length > 0) await cleanups.pop()!() await closeMockServers() vi.unstubAllEnvs() + vi.restoreAllMocks() }) async function home(): Promise { @@ -97,4 +98,45 @@ describe('request-level dynamic profiles', () => { jitterRatio: 0.2, }) }) + + it('cannot mix earlier capability facts with a later settings connection', async () => { + const dir = await home() + const first = await mockServer([{ events: textEvents }]) + const second = await mockServer([{ events: textEvents }]) + const ctx = await boot(dir, { + providers: { + deepseek: { + apiKey: 'first-key', + baseURL: first.url, + reasoning: 'off', + }, + }, + }) + const resolveModel = vi.spyOn(LlmPiAi.PiAiAdapter.prototype, 'resolveModel') + resolveModel.mockImplementation(async function ( + this: LlmPiAi.PiAiAdapter, + provider, + model, + signal, + ) { + resolveModel.mockRestore() + const resolved = await this.resolveModel(provider, model, signal) + // Land a complete settings generation after capability resolution but + // before stream dispatch. Its changed reasoning default rejects it whole. + await ctx.settings.update(NS, { + providers: { + deepseek: { + apiKey: 'second-key', + baseURL: second.url, + reasoning: 'max', + }, + }, + }) + return resolved + }) + + await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(second.requests).toHaveLength(0) + expect(first.headers[0]?.authorization).toBe('Bearer first-key') + }) }) diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index 3f12ef4460..aa8d16e3d5 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -22,8 +22,10 @@ describe('pi-ai SDK retry boundary', () => { throw failure }, }) + const compositionProfiles = resolveProfiles({ openai: { apiKey: 'test-key' } }) const adapter = new PiAiAdapter({ - profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }), + profiles: () => compositionProfiles, + compositionProfiles, resolveApiKey: () => Promise.resolve('test-key'), }) const drain = async (): Promise => { From 72a73078260f5d7d3f8e7518b1095609c03dc51f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:28:43 +0800 Subject: [PATCH 6/6] chore: regenerate module graph and refresh composer goldens after master merge --- apps/web/tests/snapshots/plan-review/approved.expected.md | 3 +-- apps/web/tests/snapshots/queue-actions/collapsed.expected.md | 3 +-- .../web/tests/snapshots/seeded-history/command-row.expected.md | 3 +-- docs/module-graph.md | 3 ++- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index dd62e265ee..e207392640 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -35,10 +35,9 @@ - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index cdbf6fc64b..856cdcc525 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -14,10 +14,9 @@ - paragraph: partial - button "2 条排队消息" - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 87ffd9fc55..798b2cf3de 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -38,10 +38,9 @@ - img - text: permission preset workspace-write - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/docs/module-graph.md b/docs/module-graph.md index 8e8b3130ab..da3910f978 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -927,6 +927,7 @@ flowchart TD pkg_client_ui_plan --> pkg_client_connection pkg_client_ui_plan --> pkg_client_runtime pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives pkg_client_ui_plan --> pkg_client_ui_slots pkg_client_ui_plan --> pkg_invariants pkg_client_ui_plan --> pkg_plan_mode @@ -1183,7 +1184,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |