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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;之后注册的卡片不会让它变大。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user