fix(settings): expose third-party settings namespaces to the web config client

The web configuration surface (apiproxy) served only model-provider plus
explicit allowlist namespaces, so a third-party plugin's settings card (e.g.
the web-ui image-understanding plugin's describe-image) showed "namespace not
exposed" and was uneditable. Let a plugin opt its namespace in via
`settings.register(..., { configurable: true })`, and let a deployment expose
any shipped third-party namespace via the gateway's new `exposeSettings`
config (the web-app bundle lists describe-image). Default stays not-exposed.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-15 17:02:57 +08:00
parent 604a817546
commit d64ff6e3d4
8 changed files with 101 additions and 7 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?, sessionExportCompressionLevel?, coldBlankProbeMaxBytes?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?, sessionExportCompressionLevel?, coldBlankProbeMaxBytes?, exposeSettings?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
## The shared Agent default (`agent-default-model` Settings section)
@@ -58,7 +58,7 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. Forwarded `agent-preset/selected` is its per-session counterpart, emitted from the logged selection commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, the host-plane plugin sections `agent-loop`, `bash`, and `web-search-deepseek` that the plugin configuration page edits, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves 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. A plugin that ships a settings card opts its namespace in via `configurable: true` on registration, and a deployment can expose a shipped third-party namespace without that plugin opting in by listing it in the gateway `exposeSettings` config (the web-app bundle does this for the third-party image-understanding plugin's `describe-image` namespace). 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.
## Carrier layer (`/client` + root)
+17
View File
@@ -668,6 +668,14 @@ export interface ApiProxyDefaults {
* falls back to platform detection ({@link canOpenNativePath}).
*/
canOpenPath?: () => boolean
/**
* Additional settings namespaces the deployment exposes to the web
* configuration client, beyond the harness allowlists and the namespaces
* plugins opted into via `configurable: true`. Lets an operator edit a
* shipped third-party plugin's settings card (e.g. describe-image) without
* that plugin's code opting in.
*/
exposeSettings?: readonly string[]
}
/** The tool/call payload fields the presenter path reads. */
@@ -1109,6 +1117,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
const coldBlankProbeMaxBytes = defaults.coldBlankProbeMaxBytes
?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES
const exposeSettings = new Set(defaults.exposeSettings ?? [])
/** The seed model each create/resume declares; re-read so it never goes stale. */
const agentOptions = (): AgentOptions => {
const { provider, model } = defaults.defaultModelSelection()
@@ -1955,6 +1964,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const exposed = modelProviderNamespaces()
for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns)
for (const ns of PRODUCT_SETTINGS_NAMESPACES) exposed.add(ns)
// A plugin that ships a settings card opts its namespace in via
// `settings.register(..., { configurable: true })`; that declaration is the
// general path so third-party namespaces need no per-plugin allowlist entry.
const settings = ctx.get('settings')
if (settings !== undefined) {
for (const ns of settings.configurableNamespaces()) exposed.add(ns)
}
for (const ns of exposeSettings) exposed.add(ns)
return exposed
}
+12
View File
@@ -59,6 +59,14 @@ export interface Config {
* @default 1024
*/
coldBlankProbeMaxBytes?: number
/**
* Additional settings namespaces the web configuration client may read and
* write, beyond the harness allowlists and namespaces plugins opted into via
* `configurable: true` on registration. An operator lists a shipped
* third-party plugin's namespace here to make its settings card editable
* without that plugin's code opting in.
*/
exposeSettings?: string[]
}
/**
@@ -77,6 +85,7 @@ export class ApiProxyService extends Service implements ApiProxy {
sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
exposeSettings: z.array(z.string()),
})
readonly sessions: ApiProxy['sessions']
@@ -106,6 +115,9 @@ export class ApiProxyService extends Service implements ApiProxy {
...(config.coldBlankProbeMaxBytes === undefined
? {}
: { coldBlankProbeMaxBytes: config.coldBlankProbeMaxBytes }),
...(config.exposeSettings === undefined
? {}
: { exposeSettings: config.exposeSettings }),
})
this.sessions = api.sessions
this.subagents = api.subagents
@@ -454,6 +454,30 @@ describe('settings domain', () => {
.toBe('settings-not-exposed')
})
it('exposes a third-party namespace the deployment lists in exposeSettings', async () => {
const ctx = await harness({ configurableProviders: false })
const describeImage = settingsNamespace('describe-image')
ctx.settings.register(describeImage, z.object({ baseURL: z.string().default('') }))
// Not a provider, not opted-in via configurable, and not in a built-in
// allowlist — yet the deployment's exposeSettings opts it into the surface.
const api = createApiProxy(ctx, { ...DEFAULTS, exposeSettings: ['describe-image'] })
const described = expectOk(await api.settings.describe(request({})))
expect(described.namespaces.map(view => view.ns)).toEqual(['describe-image'])
expect(expectOk(await api.settings.update(request({
ns: 'describe-image', patch: { baseURL: 'https://vision.example' },
}))).value).toMatchObject({ baseURL: 'https://vision.example' })
})
it('honors configurable opt-in even without a provider entry or allowlist', async () => {
const ctx = await harness({ configurableProviders: false })
ctx.settings.register(settingsNamespace('describe-image'), z.object({ baseURL: z.string().default('') }), {
configurable: true,
})
const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.toEqual(['describe-image'])
})
it('forwards a provider settings change for model-catalog consumers', async () => {
// Editing `models` changes no route, so llm/adapters-updated never fires
// and an open model picker would keep serving the stale catalog. Storing
@@ -130,11 +130,12 @@ describe('session export compression config', () => {
expect(ApiProxyService.Config({})).toEqual({
sessionExportCompressionLevel: 6,
coldBlankProbeMaxBytes: 1024,
exposeSettings: [],
})
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
.toEqual({ sessionExportCompressionLevel: 0, coldBlankProbeMaxBytes: 1024 })
.toEqual({ sessionExportCompressionLevel: 0, coldBlankProbeMaxBytes: 1024, exposeSettings: [] })
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
.toEqual({ sessionExportCompressionLevel: 9, coldBlankProbeMaxBytes: 1024 })
.toEqual({ sessionExportCompressionLevel: 9, coldBlankProbeMaxBytes: 1024, exposeSettings: [] })
for (const value of [-1, 10, 1.5]) {
expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
}
@@ -144,9 +145,9 @@ describe('session export compression config', () => {
describe('cold blank probe config', () => {
it('accepts a per-Session byte bound including zero and rejects invalid bounds', () => {
expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 0 }))
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 0 })
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 0, exposeSettings: [] })
expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 2048 }))
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 2048 })
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 2048, exposeSettings: [] })
for (const value of [-1, 1.5]) {
expect(() => ApiProxyService.Config({ coldBlankProbeMaxBytes: value })).toThrow()
}
+1 -1
View File
@@ -8,7 +8,7 @@ User-settings Service Definition (`ctx.settings`). One provider holds a raw docu
- `documentPath` — absolute path of the provider's user-editable file when it has one; non-file providers leave it `undefined`. Host configuration adapters derive availability from it, while browser protocols expose only a boolean capability and never a filesystem target.
- `prepareDocument()` — return that path after making the document ready for a native editor. The base implementation returns `documentPath`; a file provider may materialize an absent document first.
- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
- `register(ns, schema, { base?, applies?, configurable? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud. `configurable: true` opts the namespace into the web configuration client (see the apiproxy README); without it a registration stays host-only.
- `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires.
- `get(ns)` — resolved value, `undefined` while unregistered.
- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches may contain only JSON-compatible data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently change such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
+25
View File
@@ -39,6 +39,12 @@ export interface SettingsRegisterOptions<T> {
base?: Partial<T>
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
applies?: SettingsApplies
/**
* Whether the web configuration client may read and write this namespace.
* Defaults to `false` so a registration never becomes remotely editable by
* accident; a plugin that ships a settings card opts in here.
*/
configurable?: boolean
/**
* Reject a resolved section the owner could not act on, for constraints its
* schema cannot express — a cross-field requirement, or one field's validity
@@ -85,6 +91,8 @@ export interface SettingsDescriptor {
user?: unknown
/** Owner's declared effect timing. */
applies: SettingsApplies
/** Whether the web configuration client may read and write this namespace. */
configurable: boolean
/** Schema-declared secret positions; present only under `redactSecrets`. */
secrets?: RedactedSecret[]
}
@@ -326,6 +334,8 @@ interface SettingsRegistration {
schema: z<unknown>
base: unknown
applies: SettingsApplies
/** Whether the web configuration client may read and write this namespace. */
configurable: boolean
/** Owner-supplied check for constraints the schema cannot express. */
validate?: (value: unknown) => void
resolved: unknown
@@ -441,6 +451,7 @@ export abstract class SettingsProvider extends Service {
schema: schema as z<unknown>,
base: options?.base,
applies: options?.applies ?? 'live',
configurable: options?.configurable ?? false,
...options?.validate === undefined
? {}
: { validate: options.validate as (value: unknown) => void },
@@ -497,6 +508,7 @@ export abstract class SettingsProvider extends Service {
...base === undefined ? {} : { base },
...detachedUser === undefined ? {} : { user: detachedUser },
applies: registration.applies,
configurable: registration.configurable,
}
if (options?.redactSecrets !== true) return descriptor
const schema = registration.schema as z<never>
@@ -511,6 +523,19 @@ export abstract class SettingsProvider extends Service {
})
}
/**
* The namespaces a plugin opted into web configuration via `configurable:
* true` on registration. The web config boundary serves these alongside the
* explicit harness allowlists, so a plugin that ships a settings card is
* editable without hardcoding its namespace into the proxy.
* @returns the configurable namespace ids, in registration order.
*/
configurableNamespaces(): string[] {
return [...this.registrations.values()]
.filter(registration => registration.configurable)
.map(registration => String(registration.ns))
}
/**
* Read one registered namespace's resolved value.
* @param ns - the namespace to read.
@@ -140,6 +140,21 @@ describe('registration', () => {
.toThrow(/already registered/)
})
it('exposes only namespaces opted in via configurable on registration', async () => {
const { ctx } = await boot()
const theme = settingsNamespace('ui-theme')
const shell = settingsNamespace('shell')
// Default: not remotely configurable.
ctx.settings.register(theme, ThemeSchema)
// Explicit opt-in: remotely configurable.
ctx.settings.register(shell, ThemeSchema, { configurable: true })
expect(ctx.settings.configurableNamespaces()).toEqual(['shell'])
const byNs = new Map(ctx.settings.describe().map(descriptor => [String(descriptor.ns), descriptor]))
expect(byNs.get('ui-theme')?.configurable).toBe(false)
expect(byNs.get('shell')?.configurable).toBe(true)
})
it('fails registration when the stored section is invalid for the schema', async () => {
const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } })
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow()