diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml index f11d779582..674bd5bd51 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md -2026-07-30-config-plane-boundaries.md: 2dfb527749e8dea54e037abc602af1611c3f9794 -2026-07-30-config-plane-boundaries.zh.md: 8b1ec3206237b7c2deafb743b62e697b3c452ffc +2026-07-30-config-plane-boundaries.md: aab0d7a80c2732b33637ec721a9ab7c1b50fce3f +2026-07-30-config-plane-boundaries.zh.md: fb069e6dd9f276c7cc0069695b388ef37a081ab4 diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md index 2dfb527749..aab0d7a80c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md @@ -6,6 +6,8 @@ English | [中文](2026-07-30-config-plane-boundaries.zh.md) > Scope: boundary hardening of the [web configuration plane](2026-07-30-web-config-plane.md) — which namespaces reach the wire, which callers reach them, and how an editor holding a partial, possibly stale view writes without destroying what it cannot see. +> The caller boundary, the redaction, and the revision fencing remain current. Restricting which namespaces reach the wire to the configurable-provider directory is superseded by the [plugin-owned settings surface](2026-08-12-plugin-owned-settings-surface.md), which serves every registered namespace. + ## Problem The plane worked and was reachable by more callers, and with more authority, than its design claimed. diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md index 8b1ec32062..fb069e6dd9 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md @@ -6,6 +6,8 @@ Status: implemented > 范围:对 [Web 配置面](2026-07-30-web-config-plane.md)的边界加固——哪些 namespace 能抵达协议、哪些调用方能抵达它们,以及一个只持有局部且可能陈旧的视图的编辑器该如何写入,才不会毁掉它看不见的东西。 +> 调用方边界、脱敏与 revision 设栅依然有效。把「哪些 namespace 能抵达协议」限制为可配置提供方目录这一条,已被[由插件自己拥有的设置表层](2026-08-12-plugin-owned-settings-surface.md)取代——后者服务每一个已注册的 namespace。 + ## 问题 这个面能用,但能触达它的调用方、以及它们所拥有的权限,都比设计声称的更多。 diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml new file mode 100644 index 0000000000..d77b2c75c7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md +2026-08-12-plugin-owned-settings-surface.md: 3137cfe81ef3cb78a940f085c559ab4a7b62cce3 +2026-08-12-plugin-owned-settings-surface.zh.md: 8dd5e5ccebf1cfb80b55a615f6049dd391943bd7 diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md new file mode 100644 index 0000000000..3137cfe81e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md @@ -0,0 +1,61 @@ +# Agent Note: Plugin-owned settings surface + +Status: implemented + +English | [中文](2026-08-12-plugin-owned-settings-surface.zh.md) + +## Problem + +A plugin that registered a settings namespace could not reach the browser configuration page, and both gates that stopped it lived in this repository. + +`packages/host/apiproxy` held two hardcoded namespace lists. `settings.describe` filtered its answer through them and every write checked them first, so a namespace outside them answered `settings-not-exposed` even when its owner had registered it. Adding a plugin to the configuration page therefore meant editing a package the plugin author does not own. + +The plugin configuration section rendered an unordered list of whatever cards were registered into `settings.plugin.item`. A card carried an opaque `id`, never the namespace it edited, so the section could not tell which served namespaces already had a home. That left every question about "who renders this namespace" unanswerable from the ledger the section could see. + +Together the two meant a user-authored plugin was configurable only by hand-editing `settings.yaml`. The [web plugin configuration note](../feature/2026-08-10-web-plugin-configuration.md) recorded the allowlist as deliberate, and the [config-plane boundaries note](2026-07-30-config-plane-boundaries.md) tied web-configurability to membership in the configurable-provider directory. Both conclusions blocked exactly the plugin authors the general seam was built for. + +## Decision + +**Registering is exposing.** The api-proxy serves every namespace `ctx.settings.describe()` returns and gates no write. `WEB_SETTINGS_NAMESPACES`, `PRODUCT_SETTINGS_NAMESPACES`, the union with `ctx.llm.listConfigurableProviders()`, and the `settings-not-exposed` error code are gone. A name no registration answers — unknown, or malformed and therefore unable to address one — folds into the seam's own `settings-rejected`, so the proxy contributes no boundary and no vocabulary of its own. + +**The settings seam is untouched.** Which client may read a namespace, and which page renders it, are facts about consumers; a Service Definition that carried either would let one Consumer dictate its contract. `SettingsRegisterOptions` gains nothing. + +**`settings.plugin.item` is keyed on the settings namespace.** The slot moved from `list` to `keyed`, the key being the namespace the card edits, following the `tool.call.toolview` precedent where each tool plugin registers its renderer under the tool name. A card declares `key`, not `id`/`order`. The slot is declared by the Plugins section's `configurable` tab, which owns the card list. + +**The tab drives dispatch from the served namespaces.** It reads `settings.describe` once, subscribes to the settings-document invalidation and to connection resets, and dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the wire answer. + +Keying makes absence the signal, and that is what removes the bookkeeping the previous shape needed. A namespace another surface owns (`ui-theme`, `permission`, `llm-*`, `agent-presets`) has no card under its key, so it renders nothing without declaring anything anywhere. A card whose namespace this deployment does not serve is never dispatched, which also fixes the old empty-state defect: the tab counted registered cards, including ones rendering nothing, so a deployment exposing none showed an empty list instead of its empty line. + +**Nothing renders a form it was not given.** The tab supplies no fallback card. A plugin's browser half owns its card completely — chrome, controls, and copy — which is what the slot's `fallback` option would have replaced with a schema-reverse-rendered form. + +## What the allowlist protected + +The gate did keep one thing off the wire, and this note states it plainly because the decision has to survive the accurate version: a registered namespace the list did not name never had its resolved, `base`, or `user` values reach the browser at all. The plugin inventory page is not a substitute — `PluginInventoryEntry` carries `entryId`, `moduleName`, `enabled`, and `fiberPhase`, and its "configuration" row renders an enabled/disabled tag, never a stored value. + +What the gate was not is the boundary its position suggested. Every `settings.*` method sits in `PRIVILEGED_METHODS` (`packages/client/connection`), so a non-loopback or cross-origin request is refused with 403 before reaching this code; `role('secret')` fields are structurally stripped from every layer of every response; and the document the plane edits is the user's own `settings.yaml`, which the same settings page offers to open. The writes it did not block were also the consequential ones: `permission` (which can widen the approval preset) and `agent-presets` (which decides what a session mounts) were both already served. + +So the exposure this change actually adds, in this repository, is one namespace: `agent-default-model`, whose two fields name a provider and a model and which no browser half renders. A future namespace whose values genuinely must not cross the wire is answered per field by `role('secret')` — finer than a namespace switch, and already enforced. + +## Alternatives considered + +**A declaration on `settings.register()`** (`client: { surface: 'plugin-config' | 'custom', title, description }`), which the removed `WEB_SETTINGS_NAMESPACES` comment named as the intended direction. It keeps registration from crossing the transport by default and lets a plugin author self-serve in one line. Rejected because `surface` is browser-page vocabulary and `title`/`description` are presentation: a Service Definition carrying them is a seam shaped by one Consumer. Its fail-closed property is also worth less than it reads — see what the allowlist protected, above. + +**A separate exposure catalog**, a registry of its own that plugins join beside their settings registration, generalizing `ctx.llm.registerConfigurableProviders()`. Rejected because it makes one fact require two registrations that can drift: registering a namespace and forgetting the catalog entry produces a section nothing can edit, with no gate able to see the mistake. + +**A deny-list `Config` field on the api-proxy**, so a deployment could withhold a namespace. Rejected for having no consumer: every currently registered namespace is one a user may edit, and a genuinely sensitive field is answered per-field by `role('secret')`, which is the finer instrument. A namespace-wide switch invented ahead of its first use is the speculative option the package rules forbid. + +**A schema-driven generic card as the slot's `fallback`**, so a plugin with no browser half still got a form from `schema.toJSON()` (schemastery already carries `description`, `role`, `min`/`max`/`step` and serializes them). Rejected because client plugins load at runtime from mounted Loader entries, so a plugin author can ship a real card, and a reverse-rendered form was already judged worse than a hand-written one for the Models page. The `fallback` option remains available without a contract change if that judgment changes. + +**A client-side claim registry**, where each surface owning a namespace declares it so a generic card knows what is already covered. Rejected with the generic card: keyed dispatch already makes an unclaimed key render nothing, so the registry would restate what the slot ledger says. + +**Keeping the list slot and adding a namespace field to its options.** Rejected because the section would still enumerate entries rather than namespaces, keeping the empty-state defect and leaving a card for an uncomposed plugin to suppress itself. + +## Consequences + +A plugin distributed outside this repository is configurable from the settings page with no change here: it registers its namespace on the Host and its card under that key in the browser, and the section pairs the two. Cards now appear in card registration order rather than by hand-assigned `order`. That is stable for the cards this package registers, which install from one generator, and **not** stable across plugins: apply order between packages is unconstrained (`packages/client/AGENTS.md`), so several external cards can still reorder between boots. Ordering them needs an explicit key the section can sort on, which the keyed registration does not carry today. + +Deferred, and larger than this change: the redactor returns a `role('secret')` reachable only through a union, intersection, or transform verbatim (its own `TODO(settings-wire-redaction)`), and `schema.toJSON()` carries a secret's default. That gap predates this change, but serving every registered namespace widens its blast radius from schemas audited in this repository to any third-party schema, so the wire should refuse a namespace it cannot prove it can redact. Also deferred: an assembled-composition test of the headline capability — an overlay-mounted fixture plugin whose Host half registers a namespace and whose `dsh.client` half registers a card, asserted end-to-end. The current coverage proves each half separately; the shipped cards' unchanged output cannot prove the new path. + +The wire read the section adds is one `settings.describe` beside the per-scope reads the cards already make. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the section's read joins on the next commit or reconnect. + +Two frictions remain for an author outside this repository, both recorded in the section's README. 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. The bundle-purity gate forbids importing this package's card chrome or staged-form model as values, so such a card reimplements staging and revision fencing. Sharing them would mean either publishing the preset or declaring a child slot inside the card so the section supplies the chrome; neither is built. diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md new file mode 100644 index 0000000000..8dd5e5cceb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md @@ -0,0 +1,61 @@ +# Agent Note: 由插件自己拥有的设置表层 + +Status: implemented + +[English](2026-08-12-plugin-owned-settings-surface.md) | 中文 + +## Problem + +注册了 settings 命名空间的插件到不了浏览器配置页,而拦住它的两道门都在本仓库里。 + +`packages/host/apiproxy` 持有两份硬编码的命名空间清单。`settings.describe` 用它们过滤答复,每次写入也先对照它们,因此清单之外的命名空间即便其拥有方已注册,也只会得到 `settings-not-exposed`。于是把一个插件加进配置页,意味着要改一个插件作者并不拥有的包。 + +插件配置分区渲染的是注册进 `settings.plugin.item` 的卡片列表,无序。卡片携带的是不透明的 `id`,从不是它所编辑的命名空间,因此分区无从判断哪些被服务的命名空间已经有了归属。凡是"这个命名空间由谁渲染"的问题,都无法从分区看得见的账本里得到答案。 + +两者相加,用户自己写的插件就只能靠手改 `settings.yaml` 来配置。[web 插件配置 note](../feature/2026-08-10-web-plugin-configuration.md) 把白名单记为刻意为之,[配置面边界 note](2026-07-30-config-plane-boundaries.md) 则把「可在 Web 上配置」绑定到可配置提供方目录的成员资格。这两条结论恰恰挡住了那个通用 seam 本来要服务的插件作者。 + +## Decision + +**注册即暴露。** api-proxy 服务 `ctx.settings.describe()` 返回的每一个命名空间,写入不设门禁。`WEB_SETTINGS_NAMESPACES`、`PRODUCT_SETTINGS_NAMESPACES`、与 `ctx.llm.listConfigurableProviders()` 的并集,以及 `settings-not-exposed` 错误码,全部删除。没有任何注册应答的名字——未知的,或格式非法因而根本无法寻址到注册的——都折叠为 seam 自己的 `settings-rejected`,于是代理既不贡献边界,也不贡献自己的词汇。 + +**settings seam 不动。** 哪个客户端可以读某个命名空间、哪个页面渲染它,都是关于 Consumer 的事实;Service Definition 只要携带其中之一,就等于让一个 Consumer 决定它的契约。`SettingsRegisterOptions` 一个字段都没加。 + +**`settings.plugin.item` 以 settings 命名空间为键。** 该 slot 从 `list` 改为 `keyed`,键就是卡片所编辑的命名空间,沿用 `tool.call.toolview` 的先例——每个工具插件把自己的渲染器注册在工具名这个键上。卡片声明 `key`,不再声明 `id`/`order`。该 slot 由「插件」分区的 `configurable` 标签页声明,卡片列表归它所有。 + +**标签页以被服务的命名空间驱动派发。** 它读取一次 `settings.describe`,订阅 settings 文档失效通知与连接重置,并为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与协议答复算出。 + +以命名空间为键,让「缺席」本身成为信号,而这正是它消掉旧形态所需簿记的原因。归别的界面所有的命名空间(`ui-theme`、`permission`、`llm-*`、`agent-presets`)在其键上没有卡片,于是什么都不渲染,且无需在任何地方声明任何东西。命名空间未被本部署服务的卡片根本不会被派发,这同时修掉了旧的空态缺陷:标签页数的是已注册卡片,其中包含那些什么都不渲染的,因此一个都不暴露的部署看到的是空列表,而不是它那行空态文案。 + +**不渲染任何未被交给它的表单。** 标签页不提供兜底卡片。插件的浏览器半侧完整拥有自己的卡片——外观、控件与文案——而这正是 slot 的 `fallback` 选项会用一份 schema 反向渲染的表单取代掉的东西。 + +## 白名单实际护住了什么 + +这道门确实挡住了一样东西,本 note 如实写出,因为这个决策必须在准确版本下也站得住:不在名单上的已注册命名空间,其 resolved、`base` 与 `user` 值根本不会抵达浏览器。插件清单页不能替代它——`PluginInventoryEntry` 携带的是 `entryId`、`moduleName`、`enabled` 与 `fiberPhase`,它那一行「configuration」渲染的是启用/停用标签,从不是任何已存值。 + +这道门不是的,是它所处位置暗示的那种边界。每个 `settings.*` 方法都在 `PRIVILEGED_METHODS` 里(`packages/client/connection`),非回环或跨源请求在到达这段代码之前就以 403 被拒;`role('secret')` 字段在每种响应的每一层都被结构性剥离;而这个面所编辑的文档,本就是用户自己的 `settings.yaml`,同一个设置页还提供了打开它的入口。它没有挡住的写入,恰恰是有分量的那些:`permission`(能放宽审批预设)与 `agent-presets`(决定一个会话挂载什么)本来就已被服务。 + +因此本次改动在本仓库实际新增的暴露面是一个命名空间:`agent-default-model`——它的两个字段指明一个提供方与一个模型,且没有任何浏览器半侧渲染它。将来若某个命名空间的值确实不该跨越协议,由 `role('secret')` 逐字段作答:比整命名空间开关更精细,而且已经在执行。 + +## Alternatives considered + +**在 `settings.register()` 上加声明**(`client: { surface: 'plugin-config' | 'custom', title, description }`),这也是被删掉的 `WEB_SETTINGS_NAMESPACES` 注释所点名的既定方向。它让注册默认不跨越传输边界,并让插件作者一行代码自助。否决的原因是 `surface` 是浏览器页面的词汇,而 `title`/`description` 属于呈现:Service Definition 一旦携带它们,就成了被单个 Consumer 塑形的 seam。它那条 fail-closed 性质的价值也不如读起来那么高——见上文「白名单实际护住了什么」。 + +**另设一份暴露目录**,插件在注册 settings 之外再加入这份自有注册表,即把 `ctx.llm.registerConfigurableProviders()` 一般化。否决的原因是它把一件事实拆成两处可能脱节的注册:注册了命名空间却忘了目录条目,产出的是一个谁都编辑不了的分节,而没有任何门禁看得见这个错误。 + +**给 api-proxy 加一个 deny-list `Config` 字段**,让部署方能扣下某个命名空间。因为没有消费者而否决:当前每一个已注册的命名空间都是用户可以编辑的,而真正敏感的字段由 `role('secret')` 逐字段作答,那是更精细的工具。在第一个用例出现之前就发明出来的整命名空间开关,正是包规则所禁止的投机选项。 + +**把 schema 驱动的通用卡片作为该 slot 的 `fallback`**,让没有浏览器半侧的插件也能从 `schema.toJSON()` 得到一份表单(schemastery 本就携带 `description`、`role`、`min`/`max`/`step` 并将其序列化)。否决的原因是客户端插件按已挂载的 Loader entries 在运行时加载,插件作者完全可以交付一张真正的卡片;而反向渲染的表单在模型页那次已被判定不如手写。若这个判断日后改变,`fallback` 选项无需改动契约即可启用。 + +**客户端认领注册表**,让每个拥有某命名空间的界面声明它,好让通用卡片知道哪些已经有人管。与通用卡片一并否决:keyed 派发本就让无人认领的键什么都不渲染,这份注册表只会把 slot 账本已经说过的话再说一遍。 + +**保留 list slot,只给它的 options 加一个命名空间字段。** 否决的原因是分区枚举的仍是 entry 而非命名空间,空态缺陷照旧,未组装插件的卡片也仍需自我抑制。 + +## Consequences + +在本仓库之外分发的插件无需改动这里即可从设置页配置:它在 Host 上注册自己的命名空间、在浏览器里把卡片注册在该键上,由分区把两者配对。卡片现在按卡片注册顺序出现,而不再依赖手工指定的 `order`。对本包注册的这几张卡它是稳定的——它们从同一个 generator 安装;对**跨插件**的卡片它并不稳定:包与包之间的 apply 顺序是无约束的(`packages/client/AGENTS.md`),因此多个外部卡片仍可能在不同次启动之间重排。要为它们定序,需要一个 section 可排序的显式键,而 keyed 注册今天并不携带。 + +以下延后,且都大于本次改动:脱敏器对只能经由 union、intersection 或 transform 抵达的 `role('secret')` 原样返回(其自身的 `TODO(settings-wire-redaction)`),而 `schema.toJSON()` 会携带 secret 的默认值。该缺口早于本次改动,但服务每一个已注册命名空间,把它的影响面从本仓库内经审计的 schema 扩大到任意第三方 schema,因此协议应当拒绝服务它无法证明可安全脱敏的命名空间。同样延后的还有:对本次头号能力的组装态测试——用 overlay 挂载一个 fixture 插件(Host 半注册命名空间、`dsh.client` 半注册卡片)并在端到端断言。当前覆盖分别证明了两个半侧;已发卡片输出未变这一点,证明不了新路径。 + +分区新增的协议读取是一次 `settings.describe`,与卡片各自已有的 per-scope 读取并列。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在分区读取之后才被注册的命名空间,要等下一次提交或重连才会加入。 + +对仓库之外的作者仍留有两处摩擦,均记在该分区的 README 里。浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`,并非已发布的包。bundle 纯净度门禁禁止以值的形式导入本包的卡片外观与暂存表单模型,因此这样的卡片要重新实现暂存与 revision 设栅。要共享它们,要么发布该预设,要么在卡片内部声明一层子 slot 让分区提供外观;两者都尚未构建。 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml index 8a9e7a0bda..6b4b469aba 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md -2026-08-10-web-plugin-configuration.md: bdb5a3cbddabb516a0921abc00f1be97be4d17d7 -2026-08-10-web-plugin-configuration.zh.md: ed8ced71ed50063f9b1702ff2a6601271712f910 +2026-08-10-web-plugin-configuration.md: 218d5ed4536c489f89cb53c62c457a988e451b7e +2026-08-10-web-plugin-configuration.zh.md: 999e785699741084e1e230c4a240a0605491cb04 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md index bdb5a3cbdd..218d5ed453 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-08-10-web-plugin-configuration.zh.md) +> The three sections, the layering, and the staged-save form remain current. The Host allowlist and the unkeyed card list are superseded by the [plugin-owned settings surface](../architecture/2026-08-12-plugin-owned-settings-surface.md): every registered namespace is served, and cards are keyed on the namespace they edit. + ## Problem Everything a plugin can be configured with lived in `cordis.yml`. A user who wanted a longer shell timeout, a different search endpoint, or fewer parallel tool calls had to find the composition file, know its shape, and restart — while the Models page had shown for months that a settings namespace can be edited from the browser and take effect immediately. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md index ed8ced71ed..999e785699 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-08-10-web-plugin-configuration.md) | 中文 +> 三个分节、分层解析与暂存保存表单依然有效。Host 白名单与无键卡片列表已被[由插件自己拥有的设置表层](../architecture/2026-08-12-plugin-owned-settings-surface.md)取代:每一个已注册的命名空间都被服务,卡片以它所编辑的命名空间为键。 + ## 问题 插件的一切可配置项都只存在于 `cordis.yml`。想要更长的 shell 超时、不同的搜索端点或更少的并行工具调用,用户必须找到组装文件、了解它的形状,然后重启——而 Models 页几个月来一直在证明:settings 命名空间可以在浏览器里编辑并立即生效。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d1556bb542..ab7a7558b8 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 77000ce9d4608d440e1d903eb80a42f2ed6435ef -architecture.zh.md: f2f5310f665b86b86587307e7ce31c5841b96317 +architecture.md: a1507fa5e54f6703e89f09a5d387e6c9afc81ade +architecture.zh.md: 4642a1e7691bccf4d52d9a84c92c8237c3c6658b diff --git a/docs/architecture.md b/docs/architecture.md index 77000ce9d4..a1507fa5e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -126,4 +126,4 @@ New behavior attaches to a documented extension point. Changing the loop itself | Fork a live session | `ctx.sessions.fork(source, boundary?, childSessionId?)` | | Scope a registration to one agent | use that agent's `agent.ctx` | -The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [Chat nodes](cookbook/adding-a-conversation-node.md). +The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), [Chat nodes](cookbook/adding-a-conversation-node.md), and [settings cards](cookbook/adding-a-settings-card.md). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index f2f5310f66..4642a1e769 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -130,4 +130,4 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | fork 活跃会话 | `ctx.sessions.fork(source, boundary?, childSessionId?)` | | 将注册项限定到单个 agent | 使用该 agent 的 `agent.ctx` | -[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.md)和 [Chat 节点](cookbook/adding-a-conversation-node.md)的分步指南。 +[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.md)、[Chat 节点](cookbook/adding-a-conversation-node.md)和[设置卡片](cookbook/adding-a-settings-card.md)的分步指南。 diff --git a/docs/cookbook/adding-a-settings-card.i18n.yaml b/docs/cookbook/adding-a-settings-card.i18n.yaml new file mode 100644 index 0000000000..acaa9a598b --- /dev/null +++ b/docs/cookbook/adding-a-settings-card.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-settings-card.md +adding-a-settings-card.md: 56ec3be578bbc489bbb979a50bcaed063a35ace5 +adding-a-settings-card.zh.md: 4643303bfd76ba77676b7e508424f46082627b62 diff --git a/docs/cookbook/adding-a-settings-card.md b/docs/cookbook/adding-a-settings-card.md new file mode 100644 index 0000000000..56ec3be578 --- /dev/null +++ b/docs/cookbook/adding-a-settings-card.md @@ -0,0 +1,100 @@ +# Cookbook: adding a settings card + +English | [中文](adding-a-settings-card.zh.md) + +How a plugin puts its own configuration on the web settings page. Nothing in this path needs a change inside this repository: the Host serves every registered settings namespace, and the **Plugins** section keys its cards on the namespace they edit, so a plugin that registers both halves is paired up automatically. + +The two halves live in one package — the Host half under `src/`, the browser half under `src/client/`, exported as `./client` and declared with `dsh.client`. [`packages/client/ui-theme`](../../packages/client/ui-theme) is a worked example of that packaging; the cards this section ships live in [`packages/client/ui-settings-plugins`](../../packages/client/ui-settings-plugins). + +## 1. Register the namespace (Host half) + +The namespace is the join key, so pick it once and spell it in both halves. A consumer that already has a `cordis.yml` entry should register through `installSettingsSection`, which layers the entry under the user document and keeps working when no settings provider is mounted: + +```ts +import type { Context } from '@deepseek-ai/cordis' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import z from '@deepseek-ai/schemastery' + +declare function assertReachable(endpoint: string | undefined): void +declare function rebuildFromSettings(config: Config): void + +export const MY_PLUGIN_NS = settingsNamespace('my-plugin') + +export interface Config { + endpoint?: string + retries?: number +} + +export const Config: z = z.object({ + endpoint: z.string(), + retries: z.number().step(1).min(0).default(3), +}) + +export function apply(ctx: Context, config: Config) { + let source = () => config + installSettingsSection(ctx, MY_PLUGIN_NS, Config, config, { + // Constraints the schema cannot express refuse the write, not the next use. + validate: value => void assertReachable(value.endpoint), + setSource: (current) => { source = current }, + onChange: () => { rebuildFromSettings(source()) }, + }) +} +``` + +`role('secret')` on a field keeps its value off every response; the card writes such a field into an `update`/`mutate` payload, or addresses a credential reference through the `credentials` domain instead. `applies: 'restart'` tells a configuration surface the owner acts on a change only at the next start. + +## 2. Register the card (browser half) + +The card registers into `settings.plugin.item` under its namespace and owns everything inside it — chrome, controls, and copy. It reads and writes through `ctx.settingsScope`, which fences each write with the revision it read: + +```ts ignore-check +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes +// through cordis services; a value import fails the client bundle-purity gate. +import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' + +export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] + +export function apply(ctx: ClientContext): void { + const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: 'my-plugin' })) + ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ + name: 'settings.plugin.item', + key: 'my-plugin', + locale: 'settings.myPlugin', + inject: () => card.inject(), + }, MyPluginCard), + ) +} +``` + +The scope snapshot carries what a form needs: the resolved `value`, the composition `base`, and the raw `user` layer, whose key **presence** — not its value — is what marks a field overridden. `scope.set(field, value)` stores one field and `scope.unset(field)` clears it back to the composition layer. + +## 3. What the tab does with it + +The **Plugin configuration** tab reads which namespaces the Host serves and dispatches one slot key per namespace. A card is rendered when the Host serves its key and skipped when it does not, so a deployment that never composed the Host half shows no trace of the card. A served namespace no card claims renders nothing — that is how the namespaces owned by other pages (`ui-theme`, `permission`, `llm-*`) stay off this tab. + +Cards appear in the order they registered into the slot; a keyed entry declares no `order` of its own. + +## Packaging + +The browser half is served to the page by the [client module system](../../packages/client/modules), which scans the enabled Loader entries for packages declaring `dsh.client` and serves each one's built `./client` export. So the plugin appears on the page as soon as a `cordis.yml` mounts it — no rebuild of the web application. + +```jsonc +{ + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } + }, + "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings-plugins"] } } +} +``` + +The bundle must be the loader's lazy-CJS factory artifact. Inside this repository `tsdown.config.ts` is three lines over the shared preset: + +```ts ignore-check +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js']) +``` + +That preset is not published today, so a package outside this repository has to reproduce the same output format itself. The bundle-purity gate also rejects value imports across plugins, so a card cannot import this section's card chrome or its staged-form model — it renders its own, and owns its own staging and revision fencing. Both limits are recorded under [the section's known limitations](../../packages/client/ui-settings-plugins/README.md#known-limitations-and-deferred-work). diff --git a/docs/cookbook/adding-a-settings-card.zh.md b/docs/cookbook/adding-a-settings-card.zh.md new file mode 100644 index 0000000000..4643303bfd --- /dev/null +++ b/docs/cookbook/adding-a-settings-card.zh.md @@ -0,0 +1,100 @@ +# Cookbook: 新增设置卡片 + +[English](adding-a-settings-card.md) | 中文 + +插件如何把自己的配置放上 Web 设置页。这条路径上没有任何一步需要改动本仓库:Host 服务每一个已注册的 settings 命名空间,而**插件配置**分区以卡片所编辑的命名空间为键,因此同时注册了两个半侧的插件会被自动配对。 + +两个半侧住在同一个包里——Host 半侧在 `src/`,浏览器半侧在 `src/client/`,以 `./client` 导出并用 `dsh.client` 声明。[`packages/client/ui-theme`](../../packages/client/ui-theme) 是这种打包方式的现成例子;本分区自带的卡片在 [`packages/client/ui-settings-plugins`](../../packages/client/ui-settings-plugins)。 + +## 1. 注册命名空间(Host 半侧) + +命名空间就是配对用的键,所以只挑一次,并在两个半侧都写出它。已经有 `cordis.yml` entry 的消费方应通过 `installSettingsSection` 注册——它把 entry 层叠在用户文档之下,并在没有挂载 settings provider 时照常工作: + +```ts +import type { Context } from '@deepseek-ai/cordis' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import z from '@deepseek-ai/schemastery' + +declare function assertReachable(endpoint: string | undefined): void +declare function rebuildFromSettings(config: Config): void + +export const MY_PLUGIN_NS = settingsNamespace('my-plugin') + +export interface Config { + endpoint?: string + retries?: number +} + +export const Config: z = z.object({ + endpoint: z.string(), + retries: z.number().step(1).min(0).default(3), +}) + +export function apply(ctx: Context, config: Config) { + let source = () => config + installSettingsSection(ctx, MY_PLUGIN_NS, Config, config, { + // Constraints the schema cannot express refuse the write, not the next use. + validate: value => void assertReachable(value.endpoint), + setSource: (current) => { source = current }, + onChange: () => { rebuildFromSettings(source()) }, + }) +} +``` + +字段上的 `role('secret')` 让它的值不出现在任何响应里;卡片把这类字段写进 `update`/`mutate` 载荷,或改为经 `credentials` 领域寻址一个凭据引用。`applies: 'restart'` 告诉配置表层:拥有方要到下次启动才会对变更生效。 + +## 2. 注册卡片(浏览器半侧) + +卡片以自己的命名空间为键注册进 `settings.plugin.item`,并拥有其中的一切——外观、控件与文案。它通过 `ctx.settingsScope` 读写,后者用读取时的 revision 为每次写入设栅: + +```ts ignore-check +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes +// through cordis services; a value import fails the client bundle-purity gate. +import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' + +export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] + +export function apply(ctx: ClientContext): void { + const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: 'my-plugin' })) + ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ + name: 'settings.plugin.item', + key: 'my-plugin', + locale: 'settings.myPlugin', + inject: () => card.inject(), + }, MyPluginCard), + ) +} +``` + +scope 快照携带表单所需的一切:解析后的 `value`、组装层 `base`,以及原始的 `user` 层——字段是否被覆盖,取决于它在 `user` 层中是否**出现**,而非它的值。`scope.set(field, value)` 存一个字段,`scope.unset(field)` 把它清回组装层。 + +## 3. 标签页拿它做什么 + +**插件配置**标签页读取 Host 服务了哪些命名空间,并为每个命名空间派发一个 slot 键。当 Host 服务了某卡片的键时它被渲染,否则被跳过,因此从未组装过 Host 半侧的部署不会留下这张卡片的任何痕迹。被服务却无人认领的命名空间什么都不渲染——归其他页面所有的那些命名空间(`ui-theme`、`permission`、`llm-*`)正是这样留在本标签页之外的。 + +卡片按其注册进该 slot 的顺序出现;keyed entry 不声明自己的 `order`。 + +## 打包 + +浏览器半侧由[客户端模块系统](../../packages/client/modules)提供给页面:它扫描已启用的 Loader entries 中声明了 `dsh.client` 的包,并提供每个包构建出的 `./client` 导出。因此只要 `cordis.yml` 挂载了该插件,它就会出现在页面上——无需重新构建 Web 应用。 + +```jsonc +{ + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } + }, + "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings-plugins"] } } +} +``` + +bundle 必须是 loader 的 lazy-CJS factory 产物。在本仓库内,`tsdown.config.ts` 就是基于共享预设的三行: + +```ts ignore-check +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js']) +``` + +该预设目前未发布,因此本仓库之外的包得自行复刻同样的输出格式。bundle 纯净度门禁同时拒绝跨插件的值导入,所以卡片无法导入本分区的卡片外观或其暂存表单模型——它渲染自己的那一份,并自行拥有暂存与 revision 设栅。这两条限制都记在[本分区的已知限制](../../packages/client/ui-settings-plugins/README.md#known-limitations-and-deferred-work)里。 diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index 4f4e26bdf8..9589d5c2bb 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -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', diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 0a98138233..54fd600a60 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -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') }) diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index 09e742cf23..ae8c3a78bf 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md -README.md: 53418f8939649bae4413625a37630e46e7060d15 -README.zh.md: 8d4a2bf8d1af662e9f8e05ccc607cc8b66731731 +README.md: 92ba426b6bee3ae73e184dfcfd418e889bd47086 +README.zh.md: dc319ea9f128016b6df821575cb4d75740f04579 diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index 53418f8939..92ba426b6b 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -6,13 +6,13 @@ The **Plugins** settings section and its **Plugin configuration** tab. The secti ## 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 configurable tab reflects what this deployment actually runs. +The configurable tab 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 tab back from its empty line. The empty line waits for the Host's first answer, so an unanswered read never reads as "this deployment configures no plugin". Cards appear in the order they registered, which is stable for the cards one package installs together and not stable across plugins: apply order between packages is unconstrained. -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.plugins.tab`, a root list slot whose labels become ordered tabs. It keeps a tab mounted after its first selection, so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` list slot. A plugin that ships a browser half registers its own card into that nested slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Both levels follow the contribution's `order`. +The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs. It keeps a tab mounted after its first selection, so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` slot — keyed on 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. 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 tab pairs the two without learning what the namespace means. Tabs follow the contribution's `order`; cards follow registration order. ## 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 tab'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. diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index 8d4a2bf8d1..dc319ea9f1 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -6,13 +6,13 @@ ## 这里会出现什么 -只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此“插件配置”标签页反映的是该部署实际运行的东西。 +“插件配置”标签页读取 Host 服务了哪些 settings 命名空间,并为每个命名空间派发一个 slot 键,因此渲染出来的是两份账本的交集:存活 Host 插件注册的命名空间,以及注册在这些键上的卡片。被服务却无人认领的命名空间什么都不渲染——它归别的界面所有,或本部署没有为它提供浏览器半侧;而命名空间未被本部署服务的卡片根本不会被派发,因此未组装的插件不留任何痕迹,也不会挡住那行空态文案。空态文案要等 Host 的第一次答复,因此一次尚未答复的读取绝不会被读成“本部署没有可配置的插件”。卡片按自身注册的顺序出现;对同一个包一起安装的卡片这是稳定的,对跨插件的卡片则不稳定:包与包之间的 apply 顺序是无约束的。 -第一批覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 +本包自带的卡片覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 ## 扩展点 -本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页。某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` 列表 slot。带浏览器半侧的插件把自己的卡片注册进这个嵌套 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。两层排序都遵循贡献的 `order`。 +本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页。某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` slot——以卡片所编辑的 settings 命名空间为键。带浏览器半侧的插件把自己的卡片注册在自己的命名空间上,并拥有它的全部:外观、控件与文案。以命名空间为键,正是在本仓库之外分发的插件能出现在这里的原因——它在 Host 上注册命名空间、在浏览器里注册卡片,由标签页把两者配对,而无需知道命名空间意味着什么。标签页遵循贡献的 `order`;卡片遵循注册顺序。 ## 写入 @@ -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;之后注册的卡片不会让它变大。 diff --git a/packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx b/packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx index 4e7b575fd5..c9b4365a3a 100644 --- a/packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx +++ b/packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx @@ -1,25 +1,43 @@ -/** Configurable Host plugins contributed to the shared Plugins section. */ +/** + * Configurable Host plugins contributed to the shared Plugins section. + * + * The tab enumerates settings namespaces but never interprets one — a card + * arrives through `settings.plugin.item` keyed by the namespace it edits, so a + * plugin that ships a browser half owns its own card and this tab 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 { ConfigurablePluginsTabFace } from './tab-store.ts' import css from './PluginsSettingsSection.module.css' -/** Registration-side business face for the configurable tab. */ -export interface ConfigurablePluginsTabInjected { - /** How many cards the slot ledger held when the tab registration mounted. */ - cardCount: number -} - /** Props the renderer binds for the configurable tab. */ export type ConfigurablePluginsTabProps = PropsRuntime<'settings.plugins.tab'> & PropsLocale<'settings.plugins'> & PropsRenderSlots<'settings.plugin.item'> - & InjectFace + & InjectFace -/** Render cards registered by plugins that expose editable settings. */ -export function ConfigurablePluginsTab({ t, renderSlot, cardCount }: ConfigurablePluginsTabProps) { - return cardCount === 0 - ?

