fix(web-config): close the wire boundary, the redacted-replace data loss, and three P2s
Five findings from the #939 review, each reproduced before being fixed. **Configuration reads are as privileged as writes.** `settings.describe` returns every exposed namespace's configuration and `credentials.describe` reports whether an arbitrary environment-variable name is configured and from where — reconnaissance no anonymous caller should have. Both join PRIVILEGED_METHODS, so the whole configuration plane is loopback-only until real authentication exists; `trustedHosts` was never authentication. The model catalog stays reachable: it carries no endpoints or key state, and a LAN client's model picker legitimately needs it. Asserted over a real HTTP server, because the Host header a browser actually sends is what decides this. **The proxy serves only namespaces a registered model provider addresses.** The settings seam is general — any plugin may register one — but the Web configuration plane is the model-provider surface. Without the gate, every future `settings.register()` would silently become remotely readable and writable configuration. An unregistered namespace and an unexposed one answer identically, so no caller can enumerate the registry one probe at a time. **Path-addressed writes replace the redacted-document rebuild.** The editor reads the REDACTED descriptor, so rebuilding a section from it and replacing wholesale deleted every literal secret the wire never returned — reproduced as `{baseURL, reasoning}` in, stored `apiKey` gone out. `settings.mutate` applies set/unset ops to the section as it stands at the front of the seam's write queue, and the client names only fields it can see, so an unseen secret is untouched by construction rather than by care. P2s in the same pass: `llm/adapters-updated` now contains async listener rejections (an uncontained one escaped as unhandledRejection, contradicting the documented "observer failures are contained"); llm-deepseek's retry-policy swap uses the atomic `registration.replace` instead of dispose-then-register, which published `[]` then `["deepseek-official"]` so an observer saw the provider disappear and come back; and a transport rejection no longer strands the page in `loading` or a card in `busy`, with removal failures surfaced on the page banner instead of swallowed.
This commit is contained in:
@@ -162,6 +162,44 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
/**
|
||||
* One path-addressed edit to a namespace's user section. Path mutation exists
|
||||
* for a caller holding an INCOMPLETE view of the section — a configuration UI
|
||||
* reads the redacted descriptor, which by construction never received the
|
||||
* `role('secret')` fields. Such a caller can name the field it means without
|
||||
* restating the section: a wholesale `replace` rebuilt from a redacted
|
||||
* document silently deletes every secret the wire never returned.
|
||||
*/
|
||||
export type SettingsPathOp =
|
||||
| { op: 'set'; path: readonly string[]; value: unknown }
|
||||
| { op: 'unset'; path: readonly string[] }
|
||||
|
||||
/** Apply one path op to a detached section, returning the next section. */
|
||||
function applyPathOp(section: Record<string, unknown>, op: SettingsPathOp): Record<string, unknown> {
|
||||
const [head, ...rest] = op.path
|
||||
// The empty path addresses the section itself.
|
||||
if (head === undefined) {
|
||||
if (op.op === 'unset') return {}
|
||||
if (!isPlainObject(op.value)) {
|
||||
throw new TypeError('settings mutate: setting the section root requires a plain object')
|
||||
}
|
||||
return { ...op.value }
|
||||
}
|
||||
if (rest.length === 0) {
|
||||
if (op.op === 'set') return { ...section, [head]: op.value }
|
||||
const { [head]: _removed, ...kept } = section
|
||||
return kept
|
||||
}
|
||||
const child = section[head]
|
||||
if (!isPlainObject(child)) {
|
||||
// Unsetting through an absent path is already satisfied; setting through
|
||||
// one creates the intermediate objects it needs.
|
||||
if (op.op === 'unset') return section
|
||||
return { ...section, [head]: applyPathOp({}, { ...op, path: rest }) }
|
||||
}
|
||||
return { ...section, [head]: applyPathOp(child, { ...op, path: rest }) }
|
||||
}
|
||||
|
||||
/** Human label for a value rejected by the JSON-shape boundary (numbers reject inline). */
|
||||
function describeRejected(value: unknown): string {
|
||||
if (value === undefined) return 'undefined'
|
||||
@@ -443,9 +481,32 @@ export abstract class Settings extends Service {
|
||||
return this.write(ns, section, 'replace')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply path-addressed edits to one registered namespace's user section,
|
||||
* validate, persist, then commit and emit. The ops are applied to the
|
||||
* section as it stands when the write reaches the front of the queue, so a
|
||||
* caller never has to restate fields it did not touch — and, crucially,
|
||||
* cannot delete fields it never saw. This is the write path for any caller
|
||||
* holding a redacted view; `replace` remains the wholesale reset.
|
||||
* @param ns - the registered namespace to edit.
|
||||
* @param ops - ordered path edits; later ops observe earlier ones.
|
||||
*/
|
||||
async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[]): Promise<void> {
|
||||
if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${ns}" must be an array of path ops`)
|
||||
for (const op of ops) {
|
||||
if (!isPlainObject(op) || (op['op'] !== 'set' && op['op'] !== 'unset')) {
|
||||
throw new TypeError(`settings mutate for "${ns}" ops must be {op:'set'|'unset', path}`)
|
||||
}
|
||||
if (!Array.isArray(op['path']) || (op['path'] as unknown[]).some(part => typeof part !== 'string')) {
|
||||
throw new TypeError(`settings mutate for "${ns}" op paths must be arrays of strings`)
|
||||
}
|
||||
}
|
||||
return this.write(ns, ops, 'mutate')
|
||||
}
|
||||
|
||||
/** Validate a write, then queue it on the namespace's serialized write chain. */
|
||||
private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace'): Promise<void> {
|
||||
const verb = mode === 'merge' ? 'update' : 'replace'
|
||||
private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace' | 'mutate'): Promise<void> {
|
||||
const verb = mode === 'merge' ? 'update' : mode === 'replace' ? 'replace' : 'mutate'
|
||||
const registration = this.registrations.get(ns)
|
||||
if (registration === undefined) {
|
||||
throw new Error(`settings namespace "${ns}" is not registered`)
|
||||
@@ -456,13 +517,19 @@ export abstract class Settings extends Service {
|
||||
if (!this.writable) {
|
||||
throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`)
|
||||
}
|
||||
if (!isPlainObject(input)) {
|
||||
throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`)
|
||||
// A mutate's ops array is wrapped so one JSON-shape walk covers both
|
||||
// shapes; merge/replace carry the section itself.
|
||||
let payload: Record<string, unknown>
|
||||
if (mode === 'mutate') {
|
||||
payload = { ops: input }
|
||||
} else {
|
||||
if (!isPlainObject(input)) throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`)
|
||||
payload = input
|
||||
}
|
||||
// Snapshot at call time: the queue must never read a caller-owned object
|
||||
// the caller may keep mutating while the write waits its turn. The same
|
||||
// walk is the JSON-shape boundary check (see cloneJsonShaped).
|
||||
const snapshot = cloneJsonShaped(input, (label, path) =>
|
||||
const snapshot = cloneJsonShaped(payload, (label, path) =>
|
||||
new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped data (found ${label} at ${path})`))
|
||||
const previous = this.writeQueues.get(ns) ?? Promise.resolve()
|
||||
// Chain past a failed predecessor: one rejected write must not poison the
|
||||
@@ -474,9 +541,14 @@ export abstract class Settings extends Service {
|
||||
if (this.registrations.get(ns) !== registration) {
|
||||
throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`)
|
||||
}
|
||||
// Every mode derives from the section as it stands NOW, at the front of
|
||||
// the queue — never from whatever the caller last saw.
|
||||
const current = this.section(ns) ?? {}
|
||||
const section = mode === 'merge'
|
||||
? mergeLayers(this.section(ns) ?? {}, snapshot) as Record<string, unknown>
|
||||
: snapshot
|
||||
? mergeLayers(current, snapshot) as Record<string, unknown>
|
||||
: mode === 'replace'
|
||||
? snapshot
|
||||
: (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current)
|
||||
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
|
||||
await this.persist(ns, section)
|
||||
// The write reached storage either way; the cache must say so. Commit
|
||||
|
||||
@@ -725,3 +725,89 @@ describe('installSettingsSection', () => {
|
||||
expect(changes).toEqual(['user'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutate (path-addressed writes)', () => {
|
||||
interface KeyedConfig {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
const KeyedSchema: z<KeyedConfig> = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
baseURL: z.string(),
|
||||
reasoning: z.string(),
|
||||
})
|
||||
|
||||
const KEYED = settingsNamespace('keyed')
|
||||
const NESTED = settingsNamespace('workspace')
|
||||
|
||||
async function mounted(doc: Record<string, unknown>) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BareProvider, { doc })
|
||||
ctx.settings.register(KEYED, KeyedSchema)
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('removes one field without touching a secret the caller never saw', async () => {
|
||||
// The data-loss shape this exists to prevent: a configuration UI reads the
|
||||
// REDACTED descriptor (no apiKey), the user resets baseURL, and the client
|
||||
// rebuilds the section from what it holds. A wholesale replace of that
|
||||
// rebuild deletes the stored literal key; a path unset cannot.
|
||||
const ctx = await mounted({ keyed: { apiKey: 'sk-stored', baseURL: 'https://user', reasoning: 'high' } })
|
||||
const redacted = ctx.settings.describe({ redactSecrets: true }).find(d => d.ns === KEYED)!
|
||||
expect(redacted.user).toEqual({ baseURL: 'https://user', reasoning: 'high' })
|
||||
|
||||
await ctx.settings.mutate(KEYED, [{ op: 'unset', path: ['baseURL'] }])
|
||||
|
||||
const raw = ctx.settings.describe().find(d => d.ns === KEYED)!
|
||||
expect(raw.user).toEqual({ apiKey: 'sk-stored', reasoning: 'high' })
|
||||
})
|
||||
|
||||
it('applies set and unset in one write, in order', async () => {
|
||||
const ctx = await mounted({ keyed: { apiKey: 'sk-stored', baseURL: 'https://old' } })
|
||||
await ctx.settings.mutate(KEYED, [
|
||||
{ op: 'set', path: ['baseURL'], value: 'https://new' },
|
||||
{ op: 'set', path: ['reasoning'], value: 'low' },
|
||||
{ op: 'unset', path: ['reasoning'] },
|
||||
])
|
||||
expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user)
|
||||
.toEqual({ apiKey: 'sk-stored', baseURL: 'https://new' })
|
||||
})
|
||||
|
||||
it('reads the section as it stands at the front of the queue, not at call time', async () => {
|
||||
// Two concurrent writers: the mutate is issued against the pre-update
|
||||
// section but must observe the update that ran before it.
|
||||
const ctx = await mounted({ keyed: { apiKey: 'sk-stored' } })
|
||||
const first = ctx.settings.update(KEYED, { baseURL: 'https://first', reasoning: 'high' })
|
||||
const second = ctx.settings.mutate(KEYED, [{ op: 'unset', path: ['reasoning'] }])
|
||||
await Promise.all([first, second])
|
||||
expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user)
|
||||
.toEqual({ apiKey: 'sk-stored', baseURL: 'https://first' })
|
||||
})
|
||||
|
||||
it('creates intermediate objects for a nested set and leaves an absent unset alone', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BareProvider, { doc: {} })
|
||||
ctx.settings.register(NESTED, NestedSchema)
|
||||
await ctx.settings.mutate(NESTED, [{ op: 'set', path: ['retry', 'attempts'], value: 5 }])
|
||||
expect(ctx.settings.describe().find(d => d.ns === NESTED)!.user).toEqual({ retry: { attempts: 5 } })
|
||||
await ctx.settings.mutate(NESTED, [{ op: 'unset', path: ['missing', 'deep'] }])
|
||||
expect(ctx.settings.describe().find(d => d.ns === NESTED)!.user).toEqual({ retry: { attempts: 5 } })
|
||||
})
|
||||
|
||||
it('rejects a malformed op before anything is queued', async () => {
|
||||
const ctx = await mounted({ keyed: { apiKey: 'sk-stored' } })
|
||||
await expect(ctx.settings.mutate(KEYED, [{ op: 'delete' } as never]))
|
||||
.rejects.toThrow(/must be \{op:'set'\|'unset', path\}/)
|
||||
await expect(ctx.settings.mutate(KEYED, [{ op: 'unset', path: ['a', 1] as never }]))
|
||||
.rejects.toThrow(/op paths must be arrays of strings/)
|
||||
expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ apiKey: 'sk-stored' })
|
||||
})
|
||||
|
||||
it('rejects a value the JSON-shape boundary refuses', async () => {
|
||||
const ctx = await mounted({ keyed: {} })
|
||||
await expect(ctx.settings.mutate(KEYED, [{ op: 'set', path: ['baseURL'], value: new Date() }]))
|
||||
.rejects.toThrow(/must be JSON-shaped data/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user