2026-07-30 00:13:12 +08:00
|
|
|
/**
|
|
|
|
|
* Settings/credentials/llm RPC domains and their host-stream frames over
|
|
|
|
|
* createApiProxy: layered redacted describe, write-path rejection mapping,
|
|
|
|
|
* value-free credential views, the directory/live-route merge, and the three
|
|
|
|
|
* invalidation frames (settings/credentials/models changed).
|
|
|
|
|
*/
|
|
|
|
|
|
2026-08-04 17:31:36 +08:00
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
2026-07-30 00:13:12 +08:00
|
|
|
import { Context } from 'cordis'
|
|
|
|
|
import z from 'schemastery'
|
|
|
|
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import SessionStore from '@deepseek-ai/dsh-session'
|
|
|
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
|
|
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
|
|
|
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
|
|
|
|
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import { Settings, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
|
|
|
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
|
|
|
import { Credentials } from '@deepseek-ai/dsh-credentials'
|
|
|
|
|
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
|
|
|
|
|
import type { HostFrame } from '../src/api/index.ts'
|
|
|
|
|
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
|
|
|
|
import { RpcId } from '../src/api/rpc.ts'
|
|
|
|
|
import { createApiProxy } from '../src/api-proxy.ts'
|
|
|
|
|
|
|
|
|
|
const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
|
|
|
|
|
|
|
|
|
|
let nextRpc = 1
|
|
|
|
|
function request<P>(payload: P): RpcRequest<P> {
|
|
|
|
|
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function expectOk<T>(response: RpcResponse<T>): T {
|
|
|
|
|
expect(response.result.ok).toBe(true)
|
|
|
|
|
if (!response.result.ok) throw new Error('unreachable')
|
|
|
|
|
return response.result.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function expectErr<T>(response: RpcResponse<T>): { code: string; message: string; details: unknown } {
|
|
|
|
|
expect(response.result.ok).toBe(false)
|
|
|
|
|
if (response.result.ok) throw new Error('unreachable')
|
|
|
|
|
return response.result.error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** In-memory settings provider: the seam base class owns all tested behavior. */
|
|
|
|
|
class MemorySettings extends Settings {
|
|
|
|
|
doc: Record<string, unknown>
|
|
|
|
|
|
2026-08-04 16:33:35 +08:00
|
|
|
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
|
|
|
|
|
doc?: Record<string, unknown>
|
|
|
|
|
readOnly?: boolean
|
|
|
|
|
documentPath?: string
|
|
|
|
|
preparedPath?: string
|
|
|
|
|
}) {
|
2026-07-30 00:13:12 +08:00
|
|
|
super(ctx)
|
|
|
|
|
this.doc = structuredClone(options?.doc ?? {})
|
|
|
|
|
this.readOnly = options?.readOnly ?? false
|
2026-08-04 16:33:35 +08:00
|
|
|
this.path = options?.documentPath
|
|
|
|
|
this.preparedPath = options?.preparedPath
|
2026-07-30 00:13:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private readonly readOnly: boolean
|
2026-08-04 16:33:35 +08:00
|
|
|
private readonly path: string | undefined
|
|
|
|
|
private readonly preparedPath: string | undefined
|
2026-07-30 00:13:12 +08:00
|
|
|
|
|
|
|
|
get writable(): boolean {
|
|
|
|
|
return !this.readOnly
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 16:33:35 +08:00
|
|
|
override get documentPath(): string | undefined {
|
|
|
|
|
return this.path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override prepareDocument(): Promise<string | undefined> {
|
|
|
|
|
return Promise.resolve(this.preparedPath ?? this.documentPath)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 00:13:12 +08:00
|
|
|
protected load(): Promise<Record<string, unknown>> {
|
|
|
|
|
return Promise.resolve(structuredClone(this.doc))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
|
|
|
|
this.doc[ns] = structuredClone(section)
|
|
|
|
|
return Promise.resolve()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** In-memory credential provider with an env-shadow double for the rejection path. */
|
|
|
|
|
class MemoryCredentials extends Credentials {
|
|
|
|
|
private readonly values = new Map<string, string>()
|
|
|
|
|
|
|
|
|
|
constructor(ctx: ConstructorParameters<typeof Credentials>[0], options?: { shadowed?: string[] }) {
|
|
|
|
|
super(ctx)
|
|
|
|
|
this.shadowed = new Set(options?.shadowed ?? [])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private readonly shadowed: Set<string>
|
|
|
|
|
|
|
|
|
|
resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
|
|
|
|
|
if (this.shadowed.has(ref)) return Promise.resolve({ value: 'from-env', source: 'env' })
|
|
|
|
|
const value = this.values.get(ref)
|
|
|
|
|
return Promise.resolve(value === undefined ? undefined : { value, source: 'file' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe(ref: CredentialRef): Promise<CredentialInfo> {
|
|
|
|
|
if (this.shadowed.has(ref)) return Promise.resolve({ configured: true, source: 'env', writable: false })
|
|
|
|
|
const configured = this.values.has(ref)
|
|
|
|
|
return Promise.resolve({ configured, ...configured ? { source: 'file' } : {}, writable: true })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set(ref: CredentialRef, value: string): Promise<void> {
|
|
|
|
|
if (this.shadowed.has(ref)) {
|
|
|
|
|
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
|
|
|
|
|
}
|
|
|
|
|
this.values.set(ref, value)
|
|
|
|
|
this.ctx.emit('credentials/updated', ref)
|
|
|
|
|
return Promise.resolve()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unset(ref: CredentialRef): Promise<void> {
|
|
|
|
|
if (this.shadowed.has(ref)) {
|
|
|
|
|
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
|
|
|
|
|
}
|
|
|
|
|
this.values.delete(ref)
|
|
|
|
|
this.ctx.emit('credentials/updated', ref)
|
|
|
|
|
return Promise.resolve()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Catalog-serving adapter stub for the llm.models path. */
|
|
|
|
|
class CatalogAdapter extends LlmAdapter {
|
|
|
|
|
constructor(private readonly name: string, private readonly models: readonly string[]) {
|
|
|
|
|
super()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override providerInfo(provider: string): LlmProviderInfo {
|
|
|
|
|
return { id: provider, name: this.name }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
|
|
|
|
return Promise.resolve(this.models.map(id => ({ provider, id, name: id })))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
|
|
|
throw new Error('not exercised')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class BrokenCatalogAdapter extends CatalogAdapter {
|
|
|
|
|
override listModels(): Promise<readonly LlmModelInfo[]> {
|
|
|
|
|
return Promise.reject(new Error('catalog backend down'))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const NS = settingsNamespace('llm-deepseek')
|
|
|
|
|
|
|
|
|
|
const AdapterConfig = z.object({
|
|
|
|
|
apiKey: z.string().role('secret'),
|
|
|
|
|
apiKeyEnv: z.string().default('DEEPSEEK_API_KEY'),
|
|
|
|
|
baseURL: z.string(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function harness(options?: {
|
2026-08-04 16:33:35 +08:00
|
|
|
settings?: false | {
|
|
|
|
|
doc?: Record<string, unknown>
|
|
|
|
|
readOnly?: boolean
|
|
|
|
|
documentPath?: string
|
|
|
|
|
preparedPath?: string
|
|
|
|
|
}
|
2026-07-30 00:13:12 +08:00
|
|
|
credentials?: false | { shadowed?: string[] }
|
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.
2026-07-30 18:30:15 +08:00
|
|
|
/** Skip the directory registration to exercise a namespace the proxy does not expose. */
|
|
|
|
|
configurableProviders?: false
|
2026-07-30 00:13:12 +08:00
|
|
|
}): Promise<Context> {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
|
|
|
|
await ctx.plugin(ToolRegistry)
|
|
|
|
|
await ctx.plugin(UserInteractionService)
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings)
|
|
|
|
|
if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials)
|
2026-07-31 14:43:03 +08:00
|
|
|
// Model-provider namespaces plus the explicit Web preference and product
|
|
|
|
|
// onboarding allowlists are the proxy's complete settings surface.
|
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.
2026-07-30 18:30:15 +08:00
|
|
|
if (options?.configurableProviders !== false) {
|
|
|
|
|
ctx.llm.registerConfigurableProviders([
|
|
|
|
|
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
|
|
|
|
])
|
|
|
|
|
}
|
2026-07-30 00:13:12 +08:00
|
|
|
// Host-stream opener reads the committed-workspace baseline; the stub
|
|
|
|
|
// suffices — the real workspace composition is api-proxy-workspace.spec's.
|
|
|
|
|
ctx.provide('workspace', { list: () => [] } as never)
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Drain `count` host frames matching `types`, then abort the stream. */
|
|
|
|
|
async function collectHost(
|
|
|
|
|
api: ReturnType<typeof createApiProxy>,
|
|
|
|
|
types: string[],
|
|
|
|
|
count: number,
|
|
|
|
|
run: () => Promise<void>,
|
|
|
|
|
): Promise<HostFrame[]> {
|
|
|
|
|
const abort = new AbortController()
|
|
|
|
|
const frames: HostFrame[] = []
|
|
|
|
|
const stream = api.events.host(request({}), abort.signal)
|
|
|
|
|
const consume = (async () => {
|
|
|
|
|
for await (const frame of stream) {
|
|
|
|
|
if (!types.includes(frame.payload.type)) continue
|
|
|
|
|
frames.push(frame.payload)
|
|
|
|
|
if (frames.length >= count) abort.abort()
|
|
|
|
|
}
|
|
|
|
|
})()
|
|
|
|
|
await run()
|
|
|
|
|
await consume
|
|
|
|
|
return frames
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('settings domain', () => {
|
|
|
|
|
it('reports an actionable error when no settings provider is mounted', async () => {
|
|
|
|
|
const ctx = await harness({ settings: false })
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const error = expectErr(await api.settings.describe(request({})))
|
|
|
|
|
expect(error.code).toBe('internal')
|
|
|
|
|
expect(error.message).toContain('dsh-settings-local')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('describes layered redacted namespaces with their secret slots', async () => {
|
2026-08-04 16:33:35 +08:00
|
|
|
const ctx = await harness({ settings: {
|
|
|
|
|
doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } },
|
|
|
|
|
documentPath: '/tmp/custom-settings.yaml',
|
|
|
|
|
} })
|
2026-07-30 00:13:12 +08:00
|
|
|
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const value = expectOk(await api.settings.describe(request({})))
|
|
|
|
|
expect(value.writable).toBe(true)
|
2026-08-04 17:31:36 +08:00
|
|
|
expect(value.hasDocument).toBe(true)
|
2026-07-30 00:13:12 +08:00
|
|
|
expect(value.namespaces).toHaveLength(1)
|
|
|
|
|
const view = value.namespaces[0]!
|
|
|
|
|
expect(view.ns).toBe('llm-deepseek')
|
|
|
|
|
expect(view.applies).toBe('live')
|
|
|
|
|
expect((view.schema as { refs?: unknown }).refs).toBeDefined()
|
|
|
|
|
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://user' })
|
|
|
|
|
expect(view.base).toEqual({ baseURL: 'https://base' })
|
|
|
|
|
expect(view.user).toEqual({ baseURL: 'https://user' })
|
|
|
|
|
expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }])
|
|
|
|
|
expect(JSON.stringify(value)).not.toContain('user-secret')
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-04 16:33:35 +08:00
|
|
|
it('opens the provider-resolved document without accepting a browser path', async () => {
|
|
|
|
|
const ctx = await harness({ settings: {
|
|
|
|
|
documentPath: '/tmp/described-settings.yaml',
|
|
|
|
|
preparedPath: '/tmp/custom-settings.yaml',
|
|
|
|
|
} })
|
|
|
|
|
const opened: string[] = []
|
|
|
|
|
const api = createApiProxy(ctx, {
|
|
|
|
|
...DEFAULTS,
|
|
|
|
|
openTextFile: (path) => {
|
|
|
|
|
opened.push(path)
|
|
|
|
|
return Promise.resolve()
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(expectOk(await api.settings.openDocument(request({}), new AbortController().signal)))
|
|
|
|
|
.toEqual({ opened: true })
|
|
|
|
|
expect(opened).toEqual(['/tmp/custom-settings.yaml'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('refuses to open settings when the provider has no local document', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
2026-08-04 17:31:36 +08:00
|
|
|
expect(expectOk(await api.settings.describe(request({}))).hasDocument).toBe(false)
|
2026-08-04 16:33:35 +08:00
|
|
|
const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal))
|
|
|
|
|
expect(error.code).toBe('internal')
|
|
|
|
|
expect(error.message).toContain('no local document')
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-04 17:31:36 +08:00
|
|
|
it('does not prepare or open a settings document after cancellation', async () => {
|
|
|
|
|
const ctx = await harness({ settings: { documentPath: '/tmp/settings.yaml' } })
|
|
|
|
|
const opened: string[] = []
|
|
|
|
|
const api = createApiProxy(ctx, {
|
|
|
|
|
...DEFAULTS,
|
|
|
|
|
openTextFile: (path) => {
|
|
|
|
|
opened.push(path)
|
|
|
|
|
return Promise.resolve()
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const prepare = vi.spyOn(ctx.settings, 'prepareDocument')
|
|
|
|
|
const cancelled = new AbortController()
|
|
|
|
|
cancelled.abort()
|
|
|
|
|
expect(expectErr(await api.settings.openDocument(request({}), cancelled.signal)).code)
|
|
|
|
|
.toBe('cancelled')
|
|
|
|
|
expect(prepare).not.toHaveBeenCalled()
|
|
|
|
|
|
|
|
|
|
const pending = Promise.withResolvers<string | undefined>()
|
|
|
|
|
prepare.mockReturnValueOnce(pending.promise)
|
|
|
|
|
const duringPrepare = new AbortController()
|
|
|
|
|
const opening = api.settings.openDocument(request({}), duringPrepare.signal)
|
|
|
|
|
await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
|
|
|
|
|
duringPrepare.abort()
|
|
|
|
|
pending.resolve('/tmp/settings.yaml')
|
|
|
|
|
expect(expectErr(await opening).code).toBe('cancelled')
|
|
|
|
|
expect(opened).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-31 12:48:19 +08:00
|
|
|
it('serves model-provider and explicitly allowlisted Web namespaces only', async () => {
|
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.
2026-07-30 18:30:15 +08:00
|
|
|
// The settings seam is general: any plugin may register a namespace for
|
2026-07-31 12:48:19 +08:00
|
|
|
// its own configuration. The Web configuration plane remains opt-in, so a
|
|
|
|
|
// future internal plugin cannot become remotely configurable just by
|
2026-07-31 14:43:03 +08:00
|
|
|
// registering; permission and the product onboarding namespace are the
|
|
|
|
|
// non-model namespaces intentionally admitted by this surface.
|
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.
2026-07-30 18:30:15 +08:00
|
|
|
const ctx = await harness()
|
|
|
|
|
ctx.settings.register(NS, AdapterConfig)
|
|
|
|
|
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
|
2026-07-31 12:48:19 +08:00
|
|
|
ctx.settings.register(settingsNamespace('permission'), z.object({
|
|
|
|
|
defaultPreset: z.union(['read-only', 'workspace-write']).required(),
|
|
|
|
|
}), {
|
|
|
|
|
base: { defaultPreset: 'read-only' },
|
|
|
|
|
})
|
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.
2026-07-30 18:30:15 +08:00
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
|
|
|
|
|
const value = expectOk(await api.settings.describe(request({})))
|
2026-07-31 12:48:19 +08:00
|
|
|
expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission'])
|
|
|
|
|
const permission = expectOk(await api.settings.mutate(request({
|
|
|
|
|
ns: 'permission',
|
|
|
|
|
ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
|
|
|
|
|
})))
|
|
|
|
|
expect(permission.value).toEqual({ defaultPreset: 'workspace-write' })
|
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.
2026-07-30 18:30:15 +08:00
|
|
|
|
|
|
|
|
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({})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-30 22:17:56 +08:00
|
|
|
it('serves the product onboarding namespace without invalidating the model catalog', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() }))
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
|
|
|
|
|
.toEqual(['ui-onboarding'])
|
|
|
|
|
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
|
|
|
|
|
expectOk(await api.settings.mutate(request({
|
|
|
|
|
ns: 'ui-onboarding',
|
|
|
|
|
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
|
|
|
|
|
})))
|
|
|
|
|
})
|
|
|
|
|
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }])
|
|
|
|
|
})
|
|
|
|
|
|
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.
2026-07-30 18:30:15 +08:00
|
|
|
it('refuses even a model-provider namespace once its directory entry is gone', async () => {
|
|
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
feat(settings): detect stale writers with a revision, and announce raw changes
The remaining P1 from the #939 review, plus the P2 it shares a mechanism with.
Nothing carried a version, so two tabs editing one namespace silently
overwrote each other — reproduced as tab B's `reasoning` lost to tab A's
older draft. The seam's per-namespace write queue orders writes; it cannot
tell a fresh writer from one replaying a snapshot a predecessor superseded.
Each namespace now carries a monotonic `revision` over its RAW section. A
write may send `expectedRevision`, checked at the FRONT of the queue (not at
call time, which would race the very predecessor it guards against); a
mismatch rejects with `SettingsConflictError` → `settings-conflict` on the
wire, carrying both revisions. The editor captures the revision it opened at
and, on conflict, asks the user to reopen rather than replaying its snapshot.
The same counter fixes the missing broadcast. `settings/updated` is gated on
the resolved value — correct for consumers, wrong for configuration surfaces:
storing an override equal to the composition base leaves the resolved value
alone while changing what the document says (the field is now overridden, not
inherited) and moving every open editor's revision. `settings/document-updated
(ns, revision)` fires on any raw-section change, in-process or external, and
`host/settings-changed` now rides it.
That event also closes the stale model picker: editing a provider's `models`
changes no route, so `llm/adapters-updated` never fired and an open picker
kept serving the old catalog. A change to an exposed provider namespace now
emits `host/models-changed` too — that namespace holds the catalog.
Docs: both sides of the five touched README pairs, a type-equiv block for
`SettingsPathOp`, and an Agent Note recording what the plane exposes and who
may overwrite what. The deferred wire-redaction gaps (secrets behind
union/intersection/transform, `.default(...)` in the served envelope, schema
text in rejection messages, `new Function` rehydration, pi-ai's `headers`) are
recorded as TODO(settings-wire-redaction) and in Known Limitations rather than
half-fixed.
2026-07-30 19:24:21 +08:00
|
|
|
it('invalidates the model catalog when a provider namespace changes, and broadcasts a raw-only change', async () => {
|
|
|
|
|
// Editing `models` changes no route, so llm/adapters-updated never fires
|
|
|
|
|
// and an open model picker kept serving the old catalog. And storing an
|
|
|
|
|
// override equal to the resolved value emits nothing on settings/updated,
|
|
|
|
|
// so another tab never learned the field became overridden.
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
|
|
|
|
|
await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } }))
|
|
|
|
|
})
|
|
|
|
|
expect(frames).toEqual([
|
|
|
|
|
{ type: 'host/settings-changed', ns: 'llm-deepseek' },
|
|
|
|
|
{ type: 'host/models-changed' },
|
|
|
|
|
])
|
|
|
|
|
// The resolved value never moved: base already said https://base.
|
|
|
|
|
expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.value)
|
|
|
|
|
.toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-31 12:48:19 +08:00
|
|
|
it('broadcasts a permission change without invalidating the model catalog', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const permission = ctx.settings.register(settingsNamespace('permission'), z.object({
|
|
|
|
|
defaultPreset: z.union(['read-only', 'workspace-write']).required(),
|
|
|
|
|
}), {
|
|
|
|
|
base: { defaultPreset: 'read-only' },
|
|
|
|
|
})
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 1, async () => {
|
|
|
|
|
await permission.update({ defaultPreset: 'workspace-write' })
|
|
|
|
|
})
|
|
|
|
|
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
|
|
|
|
|
})
|
|
|
|
|
|
feat(settings): detect stale writers with a revision, and announce raw changes
The remaining P1 from the #939 review, plus the P2 it shares a mechanism with.
Nothing carried a version, so two tabs editing one namespace silently
overwrote each other — reproduced as tab B's `reasoning` lost to tab A's
older draft. The seam's per-namespace write queue orders writes; it cannot
tell a fresh writer from one replaying a snapshot a predecessor superseded.
Each namespace now carries a monotonic `revision` over its RAW section. A
write may send `expectedRevision`, checked at the FRONT of the queue (not at
call time, which would race the very predecessor it guards against); a
mismatch rejects with `SettingsConflictError` → `settings-conflict` on the
wire, carrying both revisions. The editor captures the revision it opened at
and, on conflict, asks the user to reopen rather than replaying its snapshot.
The same counter fixes the missing broadcast. `settings/updated` is gated on
the resolved value — correct for consumers, wrong for configuration surfaces:
storing an override equal to the composition base leaves the resolved value
alone while changing what the document says (the field is now overridden, not
inherited) and moving every open editor's revision. `settings/document-updated
(ns, revision)` fires on any raw-section change, in-process or external, and
`host/settings-changed` now rides it.
That event also closes the stale model picker: editing a provider's `models`
changes no route, so `llm/adapters-updated` never fired and an open picker
kept serving the old catalog. A change to an exposed provider namespace now
emits `host/models-changed` too — that namespace holds the catalog.
Docs: both sides of the five touched README pairs, a type-equiv block for
`SettingsPathOp`, and an Agent Note recording what the plane exposes and who
may overwrite what. The deferred wire-redaction gaps (secrets behind
union/intersection/transform, `.default(...)` in the served envelope, schema
text in rejection messages, `new Function` rehydration, pi-ai's `headers`) are
recorded as TODO(settings-wire-redaction) and in Known Limitations rather than
half-fixed.
2026-07-30 19:24:21 +08:00
|
|
|
it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
ctx.settings.register(NS, AdapterConfig)
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const opened = expectOk(await api.settings.describe(request({}))).namespaces[0]!.revision
|
|
|
|
|
expect(expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://first' }, expectedRevision: opened })))
|
|
|
|
|
.revision).toBe(opened + 1)
|
|
|
|
|
const error = expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://second' }, expectedRevision: opened })))
|
|
|
|
|
expect(error.code).toBe('settings-conflict')
|
|
|
|
|
expect(error.details).toEqual({ ns: 'llm-deepseek', expected: opened, actual: opened + 1 })
|
|
|
|
|
// The refused write changed nothing.
|
|
|
|
|
expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.user).toEqual({ baseURL: 'https://first' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-30 00:13:12 +08:00
|
|
|
it('updates the user layer, answers with the new redacted view, and broadcasts the frame', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
|
|
|
|
|
const view = expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { apiKey: 'sk-new', baseURL: 'https://next' } })))
|
|
|
|
|
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://next' })
|
|
|
|
|
expect(view.user).toEqual({ baseURL: 'https://next' })
|
|
|
|
|
expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }])
|
|
|
|
|
expect(JSON.stringify(view)).not.toContain('sk-new')
|
|
|
|
|
})
|
|
|
|
|
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'llm-deepseek' }])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('replace resets the user layer wholesale', async () => {
|
|
|
|
|
const ctx = await harness({ settings: { doc: { 'llm-deepseek': { baseURL: 'https://user' } } } })
|
|
|
|
|
ctx.settings.register(NS, AdapterConfig)
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const view = expectOk(await api.settings.replace(request({ ns: 'llm-deepseek', section: {} })))
|
|
|
|
|
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY' })
|
|
|
|
|
expect(view.user).toEqual({})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['an invalid namespace name', 'Not A Namespace', {}],
|
|
|
|
|
['a schema-invalid patch', 'llm-deepseek', { baseURL: 42 }],
|
|
|
|
|
])('rejects %s as settings-rejected', async (_case, ns, patch) => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
ctx.settings.register(NS, AdapterConfig)
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const error = expectErr(await api.settings.update(request({ ns, patch })))
|
|
|
|
|
expect(error.code).toBe('settings-rejected')
|
|
|
|
|
expect(error.details).toEqual({ ns })
|
|
|
|
|
})
|
|
|
|
|
|
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.
2026-07-30 18:30:15 +08:00
|
|
|
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.
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-30 00:13:12 +08:00
|
|
|
it('maps a read-only provider refusal onto the same rejection', async () => {
|
|
|
|
|
const ctx = await harness({ settings: { readOnly: true } })
|
|
|
|
|
ctx.settings.register(NS, AdapterConfig)
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const value = expectOk(await api.settings.describe(request({})))
|
|
|
|
|
expect(value.writable).toBe(false)
|
|
|
|
|
const error = expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: {} })))
|
|
|
|
|
expect(error.code).toBe('settings-rejected')
|
|
|
|
|
expect(error.message).toContain('read-only')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('credentials domain', () => {
|
|
|
|
|
it('reports an actionable error when no credential provider is mounted', async () => {
|
|
|
|
|
const ctx = await harness({ credentials: false })
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const error = expectErr(await api.credentials.describe(request({ refs: ['A'] })))
|
|
|
|
|
expect(error.code).toBe('internal')
|
|
|
|
|
expect(error.message).toContain('dsh-credentials-local')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('describes value-free views and flips state through set/unset with frames', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const before = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
|
|
|
|
|
expect(before.credentials).toEqual({ OPENAI_API_KEY: { configured: false, writable: true } })
|
|
|
|
|
const frames = await collectHost(api, ['host/credentials-changed'], 2, async () => {
|
|
|
|
|
expectOk(await api.credentials.set(request({ ref: 'OPENAI_API_KEY', value: 'sk-secret' })))
|
|
|
|
|
const after = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
|
|
|
|
|
expect(after.credentials).toEqual({ OPENAI_API_KEY: { configured: true, source: 'file', writable: true } })
|
|
|
|
|
expect(JSON.stringify(after)).not.toContain('sk-secret')
|
|
|
|
|
expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' })))
|
|
|
|
|
})
|
|
|
|
|
expect(frames).toEqual([
|
|
|
|
|
{ type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' },
|
|
|
|
|
{ type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' },
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('maps a shadowed write onto credential-rejected for set and unset alike', async () => {
|
|
|
|
|
const ctx = await harness({ credentials: { shadowed: ['DEEPSEEK_API_KEY'] } })
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const described = expectOk(await api.credentials.describe(request({ refs: ['DEEPSEEK_API_KEY'] })))
|
|
|
|
|
expect(described.credentials['DEEPSEEK_API_KEY']).toEqual({ configured: true, source: 'env', writable: false })
|
|
|
|
|
const setError = expectErr(await api.credentials.set(request({ ref: 'DEEPSEEK_API_KEY', value: 'x' })))
|
|
|
|
|
expect(setError.code).toBe('credential-rejected')
|
|
|
|
|
expect(setError.details).toEqual({ ref: 'DEEPSEEK_API_KEY' })
|
|
|
|
|
const unsetError = expectErr(await api.credentials.unset(request({ ref: 'DEEPSEEK_API_KEY' })))
|
|
|
|
|
expect(unsetError.code).toBe('credential-rejected')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('llm domain', () => {
|
|
|
|
|
it('merges the configurable directory with live routes and appends undeclared ones', async () => {
|
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.
2026-07-30 18:30:15 +08:00
|
|
|
const ctx = await harness({ configurableProviders: false })
|
2026-07-30 00:13:12 +08:00
|
|
|
ctx.llm.registerConfigurableProviders([
|
|
|
|
|
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
|
|
|
|
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
|
|
|
|
])
|
|
|
|
|
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash']))
|
|
|
|
|
ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1']))
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const value = expectOk(await api.llm.providers(request({})))
|
|
|
|
|
expect(value.providers).toEqual([
|
|
|
|
|
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
|
|
|
|
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false },
|
|
|
|
|
{ provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true },
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('serves the host-scoped catalog with per-provider failures contained', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash', 'deepseek-v4-pro']))
|
|
|
|
|
ctx.llm.registerAdapter(['broken'], new BrokenCatalogAdapter('Broken', []))
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const value = expectOk(await api.llm.models(request({})))
|
|
|
|
|
expect(value.groups).toEqual([{
|
|
|
|
|
id: 'deepseek-official',
|
|
|
|
|
name: 'DeepSeek',
|
|
|
|
|
models: [
|
|
|
|
|
{ id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
|
|
|
|
{ id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
|
|
|
|
],
|
|
|
|
|
}])
|
|
|
|
|
expect(value.failures).toEqual([{ id: 'broken', name: 'Broken', message: 'catalog backend down' }])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('broadcasts host/models-changed at every topology commit point', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const frames = await collectHost(api, ['host/models-changed'], 2, async () => {
|
|
|
|
|
const dispose = ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', []))
|
|
|
|
|
dispose()
|
|
|
|
|
return Promise.resolve()
|
|
|
|
|
})
|
|
|
|
|
expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }])
|
|
|
|
|
})
|
|
|
|
|
})
|