{t('empty')}

- :
    {renderSlot('settings.plugin.item', {})}
+/** + * Render cards registered by plugins that expose editable settings. + * @param props - locale copy, slot rendering, and the namespaces to dispatch. + * @returns the card list, or the empty line once the Host has answered. + */ +export function ConfigurablePluginsTab(props: ConfigurablePluginsTabProps) { + const { t, renderSlot } = props + const { loaded, namespaces } = props.useConfigurablePlugins(snapshot => snapshot) + if (namespaces.length > 0) { + return ( +
    + {namespaces.map(ns => ( + // One dispatch per namespace, so the list identity is the namespace + // rather than a position that shifts as cards arrive. + {renderSlot('settings.plugin.item', {}, { entryKey: ns })} + ))} +
+ ) + } + return loaded ?

{t('empty')}

: null } diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index dd0b80d89a..82dea6d796 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -23,17 +23,18 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' import { AgentLoopCard } from './AgentLoopCard.tsx' import { BashCard } from './BashCard.tsx' import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx' -import type { ConfigurablePluginsTabInjected } from './ConfigurablePluginsTab.tsx' import { PluginsSettingsSection } from './PluginsSettingsSection.tsx' import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx' import { WebSearchCard } from './WebSearchCard.tsx' import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts' import { SHELL_NS, BashCardController } from './bash-card-controller.ts' +import { ConfigurablePluginsTabController } from './tab-store.ts' import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts' import { en, zh } from './locales.ts' export type { PluginsSettingsSectionInjected, PluginsSettingsSectionProps } from './PluginsSettingsSection.tsx' -export type { ConfigurablePluginsTabInjected, ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx' +export type { ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx' +export type { ConfigurablePluginsTabFace, ConfigurablePluginsTabState } from './tab-store.ts' export type { PluginCardProps } from './PluginCard.tsx' export type { SettingsPluginItemOwnerProps } from './slot-contract.ts' export type { FieldProps } from './fields.tsx' @@ -71,6 +72,27 @@ export function apply(ctx: ClientContext): void { 'ui-settings-plugins: credential invalidations', ) + // 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. + const configurable = new ConfigurablePluginsTabController( + api, () => ctx.slots.entries('settings.plugin.item')) + ctx.effect(() => () => { configurable.dispose() }, 'ui-settings-plugins: tab directory') + ctx.effect( + () => ctx.remote.$on('settings/document-updated', () => { void configurable.load() }), + 'ui-settings-plugins: served-namespace invalidations', + ) + ctx.effect( + () => ctx.on('connection/reset', () => { void configurable.load() }), + 'ui-settings-plugins: 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', () => { configurable.refresh() }), + 'ui-settings-plugins: card ledger', + ) + void configurable.load() + let tabsVersion = -1 let tabsRevision = -1 let tabs: readonly PluginsSettingsTabEntry[] = [] @@ -126,31 +148,26 @@ export function apply(ctx: ClientContext): void { order: 0, label: () => t('configurableTab'), locale: NS, - inject: (): ConfigurablePluginsTabInjected => ({ - cardCount: ctx.slots.entries('settings.plugin.item').length, - }), - children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } }, + inject: () => configurable.inject(), + children: { 'settings.plugin.item': { kind: 'keyed', scope: 'root' } }, }, ConfigurablePluginsTab)) ctx.slots.inject('settings.plugin.item', function* () { yield ctx.slots.register({ name: 'settings.plugin.item', - id: 'bash', - order: 0, + key: SHELL_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) diff --git a/packages/client/ui-settings-plugins/src/client/slot-contract.ts b/packages/client/ui-settings-plugins/src/client/slot-contract.ts index 02b00ea35b..ecb84acadc 100644 --- a/packages/client/ui-settings-plugins/src/client/slot-contract.ts +++ b/packages/client/ui-settings-plugins/src/client/slot-contract.ts @@ -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. + * The `settings.plugin.item` slot type — one plugin's card inside the + * configurable-plugins tab, keyed by the settings namespace the card edits. + * Options: `key` (the namespace). A card draws its own internals; the tab 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 tab pairs the + * two without ever learning what the namespace means. + * + * TYPE HOME RATIONALE: the tab 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 } } } diff --git a/packages/client/ui-settings-plugins/src/client/tab-store.ts b/packages/client/ui-settings-plugins/src/client/tab-store.ts new file mode 100644 index 0000000000..a4ed4439f2 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/tab-store.ts @@ -0,0 +1,123 @@ +/** + * The configurable-plugins tab's card list. + * + * The tab 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 ConfigurablePluginsTabState { + /** + * 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 tab's slot entry injects. */ +export interface ConfigurablePluginsTabFace { + hooks: { + /** Section snapshot bound by the renderer as usePluginConfigSection. */ + configurablePlugins: SnapshotStore + } +} + +/** Reads the served namespaces and pairs them with the cards that claim them. */ +export class ConfigurablePluginsTabController { + private readonly store = createSnapshotStore({ 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, + private readonly entries: () => readonly StoredEntry[], + ) {} + + /** Opaque read of {@link disposed}: control flow cannot narrow it across awaits. */ + private isDisposed(): boolean { + return this.disposed + } + + /** + * Re-read the served namespaces from the Host and republish. + * @returns settlement after the read, or immediately once disposed. + */ + async load(): Promise { + if (this.isDisposed()) return + const generation = ++this.generation + let response: Awaited> + try { + response = await this.api.settings.describe({}) + } catch (_settingsReadFailure) { + // The tab keeps the namespaces it last knew; the next invalidation + // or reconnect reads again. + return + } + if (this.isDisposed() || 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 tab's slot registration injects. + * @returns the tab's snapshot source. + */ + inject(): ConfigurablePluginsTabFace { + return { hooks: { configurablePlugins: 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] : []) + const previous = this.store.getSnapshot() + // Every settings-document commit re-reads, and most of them change nothing + // this section shows. An observable source must keep its snapshot + // reference until the fact moves, or each unrelated save re-renders the + // whole card list (packages/client/AGENTS.md reactive rule 5). + if (previous.loaded === this.loaded + && previous.namespaces.length === namespaces.length + && previous.namespaces.every((ns, index) => ns === namespaces[index])) return + this.store.set({ loaded: this.loaded, namespaces }) + } +} diff --git a/packages/client/ui-settings-plugins/src/invariant.ts b/packages/client/ui-settings-plugins/src/invariant.ts index 5e21f38157..4b0eba850c 100644 --- a/packages/client/ui-settings-plugins/src/invariant.ts +++ b/packages/client/ui-settings-plugins/src/invariant.ts @@ -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 = () => {} diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 112f088006..2934097b94 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -9,19 +9,38 @@ import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-t import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { - ConfigurablePluginsTabInjected, PluginsSettingsSectionInjected, + ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' // The service reads its initial locale from the browser; these specs assert // 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(SlotRegistry).await() const locale = new LocaleRuntime(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. @@ -29,12 +48,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(SettingsScopeBinder).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials } + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings } } function declareRoot(slots: SlotRegistry): () => void { @@ -63,20 +82,11 @@ describe('ui-settings-plugins apply', () => { const tab = slots.entries('settings.plugins.tab')[0]! expect(tab.options).toMatchObject({ id: 'configurable', order: 0 }) expect(resolveSlotLabel(tab.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 () => { - 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']) - }) - - it('injects a live tab projection, a card count, and one business face per card', async () => { + it('injects a live tab projection, the card directory, and one business face per card', async () => { const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() @@ -99,7 +109,8 @@ describe('ui-settings-plugins apply', () => { unsubscribe() const tab = slots.entries('settings.plugins.tab')[0]! - expect((tab.inject as unknown as () => ConfigurablePluginsTabInjected)()).toEqual({ cardCount: 3 }) + const tabFace = (tab.inject as unknown as () => ConfigurablePluginsTabFace)() + expect(Object.keys(tabFace.hooks)).toEqual(['configurablePlugins']) for (const entry of slots.entries('settings.plugin.item')) { const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record } // Each card injects exactly one snapshot store plus its own actions. @@ -107,6 +118,58 @@ describe('ui-settings-plugins apply', () => { } }) + 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.key)) + .toEqual(['shell', 'agent-loop', 'web-search-deepseek']) + }) + + 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 tab = slots.entries('settings.plugins.tab')[0]! + const face = (tab.inject as unknown as () => ConfigurablePluginsTabFace)() + await vi.waitFor(() => { + expect(face.hooks.configurablePlugins.getSnapshot().namespaces) + .toEqual(['agent-loop', 'web-search-deepseek']) + }) + }) + + it('re-reads the served namespaces when the Host commits a settings document', async () => { + // Which namespaces the Host serves is a registration fact the wire never + // announces on its own, so the tab rides the invalidation that can + // accompany a changed composition. + const { ctx, slots, describeSettings } = await bench(['bash']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + describeSettings.mockClear() + + ctx.remote.$dispatch('settings/document-updated', ['bash', 1]) + + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + }) + + it('re-reads the served namespaces after a reconnect', async () => { + const { ctx, slots, describeSettings } = await bench(['bash']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + describeSettings.mockClear() + + ctx.emit('connection/reset') + + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + }) + it('re-reads the credential when the Host reports the watched reference changed', async () => { const { ctx, slots, describeCredentials } = await bench() declareRoot(slots) diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index fda55c3bf1..f521d7189f 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -22,6 +22,7 @@ import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts' import type { BashCardState } from '../src/client/bash-card-controller.ts' import type { CardFieldState, CardShell } from '../src/client/card-form.ts' +import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts' import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' import { en } from '../src/client/locales.ts' @@ -59,11 +60,20 @@ function renderSection(rows: readonly PluginsSettingsTabEntry[]) { render() } -function renderConfigurable(cardCount: number, cards = 'cards') { +/** + * Render the tab 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 renderConfigurable(namespaces: string[], cards: Record = {}, loaded = true) { + const store = createSnapshotStore({ loaded, namespaces }) const props = { t, - cardCount, - renderSlot: () =>
  • {cards}
  • , + useConfigurablePlugins: bindSnapshotSelector(store), + renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => { + const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey] + return card === undefined ? null :
  • {card}
  • + }, } as unknown as ConfigurablePluginsTabProps render() } @@ -153,16 +163,24 @@ describe('PluginsSettingsSection', () => { describe('ConfigurablePluginsTab', () => { it('says so when no plugin contributed a card', () => { - renderConfigurable(0) + renderConfigurable([], { 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', () => { - renderConfigurable(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. + renderConfigurable([], { bash: 'shell' }, false) - expect(screen.getByText('cards')).toBeTruthy() + expect(screen.queryByText(en.empty)).toBeNull() + }) + + it('dispatches one card per namespace, keyed by it', () => { + renderConfigurable(['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() }) }) diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 15ad7cf76a..9901bc5eb1 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -8,6 +8,7 @@ import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-clie import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts' +import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts' import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' /** Make the stub behave like a Host that accepts every write. */ @@ -380,7 +381,7 @@ describe('AgentLoopCardController', () => { }) describe('WebSearchCardController', () => { - it('reads the credential state for the reference the section names', async () => { + it('reads the credential state for the reference the tab names', async () => { const host = stubSettingsScope() const credentials = credentialsApi(true) const controller = new WebSearchCardController(host.scope, credentials.api) @@ -460,7 +461,7 @@ describe('WebSearchCardController', () => { }) }) - it('addresses the reference the section declares rather than the default', async () => { + it('addresses the reference the tab declares rather than the default', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) const controller = new WebSearchCardController(host.scope, credentials.api) @@ -538,3 +539,131 @@ describe('WebSearchCardController', () => { expect(credentials.set).not.toHaveBeenCalled() }) }) + +describe('ConfigurablePluginsTabController', () => { + 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 ConfigurablePluginsTabController(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.configurablePlugins.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 ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'web-search-deepseek')) + + await controller.load() + + expect(controller.inject().hooks.configurablePlugins.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 ConfigurablePluginsTabController(settings.api, () => entries) + await controller.load() + expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([]) + + entries = ledger('bash') + controller.refresh() + + expect(controller.inject().hooks.configurablePlugins.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 ConfigurablePluginsTabController(settings.api, () => ledger('bash')) + await controller.load() + settings.describe.mockRejectedValueOnce(new Error('offline')) + + await controller.load() + + expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) + }) + + it('publishes nothing once disposed, and never claims it was answered', async () => { + const settings = settingsApi(['bash']) + const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash')) + + controller.dispose() + await controller.load() + + expect(controller.inject().hooks.configurablePlugins.getSnapshot()) + .toEqual({ loaded: false, namespaces: [] }) + expect(settings.describe).not.toHaveBeenCalled() + }) + + it('ignores a slot-ledger change that arrives after disposal', async () => { + const settings = settingsApi(['bash']) + let entries = ledger() + const controller = new ConfigurablePluginsTabController(settings.api, () => entries) + await controller.load() + + controller.dispose() + entries = ledger('bash') + controller.refresh() + + expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([]) + }) + + it('drops a read a newer one superseded', async () => { + // The section re-reads on every settings-document invalidation, so a slow + // first answer must not overwrite the newer one that already landed. + const settings = settingsApi(['bash']) + const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'agent-loop')) + const slow = Promise.withResolvers() + settings.describe.mockReturnValueOnce(slow.promise as never) + const stale = controller.load() + + await controller.load() + expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) + slow.resolve({ + rpcId: 's-0', + result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [ + { ns: 'agent-loop', schema: {}, value: {}, applies: 'live', secrets: [], revision: 0 }, + ] } }, + }) + await stale + + expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash']) + }) + + it('reports the Host answered even when it serves nothing this tab shows', async () => { + const settings = settingsApi(['ui-theme']) + const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash')) + + await controller.load() + + expect(controller.inject().hooks.configurablePlugins.getSnapshot()) + .toEqual({ loaded: true, namespaces: [] }) + }) +}) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 0cd1f51cd2..dbd3731614 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1268,28 +1268,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, { key: 'settings.plugin.item', - kind: 'list', + kind: 'keyed', scope: 'root', summary: 'One plugin\'s card inside the plugin configuration section (see module JSDoc).', doc: 'One plugin\'s card inside the plugin configuration section (see module JSDoc).', registerOptions: [ { - name: 'id', + name: 'key', requirement: 'required', type: 'string', - doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', - }, - { - name: 'order', - requirement: 'optional', - type: 'number', - doc: 'Position among the entries, ascending (default 0).', - }, - { - name: 'label', - requirement: 'optional', - type: 'string | (() => string)', - doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', }, ], ownerProps: [ @@ -1300,18 +1288,18 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSessions: SnapshotSelectorHook', 'useWorkspaces: SnapshotSelectorHook', ], - keyDomain: '', + keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: '', slotInject: '', declaredBy: 'an entry in \'settings.plugins.tab\' (client-ui-settings-plugins), so it exists while that entry is mounted', occupants: [ - 'client-ui-settings-plugins BashCard id \'bash\'', - 'client-ui-settings-plugins AgentLoopCard id \'agent-loop\'', - 'client-ui-settings-plugins WebSearchCard id \'web-search\'', + 'client-ui-settings-plugins BashCard', + 'client-ui-settings-plugins AgentLoopCard', + 'client-ui-settings-plugins WebSearchCard', ], replaceRisk: 'none', - example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.plugin.item\', () => ctx.slots.register(\n { name: \'settings.plugin.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings-plugins/src/client/slot-contract.ts:16', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.plugin.item\', () => ctx.slots.register(\n { name: \'settings.plugin.item\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-settings-plugins/src/client/slot-contract.ts:19', }, { key: 'settings.plugins.tab', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 2ae197d230..5826f75f75 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: b7dee9488c1b123172c225d4d4235bdb3c76911d -README.zh.md: 46835a84254c4bbc45300a18d29aae5a6ac66f14 +README.md: 607cd4e4176631b64daf4a298b5d86a75ccdce68 +README.zh.md: cdfb5aa65b3ad5b00596487aae6c99e2f9d4e433 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index b7dee9488c..607cd4e417 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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 `` 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) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 46835a8425..cdfb5aa65b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -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,并以注入的 `` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent 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 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `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 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 7c6abbf271..4ae8afb82e 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -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' @@ -113,20 +112,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', 'shell', '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 @@ -245,16 +230,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_+.-]+)+$/ @@ -1944,39 +1919,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } - /** Settings namespaces whose changes can invalidate the model catalog. */ - function modelProviderNamespaces(): Set { - 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 { - 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, ns: string): RpcResponse { - 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, @@ -2007,11 +1954,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) @@ -3266,13 +3212,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) { diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 177f0ffd29..03cfdd1e15 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -59,7 +59,6 @@ export const rpcErrorSchema: z.ZodType = 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() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 134a39eba9..0b5506b6b6 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -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 diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index d4ba7b374b..0ca2b3d353 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -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', - 'shell', 'agent-loop', 'web-search-deepseek', + 'llm-deepseek', 'some-other-plugin', 'permission', 'ui-theme', 'locale', + 'ui-conversation', 'shell', '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 () => { diff --git a/website/docs.ts b/website/docs.ts index 97fdfddd2a..7acd14092c 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -392,6 +392,7 @@ const reference = [ ['adding-a-package.md', '新增 Package', 'Adding a package'], ['adding-a-tool.md', '新增 Tool', 'Adding a tool'], ['adding-an-llm-adapter.md', '新增 LLM Adapter', 'Adding an LLM adapter'], + ['adding-a-settings-card.md', '新增设置卡片', 'Adding a settings card'], ['extension-cookbook.md', '扩展模式', 'Extension patterns'], ] as const).map(([file, rootLabel, enLabel], order): PairedPage => ({ source: `docs/cookbook/${file}`, @@ -407,7 +408,7 @@ const reference = [ label: { root: '新增 Conversation Node', en: 'Adding a Conversation Node' }, sidebar: { root: 'zh-reference', en: 'en-reference' }, section: { root: '开发手册', en: 'Cookbook' }, - order: 4, + order: 5, }]), ]