DSH / Atlas
2026-08-10implementedfeature

Plugin configuration in the web settings page

Plugin configuration in the web settings page

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. The seam that made the Models page possible was already gener

English

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.

The seam that made the Models page possible was already general: any plugin may register a namespace, and settings.describe serves its schema, its layers, and its revision. What was missing was on the two ends. No plugin outside the LLM adapters and the permission service had registered one, and there was no surface for a namespace that is not a model provider.

Decision

Three host-plane plugins register their own settings namespace, and one browser-side Plugins section aggregates feature-owned tabs. Its configurable tab renders whatever editable settings the deployment exposes.

Layering, unchanged. A section resolves as schema defaults → the plugin's composition entry → the user layer. Each plugin passes its cordis.yml entry as the base and reads its config through a source thunk, so a stored change reaches the next use and a detaching settings provider leaves the composition entry running. Constraints the schema cannot express — positive and finite, the timer bound on graceMs, the parallel cap being a positive integer — become the section validator, so a bad value is refused at the write instead of at the next command.

The shell namespace names the capability, not an implementation. SHELL_SETTINGS_NAMESPACE is exported by @deepseek-ai/dsh-shell because a host composes exactly one provider of ctx.shell: the win32 layer swaps the POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate service registration. Both families therefore register the same namespace with their own schema and entry without ever colliding, and a settings.yaml carried between platforms keeps resolving on both — schemastery objects preserve keys the active schema does not declare.

A section is a subset when the plugin config is bigger than what a user owns. agent-loop exposes only maxParallelToolCalls; its agents array is consumed once when the service starts, so a stored change there could only look like it had an effect.

The provider projects, rather than captures. web-search-deepseek hands its provider a thunk instead of an options value, so an endpoint or model change reaches the next search without re-registering the provider — which would make the web seam's provider selection observable to the user as a flicker.

Exposure stays a Host allowlist. The three namespaces join WEB_SETTINGS_NAMESPACES; registration alone still never crosses the transport, and a namespace absent from that list answers settings-not-exposed exactly as an unregistered one does.

The configurable tab knows no namespace. dsh-client-ui-settings-plugins owns the Plugins section, contributes its configurable page through settings.plugins.tab, and declares a nested settings.plugin.item slot there. It renders the cards registered into that nested slot, so a plugin that ships a browser half owns its card and its controls. Each card binds its namespace through the client settings scope, which gained the two things a form needs: the raw user layer, whose key PRESENCE is what marks a field overridden, and unset, which clears one field back to the composition layer. A card renders nothing while its namespace is unavailable, so a deployment that does not compose the owning plugin shows no trace of it.

A card stages its edits and writes them on save. Controls hold no draft of their own: the card's form owns the staged text, every control renders it, and only Save turns it into document mutations. A settings write is durable and revision-fenced, so a control that committed as it settled spent a revision on a value the user had not decided to store and could not preview; the reset stages the composed default the same way. Because the Host's validators own the constraints no schema can express, the form reads the section back after writing and reports a save that did not land instead of predicting the outcome, keeping those drafts for the user to correct. The credential control is staged with the rest even though it writes through the credentials domain, so one save covers everything the card shows.

Alternatives considered

  • A registration-time exposure declaration replacing the allowlist. The honest shape — the namespace's owner declares its own exposure, and a plugin distributed outside this repository can surface its configuration without a change in packages/host/apiproxy. Deferred because it changes the seam contract, every existing registration site, and the anti-enumeration semantics at once, and because a plugin exposing an arbitrary schema needs a fail-closed redaction path first: a secret reachable only through a union or transform is currently returned verbatim.
  • A generic schema-driven form renderer. Declined again for the reason recorded in the web-config-plane note: field truth without a presentation vocabulary produced an unusable card. Three plugins of hand-written controls cost about the same and read better, and the slot keeps the fourth plugin from having to negotiate with this package.
  • Editing preset-mounted plugins from this page. Out of scope, and not merely unbuilt: a preset's rows carry their configuration inline in agent.cordis.yml and cannot register a settings namespace at all, because a second session mounting the same preset would fail on a duplicate registration. A user layer shared across presets would also overwrite the fields a preset uses to define its agent's identity — its persona text, its delegation wiring — which are per-preset by design.
  • One namespace per executor package instead of the capability-named bash. Declined because the composed executor differs by platform while the settings document does not: a user who set a timeout on macOS would silently lose it on Windows.
  • Writing the search key into the settings section. Declined because the literal would then have to ride a describe response to be rendered. The card reports only whether a key is configured and writes through the credentials domain, addressed by the reference the section names.
  • Committing each control as it settles, with no save. Built first, and replaced: blur is not a decision. It spent a namespace revision per control, gave the user nothing to preview or undo before the write, and left an invalid draft silently discarded — a value the Host's validator refuses simply snapped back with no reason given. One save per card makes the write a gesture the user performs.
  • Letting the provider read its options per property. The thunk was read at each use site so read sites could stay unchanged, which quietly broke the contract the constructor states: search() awaits credential resolution and then reads the endpoint, model, and budget, so a settings write landing inside that await sent the key resolved from the old section to the endpoint named by the new one. Each operation now snapshots once at its entry and threads that snapshot into credential resolution.
  • Validating the fields in the browser to keep the save honest. Declined: the constraints live in the owning plugin's section validator, and restating them here would make two homes for one rule that could disagree per release. The card checks only what its own control can decide — that a numeric draft is a number — and lets the Host answer for the rest, which is why the save reads the section back.

