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
+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()