feat(settings): serve every registered namespace and key plugin cards on it

A plugin that registered a settings namespace could not reach the browser
configuration page: the api-proxy filtered every read and gated every
write through two hardcoded namespace lists, and the plugin configuration
section rendered an unordered list of cards carrying an opaque id rather
than the namespace they edit. Both gates lived in this repository, so a
user-authored plugin was configurable only by hand-editing settings.yaml.

The proxy now serves whatever ctx.settings.describe() returns and adds no
boundary of its own; a name no registration answers folds into the seam's
own settings-rejected, and the settings-not-exposed code retires. The
settings seam is untouched: which client may read a namespace, and which
page renders it, are facts about consumers.

settings.plugin.item becomes a keyed slot whose key is the namespace a
card edits, following tool.call.toolview. The section reads describe once
and dispatches the intersection of the slot ledger and the served set, so
a namespace another surface owns renders nothing without declaring
anything, and a card for an uncomposed plugin is never dispatched.
This commit is contained in:
Yichen Jiang
2026-08-12 21:07:57 +08:00
parent 4bc526f40b
commit 4366528a38
36 changed files with 745 additions and 197 deletions

View File

@@ -216,7 +216,7 @@ export class AgentPresetSettingsController {
// The roster says what may be chosen; `settings.describe` says whether
// this browser may write the choice down. A non-loopback browser reaches
// neither method, so a refused describe leaves the row read-only rather
// than offering a control whose write answers `settings-not-exposed`.
// than offering a control whose write the Host would refuse.
const described = await this.api.settings.describe({})
this.set({
status: 'ready',

View File

@@ -67,8 +67,8 @@ describe('the agent-preset settings controller', () => {
await controller.load()
// `settings.describe` is loopback-only and reports a read-only provider;
// offering a control whose write answers `settings-not-exposed` would
// promise a switch the host refuses.
// offering a control whose write answers `settings-rejected` would promise
// a switch the host refuses.
expect(controller.store.getSnapshot().writable).toBe(false)
expect(controller.store.getSnapshot().currentValue).toBe('standard')
})

View File

@@ -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: 7e530d70f6573d619378e43b0245345b45d6db18
README.zh.md: fd4f980fcf71c00c2357017fb40c76a9ca7a72cc
README.md: 569f3a404f2b94fd6fb8dc5a4191cf66f37d55e2
README.zh.md: 5eb96dc07ba7a438824ce6c6c3da707dd3d26285

View File

@@ -6,13 +6,13 @@ The **Plugins** settings section: one expandable card per Host plugin whose conf
## What appears here
A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the section reflects what this deployment actually runs.
The section reads which settings namespaces the Host serves and dispatches one slot key per namespace, so what renders is the intersection of two ledgers: the namespaces a live Host plugin registered, and the cards registered under those keys. A served namespace no card claims renders nothing — another surface owns it, or this deployment ships no browser half for it — and a card whose namespace this deployment does not serve is never dispatched, so an uncomposed plugin leaves no trace and does not hold the section back from its empty line. Cards appear in the order they registered, not the order the Host describes their namespaces — plugin activation can reorder the description between boots. The empty line waits for the Host's first answer, so an unanswered read never reads as "this deployment configures no plugin".
The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
## Extension point
The section declares `settings.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`.
The section declares `settings.plugin.item`, a root keyed slot whose key is the settings namespace a card edits. A plugin that ships a browser half registers its own card under its own namespace and owns every part of it — chrome, controls, and copy; this package supplies no form it was not given and never learns what a namespace means. Keying on the namespace is what lets a plugin distributed outside this repository appear here: it registers the namespace on the Host and the card in the browser, and the section pairs the two.
## Writes
@@ -35,6 +35,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **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`.
- **A card still needs a browser bundle** — the browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `packages/client/tsdown.client.ts` rather than a published package, so a plugin outside this repository has to reproduce that build itself. The bundle-purity gate also forbids importing this package's card chrome or form model as values, so such a card owns its own staging and revision fencing.
- **The served namespaces re-read on two signals only** — the wire announces settings-document commits and connection resets, not registrations, so a namespace whose owner registers after the section's read joins the list on the next document commit or reconnect.
- **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.

View File

@@ -6,13 +6,13 @@
## 这里会出现什么
只有当某个命名空间既被存活 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西
本分区读取 Host 服务了哪些 settings 命名空间,并为每个命名空间派发一个 slot 键,因此渲染出来的是两份账本的交集:存活 Host 插件注册的命名空间,以及注册在这些键上的卡片。被服务却无人认领的命名空间什么都不渲染——它归别的界面所有,或本部署没有为它提供浏览器半侧;而命名空间未被本部署服务的卡片根本不会被派发,因此未组装插件不留任何痕迹,也不会挡住那行空态文案。卡片按自身注册的顺序出现,而非 Host 描述其命名空间的顺序——插件激活时序会让后者在不同次启动之间变化。空态文案要等 Host 的第一次答复,因此一次尚未答复的读取绝不会被读成"本部署没有可配置的插件"
第一批覆盖 shell 执行器(`bash`、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
本包自带的卡片覆盖 shell 执行器(`bash`、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
## 扩展点
本分区声明了根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进该 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot 的 `order`
本分区声明了根级 keyed slot `settings.plugin.item`,其键就是卡片所编辑的 settings 命名空间。带浏览器半侧的插件把自己的卡片注册在自己的命名空间上,并拥有它的全部——外观、控件与文案;本包不提供任何未被交给它的表单,也从不知道某个命名空间意味着什么。以命名空间为键,正是在本仓库之外分发的插件能出现在这里的原因:它在 Host 上注册命名空间、在浏览器里注册卡片,由本分区把两者配对
## 写入
@@ -35,6 +35,6 @@
## 已知限制与暂缓事项
- **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。
- **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里
- **卡片仍然需要一份浏览器 bundle**——浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`并非已发布的包因此本仓库之外的插件得自行复刻该构建。bundle 纯净度门禁同时禁止以值的形式导入本包的卡片外观与表单模型,所以这样的卡片要自行拥有暂存与 revision 设栅
- **被服务的命名空间只在两种信号上重读**——协议通告的是 settings 文档提交与连接重置,而非注册行为,因此在本分区读取之后才被其拥有方注册的命名空间,要等下一次文档提交或重连才会加入列表。
- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段;而两者都不组装的部署不会显示这张卡片。
- **空态数的是已注册卡片,不是可见卡片**——命名空间未被本部署暴露的卡片什么都不渲染,但仍计入数量,因此一个都不暴露的部署看到的是空列表而非那行空态文案。该计数还只读取一次,因为渲染器会缓存根级 entry 的 inject face之后注册的卡片不会让它变大。

View File

@@ -1,42 +1,48 @@
/**
* Plugin configuration section: the shell around the per-plugin cards. It
* enumerates nothing itself — cards arrive through the `settings.plugin.item`
* slot it declares, so a plugin that ships a browser half owns its own card
* and this section never learns what a namespace means.
* enumerates settings namespaces but never interprets one — a card arrives
* through the `settings.plugin.item` slot keyed by the namespace it edits, so
* a plugin that ships a browser half owns its own card and this section only
* decides which keys to dispatch.
*/
import { Fragment } from 'react'
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from './slot-contract.ts'
import type { PluginConfigSectionFace } from './section-store.ts'
import type { PluginConfigKey } from './locales.ts'
import css from './PluginConfigSection.module.css'
/** Registration-side business face for the section. */
export interface PluginConfigSectionInjected {
/** How many cards the slot ledger currently holds; zero renders the empty line. */
cardCount: number
}
/** Props the renderer binds for the section. */
export type PluginConfigSectionProps =
PropsRuntime<'settings.section'>
& PropsLocale<'settings.pluginConfig'>
& PropsRenderSlots<'settings.plugin.item'>
& InjectFace<PluginConfigSectionInjected>
& InjectFace<PluginConfigSectionFace>
/**
* Render the plugin configuration section.
* @param props - runtime slot rendering, locale copy, and the card count.
* @param props - runtime slot rendering, locale copy, and the namespaces to dispatch.
* @returns the section.
*/
export function PluginConfigSection(props: PluginConfigSectionProps) {
const { t, renderSlot, cardCount } = props
const { t, renderSlot } = props
const { loaded, namespaces } = props.usePluginConfigSection(snapshot => snapshot)
return (
<div className={css.section}>
<h2 className={css.heading}>{t('title')}</h2>
<p className={css.intro}>{t('intro')}</p>
{cardCount === 0
? <p className={css.empty}>{t('empty')}</p>
: <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>}
{namespaces.length > 0
? (
<ul className={css.cards}>
{namespaces.map(ns => (
// One dispatch per namespace, so the list identity is the
// namespace rather than a position that shifts as cards arrive.
<Fragment key={ns}>{renderSlot('settings.plugin.item', {}, { entryKey: ns })}</Fragment>
))}
</ul>
)
: loaded ? <p className={css.empty}>{t('empty')}</p> : null}
</div>
)
}

View File

@@ -2,12 +2,13 @@
* Plugin configuration surface, browser half — one settings section holding
* an expandable card per Host plugin whose configuration a user owns.
*
* The section owns no knowledge of any namespace: it declares the
* `settings.plugin.item` slot and renders whatever cards were registered into
* it, so a plugin that ships a browser half contributes its own card and its
* own controls. The three cards this package registers are the host-plane
* sections the deployment already exposes; each binds its namespace through
* the client settings scope, which keeps them unaware of one another.
* The section owns no knowledge of any namespace's meaning: it declares the
* `settings.plugin.item` slot, reads which namespaces the Host serves, and
* dispatches one key per namespace, so a plugin that ships a browser half
* contributes its own card under its own namespace and owns its controls. The
* three cards this package registers are the host-plane sections this
* repository ships; each binds its namespace through the client settings
* scope, which keeps them unaware of one another.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
@@ -26,16 +27,18 @@ import { PluginConfigSection } from './PluginConfigSection.tsx'
import { WebSearchCard } from './WebSearchCard.tsx'
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts'
import { BASH_NS, BashCardController } from './bash-store.ts'
import { PluginConfigSectionController } from './section-store.ts'
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts'
import { en, zh } from './locales.ts'
export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx'
export type { PluginConfigSectionProps } from './PluginConfigSection.tsx'
export type { PluginCardProps } from './PluginCard.tsx'
export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
export type { FieldProps } from './fields.tsx'
export type {
CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell,
} from './card-store.ts'
export type { PluginConfigSectionFace, PluginConfigSectionState } from './section-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'
@@ -58,6 +61,8 @@ export function apply(ctx: ClientContext): void {
const bash = new BashCardController(ctx.settingsScope.bind({ namespace: BASH_NS }))
const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
const webSearch = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), api)
const section = new PluginConfigSectionController(api, () => ctx.slots.entries('settings.plugin.item'))
ctx.effect(() => () => { section.dispose() }, 'ui-plugin-config: section directory')
// The credential a card reports is not part of any settings section, so its
// scope publishes nothing when one is written. This is the only signal that
@@ -67,42 +72,50 @@ export function apply(ctx: ClientContext): void {
'ui-plugin-config: credential invalidations',
)
// 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.
// Which namespaces the Host serves is a registration fact the wire does not
// announce, so the directory re-reads on the two signals that can carry a
// changed composition: a settings document commit and a reconnect.
ctx.effect(
() => ctx.remote.$on('settings/document-updated', () => { void section.load() }),
'ui-plugin-config: served-namespace invalidations',
)
ctx.effect(
() => ctx.on('connection/reset', () => { void section.load() }),
'ui-plugin-config: served-namespace reconnect',
)
// A card registered after the first read joins the list without a wire call.
ctx.effect(
() => ctx.slots.subscribe('settings.plugin.item', () => { section.refresh() }),
'ui-plugin-config: card ledger',
)
void section.load()
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'plugins',
order: 30,
label: () => t('nav'),
locale: NS,
inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }),
children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
inject: () => section.inject(),
children: { 'settings.plugin.item': { kind: 'keyed', scope: 'root' } },
}, PluginConfigSection))
ctx.slots.inject('settings.plugin.item', function* () {
yield ctx.slots.register({
name: 'settings.plugin.item',
id: 'bash',
order: 0,
key: BASH_NS,
locale: NS,
inject: () => bash.inject(),
}, BashCard)
yield ctx.slots.register({
name: 'settings.plugin.item',
id: 'agent-loop',
order: 10,
key: AGENT_LOOP_NS,
locale: NS,
inject: () => agentLoop.inject(),
}, AgentLoopCard)
yield ctx.slots.register({
name: 'settings.plugin.item',
id: 'web-search',
order: 20,
key: WEB_SEARCH_NS,
locale: NS,
inject: () => webSearch.inject(),
}, WebSearchCard)

View File

@@ -0,0 +1,110 @@
/**
* The plugin configuration section's card list.
*
* The section dispatches its slot by settings namespace, so what it renders is
* the intersection of two ledgers: the namespaces the Host serves and the
* cards registered into `settings.plugin.item`. A served namespace no card
* claims renders nothing — another surface owns it, or this deployment ships
* no browser half for it — and a card whose namespace the Host does not serve
* is never dispatched, so a plugin this deployment did not compose leaves no
* trace and does not count toward the empty line.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** What the section renders. */
export interface PluginConfigSectionState {
/**
* Whether the Host has answered once. The empty line waits for it: an
* unanswered read is not the same statement as "this deployment configures
* no plugin", and saying the second while the first is true would flash a
* wrong answer on every open.
*/
loaded: boolean
/**
* Namespaces to dispatch, in the order their cards registered, narrowed to
* those the Host serves. Card registration order rather than the Host's
* description order: the latter follows plugin activation, which async
* settings injection can reorder between boots, and a settings page whose
* cards move between visits is worse than one whose order a registrant
* chose.
*/
namespaces: string[]
}
/** The registration-side face the section's slot entry injects. */
export interface PluginConfigSectionFace {
hooks: {
/** Section snapshot bound by the renderer as usePluginConfigSection. */
pluginConfigSection: SnapshotStore<PluginConfigSectionState>
}
}
/** Reads the served namespaces and pairs them with the cards that claim them. */
export class PluginConfigSectionController {
private readonly store = createSnapshotStore<PluginConfigSectionState>({ loaded: false, namespaces: [] })
/** Last Host answer; kept so a slot mutation republishes without a wire read. */
private served: readonly string[] = []
private loaded = false
private generation = 0
private disposed = false
/**
* @param api - settings wire face.
* @param entries - reads the cards currently registered into the section's slot.
*/
constructor(
private readonly api: Pick<IApiClient, 'settings'>,
private readonly entries: () => readonly StoredEntry[],
) {}
/**
* Re-read the served namespaces from the Host and republish.
* @returns settlement after the read, or immediately once disposed.
*/
async load(): Promise<void> {
if (this.disposed) return
const generation = ++this.generation
let response: Awaited<ReturnType<IApiClient['settings']['describe']>>
try {
response = await this.api.settings.describe({})
} catch (_settingsReadFailure) {
// The section keeps the namespaces it last knew; the next invalidation
// or reconnect reads again.
return
}
if (this.disposed || generation !== this.generation || !response.result.ok) return
this.served = response.result.value.namespaces.map(view => view.ns)
this.loaded = true
this.publish()
}
/** Republish after the slot ledger changed; a card registered late joins here. */
refresh(): void {
if (this.disposed) return
this.publish()
}
/** Stop publishing; an in-flight read settles without touching the store. */
dispose(): void {
this.disposed = true
this.generation += 1
}
/**
* Build the face the section's slot registration injects.
* @returns the section's snapshot source.
*/
inject(): PluginConfigSectionFace {
return { hooks: { pluginConfigSection: this.store } }
}
private publish(): void {
const served = new Set(this.served)
const namespaces = this.entries().flatMap(entry =>
entry.options.key !== undefined && served.has(entry.options.key) ? [entry.options.key] : [])
this.store.set({ loaded: this.loaded, namespaces })
}
}

View File

@@ -1,19 +1,22 @@
/**
* The `settings.plugin.item` slot type — one plugin's card inside the plugin
* configuration section. Options: `id` (card key), `order` (card position).
* A card draws its own internals; the section only stacks them and reports
* how many there are.
* configuration section, keyed by the settings namespace the card edits.
* Options: `key` (the namespace). A card draws its own internals; the section
* only decides which namespaces to dispatch and stacks what comes back.
*
* TYPE HOME RATIONALE: unlike `settings.general.item`, whose registrants span
* packages that cannot reference its declarer, every current registrant of
* this slot ships in this package, and a plugin registering its own card
* already depends on this package for the card chrome. The type therefore
* lives with the section that declares it at runtime.
* Keying on the namespace is what lets a plugin distributed outside this
* repository contribute a card: it registers its own settings namespace on the
* Host and its own card under that key in the browser, and the section pairs
* the two without ever learning what the namespace means.
*
* TYPE HOME RATIONALE: the section declares this slot at runtime, and a plugin
* registering its own card already depends on this package for the slot's
* declaration. The type therefore lives with its declarer.
*/
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** One plugin's card inside the plugin configuration section (see module JSDoc). */
'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
}
}