Consequences

A user edits the shell's command timeout and output cap, the agent loop's parallel tool-call cap, and the search provider's key, endpoint, and per-request budget from the settings page, with each field marking whether they set it and offering a reset.

Two costs are real. Adding a fourth plugin still requires an entry in the apiproxy allowlist, so the page's reach is a Host decision rather than a plugin's. And the plugins the web deployment moved into the agent plane — the file tools, the skills, compaction, the todo tool — appear nowhere here, which is most of what a user might expect to find; their configuration remains the preset editor's.

The bash and pwsh executors now expose config as a getter over a source thunk rather than a readonly field. Every read site was already per-call, so nothing else changed, but a subclass that captured this.config at construction would silently pin the composition entry.

verify-cordis-config gained one check, paid for by this branch: merging master's rename of the client manifest field (dshClientdsh.client) left this package declaring the old name, and the whole section vanished from the browser with no error anywhere — the row composed, the empty node half activated, and the browser roster scan simply never matched it. Nothing could catch that, because the composition file cannot tell a surface plugin from a Host plugin: the difference lives in the manifest. The gate now requires a packages/client package's ./client export and its dsh.client declaration to agree in both directions. The check is scoped to that group because a Host package's ./client export is the typed wire face its browser consumers import, not a plugin the roster serves.

中文

问题

插件的一切可配置项都只存在于 cordis.yml。想要更长的 shell 超时、不同的搜索端点或更少的并行工具调用,用户必须找到组装文件、了解它的形状,然后重启——而 Models 页几个月来一直在证明:settings 命名空间可以在浏览器里编辑并立即生效。

支撑 Models 页的那条 seam 本就是通用的:任何插件都可以注册命名空间,settings.describe 会提供它的 schema、分层与 revision。缺的是两端。除 LLM 适配器与权限服务外,没有插件注册过命名空间;而对于非模型提供方的命名空间,也没有任何表层。

决策

三个宿主平面插件各自注册 settings 命名空间,一个浏览器侧“插件”分区聚合由各功能持有的标签页。它的“可配置”标签页渲染该部署所暴露的一切可编辑设置。

分层不变。 一个分节按 schema 默认值 → 插件的组装条目 → 用户层解析。每个插件把自己的 cordis.yml 条目作为 base 传入,并通过 source thunk 读取配置,因此存储的变更会作用于下一次使用,而脱离的 settings 提供方会让组装条目继续运行。schema 无法表达的约束——正有限、graceMs 的定时器上界、并行上限必须是正整数——成为分节的校验器,因此错误的值在写入时被拒绝,而不是到下一条命令时才失败。

shell 命名空间命名的是能力,而非某个实现。 SHELL_SETTINGS_NAMESPACE@deepseek-ai/dsh-shell 导出,因为一个宿主只组装一个 ctx.shell 提供方:win32 层会把 POSIX 行换成 pwsh 行,而同时挂载两者会因服务重复注册在加载期失败。因此两个家族都能用自己的 schema 与条目注册同一个命名空间而永不相撞;在平台间携带的 settings.yaml 也能在两边继续解析——schemastery 对象会保留当前 schema 未声明的键。

当插件配置大于用户所拥有的部分时,分节就是一个子集。 agent-loop 只暴露 maxParallelToolCalls;它的 agents 数组在服务启动时被消费一次,所以存储在那里的变更只会看起来生效。

提供方按次投影,而不是固化。 web-search-deepseek 交给提供方的是一个 thunk 而非 options 值,因此端点或模型的变更无需重新注册提供方即可作用于下一次搜索——重新注册会让 web seam 的提供方选择以闪断的形式被用户看到。

暴露仍是 Host 的白名单。 这三个命名空间加入 WEB_SETTINGS_NAMESPACES;仅有注册依然不会跨越传输边界,而不在该名单中的命名空间会与未注册的命名空间得到完全相同的 settings-not-exposed

“可配置”标签页不认识任何命名空间。 dsh-client-ui-settings-plugins 拥有“插件”分区,通过 settings.plugins.tab 贡献自己的 configurable 页面,并在其中声明嵌套的 settings.plugin.item slot。它渲染注册进这个嵌套 slot 的卡片,因此带浏览器半侧的插件拥有自己的卡片与控件。每张卡片通过客户端 settings scope 绑定其命名空间,而该 scope 补上了表单所需的两样东西:原始 user 层——键的存在才标记字段被覆盖——以及把单个字段清回组装层的 unset。命名空间不可用时卡片什么都不渲染,因此未组装该插件的部署不会显示它的任何痕迹。

