From ca119b0e1034e17b028bbeb522c112f374408264 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 11:16:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(web-plugin-config):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20one=20options=20snapshot=20per=20search,=20no=20pub?= =?UTF-8?q?lic=20value=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from review survived against the staged-save head: The search provider read its options thunk per property, so a settings write landing inside credential resolution sent the key resolved from the old section to the endpoint named by the new one. Each operation now snapshots once at its entry and threads that snapshot into credential resolution; a regression test drives a commit into the middle of a search and pins that the endpoint, model, and key all come from the section the search started on. The /client entry exported components, controllers, and namespace constants with no consumer, which the client export discipline allows only with sign-off. Only types remain. The duplicate per-card Injected/Face interface pairs are one declaration each now, so a member added to one side cannot silently miss the other. The credential state carries the reference it describes and its writability: a reference change no longer projects the old answer onto the new name, an out-of-order response for a stale reference is dropped, and a key that a deployment sources from the process environment disables the control instead of inviting a write the Host must refuse. Also corrected three prose claims against the code they describe: the card's fields do not differ by platform (the served schema does), the section's empty line counts registered rather than visible cards and is read once, and the search README overstated what a configuration surface learns about a key. --- ...6-08-10-web-plugin-configuration.i18n.yaml | 4 +- .../2026-08-10-web-plugin-configuration.md | 3 +- .../2026-08-10-web-plugin-configuration.zh.md | 3 +- .../client/ui-plugin-config/README.i18n.yaml | 4 +- packages/client/ui-plugin-config/README.md | 3 +- packages/client/ui-plugin-config/README.zh.md | 3 +- .../src/client/AgentLoopCard.tsx | 14 +---- .../ui-plugin-config/src/client/BashCard.tsx | 14 +---- .../src/client/WebSearchCard.tsx | 18 ++---- .../ui-plugin-config/src/client/index.ts | 23 ++++---- .../src/client/web-search-store.ts | 49 +++++++++++++--- .../ui-plugin-config/tests/section.spec.tsx | 11 ++++ .../web/web-search-deepseek/README.i18n.yaml | 4 +- packages/web/web-search-deepseek/README.md | 2 +- packages/web/web-search-deepseek/README.zh.md | 2 +- .../web/web-search-deepseek/src/provider.ts | 58 ++++++++++--------- .../tests/deepseek.spec.ts | 28 +++++++++ 17 files changed, 150 insertions(+), 93 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml index 9295085481..a27cb812e9 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md -2026-08-10-web-plugin-configuration.md: ed81f94ac7dcc66236907b6994b850a6731bce1d -2026-08-10-web-plugin-configuration.zh.md: 61b63903d5a9974469b0acb6dd1f680aad8f95b0 +2026-08-10-web-plugin-configuration.md: 7375f496c7af1a695243444fe56aca7262d3dedd +2026-08-10-web-plugin-configuration.zh.md: 59d65db39bcc2306983f2a26dcf252164d7a6f37 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md index ed81f94ac7..7375f496c7 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md @@ -31,11 +31,12 @@ Three host-plane plugins register their own settings namespace, and one browser- ## Alternatives considered - **A registration-time exposure declaration replacing the allowlist.** The honest shape — the namespace's owner declares its own exposure, and a plugin distributed outside this repository can surface its configuration without a change in `packages/host/apiproxy`. Deferred because it changes the seam contract, every existing registration site, and the anti-enumeration semantics at once, and because a plugin exposing an arbitrary schema needs a fail-closed redaction path first: a secret reachable only through a union or transform is currently returned verbatim. -- **A generic schema-driven form renderer.** Declined again for the reason recorded in the web-config-plane note: field truth without a presentation vocabulary produced an unusable card. Three plugins of hand-written controls cost about the same and read better, and the slot keeps the fourth plugin from having to negotiate with this package. +- **A generic schema-driven form renderer.** Declined again for the reason recorded in the [web-config-plane note](../architecture/2026-07-30-web-config-plane.md): field truth without a presentation vocabulary produced an unusable card. Three plugins of hand-written controls cost about the same and read better, and the slot keeps the fourth plugin from having to negotiate with this package. - **Editing preset-mounted plugins from this page.** Out of scope, and not merely unbuilt: a preset's rows carry their configuration inline in `agent.cordis.yml` and cannot register a settings namespace at all, because a second session mounting the same preset would fail on a duplicate registration. A user layer shared across presets would also overwrite the fields a preset uses to define its agent's identity — its persona text, its delegation wiring — which are per-preset by design. - **One namespace per executor package instead of the capability-named `bash`.** Declined because the composed executor differs by platform while the settings document does not: a user who set a timeout on macOS would silently lose it on Windows. - **Writing the search key into the settings section.** Declined because the literal would then have to ride a `describe` response to be rendered. The card reports only whether a key is configured and writes through the credentials domain, addressed by the reference the section names. - **Committing each control as it settles, with no save.** Built first, and replaced: blur is not a decision. It spent a namespace revision per control, gave the user nothing to preview or undo before the write, and left an invalid draft silently discarded — a value the Host's validator refuses simply snapped back with no reason given. One save per card makes the write a gesture the user performs. +- **Letting the provider read its options per property.** The thunk was read at each use site so read sites could stay unchanged, which quietly broke the contract the constructor states: `search()` awaits credential resolution and then reads the endpoint, model, and budget, so a settings write landing inside that await sent the key resolved from the old section to the endpoint named by the new one. Each operation now snapshots once at its entry and threads that snapshot into credential resolution. - **Validating the fields in the browser to keep the save honest.** Declined: the constraints live in the owning plugin's section validator, and restating them here would make two homes for one rule that could disagree per release. The card checks only what its own control can decide — that a numeric draft is a number — and lets the Host answer for the rest, which is why the save reads the section back. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md index 61b63903d5..59d65db39b 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md @@ -31,11 +31,12 @@ Status: implemented ## 备选方案 - **用注册期的暴露声明取代白名单。** 这才是诚实的形状——命名空间的拥有方声明自己的暴露,在本仓库之外分发的插件也无需改动 `packages/host/apiproxy` 就能呈现自己的配置。之所以暂缓,是因为它会同时改变 seam 契约、全部现有注册点与防枚举语义;而且插件要暴露任意 schema,还得先有 fail-closed 的脱敏路径:目前只能经由 union 或 transform 抵达的 secret 会被原样返回。 -- **通用 schema 驱动的表单渲染器。** 再次否决,理由与 web-config-plane 笔记所记一致:没有呈现词汇的字段真值产出的是无法使用的卡片。三个插件的手写控件成本相当而可读性更好,且该 slot 让第四个插件无需与本包协商。 +- **通用 schema 驱动的表单渲染器。** 再次否决,理由与 [web-config-plane 笔记](../architecture/2026-07-30-web-config-plane.md)所记一致:没有呈现词汇的字段真值产出的是无法使用的卡片。三个插件的手写控件成本相当而可读性更好,且该 slot 让第四个插件无需与本包协商。 - **在本页编辑 preset 挂载的插件。** 超出范围,而且不只是「尚未实现」:preset 的行把配置内联在 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间——同一 preset 挂载第二个会话时会因重复注册而失败。跨 preset 共享的用户层还会覆盖 preset 用来定义其 agent 身份的字段——人设文本、委派接线——而这些字段按设计就是各 preset 各自的。 - **按执行器包各取一个命名空间,而非按能力命名的 `bash`。** 否决,因为被组装的执行器随平台不同,而设置文档不随平台不同:在 macOS 上设过超时的用户,到 Windows 上会悄无声息地失去它。 - **把搜索密钥写进 settings 分节。** 否决,因为那样字面值就必须搭乘 `describe` 响应才能被渲染。卡片只报告是否已配置密钥,并按分节所命名的引用经由 credentials 领域写入。 - **每个控件失焦即提交,不设保存。** 最初就是这么做的,后被替换:失焦不是决定。它每个控件花掉一个命名空间 revision,写入前不给用户任何预览或撤销的余地,还会把无效草稿悄悄丢弃——被 Host 校验器拒绝的值只是弹回原样,不给任何理由。每张卡片一个保存,才让写入成为用户执行的动作。 +- **让提供方按属性逐次读取 options。** 最初为了不改动读取点而在每个使用处读 thunk,这悄悄违背了构造函数自己声明的契约:`search()` 先 await 凭据解析,之后才读端点、模型与预算,因此落在那段 await 里的设置写入会把按旧分节解析出的密钥发往新分节命名的端点。现在每次操作在入口只快照一次,并把该快照传进凭据解析。 - **在浏览器端校验字段,好让保存诚实。** 否决:这些约束住在拥有方插件的分节校验器里,在这里重述一遍就会让同一条规则有两个家,且可能随版本各说各话。卡片只判断自己的控件能判断的事——数字草稿是不是数字——其余交给 Host 回答,这正是保存要回读分节的原因。 ## 影响 diff --git a/packages/client/ui-plugin-config/README.i18n.yaml b/packages/client/ui-plugin-config/README.i18n.yaml index 3e8e6143a9..ea6b60fb95 100644 --- a/packages/client/ui-plugin-config/README.i18n.yaml +++ b/packages/client/ui-plugin-config/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-plugin-config/README.md -README.md: 51d86e8fbfd19d3d37f68650163180751fb27061 -README.zh.md: 48a68900a20aaaabcc9763b5aa61bc23cf3d16d8 +README.md: 9297379a940d004acc27e2dcb1bb879fa2142f30 +README.zh.md: 2b3d73bc6e1ccd72cad9fe86acb357c2ab269a41 diff --git a/packages/client/ui-plugin-config/README.md b/packages/client/ui-plugin-config/README.md index 51d86e8fbf..9297379a94 100644 --- a/packages/client/ui-plugin-config/README.md +++ b/packages/client/ui-plugin-config/README.md @@ -34,4 +34,5 @@ None; this package neither assembles nor sends a provider request. - **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job. - **Exposure is a Host allowlist, not a plugin declaration** — a namespace absent from the api-proxy's allowlist answers `settings-not-exposed` even when its owner registered it, so a plugin distributed outside this repository cannot surface its own configuration here without a change in `packages/host/apiproxy`. -- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the card's fields differ by platform and a deployment composing neither shows no card. +- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the card edits the same two fields on both, and a deployment composing neither shows no card. +- **The empty line counts registered cards, not visible ones** — a card whose namespace this deployment does not expose renders nothing, but still counts, so a deployment that exposes none shows an empty list rather than the empty line. The count is also read once, because the renderer caches a root entry's inject face; a card registered later does not raise it. diff --git a/packages/client/ui-plugin-config/README.zh.md b/packages/client/ui-plugin-config/README.zh.md index 48a68900a2..2b3d73bc6e 100644 --- a/packages/client/ui-plugin-config/README.zh.md +++ b/packages/client/ui-plugin-config/README.zh.md @@ -34,4 +34,5 @@ - **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。 - **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里。 -- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以该卡片的字段随平台不同,而两者都不组装的部署不会显示这张卡片。 +- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段;而两者都不组装的部署不会显示这张卡片。 +- **空态数的是已注册卡片,不是可见卡片**——命名空间未被本部署暴露的卡片什么都不渲染,但仍计入数量,因此一个都不暴露的部署看到的是空列表而非那行空态文案。该计数还只读取一次,因为渲染器会缓存根级 entry 的 inject face;之后注册的卡片不会让它变大。 diff --git a/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx index 5231b73b64..450e7f4b3e 100644 --- a/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx +++ b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx @@ -1,26 +1,16 @@ /** The agent loop's card: how many tool calls one step may run at once. */ -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { ValueField } from './fields.tsx' import { PluginCard } from './PluginCard.tsx' -import type { CardActions } from './card-store.ts' -import type { AgentLoopCardState } from './agent-loop-store.ts' +import type { AgentLoopCardFace } from './agent-loop-store.ts' import type {} from './slot-contract.ts' -/** Registration-side business face for the agent-loop card. */ -export interface AgentLoopCardInjected extends CardActions { - hooks: { - /** Card snapshot bound by the renderer as useAgentLoopCard. */ - agentLoopCard: SnapshotStore - } -} - /** Props the renderer binds for the agent-loop card. */ export type AgentLoopCardProps = PropsRuntime<'settings.plugin.item'> & PropsLocale<'settings.pluginConfig'> - & InjectFace + & InjectFace /** * Render the agent-loop card. diff --git a/packages/client/ui-plugin-config/src/client/BashCard.tsx b/packages/client/ui-plugin-config/src/client/BashCard.tsx index ade767fa56..d7f9918a39 100644 --- a/packages/client/ui-plugin-config/src/client/BashCard.tsx +++ b/packages/client/ui-plugin-config/src/client/BashCard.tsx @@ -1,26 +1,16 @@ /** The shell plugin's card: the limits every command the agent runs is bound by. */ -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { ValueField } from './fields.tsx' import { PluginCard } from './PluginCard.tsx' -import type { CardActions } from './card-store.ts' -import type { BashCardState } from './bash-store.ts' +import type { BashCardFace } from './bash-store.ts' import type {} from './slot-contract.ts' -/** Registration-side business face for the shell card. */ -export interface BashCardInjected extends CardActions { - hooks: { - /** Card snapshot bound by the renderer as useBashCard. */ - bashCard: SnapshotStore - } -} - /** Props the renderer binds for the shell card. */ export type BashCardProps = PropsRuntime<'settings.plugin.item'> & PropsLocale<'settings.pluginConfig'> - & InjectFace + & InjectFace /** * Render the shell card. diff --git a/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx index 302bbf09e5..02762a802f 100644 --- a/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx +++ b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx @@ -4,27 +4,17 @@ * the settings section, so the literal never rides a response. */ -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { SecretField, ValueField } from './fields.tsx' import { PluginCard } from './PluginCard.tsx' -import type { CardActions } from './card-store.ts' -import type { WebSearchCardState } from './web-search-store.ts' +import type { WebSearchCardFace } from './web-search-store.ts' import type {} from './slot-contract.ts' -/** Registration-side business face for the web-search card. */ -export interface WebSearchCardInjected extends CardActions { - hooks: { - /** Card snapshot bound by the renderer as useWebSearchCard. */ - webSearchCard: SnapshotStore - } -} - /** Props the renderer binds for the web-search card. */ export type WebSearchCardProps = PropsRuntime<'settings.plugin.item'> & PropsLocale<'settings.pluginConfig'> - & InjectFace + & InjectFace /** * Render the web-search card. @@ -50,7 +40,9 @@ export function WebSearchCard(props: WebSearchCardProps) { hint={t('webSearchApiKeyHint')} // The credentials domain accepts a key even when the settings document // itself is read-only; they are separate stores with separate refusals. - disabled={false} + // Its own writability is what disables this control — a key sourced + // from the process environment cannot be written from here. + disabled={!state.apiKeyWritable} text={state.apiKey.text} configured={state.apiKeyConfigured} stateLabel={state.apiKeyConfigured ? t('webSearchApiKeySet') : t('webSearchApiKeyUnset')} diff --git a/packages/client/ui-plugin-config/src/client/index.ts b/packages/client/ui-plugin-config/src/client/index.ts index d097a8a756..d49728998b 100644 --- a/packages/client/ui-plugin-config/src/client/index.ts +++ b/packages/client/ui-plugin-config/src/client/index.ts @@ -28,14 +28,13 @@ import { en, zh } from './locales.ts' export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx' export type { PluginCardProps } from './PluginCard.tsx' export type { SettingsPluginItemOwnerProps } from './slot-contract.ts' -export { SecretField, ValueField, type FieldProps } from './fields.tsx' -export { - CardForm, numberField, textField, - type CardActions, type CardFieldSpec, type CardFieldState, type CardSecretSpec, type CardShell, +export type { FieldProps } from './fields.tsx' +export type { + CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell, } from './card-store.ts' -export { AGENT_LOOP_NS, AgentLoopCardController, type AgentLoopCardState } from './agent-loop-store.ts' -export { BASH_NS, BashCardController, type BashCardState } from './bash-store.ts' -export { WEB_SEARCH_NS, WebSearchCardController, type WebSearchCardState } from './web-search-store.ts' +export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-store.ts' +export type { BashCardFace, BashCardState } from './bash-store.ts' +export type { WebSearchCardFace, WebSearchCardState } from './web-search-store.ts' /** Dictionary namespace owned by this plugin. */ const NS = 'settings.pluginConfig' @@ -56,9 +55,13 @@ export function apply(ctx: ClientContext): void { const agentLoop = new AgentLoopCardController(bindSettingsScope(ctx, { namespace: AGENT_LOOP_NS })) const webSearch = new WebSearchCardController(bindSettingsScope(ctx, { namespace: WEB_SEARCH_NS }), api) - // The section renders the empty line rather than an empty list when no card - // is registered; the ledger is read at render time so a card arriving later - // (or leaving with its plugin) is reflected without the section subscribing. + // The section renders the empty line rather than an empty list when no plugin + // contributed a card. The count is read once: the renderer caches a root + // entry's inject face per registration, so this reports what was registered + // when the section mounted, not what is visible now. Both gaps are bounded by + // this deployment always registering the three cards below — a card that + // arrives later would not raise the count, and a namespace this deployment + // does not expose leaves its card rendering nothing inside a non-empty list. ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'plugins', diff --git a/packages/client/ui-plugin-config/src/client/web-search-store.ts b/packages/client/ui-plugin-config/src/client/web-search-store.ts index 5fc4ad7ab5..718aa414bd 100644 --- a/packages/client/ui-plugin-config/src/client/web-search-store.ts +++ b/packages/client/ui-plugin-config/src/client/web-search-store.ts @@ -38,6 +38,16 @@ export interface WebSearchSettings { maxUses?: number } +/** What the credentials domain last reported, and for which reference. */ +interface CredentialState { + /** Reference this answer describes; a stale response for another one is dropped. */ + ref: string + /** Whether any layer supplies a value for it. */ + configured: boolean + /** Whether `credentials.set` can affect it; false disables the control. */ + writable: boolean +} + /** What the web-search card renders. */ export interface WebSearchCardState extends CardShell { /** Provider endpoint. */ @@ -48,6 +58,8 @@ export interface WebSearchCardState extends CardShell { apiKey: CardFieldState /** Whether the Host reports a credential configured for the referenced key. */ apiKeyConfigured: boolean + /** Whether the credentials domain accepts a write for it; false disables the control. */ + apiKeyWritable: boolean } /** The registration-side face the web-search card's slot entry injects. */ @@ -62,7 +74,7 @@ export interface WebSearchCardFace extends CardActions { export class WebSearchCardController { private readonly form: CardForm private readonly store: SnapshotStore - private configured = false + private credential: CredentialState = { ref: '', configured: false, writable: true } /** * @param scope - the bound settings scope for the `web-search-deepseek` namespace. @@ -88,13 +100,27 @@ export class WebSearchCardController { baseURL: this.form.field('baseURL'), maxUses: this.form.field('maxUses'), apiKey: this.form.field(API_KEY_FIELD), - apiKeyConfigured: this.configured, + apiKeyConfigured: this.credential.configured, + apiKeyWritable: this.credential.writable, } } - /** Ask the credentials domain whether the referenced key exists. */ + /** + * Ask the credentials domain about the reference the section currently names. + * + * The answer is stored with the reference it describes: `apiKeyEnv` can + * change between the request and its response, and two reads can settle out + * of order, so a response is published only while it still answers for the + * reference in force. + */ private async readCredential(): Promise { const ref = refOf(this.scope.getSnapshot()) + if (ref !== this.credential.ref) { + // A new reference knows nothing yet; keeping the old answer would claim + // the key is configured under a name nobody has checked. + this.credential = { ref, configured: false, writable: true } + this.store.set(this.projection()) + } let response: Awaited> try { response = await this.api.credentials.describe({ refs: [ref] }) @@ -103,10 +129,17 @@ export class WebSearchCardController { // last state it knew, and a write still reaches the Host. return } - if (!response.result.ok) return - const next = response.result.value.credentials[ref]?.configured ?? false - if (next === this.configured) return - this.configured = next + if (!response.result.ok || ref !== refOf(this.scope.getSnapshot())) return + const view = response.result.value.credentials[ref] + const next: CredentialState = { + ref, + configured: view?.configured ?? false, + // An unknown reference is treated as writable: the control stays usable + // and the Host is what refuses, rather than the card guessing a refusal. + writable: view?.writable ?? true, + } + if (next.configured === this.credential.configured && next.writable === this.credential.writable) return + this.credential = next this.store.set(this.projection()) } @@ -131,7 +164,7 @@ export class WebSearchCardController { // authority on whether the key now exists. } await this.readCredential() - return this.configured + return this.credential.configured } } diff --git a/packages/client/ui-plugin-config/tests/section.spec.tsx b/packages/client/ui-plugin-config/tests/section.spec.tsx index 1452345804..3945092587 100644 --- a/packages/client/ui-plugin-config/tests/section.spec.tsx +++ b/packages/client/ui-plugin-config/tests/section.spec.tsx @@ -263,6 +263,7 @@ describe('WebSearchCard', () => { maxUses: field('5'), apiKey: field(''), apiKeyConfigured: false, + apiKeyWritable: true, ...state, }) const actions = cardActions() @@ -292,6 +293,16 @@ describe('WebSearchCard', () => { expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret') }) + it('disables the key control when the reference itself is not writable', () => { + // A key coming from the process environment: the settings document is + // writable, the credential is not. + renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false }) + fireEvent.click(screen.getByText(en.webSearchTitle)) + + expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true) + expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false) + }) + it('stages the endpoint, the search budget, and their resets', () => { const actions = renderWebSearch({ baseURL: field('https://search.test/v1', { overridden: true }), diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 09e251b340..04c3822b16 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-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/web/web-search-deepseek/README.md -README.md: 3e8b631b5775793e6e6e542c810edce9d92a0d59 -README.zh.md: 9bd59000c2a0054265444aa90fc46ff5f34fd607 +README.md: 1e90f947b4a58288722307aaed50c4889cfe7cb4 +README.zh.md: 21962bcd390f253f899000be7273a79485821018 diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 3e8b631b57..1e90f947b4 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -34,7 +34,7 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not* baseURL: https://gateway.internal/anthropic/v1 ``` -The entry above is the base layer of the `web-search-deepseek` Settings section: a user layer over it reaches the NEXT search, because the provider projects the section per call rather than capturing it at registration. The seam's provider selection therefore never flickers when an endpoint or model changes. `apiKey` carries `role('secret')`, so it never rides a `describe()` response in any layer — a configuration surface learns only that a key is set. +The entry above is the base layer of the `web-search-deepseek` Settings section: a user layer over it reaches the NEXT search, because the provider projects the section per call rather than capturing it at registration. The seam's provider selection therefore never flickers when an endpoint or model changes. `apiKey` carries `role('secret')`, so it never rides a `describe()` response in any layer — a configuration surface learns only whether the credentials domain holds a value for the reference `apiKeyEnv` names, never whether a layer carries a literal key. ## Mapping diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index 9bd59000c2..21962bcd39 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -34,7 +34,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方 baseURL: https://gateway.internal/anthropic/v1 ``` -上面的条目是 `web-search-deepseek` Settings 段的 base 层:叠加其上的用户层会作用于**下一次**搜索,因为提供方是按次投影该段,而不是在注册时固化它。因此端点或模型变化时,seam 的提供方选择不会闪断。`apiKey` 带有 `role('secret')`,所以它在任何一层都不会出现在 `describe()` 响应中——配置表层只能知道密钥是否已设置。 +上面的条目是 `web-search-deepseek` Settings 段的 base 层:叠加其上的用户层会作用于**下一次**搜索,因为提供方是按次投影该段,而不是在注册时固化它。因此端点或模型变化时,seam 的提供方选择不会闪断。`apiKey` 带有 `role('secret')`,所以它在任何一层都不会出现在 `describe()` 响应中——配置表层只能知道 credentials 领域是否为 `apiKeyEnv` 所命名的引用持有值,而无从知道某一层是否带着字面密钥。 ## 映射 diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 4b37f26bee..ce19474ab0 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -178,41 +178,42 @@ export class DeepSeekSearchProvider implements WebSearchProvider { readonly id = DEEPSEEK_PROVIDER_ID /** - * @param resolveOptions - the options for the NEXT operation. A thunk rather - * than a value because the plugin's settings section can change between - * searches, and re-registering the provider to carry a new endpoint would - * make the seam's selection observable to the user as a flicker. + * @param resolveOptions - the options for the NEXT operation, snapshotted + * once at each operation's entry so one search never mixes two sections. A + * thunk rather than a value because the plugin's settings section can change + * between searches, and re-registering the provider to carry a new endpoint + * would make the seam's selection observable to the user as a flicker. */ constructor(private readonly resolveOptions: () => DeepSeekSearchProviderOptions) {} - /** Options resolved per read, so a committed settings change reaches the next search. */ - private get options(): DeepSeekSearchProviderOptions { - return this.resolveOptions() - } - available(): boolean { - return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== undefined) - && URL.canParse(this.options.baseURL) - && isPositiveInteger(this.options.maxTokens) - && isPositiveInteger(this.options.maxUses) + const options = this.resolveOptions() + return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== undefined) + && URL.canParse(options.baseURL) + && isPositiveInteger(options.maxTokens) + && isPositiveInteger(options.maxUses) } async search(request: WebSearchRequest, signal?: AbortSignal): Promise { - const apiKey = await this.apiKey(signal) + // One snapshot for the whole operation: credential resolution awaits, and a + // settings write landing inside that await must not send the key resolved + // from the old section to the endpoint named by the new one. + const options = this.resolveOptions() + const apiKey = await this.apiKey(options, signal) throwIfSearchAborted(signal) - const endpoint = `${this.options.baseURL}/messages` + const endpoint = `${options.baseURL}/messages` const body: DeepSeekSearchLlmRequest['body'] = { - model: this.options.model, - max_tokens: this.options.maxTokens, + model: options.model, + max_tokens: options.maxTokens, messages: [{ role: 'user', content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }], }], - tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }], + tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: options.maxUses }], } - this.options.recordRequest?.({ + options.recordRequest?.({ endpoint, - apiVersion: this.options.apiVersion, + apiVersion: options.apiVersion, body, }) throwIfSearchAborted(signal) @@ -226,7 +227,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { // may expect `Authorization: Bearer` — send both so either resolves. 'x-api-key': apiKey, 'authorization': `Bearer ${apiKey}`, - 'anthropic-version': this.options.apiVersion, + 'anthropic-version': options.apiVersion, 'content-type': 'application/json', 'accept': 'application/json', 'user-agent': USER_AGENT, @@ -268,13 +269,18 @@ export class DeepSeekSearchProvider implements WebSearchProvider { } } - /** Resolve one operation's credential without retaining it on the provider. */ - private async apiKey(signal?: AbortSignal): Promise { + /** + * Resolve one operation's credential without retaining it on the provider. + * @param options - the caller's snapshot, so the key and the endpoint it is sent to come from one section. + * @param signal - abort signal for the surrounding search. + * @returns the resolved key. + */ + private async apiKey(options: DeepSeekSearchProviderOptions, signal?: AbortSignal): Promise { throwIfSearchAborted(signal) - if (this.options.apiKey !== undefined && this.options.apiKey.length > 0) return this.options.apiKey + if (options.apiKey !== undefined && options.apiKey.length > 0) return options.apiKey let resolved: string | undefined try { - resolved = await abortable(this.options.resolveApiKey?.() ?? Promise.resolve(undefined), signal) + resolved = await abortable(options.resolveApiKey?.() ?? Promise.resolve(undefined), signal) } catch (error: unknown) { if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error) throw new WebError( @@ -284,7 +290,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { ) } if (resolved !== undefined && resolved.length > 0) return resolved - const ref = this.options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' + const ref = options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' throw new WebError( `DeepSeek search has no API key for "${ref}"; store it through the credentials service` + ' (the web Models page writes it), export it in the launching environment, or set a literal' diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index b57c44c731..c1769f42fc 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -205,6 +205,34 @@ describe('DeepSeekSearchProvider request mapping', () => { }) }) +describe('DeepSeekSearchProvider settings changes mid-search', () => { + it('serves one search from one section even when settings land during credential resolution', async () => { + // The section the search starts on, and the one a user commits while the + // credential is still resolving. + const before = { ...options, apiKey: '', baseURL: 'https://before.test/v1', model: 'model-before', maxUses: 2 } + const after = { ...options, apiKey: '', baseURL: 'https://after.test/v1', model: 'model-after', maxUses: 9 } + let current = before + let commitSettings = () => {} + const resolveApiKey = () => new Promise((resolve) => { + commitSettings = () => { current = after; resolve('key-from-before') } + }) + const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + + const provider = new DeepSeekSearchProvider(() => ({ ...current, resolveApiKey })) + const search = provider.search({ query: 'q' }) + await vi.waitFor(() => { expect(typeof commitSettings).toBe('function') }) + commitSettings() + await search + + const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + // The key resolved from `before` must never reach `after`'s origin. + expect(endpoint).toBe('https://before.test/v1/messages') + expect((init.headers as Record)['x-api-key']).toBe('key-from-before') + expect(JSON.parse(String(init.body))).toMatchObject({ model: 'model-before' }) + }) +}) + describe('DeepSeekSearchProvider error handling', () => { it('does not start credential resolution or dispatch for a pre-aborted call', async () => { const resolveApiKey = vi.fn(async () => 'late-key')