fix(web-plugin-config): address review — one options snapshot per search, no public value exports

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.
This commit is contained in:
Yichen Jiang
2026-08-11 11:16:42 +08:00
parent fdd64e9283
commit ca119b0e10
17 changed files with 150 additions and 93 deletions

View File

@@ -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<AgentLoopCardState>
}
}
/** Props the renderer binds for the agent-loop card. */
export type AgentLoopCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.pluginConfig'>
& InjectFace<AgentLoopCardInjected>
& InjectFace<AgentLoopCardFace>
/**
* Render the agent-loop card.

View File

@@ -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<BashCardState>
}
}
/** Props the renderer binds for the shell card. */
export type BashCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.pluginConfig'>
& InjectFace<BashCardInjected>
& InjectFace<BashCardFace>
/**
* Render the shell card.

View File

@@ -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<WebSearchCardState>
}
}
/** Props the renderer binds for the web-search card. */
export type WebSearchCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.pluginConfig'>
& InjectFace<WebSearchCardInjected>
& InjectFace<WebSearchCardFace>
/**
* 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')}

View File

@@ -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',

View File

@@ -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<WebSearchSettings>
private readonly store: SnapshotStore<WebSearchCardState>
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<void> {
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<ReturnType<IApiClient['credentials']['describe']>>
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
}
}