View File

@@ -16,8 +16,8 @@ export const inject = ['invariants']
/**
* No runtime invariant: this is a browser-side settings surface whose node half owns no event
* stream or mutable runtime data; the layering, write refusals, and exposure boundary are Host
* contracts covered by the owning plugins and the api-proxy.
* stream or mutable runtime data; the layering and write refusals are Host contracts covered by
* the owning plugins and the api-proxy.
*/
const install: InvariantInstaller = () => {}

View File

@@ -13,12 +13,31 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client'
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
async function bench() {
/**
* @param served - namespaces the Host describes; omitted answers a failed read,
* which is what most of these specs want (no card has anything to render).
*/
async function bench(served?: string[]) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
const describeSettings = vi.fn(() => Promise.resolve(served === undefined
? { rpcId: 's', result: { ok: false, error: {} } }
: {
rpcId: 's',
result: {
ok: true,
value: {
writable: true,
hasDocument: true,
namespaces: served.map(ns => ({
ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
})),
},
},
}))
// The section binds its scopes through the Settings surface's service, and
// forwarded Host events reach it through the same `$dispatch` handoff the
// connection sink makes.
@@ -26,12 +45,12 @@ async function bench() {
ctx.provide('connection', {
isLoopback: true,
api: {
settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) },
settings: { describe: describeSettings },
credentials: { describe: describeCredentials },
},
} as never)
await ctx.plugin(SettingsScopeService).await()
return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials }
return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials, describeSettings }
}
function declareRoot(slots: SlotsService): () => void {
@@ -56,26 +75,40 @@ describe('ui-plugin-config apply', () => {
expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
// The nav label is a locale-following thunk; owners resolve it at read time.
expect(resolveSlotLabel(section.options.label)).toBe('插件配置')
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' })
})
it('registers one card per host-plane section it ships, in a stable order', async () => {
it('keys each card it ships on the settings namespace that card edits', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
expect(slots.entries('settings.plugin.item').map(entry => entry.options.id))
.toEqual(['bash', 'agent-loop', 'web-search'])
expect(slots.entries('settings.plugin.item').map(entry => entry.options.key))
.toEqual(['bash', 'agent-loop', 'web-search-deepseek'])
})
it('injects a live card count and one business face per card', async () => {
const { ctx, slots } = await bench()
it('dispatches the served namespaces its cards claim, and no others', async () => {
// ui-theme is served but belongs to another surface, and a deployment
// composing no PowerShell/POSIX executor serves no `bash` at all.
const { ctx, slots } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek'])
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = slots.entries('settings.section')[0]!
expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 })
const face = (section as { inject?: () => unknown })
.inject?.() as { hooks: { pluginConfigSection: { getSnapshot: () => { namespaces: string[] } } } }
await vi.waitFor(() => {
expect(face.hooks.pluginConfigSection.getSnapshot().namespaces)
.toEqual(['agent-loop', 'web-search-deepseek'])
})
})
it('injects one business face per card', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
for (const entry of slots.entries('settings.plugin.item')) {
const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
// Each card injects exactly one snapshot store plus its own actions.

View File

@@ -20,6 +20,7 @@ import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts'
import type { BashCardState } from '../src/client/bash-store.ts'
import type { CardFieldState, CardShell } from '../src/client/card-store.ts'
import type { PluginConfigSectionState } from '../src/client/section-store.ts'
import type { WebSearchCardState } from '../src/client/web-search-store.ts'
import { en } from '../src/client/locales.ts'
@@ -46,11 +47,20 @@ function cardActions() {
return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
}
function renderSection(cardCount: number, cards = 'cards') {
/**
* Render the section over the namespaces it was told to dispatch, with `cards`
* standing in for the slot ledger: a key it names renders that text, and one
* it does not renders nothing, exactly as an unclaimed key does.
*/
function renderSection(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
const store = createSnapshotStore<PluginConfigSectionState>({ loaded, namespaces })
const props = {
t,
cardCount,
renderSlot: () => <li>{cards}</li>,
usePluginConfigSection: bindSnapshotSelector(store),
renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => {
const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey]
return card === undefined ? null : <li>{card}</li>
},
} as unknown as PluginConfigSectionProps
render(<PluginConfigSection {...props} />)
}
@@ -70,21 +80,30 @@ function renderBash(state: Partial<BashCardState> = {}) {
describe('PluginConfigSection', () => {
it('says so when no plugin contributed a card', () => {
renderSection(0)
renderSection([], { bash: 'shell' })
expect(screen.getByText(en.empty)).toBeTruthy()
expect(screen.queryByText('cards')).toBeNull()
expect(screen.queryByText('shell')).toBeNull()
})
it('renders the card list once a plugin contributed one', () => {
renderSection(1)
it('withholds the empty line until the Host has answered once', () => {
// An unanswered read is not the statement that this deployment configures
// no plugin; saying it anyway would flash a wrong answer on every open.
renderSection([], { bash: 'shell' }, false)
expect(screen.getByText('cards')).toBeTruthy()
expect(screen.queryByText(en.empty)).toBeNull()
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
})
it('dispatches one card per namespace, keyed by it', () => {
renderSection(['bash', 'agent-loop'], { bash: 'shell', 'agent-loop': 'loop' })
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['shell', 'loop'])
expect(screen.queryByText(en.empty)).toBeNull()
})
it('leads with its own heading and intro', () => {
renderSection(1)
renderSection(['bash'], { bash: 'shell' })
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
expect(screen.getByText(en.intro)).toBeTruthy()

View File

@@ -8,6 +8,7 @@ import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-clie
import { CardForm, numberField, textField } from '../src/client/card-store.ts'
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-store.ts'
import { BashCardController, type BashSettings } from '../src/client/bash-store.ts'
import { PluginConfigSectionController } from '../src/client/section-store.ts'
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-store.ts'
/** Make the stub behave like a Host that accepts every write. */
@@ -538,3 +539,96 @@ describe('WebSearchCardController', () => {
expect(credentials.set).not.toHaveBeenCalled()
})
})
describe('PluginConfigSectionController', () => {
function settingsApi(namespaces: string[]) {
const describe = vi.fn(() => Promise.resolve({
rpcId: 's-1' as never,
result: {
ok: true as const,
value: {
writable: true,
hasDocument: true,
namespaces: namespaces.map(ns => ({
ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0,
})),
},
},
}))
return { api: { settings: { describe } } as never, describe }
}
/** Slot ledger stand-in: one stored entry per registered card key. */
function ledger(...keys: string[]) {
return keys.map(key => ({ component: null, options: { key } }))
}
it('dispatches the served namespaces a card claims, in card registration order', async () => {
const settings = settingsApi(['bash', 'ui-theme', 'agent-loop'])
const controller = new PluginConfigSectionController(settings.api, () => ledger('agent-loop', 'bash'))
await controller.load()
// ui-theme is served but claimed by no card here — another surface owns
// it. The order is the cards', not the Host's: plugin activation can
// reorder the description between boots.
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces)
.toEqual(['agent-loop', 'bash'])
})
it('never dispatches a card whose namespace this deployment does not serve', async () => {
const settings = settingsApi(['bash'])
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash', 'web-search-deepseek'))
await controller.load()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash'])
})
it('takes a card registered after the read without asking the Host again', async () => {
const settings = settingsApi(['bash'])
let entries = ledger()
const controller = new PluginConfigSectionController(settings.api, () => entries)
await controller.load()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual([])
entries = ledger('bash')
controller.refresh()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash'])
expect(settings.describe).toHaveBeenCalledOnce()
})
it('keeps the namespaces it knew when a read fails', async () => {
const settings = settingsApi(['bash'])
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash'))
await controller.load()
settings.describe.mockRejectedValueOnce(new Error('offline') as never)
await controller.load()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash'])
})
it('publishes nothing once disposed, and never claims it was answered', async () => {
const settings = settingsApi(['bash'])
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash'))
controller.dispose()
await controller.load()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot())
.toEqual({ loaded: false, namespaces: [] })
expect(settings.describe).not.toHaveBeenCalled()
})
it('reports the Host answered even when it serves nothing this section shows', async () => {
const settings = settingsApi(['ui-theme'])
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash'))
await controller.load()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot())
.toEqual({ loaded: true, namespaces: [] })
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 059c3eacbcd47bfc39820ab3db5545dbc2e2ccb8
README.zh.md: 17bbad0094bfac49d63d6076a01d4c5cd2c5aa6b
README.md: 79a1386b5eb7d61079c34da4ad1e392560f6414f
README.zh.md: 334a5edb49b795f8e036b71206c1d720542f3607

View File

@@ -58,7 +58,7 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. Forwarded `agent-preset/selected` is its per-session counterpart, emitted from the logged selection commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, the host-plane plugin sections `agent-loop`, `bash`, and `web-search-deepseek` that the plugin configuration page edits, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves every registered namespace: a plugin distributed outside this repository becomes browser-configurable by registering its section, with no change here, and this proxy adds no boundary of its own — a name no registration answers folds into the seam's own `settings-rejected`. Which surface renders a namespace is the browser's decision (the plugin configuration page keys its cards on the namespace), never this proxy's. `settings.describe` returns each namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
## Carrier layer (`/client` + root)

View File

@@ -58,7 +58,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和 skill技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt``dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token并以注入的 `<skill_content>` 上下文作答因此所有入口Web、TUI 与 ACPAgent Client Protocol共享同一条确定性路径手动键入的文本也走该路径且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。转发的 `agent-preset/selected` 是它按会话粒度的对应物,由落账的选择提交点发出:重组空会话的 agent 只是重新挂接其 scope不产生任何注册因此该会话组成所决定的两份目录`command.list``skill.list`)都会失效,却没有任何注册表变化来宣告它。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale``permission``ui-conversation``ui-theme`、插件配置页所编辑的宿主平面插件分节 `agent-loop``bash``web-search-deepseek`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable``credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate``credentials.set`且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据都折叠为 `model-discovery-failed`其消息是适配器自己的文本details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated``credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated``settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于每一个已注册 namespace在本仓库之外分发的插件只要注册自己的分节即可变得可从浏览器配置无需改动这里本代理也不再自设边界——没有任何注册应答的名字会折叠为 seam 自己的 `settings-rejected`。由哪个界面渲染某个 namespace 是浏览器的决定(插件配置页按 namespace 为其卡片编键),从不由本代理决定`settings.describe` 为每个 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable``credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate``credentials.set`且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据都折叠为 `model-discovery-failed`其消息是适配器自己的文本details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated``credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated``settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
## 载体层(`/client` + 根路径)

View File

@@ -30,8 +30,7 @@ import {
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import {
InvalidPresetIdError, PresetExistsError, PresetMountError,
PresetNotWritableError, resolveSessionPreset,
SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
PresetNotWritableError, resolveSessionPreset, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets'
import type {} from '@deepseek-ai/dsh-tools'
@@ -108,20 +107,6 @@ import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/**
* Non-model settings namespaces intentionally served to the Web client. The
* plugin-owned entries (`agent-loop`, `bash`, `web-search-deepseek`) are the
* host-plane sections the plugin configuration page edits; a namespace absent
* here answers `settings-not-exposed` even when its owner registered it, so
* adding a section to that page is a decision made here rather than by the
* registering plugin. Moving that declaration to `settings.register()`, so a
* plugin can expose its own configuration without a change in this package,
* is deferred work.
*/
const WEB_SETTINGS_NAMESPACES = [
'agent-loop', 'bash', 'locale', 'permission', 'ui-conversation', 'ui-theme', 'web-search-deepseek',
] as const
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
@@ -238,16 +223,6 @@ function referencedImage(events: readonly SessionEvent[], attachmentId: string):
return undefined
}
/**
* Product settings intentionally exposed beside model-provider namespaces.
*
* The agent-preset namespace carries one field — which preset a session with
* no explicit choice is composed from — and both browser surfaces that offer
* that choice write it through `settings.update`, so it has to cross the
* configuration boundary or the pickers silently fail to persist.
*/
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding', AGENT_PRESET_SETTINGS_NAMESPACE])
/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */
const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
@@ -1857,39 +1832,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}
/** Settings namespaces whose changes can invalidate the model catalog. */
function modelProviderNamespaces(): Set<string> {
return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs))
}
/**
* The settings namespaces this proxy serves: configurable model providers
* plus the small explicit Web preference and product-owned allowlists. The
* settings seam remains general; a future registration does not become
* remotely readable or writable by default.
*/
function exposedNamespaces(): Set<string> {
const exposed = modelProviderNamespaces()
for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns)
for (const ns of PRODUCT_SETTINGS_NAMESPACES) exposed.add(ns)
return exposed
}
/** Refuse a namespace outside the explicit configuration-client boundary. */
function notExposed(request: RpcRequest<unknown>, ns: string): RpcResponse<SettingsNamespaceView> {
return err(request, {
code: 'settings-not-exposed',
message: `settings namespace "${ns}" is not exposed to configuration clients`,
details: { ns },
})
}
/**
* Run one settings write (merge or wholesale replace) and acknowledge with
* the namespace's new redacted view. A namespace outside the configuration
* boundary is refused before the seam is touched; every seam refusal —
* unknown or invalid namespace, read-only provider, schema validation,
* storage — becomes one `settings-rejected` carrying the seam's own message.
* the namespace's new redacted view. Every seam refusal — unknown or invalid
* namespace, read-only provider, schema validation, storage — becomes one
* `settings-rejected` carrying the seam's own message.
*/
async function settingsWrite(
request: RpcRequest<unknown>,
@@ -1920,11 +1867,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
try {
branded = settingsNamespace(ns)
} catch (error: unknown) {
// A malformed name is a client bug, reported as such; it could never be
// in the exposed set either, so naming the real fault costs no ground.
// A malformed name can address no registration, so it fails exactly as
// an unregistered one does.
return rejected(error)
}
if (!exposedNamespaces().has(ns)) return notExposed(request, ns)
try {
if (mode === 'update') await settings.update(branded, section, expectedRevision)
else if (mode === 'replace') await settings.replace(branded, section, expectedRevision)
@@ -3179,13 +3125,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
describe(request) {
const settings = ctx.get('settings')
if (settings === undefined) return Promise.resolve(err(request, settingsAbsent()))
const exposed = exposedNamespaces()
return Promise.resolve(ok(request, {
writable: settings.writable,
hasDocument: settings.documentPath !== undefined,
namespaces: settings.describe({ redactSecrets: true })
.filter(descriptor => exposed.has(String(descriptor.ns)))
.map(namespaceView),
namespaces: settings.describe({ redactSecrets: true }).map(namespaceView),
}))
},
async openDocument(request, signal) {

View File

@@ -59,7 +59,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }),

View File

@@ -63,12 +63,6 @@ export interface RpcErrorDetailsMap {
* read-only provider, or storage failure); the message is the seam's text.
*/
'settings-rejected': { ns: string }
/**
* A settings namespace exists in the seam but is outside the configuration
* plane's model-provider boundary, so this proxy neither reads nor writes
* it; the message names the namespace.
*/
'settings-not-exposed': { ns: string }
/**
* A settings write carried an `expectedRevision` the namespace has already
* moved past: another writer (tab, editor, or an external file edit) landed

View File

@@ -321,12 +321,11 @@ describe('settings domain', () => {
expect(opened).toEqual([])
})
it('serves model-provider and explicitly allowlisted Web namespaces only', async () => {
// The settings seam is general: any plugin may register a namespace for
// its own configuration. The Web configuration plane remains opt-in, so a
// future internal plugin cannot become remotely configurable just by
// registering; locale, permission, conversation, theme, and the product
// onboarding namespace are intentionally admitted by this surface.
it('serves every registered namespace, including one this repository never named', async () => {
// Registering IS the exposure: a plugin distributed outside this
// repository configures itself from the browser without a change here.
// The plane stays loopback-only and secret-redacted, and which surface
// renders a namespace is the browser's decision, not this proxy's.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
@@ -357,8 +356,8 @@ describe('settings domain', () => {
const value = expectOk(await api.settings.describe(request({})))
expect(value.namespaces.map(view => view.ns)).toEqual([
'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation',
'bash', 'agent-loop', 'web-search-deepseek',
'llm-deepseek', 'some-other-plugin', 'permission', 'ui-theme', 'locale',
'ui-conversation', 'bash', 'agent-loop', 'web-search-deepseek',
])
const permission = expectOk(await api.settings.mutate(request({
ns: 'permission',
@@ -396,16 +395,13 @@ describe('settings domain', () => {
})))
expect(webSearch.value).toEqual({ baseURL: 'https://search.test/v1' })
for (const response of [
await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })),
await api.settings.replace(request({ ns: 'some-other-plugin', section: {} })),
]) {
const error = expectErr(response)
expect(error.code).toBe('settings-not-exposed')
expect(error.details).toEqual({ ns: 'some-other-plugin' })
}
// The write never reached the seam.
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
const other = expectOk(await api.settings.update(request({
ns: 'some-other-plugin',
patch: { secretPath: '/etc/shadow' },
})))
expect(other.value).toEqual({ secretPath: '/etc/shadow' })
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value)
.toEqual({ secretPath: '/etc/shadow' })
})
it('serves product preference namespaces without invalidating the model catalog', async () => {
@@ -445,13 +441,17 @@ describe('settings domain', () => {
.toEqual({ default: 'minimal' })
})
it('refuses even a model-provider namespace once its directory entry is gone', async () => {
it('keeps serving a provider namespace whose directory entry is gone', async () => {
// The configurable-provider directory says what the Models page can offer,
// not what a user may configure: a dormant route's stored section is still
// theirs to edit, and losing the entry must not strand it.
const ctx = await harness({ configurableProviders: false })
ctx.settings.register(NS, AdapterConfig)
const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).namespaces).toEqual([])
expect(expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).code)
.toBe('settings-not-exposed')
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.toEqual(['llm-deepseek'])
expect(expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).value)
.toMatchObject({ baseURL: 'https://x' })
})
it('forwards a provider settings change for model-catalog consumers', async () => {
@@ -551,19 +551,18 @@ describe('settings domain', () => {
expect(error.details).toEqual({ ns })
})
it('answers an unregistered namespace exactly like an unexposed one', async () => {
// Deliberately indistinguishable: separating "does not exist" from
// "exists but is not yours to configure" would let a caller enumerate the
// registered namespaces one probe at a time.
it('answers an unregistered namespace as the seam does, and a malformed one alike', async () => {
// A name no registration answers and a name no registration could answer
// fold into the same rejection: the proxy adds no boundary of its own, so
// the seam's own refusal is the whole answer.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
const api = createApiProxy(ctx, DEFAULTS)
const unknown = expectErr(await api.settings.update(request({ ns: 'unknown-ns', patch: {} })))
const unexposed = expectErr(await api.settings.update(request({ ns: 'some-other-plugin', patch: {} })))
expect(unknown.code).toBe('settings-not-exposed')
expect(unexposed.code).toBe(unknown.code)
expect(unexposed.message.replace('some-other-plugin', 'unknown-ns')).toBe(unknown.message)
const malformed = expectErr(await api.settings.update(request({ ns: 'Not A Namespace', patch: {} })))
expect(unknown.code).toBe('settings-rejected')
expect(unknown.message).toContain('is not registered')
expect(malformed.code).toBe(unknown.code)
})
it('maps a read-only provider refusal onto the same rejection', async () => {