卡片暂存修改,保存时才写入。 控件不持有自己的草稿:暂存文本归卡片的表单所有,所有控件渲染的都是它,只有保存才把它变成文档变更。settings 写入是持久且带 revision 栅栏的,因此「失焦即提交」的控件会为用户尚未决定存储、也无从预览的值花掉一个 revision;重置同样只是暂存组装默认值。schema 表达不了的约束归 Host 的校验器所有,所以表单在写入后回读分节、报告没有落盘的保存,而不是自行预测结果,并保留这些草稿供用户修改。密钥控件虽然经由 credentials 领域写入,也和其余字段一起暂存,因此一次保存覆盖卡片上的全部内容。

备选方案

  • 用注册期的暴露声明取代白名单。 这才是诚实的形状——命名空间的拥有方声明自己的暴露,在本仓库之外分发的插件也无需改动 packages/host/apiproxy 就能呈现自己的配置。之所以暂缓,是因为它会同时改变 seam 契约、全部现有注册点与防枚举语义;而且插件要暴露任意 schema,还得先有 fail-closed 的脱敏路径:目前只能经由 union 或 transform 抵达的 secret 会被原样返回。
  • 通用 schema 驱动的表单渲染器。 再次否决,理由与 web-config-plane 笔记所记一致:没有呈现词汇的字段真值产出的是无法使用的卡片。三个插件的手写控件成本相当而可读性更好,且该 slot 让第四个插件无需与本包协商。
  • 在本页编辑 preset 挂载的插件。 超出范围,而且不只是「尚未实现」:preset 的行把配置内联在 agent.cordis.yml 中,且根本无法注册 settings 命名空间——同一 preset 挂载第二个会话时会因重复注册而失败。跨 preset 共享的用户层还会覆盖 preset 用来定义其 agent 身份的字段——人设文本、委派接线——而这些字段按设计就是各 preset 各自的。
  • 按执行器包各取一个命名空间,而非按能力命名的 bash 否决,因为被组装的执行器随平台不同,而设置文档不随平台不同:在 macOS 上设过超时的用户,到 Windows 上会悄无声息地失去它。
  • 把搜索密钥写进 settings 分节。 否决,因为那样字面值就必须搭乘 describe 响应才能被渲染。卡片只报告是否已配置密钥,并按分节所命名的引用经由 credentials 领域写入。
  • 每个控件失焦即提交,不设保存。 最初就是这么做的,后被替换:失焦不是决定。它每个控件花掉一个命名空间 revision,写入前不给用户任何预览或撤销的余地,还会把无效草稿悄悄丢弃——被 Host 校验器拒绝的值只是弹回原样,不给任何理由。每张卡片一个保存,才让写入成为用户执行的动作。
  • 让提供方按属性逐次读取 options。 最初为了不改动读取点而在每个使用处读 thunk,这悄悄违背了构造函数自己声明的契约:search() 先 await 凭据解析,之后才读端点、模型与预算,因此落在那段 await 里的设置写入会把按旧分节解析出的密钥发往新分节命名的端点。现在每次操作在入口只快照一次,并把该快照传进凭据解析。
  • 在浏览器端校验字段,好让保存诚实。 否决:这些约束住在拥有方插件的分节校验器里,在这里重述一遍就会让同一条规则有两个家,且可能随版本各说各话。卡片只判断自己的控件能判断的事——数字草稿是不是数字——其余交给 Host 回答,这正是保存要回读分节的原因。

影响

用户可以在设置页编辑 shell 的命令超时与输出上限、agent 循环的并行工具调用上限,以及搜索提供方的密钥、端点与单次请求预算,每个字段都标注是否由自己设定,并提供重置。

有两项真实代价。加入第四个插件仍需要在 apiproxy 白名单里添一条,因此本页的覆盖面是 Host 的决定而非插件的决定。而 web 部署移入 agent 平面的那些插件——文件工具、技能、压缩、todo 工具——在这里一个都不出现,而它们恰恰是用户最可能期待找到的;它们的配置仍归 preset 编辑器。

bash 与 pwsh 执行器现在把 config 暴露为 source thunk 之上的 getter,而不再是 readonly 字段。所有读取点本就是按次读取,因此别无变化;但若某个子类在构造期捕获 this.config,就会悄然把组装条目钉死。

verify-cordis-config 新增一项检查,代价由本分支付过:合并 master 对客户端清单字段的重命名(dshClientdsh.client)后,本包仍声明旧名,于是整个分区从浏览器上消失,且任何地方都不报错——行照常组装、空的 node 半侧照常激活,只是浏览器 roster 扫描永远匹配不到它。这一点无从被既有门禁发现,因为组装文件区分不了 surface 插件与 Host 插件:差别在清单里。现在门禁要求 packages/client 包的 ./client 导出与 dsh.client 声明双向一致。之所以只限这一组:Host 包的 ./client 导出是给浏览器消费方 import 的类型化 wire face,不是 roster 要服务的插件。