2026-07-30 00:13:12 +08:00
|
|
|
/**
|
2026-08-22 21:11:51 +08:00
|
|
|
* Settings/credentials/llm RPC domains and their owner events over
|
2026-07-30 00:13:12 +08:00
|
|
|
* 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'
|
build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.
Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.
The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.
Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:06 +08:00
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
|
|
import z from '@deepseek-ai/schemastery'
|
2026-07-30 00:13:12 +08:00
|
|
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import SessionStore from '@deepseek-ai/dsh-session'
|
|
|
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
2026-08-13 00:36:22 +08:00
|
|
|
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
|
|
|
|
import LlmRuntime, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
2026-07-30 00:13:12 +08:00
|
|
|
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
2026-08-13 00:36:22 +08:00
|
|
|
import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
2026-07-30 00:13:12 +08:00
|
|
|
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
2026-08-13 00:36:22 +08:00
|
|
|
import { CredentialProvider } from '@deepseek-ai/dsh-credentials'
|
feat(credentials): store durable credential records beside references
The seam answered one question — what is behind this environment-variable
name — and that shape cannot hold what an authorization grant is: a
multi-field, rotating value keyed by a provider id rather than by a POSIX
identifier. The Models page already works around the gap by inventing a
synthetic environment name (`MINIMAX_CN_API_KEY`) for a route the user added
by hand, because the store's key must look like one.
`CredentialKey` is `<scope>/<id>`, where the scope is the owning plugin's
registered name. The owner is in the key because a `grant` payload is written
in its owner's format: two plugins serving the same provider name would
otherwise read each other's payload, and a record left by an uninstalled
plugin could not be told from a live one. The `/` also keeps the grammar
disjoint from `CredentialRef`, so the key spaces cannot collide.
`CredentialRecord` is `api-key` (key and/or provider environment values) or
`grant` (an opaque, owner-owned payload). The asymmetry is deliberate: an api
key is the harness's own data, a grant is a package it carries for someone
else. `modifyRecord` is the only write path because a correct write depends
on the current value — a token refresh is read-decide-replace under one
cross-process lock, without which two processes rotating one refresh token
lose whichever wrote first.
`.credentials.yaml` becomes a versioned two-section document. The pre-release
flat layout is refused by name, with the entry count and the one edit needed,
rather than read as an empty store — which would surface as an authentication
failure on the first request instead of at load. A grant payload is admitted
in both directions, so a value the document could not read back exactly as
written is refused rather than stored lossily.
2026-08-13 15:00:09 +08:00
|
|
|
import type {
|
|
|
|
|
CredentialInfo,
|
|
|
|
|
CredentialKey,
|
|
|
|
|
CredentialRecord,
|
|
|
|
|
CredentialRecordEntry,
|
|
|
|
|
CredentialRecordInfo,
|
|
|
|
|
CredentialRef,
|
|
|
|
|
ResolvedCredential,
|
|
|
|
|
} from '@deepseek-ai/dsh-credentials'
|
2026-07-30 00:13:12 +08:00
|
|
|
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
|
|
|
|
import { RpcId } from '../src/api/rpc.ts'
|
2026-08-09 12:13:58 +08:00
|
|
|
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
|
|
|
|
|
import { createApiProxy } from '../src/api-proxy.ts'
|
2026-07-30 00:13:12 +08:00
|
|
|
|
2026-08-10 15:49:34 +08:00
|
|
|
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
|
2026-07-30 00:13:12 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 15:34:32 +08:00
|
|
|
/** In-memory settings provider: the Service Definition base class owns all tested behavior. */
|
2026-08-13 00:36:22 +08:00
|
|
|
class MemorySettings extends SettingsProvider {
|
2026-07-30 00:13:12 +08:00
|
|
|
doc: Record<string, unknown>
|
|
|
|
|
|
2026-08-13 00:36:22 +08:00
|
|
|
constructor(ctx: ConstructorParameters<typeof SettingsProvider>[0], options?: {
|
2026-08-04 16:33:35 +08:00
|
|
|
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. */
|
2026-08-13 00:36:22 +08:00
|
|
|
class MemoryCredentials extends CredentialProvider {
|
2026-07-30 00:13:12 +08:00
|
|
|
private readonly values = new Map<string, string>()
|
|
|
|
|
|
2026-08-13 00:36:22 +08:00
|
|
|
constructor(ctx: ConstructorParameters<typeof CredentialProvider>[0], options?: { shadowed?: string[] }) {
|
2026-07-30 00:13:12 +08:00
|
|
|
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)
|
2026-08-18 17:28:43 +08:00
|
|
|
this.ctx.emit('credentials/reference-updated', ref)
|
2026-07-30 00:13:12 +08:00
|
|
|
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)
|
2026-08-18 17:28:43 +08:00
|
|
|
this.ctx.emit('credentials/reference-updated', ref)
|
2026-07-30 00:13:12 +08:00
|
|
|
return Promise.resolve()
|
|
|
|
|
}
|
feat(credentials): store durable credential records beside references
The seam answered one question — what is behind this environment-variable
name — and that shape cannot hold what an authorization grant is: a
multi-field, rotating value keyed by a provider id rather than by a POSIX
identifier. The Models page already works around the gap by inventing a
synthetic environment name (`MINIMAX_CN_API_KEY`) for a route the user added
by hand, because the store's key must look like one.
`CredentialKey` is `<scope>/<id>`, where the scope is the owning plugin's
registered name. The owner is in the key because a `grant` payload is written
in its owner's format: two plugins serving the same provider name would
otherwise read each other's payload, and a record left by an uninstalled
plugin could not be told from a live one. The `/` also keeps the grammar
disjoint from `CredentialRef`, so the key spaces cannot collide.
`CredentialRecord` is `api-key` (key and/or provider environment values) or
`grant` (an opaque, owner-owned payload). The asymmetry is deliberate: an api
key is the harness's own data, a grant is a package it carries for someone
else. `modifyRecord` is the only write path because a correct write depends
on the current value — a token refresh is read-decide-replace under one
cross-process lock, without which two processes rotating one refresh token
lose whichever wrote first.
`.credentials.yaml` becomes a versioned two-section document. The pre-release
flat layout is refused by name, with the entry count and the one edit needed,
rather than read as an empty store — which would surface as an authentication
failure on the first request instead of at load. A grant payload is admitted
in both directions, so a value the document could not read back exactly as
written is refused rather than stored lossily.
2026-08-13 15:00:09 +08:00
|
|
|
|
|
|
|
|
// The record half has no wire face on this proxy, so the double answers the
|
|
|
|
|
// empty store rather than modelling storage the tests never exercise.
|
|
|
|
|
readRecord(): Promise<CredentialRecord | undefined> {
|
|
|
|
|
return Promise.resolve(undefined)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describeRecord(): Promise<CredentialRecordInfo> {
|
|
|
|
|
return Promise.resolve({ configured: false, writable: true })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
listRecords(): Promise<readonly CredentialRecordEntry[]> {
|
|
|
|
|
return Promise.resolve([])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modifyRecord(
|
|
|
|
|
_key: CredentialKey,
|
|
|
|
|
mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
|
|
|
|
|
): Promise<CredentialRecord | undefined> {
|
|
|
|
|
return mutate(undefined)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deleteRecord(): Promise<void> {
|
|
|
|
|
return Promise.resolve()
|
|
|
|
|
}
|
2026-07-30 00:13:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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: '' })
|
2026-08-13 00:36:22 +08:00
|
|
|
await ctx.plugin(ToolRuntime)
|
2026-07-30 00:13:12 +08:00
|
|
|
await ctx.plugin(AgentRegistry)
|
2026-08-13 00:36:22 +08:00
|
|
|
await ctx.plugin(LlmRuntime)
|
2026-07-30 00:13:12 +08:00
|
|
|
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
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 21:11:51 +08:00
|
|
|
/** Observe settings commits while one API operation runs. */
|
|
|
|
|
async function captureSettingsUpdates(
|
|
|
|
|
ctx: Context,
|
2026-07-30 00:13:12 +08:00
|
|
|
run: () => Promise<void>,
|
2026-08-22 21:11:51 +08:00
|
|
|
): Promise<Array<readonly [SettingsNamespace, number]>> {
|
|
|
|
|
const updates: Array<readonly [SettingsNamespace, number]> = []
|
|
|
|
|
const dispose = ctx.on('settings/document-updated', (namespace, revision) => {
|
|
|
|
|
updates.push([namespace, revision])
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
await run()
|
|
|
|
|
return updates
|
|
|
|
|
} finally {
|
|
|
|
|
dispose()
|
|
|
|
|
}
|
2026-07-30 00:13:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-22 21:11:51 +08:00
|
|
|
/** Observe credential commits while one API operation runs. */
|
|
|
|
|
async function captureCredentialUpdates(ctx: Context, run: () => Promise<void>): Promise<CredentialRef[]> {
|
|
|
|
|
const updates: CredentialRef[] = []
|
|
|
|
|
const dispose = ctx.on('credentials/reference-updated', (ref) => { updates.push(ref) })
|
|
|
|
|
try {
|
|
|
|
|
await run()
|
|
|
|
|
return updates
|
|
|
|
|
} finally {
|
|
|
|
|
dispose()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Count model-adapter topology commits while one API operation runs. */
|
|
|
|
|
async function countAdapterUpdates(ctx: Context, run: () => Promise<void>): Promise<number> {
|
|
|
|
|
let updates = 0
|
|
|
|
|
const dispose = ctx.on('llm/adapters-updated', () => { updates += 1 })
|
|
|
|
|
try {
|
|
|
|
|
await run()
|
|
|
|
|
return updates
|
|
|
|
|
} finally {
|
|
|
|
|
dispose()
|
2026-08-10 21:32:50 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 21:11:51 +08:00
|
|
|
/** Expected settings event tuple with its owner-assigned revision. */
|
|
|
|
|
function expectedSettingsUpdate(ns: string): readonly unknown[] {
|
|
|
|
|
return [ns, expect.any(Number)]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 00:13:12 +08:00
|
|
|
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')
|
2026-08-13 00:36:22 +08:00
|
|
|
expect(error.message).toContain('dsh-settings-file')
|
2026-07-30 00:13:12 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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-08-12 21:07:57 +08:00
|
|
|
it('serves every registered namespace, including one this repository never named', async () => {
|
|
|
|
|
// Registering IS the exposure: a plugin distributed outside this
|
|
|
|
|
// repository configures itself from the browser without a change here.
|
|
|
|
|
// The plane stays loopback-only and secret-redacted, and which surface
|
|
|
|
|
// renders a namespace is the browser's decision, not this proxy's.
|
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' },
|
|
|
|
|
})
|
2026-08-06 20:27:31 +08:00
|
|
|
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
|
|
|
|
preference: z.union(['light', 'dark', 'system']).default('system'),
|
|
|
|
|
}))
|
2026-08-07 16:43:59 +08:00
|
|
|
ctx.settings.register(settingsNamespace('locale'), z.object({
|
|
|
|
|
preference: z.union(['zh', 'en']).required(false),
|
|
|
|
|
}))
|
|
|
|
|
ctx.settings.register(settingsNamespace('ui-conversation'), z.object({
|
|
|
|
|
busyEnter: z.union(['queue', 'steer']).default('queue'),
|
|
|
|
|
}))
|
2026-08-13 00:36:22 +08:00
|
|
|
ctx.settings.register(settingsNamespace('shell'), z.object({
|
2026-08-10 18:09:09 +08:00
|
|
|
timeoutMs: z.number().default(120_000),
|
|
|
|
|
}))
|
|
|
|
|
ctx.settings.register(settingsNamespace('agent-loop'), z.object({
|
|
|
|
|
maxParallelToolCalls: z.number().default(10),
|
|
|
|
|
}))
|
|
|
|
|
ctx.settings.register(settingsNamespace('web-search-deepseek'), z.object({
|
|
|
|
|
baseURL: z.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
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
|
|
|
|
|
const value = expectOk(await api.settings.describe(request({})))
|
2026-08-07 16:43:59 +08:00
|
|
|
expect(value.namespaces.map(view => view.ns)).toEqual([
|
2026-08-12 21:07:57 +08:00
|
|
|
'llm-deepseek', 'some-other-plugin', 'permission', 'ui-theme', 'locale',
|
2026-08-14 15:46:01 +08:00
|
|
|
'ui-conversation', 'shell', 'agent-loop', 'web-search-deepseek',
|
2026-08-07 16:43:59 +08:00
|
|
|
])
|
2026-07-31 12:48:19 +08:00
|
|
|
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' })
|
2026-08-06 20:27:31 +08:00
|
|
|
const theme = expectOk(await api.settings.mutate(request({
|
|
|
|
|
ns: 'ui-theme',
|
|
|
|
|
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
|
|
|
|
})))
|
|
|
|
|
expect(theme.value).toEqual({ preference: 'dark' })
|
2026-08-07 16:43:59 +08:00
|
|
|
const locale = expectOk(await api.settings.mutate(request({
|
|
|
|
|
ns: 'locale',
|
|
|
|
|
ops: [{ op: 'set', path: ['preference'], value: 'en' }],
|
|
|
|
|
})))
|
|
|
|
|
expect(locale.value).toEqual({ preference: 'en' })
|
|
|
|
|
const conversation = expectOk(await api.settings.mutate(request({
|
|
|
|
|
ns: 'ui-conversation',
|
|
|
|
|
ops: [{ op: 'set', path: ['busyEnter'], value: 'steer' }],
|
|
|
|
|
})))
|
|
|
|
|
expect(conversation.value).toEqual({ busyEnter: 'steer' })
|
2026-08-10 18:09:09 +08:00
|
|
|
const bash = expectOk(await api.settings.mutate(request({
|
2026-08-13 00:36:22 +08:00
|
|
|
ns: 'shell',
|
2026-08-10 18:09:09 +08:00
|
|
|
ops: [{ op: 'set', path: ['timeoutMs'], value: 5_000 }],
|
|
|
|
|
})))
|
|
|
|
|
expect(bash.value).toEqual({ timeoutMs: 5_000 })
|
|
|
|
|
const agentLoop = expectOk(await api.settings.mutate(request({
|
|
|
|
|
ns: 'agent-loop',
|
|
|
|
|
ops: [{ op: 'set', path: ['maxParallelToolCalls'], value: 2 }],
|
|
|
|
|
})))
|
|
|
|
|
expect(agentLoop.value).toEqual({ maxParallelToolCalls: 2 })
|
|
|
|
|
const webSearch = expectOk(await api.settings.mutate(request({
|
|
|
|
|
ns: 'web-search-deepseek',
|
|
|
|
|
ops: [{ op: 'set', path: ['baseURL'], value: 'https://search.test/v1' }],
|
|
|
|
|
})))
|
|
|
|
|
expect(webSearch.value).toEqual({ baseURL: 'https://search.test/v1' })
|
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
|
|
|
|
2026-08-12 21:07:57 +08:00
|
|
|
const other = expectOk(await api.settings.update(request({
|
|
|
|
|
ns: 'some-other-plugin',
|
|
|
|
|
patch: { secretPath: '/etc/shadow' },
|
|
|
|
|
})))
|
|
|
|
|
expect(other.value).toEqual({ secretPath: '/etc/shadow' })
|
|
|
|
|
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value)
|
|
|
|
|
.toEqual({ secretPath: '/etc/shadow' })
|
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
|
|
|
})
|
|
|
|
|
|
2026-08-06 20:27:31 +08:00
|
|
|
it('serves product preference namespaces without invalidating the model catalog', async () => {
|
2026-07-30 22:17:56 +08:00
|
|
|
const ctx = await harness()
|
|
|
|
|
ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() }))
|
2026-08-06 20:27:31 +08:00
|
|
|
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
|
|
|
|
preference: z.union(['light', 'dark', 'system']).default('system'),
|
|
|
|
|
}))
|
2026-07-30 22:17:56 +08:00
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
|
2026-08-06 20:27:31 +08:00
|
|
|
.toEqual(['ui-onboarding', 'ui-theme'])
|
2026-08-22 21:11:51 +08:00
|
|
|
const updates = await captureSettingsUpdates(ctx, async () => {
|
2026-07-30 22:17:56 +08:00
|
|
|
expectOk(await api.settings.mutate(request({
|
|
|
|
|
ns: 'ui-onboarding',
|
|
|
|
|
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
|
|
|
|
|
})))
|
2026-08-06 20:27:31 +08:00
|
|
|
expectOk(await api.settings.mutate(request({
|
|
|
|
|
ns: 'ui-theme',
|
|
|
|
|
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
|
|
|
|
})))
|
2026-07-30 22:17:56 +08:00
|
|
|
})
|
2026-08-22 21:11:51 +08:00
|
|
|
expect(updates).toEqual([
|
|
|
|
|
expectedSettingsUpdate('ui-onboarding'),
|
|
|
|
|
expectedSettingsUpdate('ui-theme'),
|
|
|
|
|
])
|
2026-07-30 22:17:56 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-04 13:34:22 +08:00
|
|
|
it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
ctx.settings.register(settingsNamespace('agent-presets'), z.object({ default: z.string() }))
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
|
2026-08-05 16:40:03 +08:00
|
|
|
expectOk(await api.settings.update(request({ ns: 'agent-presets', patch: { default: 'minimal' } })))
|
2026-08-04 13:34:22 +08:00
|
|
|
|
|
|
|
|
// Both browser surfaces that offer the choice — the General row and the
|
|
|
|
|
// management section — write the default through `settings.update`, so a
|
|
|
|
|
// namespace outside this boundary makes the picker move and then silently
|
|
|
|
|
// forget, which is worse than refusing the control.
|
|
|
|
|
expect(ctx.settings.describe().find(view => String(view.ns) === 'agent-presets')?.value)
|
2026-08-05 16:40:03 +08:00
|
|
|
.toEqual({ default: 'minimal' })
|
2026-08-04 13:34:22 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-12 21:07:57 +08:00
|
|
|
it('keeps serving a provider namespace whose directory entry is gone', async () => {
|
|
|
|
|
// The configurable-provider directory says what the Models page can offer,
|
|
|
|
|
// not what a user may configure: a dormant route's stored section is still
|
|
|
|
|
// theirs to edit, and losing the entry must not strand it.
|
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 })
|
|
|
|
|
ctx.settings.register(NS, AdapterConfig)
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
2026-08-12 21:07:57 +08:00
|
|
|
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
|
|
|
|
|
.toEqual(['llm-deepseek'])
|
|
|
|
|
expect(expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).value)
|
|
|
|
|
.toMatchObject({ baseURL: 'https://x' })
|
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
|
|
|
})
|
|
|
|
|
|
2026-08-11 16:50:03 +08:00
|
|
|
it('forwards a provider settings change for model-catalog consumers', async () => {
|
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
|
|
|
// Editing `models` changes no route, so llm/adapters-updated never fires
|
docs: purge chain-of-thought leakage from prose
Delete design-session citations (decision/audit/plan ordinals, stack
positions), change narration, review choreography, and reviewer-addressed
justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and
generator templates; restate every affected fact as current-state contract
prose. Fix generated docs at their sources and regenerate the catalogs and
cordis-surface regions; re-paste type-equiv blocks; update every bilingual
counterpart and re-record the pairs. Record the citation rule in the
committed-artifact-citations Agent Note.
2026-08-09 15:09:19 +08:00
|
|
|
// and an open model picker would keep serving the stale catalog. Storing
|
|
|
|
|
// an override equal to the resolved value emits nothing on
|
|
|
|
|
// settings/updated, so another tab would never learn the field became
|
|
|
|
|
// overridden.
|
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
|
|
|
const ctx = await harness()
|
|
|
|
|
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
2026-08-22 21:11:51 +08:00
|
|
|
const updates = await captureSettingsUpdates(ctx, async () => {
|
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
|
|
|
await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } }))
|
|
|
|
|
})
|
2026-08-22 21:11:51 +08:00
|
|
|
expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')])
|
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
|
|
|
// 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' },
|
|
|
|
|
})
|
2026-08-22 21:11:51 +08:00
|
|
|
const updates = await captureSettingsUpdates(ctx, async () => {
|
2026-07-31 12:48:19 +08:00
|
|
|
await permission.update({ defaultPreset: 'workspace-write' })
|
|
|
|
|
})
|
2026-08-22 21:11:51 +08:00
|
|
|
expect(updates).toEqual([expectedSettingsUpdate('permission')])
|
2026-07-31 12:48:19 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-11 16:50:03 +08:00
|
|
|
it('forwards an Agent-default settings change for model-catalog consumers', async () => {
|
2026-08-07 15:26:42 +08:00
|
|
|
const ctx = await harness()
|
2026-08-09 12:13:58 +08:00
|
|
|
const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({
|
2026-08-07 15:26:42 +08:00
|
|
|
provider: z.string().required(),
|
|
|
|
|
model: z.string().required(),
|
|
|
|
|
}), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } })
|
2026-08-09 12:13:58 +08:00
|
|
|
// The shared section names the selection every blank session resolves to,
|
|
|
|
|
// so an externally edited default — another tab, a
|
2026-08-07 15:26:42 +08:00
|
|
|
// hand-edited settings.yaml — has to reach an open selector as well.
|
2026-08-22 21:11:51 +08:00
|
|
|
const updates = await captureSettingsUpdates(ctx, async () => {
|
2026-08-09 12:13:58 +08:00
|
|
|
await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
2026-08-07 15:26:42 +08:00
|
|
|
})
|
2026-08-22 21:11:51 +08:00
|
|
|
expect(updates).toEqual([expectedSettingsUpdate('agent-default-model')])
|
2026-08-07 15:26:42 +08:00
|
|
|
})
|
|
|
|
|
|
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)
|
2026-08-22 21:11:51 +08:00
|
|
|
const updates = await captureSettingsUpdates(ctx, async () => {
|
2026-07-30 00:13:12 +08:00
|
|
|
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')
|
|
|
|
|
})
|
2026-08-22 21:11:51 +08:00
|
|
|
expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')])
|
2026-07-30 00:13:12 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 })
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-12 21:07:57 +08:00
|
|
|
it('answers an unregistered namespace as the seam does, and a malformed one alike', async () => {
|
|
|
|
|
// A name no registration answers and a name no registration could answer
|
|
|
|
|
// fold into the same rejection: the proxy adds no boundary of its own, so
|
|
|
|
|
// the seam's own refusal is the whole answer.
|
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)
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const unknown = expectErr(await api.settings.update(request({ ns: 'unknown-ns', patch: {} })))
|
2026-08-12 21:07:57 +08:00
|
|
|
const malformed = expectErr(await api.settings.update(request({ ns: 'Not A Namespace', patch: {} })))
|
|
|
|
|
expect(unknown.code).toBe('settings-rejected')
|
|
|
|
|
expect(unknown.message).toContain('is not registered')
|
|
|
|
|
expect(malformed.code).toBe(unknown.code)
|
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
|
|
|
})
|
|
|
|
|
|
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 } })
|
2026-08-22 21:11:51 +08:00
|
|
|
const updates = await captureCredentialUpdates(ctx, async () => {
|
2026-07-30 00:13:12 +08:00
|
|
|
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' })))
|
|
|
|
|
})
|
2026-08-22 21:11:51 +08:00
|
|
|
expect(updates).toEqual(['OPENAI_API_KEY', 'OPENAI_API_KEY'])
|
2026-07-30 00:13:12 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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']))
|
2026-08-04 12:30:41 +08:00
|
|
|
// Only one namespace can answer an interrogation, so the flag follows the
|
|
|
|
|
// entry's namespace rather than being assumed for every row.
|
|
|
|
|
ctx.llm.registerModelDiscovery('llm-pi-ai', () => Promise.resolve([]))
|
2026-07-30 00:13:12 +08:00
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
const value = expectOk(await api.llm.providers(request({})))
|
|
|
|
|
expect(value.providers).toEqual([
|
fix(host): pin model discovery to loopback and drop its unread wire field
llm.discoverModels was reachable from any declared trusted host. The
method takes a caller-supplied baseURL and makes the host issue a GET to
it, then reports the status or the parsed body — so on a LAN deployment
an anonymous caller had a probe for whatever the host can reach and the
browser cannot, plus a path that carries a draft credential. The
PRIVILEGED_METHODS doc already states the rule this broke: trustedHosts
is a DNS-rebinding fence, not authentication, so the configuration plane
stays loopback-same-origin. It is in that set now, asserted both against
the hand-built fence and over real HTTP beside the catalog reads that
deliberately stay reachable.
supportsDiscovery and listModelDiscoveryNamespaces are gone. The field
was required on the wire and read by nobody: its own contract said a
surface should offer the action "instead of naming an adapter family it
would have to hardcode", while the surface hardcodes llm-pi-ai in two
places and gates the button on whether there is anything to probe. Its
shape did not fit the second caller either — the create card has no row
to read a per-row field from. Keeping a required field alive for a
consumer that may never arrive costs every producer and fixture a value
nobody consults, which is exactly how the fixtures drifted. The registry
that fed it had no other production consumer, so registration and
disposal are now observed through the offer itself.
The Agent Note claimed the key is never logged, which the wire schema
beside it already contradicts, and predated both the provider field and
the catalog-answer path. The two new public types pointed at core.md
without a type-equiv block or manifest entry, so the generated service
catalog named documentation that did not exist.
2026-08-04 16:08:07 +08:00
|
|
|
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
|
|
|
|
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false },
|
2026-08-04 12:30:41 +08:00
|
|
|
// An undeclared live route has no settings address, so nothing can be
|
|
|
|
|
// interrogated on its behalf either.
|
fix(host): pin model discovery to loopback and drop its unread wire field
llm.discoverModels was reachable from any declared trusted host. The
method takes a caller-supplied baseURL and makes the host issue a GET to
it, then reports the status or the parsed body — so on a LAN deployment
an anonymous caller had a probe for whatever the host can reach and the
browser cannot, plus a path that carries a draft credential. The
PRIVILEGED_METHODS doc already states the rule this broke: trustedHosts
is a DNS-rebinding fence, not authentication, so the configuration plane
stays loopback-same-origin. It is in that set now, asserted both against
the hand-built fence and over real HTTP beside the catalog reads that
deliberately stay reachable.
supportsDiscovery and listModelDiscoveryNamespaces are gone. The field
was required on the wire and read by nobody: its own contract said a
surface should offer the action "instead of naming an adapter family it
would have to hardcode", while the surface hardcodes llm-pi-ai in two
places and gates the button on whether there is anything to probe. Its
shape did not fit the second caller either — the create card has no row
to read a per-row field from. Keeping a required field alive for a
consumer that may never arrive costs every producer and fixture a value
nobody consults, which is exactly how the fixtures drifted. The registry
that fed it had no other production consumer, so registration and
disposal are now observed through the offer itself.
The Agent Note claimed the key is never logged, which the wire schema
beside it already contradicts, and predated both the provider field and
the catalog-answer path. The two new public types pointed at core.md
without a type-equiv block or manifest entry, so the generated service
catalog named documentation that did not exist.
2026-08-04 16:08:07 +08:00
|
|
|
{ provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true },
|
2026-07-30 00:13:12 +08:00
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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({})))
|
2026-08-25 06:08:14 +08:00
|
|
|
expect(value.default).toEqual({ provider: 'p', model: 'm' })
|
|
|
|
|
expect(value.routableProviders).toEqual(['deepseek-official', 'broken'])
|
2026-07-30 00:13:12 +08:00
|
|
|
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' }])
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-11 16:50:03 +08:00
|
|
|
it('forwards llm/adapters-updated at every topology commit point', async () => {
|
2026-07-30 00:13:12 +08:00
|
|
|
const ctx = await harness()
|
2026-08-22 21:11:51 +08:00
|
|
|
const updates = await countAdapterUpdates(ctx, async () => {
|
2026-07-30 00:13:12 +08:00
|
|
|
const dispose = ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', []))
|
|
|
|
|
dispose()
|
|
|
|
|
return Promise.resolve()
|
|
|
|
|
})
|
2026-08-22 21:11:51 +08:00
|
|
|
expect(updates).toBe(2)
|
2026-07-30 00:13:12 +08:00
|
|
|
})
|
|
|
|
|
})
|
feat(llm): interrogate a draft provider endpoint for its models
Once a pi-ai route became a declaration rather than a catalog lookup,
adding an OpenAI-compatible gateway meant knowing its model ids up
front. Most such endpoints publish that list at `GET /models`, but no
seam operation could ask: every one is keyed by a registered provider
route, and the provider being added has no route, no stored profile,
and no stored credential — the endpoint and key are values in a form.
Interrogation is therefore keyed by settings namespace, which a
configuration surface already holds from the configurable-provider
directory. `registerModelDiscovery` offers it per namespace,
`discoverModels` asks, and the request carries the draft itself. The
reply is candidates, not a catalog: every field but the id is optional
because most listings disclose nothing else, and adopting one is a
settings write like any other. Nothing here reads or writes settings or
credentials, so `settings.yaml` still decides what a route serves.
`llm.discoverModels` carries the same draft over the wire. Its apiKey is
the third and last payload a secret may ride, and it is never stored,
logged, or echoed; every refusal folds into `model-discovery-failed`,
naming the endpoint asked but never the credential offered.
The pi-ai side is a plain GET for OpenAI-compatible protocols only —
their listing shape is the one gateways, self-hosted servers, and the
official endpoints agree on. Others say so, sending the user to
hand-entry rather than reporting a guessed shape as an empty provider.
The reply is read under a four-megabyte ceiling held on the bytes
actually received, because the endpoint is a URL the user typed.
2026-08-04 10:14:46 +08:00
|
|
|
|
|
|
|
|
describe('llm.discoverModels', () => {
|
|
|
|
|
it('carries a draft to its namespace and returns candidates without storing anything', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const seen: unknown[] = []
|
|
|
|
|
ctx.llm.registerModelDiscovery('llm-pi-ai', (probe) => {
|
|
|
|
|
seen.push({ baseURL: probe.baseURL, api: probe.api, apiKey: probe.apiKey })
|
|
|
|
|
return Promise.resolve([
|
|
|
|
|
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
|
|
|
|
|
{ id: 'acme-small' },
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
|
|
|
|
|
const value = expectOk(await api.llm.discoverModels(request({
|
|
|
|
|
settingsNs: 'llm-pi-ai',
|
|
|
|
|
baseURL: 'https://gateway.acme.example/v1',
|
|
|
|
|
api: 'openai-completions',
|
|
|
|
|
apiKey: 'probe-key',
|
|
|
|
|
})))
|
|
|
|
|
|
|
|
|
|
expect(value.models).toEqual([
|
|
|
|
|
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
|
|
|
|
|
{ id: 'acme-small' },
|
|
|
|
|
])
|
|
|
|
|
expect(seen).toEqual([{
|
|
|
|
|
baseURL: 'https://gateway.acme.example/v1',
|
|
|
|
|
api: 'openai-completions',
|
|
|
|
|
apiKey: 'probe-key',
|
|
|
|
|
}])
|
|
|
|
|
// Interrogating a draft is a read: no namespace gained a section, and no
|
|
|
|
|
// credential reference was written.
|
|
|
|
|
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
|
|
|
|
|
.not.toContain('llm-pi-ai')
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-04 12:30:41 +08:00
|
|
|
it('carries the route being edited so an adapter can answer from its own registry', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
let probe: unknown
|
|
|
|
|
ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => {
|
|
|
|
|
probe = request_
|
|
|
|
|
return Promise.resolve([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }])
|
|
|
|
|
})
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
|
|
|
|
|
const value = expectOk(await api.llm.discoverModels(request({
|
|
|
|
|
settingsNs: 'llm-pi-ai',
|
|
|
|
|
provider: 'deepseek',
|
|
|
|
|
})))
|
|
|
|
|
|
|
|
|
|
// No endpoint at all: a route the adapter already describes needs none.
|
|
|
|
|
expect(probe).toEqual({ provider: 'deepseek' })
|
|
|
|
|
expect(value.models).toEqual([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }])
|
|
|
|
|
})
|
|
|
|
|
|
feat(llm): interrogate a draft provider endpoint for its models
Once a pi-ai route became a declaration rather than a catalog lookup,
adding an OpenAI-compatible gateway meant knowing its model ids up
front. Most such endpoints publish that list at `GET /models`, but no
seam operation could ask: every one is keyed by a registered provider
route, and the provider being added has no route, no stored profile,
and no stored credential — the endpoint and key are values in a form.
Interrogation is therefore keyed by settings namespace, which a
configuration surface already holds from the configurable-provider
directory. `registerModelDiscovery` offers it per namespace,
`discoverModels` asks, and the request carries the draft itself. The
reply is candidates, not a catalog: every field but the id is optional
because most listings disclose nothing else, and adopting one is a
settings write like any other. Nothing here reads or writes settings or
credentials, so `settings.yaml` still decides what a route serves.
`llm.discoverModels` carries the same draft over the wire. Its apiKey is
the third and last payload a secret may ride, and it is never stored,
logged, or echoed; every refusal folds into `model-discovery-failed`,
naming the endpoint asked but never the credential offered.
The pi-ai side is a plain GET for OpenAI-compatible protocols only —
their listing shape is the one gateways, self-hosted servers, and the
official endpoints agree on. Others say so, sending the user to
hand-entry rather than reporting a guessed shape as an empty provider.
The reply is read under a four-megabyte ceiling held on the bytes
actually received, because the endpoint is a URL the user typed.
2026-08-04 10:14:46 +08:00
|
|
|
it('omits a credential and protocol the draft does not name', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
let probe: unknown
|
|
|
|
|
ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => {
|
|
|
|
|
probe = request_
|
|
|
|
|
return Promise.resolve([])
|
|
|
|
|
})
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
|
|
|
|
|
expectOk(await api.llm.discoverModels(request({
|
|
|
|
|
settingsNs: 'llm-pi-ai',
|
|
|
|
|
baseURL: 'https://gateway.acme.example/v1',
|
|
|
|
|
})))
|
|
|
|
|
|
|
|
|
|
// Absent fields stay absent rather than crossing as explicit undefined:
|
|
|
|
|
// the adapter distinguishes "no protocol named" from "protocol undefined".
|
|
|
|
|
expect(probe).toEqual({ baseURL: 'https://gateway.acme.example/v1' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports a failed interrogation as the form\'s next move, naming no credential', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
ctx.llm.registerModelDiscovery('llm-pi-ai', () =>
|
|
|
|
|
Promise.reject(new Error('https://gateway.acme.example/v1/models answered 401; check the API key')))
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
|
|
|
|
|
const error = expectErr(await api.llm.discoverModels(request({
|
|
|
|
|
settingsNs: 'llm-pi-ai',
|
|
|
|
|
baseURL: 'https://gateway.acme.example/v1',
|
|
|
|
|
apiKey: 'wrong',
|
|
|
|
|
})))
|
|
|
|
|
|
|
|
|
|
expect(error.code).toBe('model-discovery-failed')
|
|
|
|
|
expect(error.message).toContain('answered 401; check the API key')
|
|
|
|
|
expect(error.details).toEqual({ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.acme.example/v1' })
|
|
|
|
|
expect(JSON.stringify(error)).not.toContain('wrong')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports a namespace no adapter family serves', async () => {
|
|
|
|
|
const ctx = await harness()
|
|
|
|
|
const api = createApiProxy(ctx, DEFAULTS)
|
|
|
|
|
|
|
|
|
|
const error = expectErr(await api.llm.discoverModels(request({
|
|
|
|
|
settingsNs: 'llm-deepseek',
|
|
|
|
|
baseURL: 'https://api.deepseek.com',
|
|
|
|
|
})))
|
|
|
|
|
|
|
|
|
|
expect(error.code).toBe('model-discovery-failed')
|
|
|
|
|
expect(error.message).toContain('no model discovery is registered')
|
|
|
|
|
})
|
|
|
|
|
})
|