fix(web-config): close the wire boundary, the redacted-replace data loss, and three P2s

Five findings from the #939 review, each reproduced before being fixed.

**Configuration reads are as privileged as writes.** `settings.describe`
returns every exposed namespace's configuration and `credentials.describe`
reports whether an arbitrary environment-variable name is configured and from
where — reconnaissance no anonymous caller should have. Both join
PRIVILEGED_METHODS, so the whole configuration plane is loopback-only until
real authentication exists; `trustedHosts` was never authentication. The model
catalog stays reachable: it carries no endpoints or key state, and a LAN
client's model picker legitimately needs it. Asserted over a real HTTP server,
because the Host header a browser actually sends is what decides this.

**The proxy serves only namespaces a registered model provider addresses.**
The settings seam is general — any plugin may register one — but the Web
configuration plane is the model-provider surface. Without the gate, every
future `settings.register()` would silently become remotely readable and
writable configuration. An unregistered namespace and an unexposed one answer
identically, so no caller can enumerate the registry one probe at a time.

**Path-addressed writes replace the redacted-document rebuild.** The editor
reads the REDACTED descriptor, so rebuilding a section from it and replacing
wholesale deleted every literal secret the wire never returned — reproduced as
`{baseURL, reasoning}` in, stored `apiKey` gone out. `settings.mutate` applies
set/unset ops to the section as it stands at the front of the seam's write
queue, and the client names only fields it can see, so an unseen secret is
untouched by construction rather than by care.

P2s in the same pass: `llm/adapters-updated` now contains async listener
rejections (an uncontained one escaped as unhandledRejection, contradicting
the documented "observer failures are contained"); llm-deepseek's retry-policy
swap uses the atomic `registration.replace` instead of dispose-then-register,
which published `[]` then `["deepseek-official"]` so an observer saw the
provider disappear and come back; and a transport rejection no longer strands
the page in `loading` or a card in `busy`, with removal failures surfaced on
the page banner instead of swallowed.
This commit is contained in:
Yichen Jiang
2026-07-30 18:30:15 +08:00
parent b73e1811ff
commit 9f996be8e3
29 changed files with 676 additions and 156 deletions

View File

@@ -14,7 +14,7 @@ export type {
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsSecretView,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'

View File

@@ -1545,6 +1545,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
message: 'fixture: no settings namespaces are registered',
details: { ns: request.payload.ns },
}),
mutate: request => err(request, {
code: 'settings-rejected',
message: 'fixture: no settings namespaces are registered',
details: { ns: request.payload.ns },
}),
},
credentials: {
describe: request => ok(request, {
@@ -1668,6 +1673,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'settings.describe': return this.api.settings.describe(request)
case 'settings.update': return this.api.settings.update(request)
case 'settings.replace': return this.api.settings.replace(request)
case 'settings.mutate': return this.api.settings.mutate(request)
case 'credentials.describe': return this.api.credentials.describe(request)
case 'credentials.set': return this.api.credentials.set(request)
case 'credentials.unset': return this.api.credentials.unset(request)

View File

@@ -22,7 +22,7 @@ export type {
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsSecretView,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
} from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'

View File

@@ -35,16 +35,26 @@ export const Config: z<ConnectionConfig> = z.object({
/**
* Methods gated to loopback even on a trusted-host deployment. Native dialogs
* act on the host machine; settings and credential writes mutate the user's
* configuration and secret store. A declared `trustedHosts` authority reaches
* every other method, but these stay loopback-same-origin until a real
* authentication layer exists.
* act on the host machine; the settings and credential domains mutate the
* user's configuration and secret store, and READING them is equally
* privileged — `settings.describe` returns every exposed namespace's
* configuration and `credentials.describe` reports whether an arbitrary
* environment-variable name is configured and where from, which is
* reconnaissance no anonymous caller should have. `trustedHosts` is a
* DNS-rebinding fence, explicitly not authentication, so the whole
* configuration plane stays loopback-same-origin until a real authentication
* layer exists. The model catalog (`llm.providers`, `llm.models`) is
* deliberately NOT here: it carries provider ids, display names, and model
* lists — no endpoints, keys, or key state — and a LAN client's model picker
* legitimately needs it.
*/
const PRIVILEGED_METHODS = new Set([
'host.pickDirectory',
'host.openPath',
'settings.describe',
'settings.update',
'settings.replace',
'credentials.describe',
'credentials.set',
'credentials.unset',
])

View File

@@ -158,6 +158,7 @@ export class FakeApiClient implements IApiClient {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
}
readonly credentials: IApiClient['credentials'] = {

View File

@@ -1,8 +1,10 @@
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { EventEmitter } from 'node:events'
import { createServer, request as httpRequest } from 'node:http'
import { Readable } from 'node:stream'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { AddressInfo } from 'node:net'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
@@ -99,14 +101,14 @@ describe('connection node half', () => {
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
// The privileged set: native dialogs plus every settings/credential write.
// The same declared authority reaches ordinary reads (carrier-level 404
// from the empty proxy proves the fence passed), but each privileged
// method stays loopback-only and short-circuits 403.
// The privileged set: native dialogs plus the whole settings/credential
// configuration plane, reads included. The same declared authority reaches
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
// passed), but each privileged method stays loopback-only and 403s.
for (const method of [
'host.pickDirectory', 'host.openPath',
'settings.update', 'settings.replace',
'credentials.set', 'credentials.unset',
'settings.describe', 'settings.update', 'settings.replace',
'credentials.describe', 'credentials.set', 'credentials.unset',
]) {
const denied = fakeResponse()
await routes[0]!.handler(
@@ -143,3 +145,69 @@ describe('connection node half', () => {
await dispose()
})
})
describe('connection node half over a real HTTP server', () => {
/** Serve the registered prefix route from a real server and return its port. */
async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
const server = createServer((request, response) => {
void routes[0]!.handler(request, response)
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as AddressInfo
return {
port: address.port,
close: () => new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error === undefined || error === null) resolve()
else reject(error)
})
}),
}
}
/** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
function call(port: number, method: string, host: string): Promise<number> {
return new Promise((resolve, reject) => {
const request = httpRequest(
{ host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } },
(response) => {
response.resume()
response.on('end', () => { resolve(response.statusCode ?? 0) })
},
)
request.on('error', reject)
request.end()
})
}
it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => {
// The fence's input is a real IncomingMessage parsed by Node from the
// wire, not a hand-assembled object: the Host header a LAN browser sends
// is exactly what decides loopback-only here, so the boundary is asserted
// against the parse the server actually performs.
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
const { port, close } = await serve(routes)
try {
// Reads are as privileged as writes: describe returns the exposed
// configuration, and credentials.describe probes arbitrary env-var names.
for (const method of [
'settings.describe', 'settings.update', 'settings.replace',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
]) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
}
// The model catalog stays reachable for the same authority: a LAN
// client's model picker needs it, and it carries no key or endpoint
// state (404 is the empty proxy's carrier answer — the fence passed).
for (const method of ['llm.providers', 'llm.models']) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
}
// Loopback reaches everything, configuration included.
expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404)
} finally {
await close()
await dispose()
}
})
})

View File

@@ -185,6 +185,7 @@ export class FakeApiClient implements IApiClient {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))),
}
readonly credentials: IApiClient['credentials'] = {

View File

@@ -11,7 +11,6 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import { deletePath } from '@deepseek-ai/dsh-client-schema-form'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
import { ProviderEditor } from './ProviderEditor.tsx'
@@ -45,24 +44,35 @@ interface EditorTarget {
}
/**
* Remove one user-added provider profile from its namespace's user section
* (wholesale replace — merge cannot express a removal) and reload on success.
* Remove one user-added provider profile by unsetting its path in the stored
* user section, then reload. The removal names the profile rather than
* rebuilding the section: this page only ever holds the redacted descriptor,
* so a rebuilt section would drop every literal secret stored elsewhere in
* the namespace along with the profile being removed.
* @param api - settings wire face.
* @param controller - the page store to refresh.
* @param target - the provider's settings address.
* @param namespace - the owning namespace view.
* @returns settles when the write and any reload finished.
* @returns the failure message, or undefined once the write and reload landed.
*/
export async function removeProviderProfile(
api: Pick<IApiClient, 'settings'>,
controller: ModelsSettingsStore,
target: { settingsNs: string; settingsPath: readonly string[] },
namespace: SettingsNamespaceView,
): Promise<void> {
const user = structuredClone((namespace.user ?? {}) as Record<string, unknown>)
const next = deletePath(user, [...target.settingsPath])
const response = await api.settings.replace({ ns: target.settingsNs, section: next })
if (response.result.ok) await controller.load()
): Promise<string | undefined> {
let response
try {
response = await api.settings.mutate({
ns: target.settingsNs,
ops: [{ op: 'unset', path: [...target.settingsPath] }],
})
} catch (error) {
// The transport rejected rather than answering; the caller must be able
// to say so instead of the row silently staying put.
return error instanceof Error ? error.message : String(error)
}
if (!response.result.ok) return response.result.error.message
await controller.load()
return undefined
}
/**
@@ -184,7 +194,11 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
type="button"
className={styles['dangerButton']}
disabled={!state.writable}
onClick={() => { void removeProviderProfile(api, controller, target, namespace) }}
onClick={() => {
void removeProviderProfile(api, controller, target).then((failure) => {
if (failure !== undefined) controller.fail(failure)
})
}}
>
{t('remove')}
</button>

View File

@@ -6,15 +6,15 @@
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
* both families, plus `reasoningEffort` for deepseek / `reasoning` for
* pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as a
* minimal `settings.update` merge patch; clearing a field back to inherited
* removes its key, so that apply replaces the user section (safe: the section
* stores references, never key values).
* pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as
* minimal `settings.mutate` path ops against the stored section — the card
* reads the redacted descriptor, so it names only the fields it can see and a
* stored literal secret is never collaterally removed.
*/
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { CredentialView, IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client'
import {
deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from '@deepseek-ai/dsh-client-schema-form'
@@ -70,21 +70,33 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec
}
/**
* Whether any key present in `before` is absent from `after` (a reset
* happened somewhere in the draft, so the apply must replace, not merge).
* @param before - the user-layer subtree the draft started from.
* @param after - the edited draft.
* @returns whether a removal exists at any depth.
* The minimal path ops carrying `after` over `before`, both as the card sees
* them (that is, redacted). Only keys the card observed are named: a stored
* `role('secret')` field appears in neither side, so it produces no op and
* survives the write — the whole reason edits are path-addressed rather than
* a rebuilt section.
* @param base - path of the edited subtree inside the user section.
* @param before - the subtree as loaded, or undefined when it is new.
* @param after - the subtree as edited.
* @returns ordered set/unset ops; empty when nothing changed.
*/
export function removedAny(before: unknown, after: unknown): boolean {
if (typeof before !== 'object' || before === null) return false
/* v8 ignore next -- the editor edits containers in place; a container cannot become a primitive */
if (typeof after !== 'object' || after === null) return true
for (const [key, value] of Object.entries(before)) {
if (!(key in (after as Record<string, unknown>))) return true
if (removedAny(value, (after as Record<string, unknown>)[key])) return true
export function pathOps(
base: readonly string[],
before: unknown,
after: Record<string, unknown>,
): SettingsPathOpView[] {
const previous = typeof before === 'object' && before !== null && !Array.isArray(before)
? before as Record<string, unknown>
: {}
const ops: SettingsPathOpView[] = []
for (const [key, value] of Object.entries(after)) {
if (JSON.stringify(previous[key]) === JSON.stringify(value)) continue
ops.push({ op: 'set', path: [...base, key], value })
}
return false
for (const key of Object.keys(previous)) {
if (!(key in after)) ops.push({ op: 'unset', path: [...base, key] })
}
return ops
}
/** The editor layout the owning namespace selects. */
@@ -140,9 +152,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
}
const apply = async (): Promise<void> => {
setBusy(true)
setFailure(undefined)
/**
* The write for this card, or a failure message. Every edit travels as
* path ops against the STORED section: the draft comes from the redacted
* descriptor, so a wholesale replace rebuilt from it would delete the
* literal secrets the wire never returned. Ops name only the fields this
* card can see, so a stored secret is untouched by construction.
*/
const applyOnce = async (): Promise<string | undefined> => {
const ns = namespace.ns
const original = getPath(namespace.user, settingsPath)
// The pi-ai profile must name the reference the key stores under, so a
@@ -151,45 +168,42 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
&& stringAt(fallback, 'apiKeyEnv') === undefined
? setPath(draft, ['apiKeyEnv'], keyRef)
: draft
const settingsChanged = JSON.stringify(next) !== JSON.stringify(original ?? {})
if (settingsChanged) {
const needsReplace = removedAny(original, next)
// Merge patches stay minimal (just this profile); a replace must carry
// the complete next user section because it lands wholesale.
const patch = settingsPath.length === 0 ? next : setPath({}, [...settingsPath], next)
/* v8 ignore next 3 -- a subtree apply implies the join served this namespace's user layer */
const nextSection = settingsPath.length === 0
? next
: setPath(structuredClone((namespace.user ?? {}) as Record<string, unknown>), [...settingsPath], next)
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
if (node !== undefined) {
const sectionError = settingsPath.length === 0 ? validateDraft(node, next) : undefined
if (sectionError !== undefined) {
setBusy(false)
setFailure(sectionError)
return
}
}
const response = needsReplace
? await api.settings.replace({ ns, section: nextSection })
: await api.settings.update({ ns, patch })
if (!response.result.ok) {
setBusy(false)
setFailure(response.result.error.message)
return
}
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
if (node !== undefined && settingsPath.length === 0) {
const sectionError = validateDraft(node, next)
if (sectionError !== undefined) return sectionError
}
const ops = pathOps(settingsPath, original, next)
if (ops.length > 0) {
const response = await api.settings.mutate({ ns, ops })
if (!response.result.ok) return response.result.error.message
}
if (keyDraft.length > 0) {
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
if (!stored.result.ok) {
setBusy(false)
setFailure(stored.result.error.message)
if (!stored.result.ok) return stored.result.error.message
}
setKeyDraft('')
return undefined
}
const apply = async (): Promise<void> => {
setBusy(true)
setFailure(undefined)
try {
const failure = await applyOnce()
if (failure !== undefined) {
setFailure(failure)
return
}
setKeyDraft('')
props.onClose(true)
} catch (error) {
// A transport failure (disconnect, a request the host refuses) rejects
// rather than answering; without this the card would stay busy forever
// with no error shown.
setFailure(error instanceof Error ? error.message : String(error))
} finally {
setBusy(false)
}
setBusy(false)
props.onClose(true)
}
if (node === undefined) {

View File

@@ -75,6 +75,18 @@ export class ModelsSettingsStore {
*/
constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
/**
* Surface a failure from an operation the page ran outside {@link load} —
* a row removal — on the same banner a load failure uses.
* @param message - the failure text to show.
*/
fail(message: string): void {
this.store.update((s) => {
s.status = 'error'
s.error = message
})
}
/**
* Refresh the whole page snapshot: directory and namespaces in parallel,
* then one batched credential describe over every referenced ref. A
@@ -125,10 +137,12 @@ export class ModelsSettingsStore {
const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
let credentials: Record<string, CredentialView> = {}
if (refs.length > 0) {
const response = await this.api.credentials.describe({ refs })
// Credential state is an enrichment: rows render without it, so a
// missing credential provider degrades the badge, not the page.
if (response.result.ok) credentials = response.result.value.credentials
// Credential state is an enrichment: rows render without it, so neither
// a business rejection nor a transport failure (disconnect, a request
// the host refuses) may fail the load — an escaping rejection would
// leave the page stuck in `loading` with no error shown.
const response = await this.api.credentials.describe({ refs }).catch(() => undefined)
if (response?.result.ok === true) credentials = response.result.value.credentials
}
if (generation !== this.generation) return
this.store.update((s) => {

View File

@@ -7,7 +7,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
import { removedAny } from '../src/client/ProviderEditor.tsx'
import { pathOps } from '../src/client/ProviderEditor.tsx'
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
import type { ProviderRow } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
@@ -79,10 +79,12 @@ function fail<T>(message: string, code = 'settings-rejected'): RpcResponse<T> {
function scriptedFace(overrides: {
update?: ReturnType<typeof vi.fn>
replace?: ReturnType<typeof vi.fn>
mutate?: ReturnType<typeof vi.fn>
set?: ReturnType<typeof vi.fn>
} = {}) {
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
const face = {
llm: {
@@ -102,6 +104,7 @@ function scriptedFace(overrides: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))),
update,
replace,
mutate,
},
credentials: {
describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
@@ -115,13 +118,13 @@ function scriptedFace(overrides: {
unset: vi.fn(() => Promise.resolve(ok({}))),
},
}
return { face, update, replace, set }
return { face, update, replace, mutate, set }
}
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
const { face, update, replace, set } = scriptedFace(overrides)
const { face, update, replace, mutate, set } = scriptedFace(overrides)
const controller = new ModelsSettingsStore(face as unknown as WireFace)
await controller.load()
const injected: ModelsSectionInjected = {
@@ -131,7 +134,7 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
t,
}
const view = render(<ModelsSection {...injected} />)
return { view, face, update, replace, set, controller }
return { view, face, update, replace, mutate, set, controller }
}
describe('ModelsSection', () => {
@@ -184,10 +187,15 @@ describe('ModelsSection', () => {
expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
})
it('detects removals at any draft depth', () => {
expect(removedAny({ a: { b: 1, c: 2 } }, { a: { b: 1 } })).toBe(true)
expect(removedAny({ a: { b: 1 } }, { a: { b: 2 }, d: 3 })).toBe(false)
expect(removedAny(undefined, {})).toBe(false)
it('names only the fields the card can see, so an unseen secret survives', () => {
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
// side, so no op mentions it and the seam leaves it alone.
expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' }))
.toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }])
expect(pathOps([], { b: 1 }, { b: 2, d: 3 }))
.toEqual([{ op: 'set', path: ['b'], value: 2 }, { op: 'set', path: ['d'], value: 3 }])
expect(pathOps([], undefined, {})).toEqual([])
expect(pathOps([], { a: 1 }, { a: 1 })).toEqual([])
})
it('stores a typed key write-only from the setup card without touching settings', async () => {
@@ -200,9 +208,9 @@ describe('ModelsSection', () => {
await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
})
it('applies customized deepseek fields as a merge patch', async () => {
const { update } = await mountSection({
update: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
it('applies customized deepseek fields as path ops', async () => {
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
@@ -211,23 +219,31 @@ describe('ModelsSection', () => {
expect(baseURL.placeholder).toBe('https://api.deepseek.com')
fireEvent.change(baseURL, { target: { value: 'https://next2' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
expect(update.mock.calls[0]?.[0]).toEqual({
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
// Only the field that actually changed: reasoningEffort was already
// 'high' in the loaded profile, so it produces no op.
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
patch: { reasoningEffort: 'high', baseURL: 'https://next2' },
ops: [{ op: 'set', path: ['baseURL'], value: 'https://next2' }],
})
})
it('clears an inherited override through replace so the removal lands', async () => {
const { replace, update } = await mountSection()
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
// The data-loss shape: the old path rebuilt the section from the REDACTED
// user layer and replaced it wholesale, deleting any stored literal key.
const { replace, update, mutate } = await mountSection()
fireEvent.click(screen.getByText(en.customized))
const effort = screen.getByLabelText<HTMLSelectElement>(en.effort)
expect(effort.value).toBe('high')
fireEvent.change(effort, { target: { value: '' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(replace).not.toHaveBeenCalled()
expect(update).not.toHaveBeenCalled()
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', section: {} })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{ op: 'unset', path: ['reasoningEffort'] }],
})
})
it('pins the deepseek placeholder and clears typed input back to inherited', async () => {
@@ -269,7 +285,7 @@ describe('ModelsSection', () => {
})
it('edits a pi-ai profile with the curated fields only', async () => {
const { update } = await mountSection()
const { mutate } = await mountSection()
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
// The configured credential shows as the stored placeholder.
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
@@ -284,19 +300,19 @@ describe('ModelsSection', () => {
const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
expect(update.mock.calls[0]?.[0]).toEqual({
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
// Only the edited field travels: apiKeyEnv, baseURL and headers were
// already stored with these values, so no op restates them — and the
// profile's stored literal apiKey, absent from the redacted view the card
// read, is named by nothing at all.
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
patch: {
providers: {
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' }, reasoning: 'xhigh' },
},
},
ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }],
})
})
it('adds a dormant provider with a derived reference and stores its key', async () => {
const { update, set } = await mountSection()
const { mutate, set } = await mountSection()
fireEvent.click(screen.getByText(`+ ${en.add}`))
const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
@@ -310,10 +326,10 @@ describe('ModelsSection', () => {
const addKey = keys[keys.length - 1] as HTMLInputElement
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
expect(update.mock.calls[0]?.[0]).toEqual({
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
patch: { providers: { anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' } } },
ops: [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }],
})
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
})
@@ -336,7 +352,7 @@ describe('ModelsSection', () => {
it('surfaces a rejected settings write and never stores the key after it', async () => {
const { set } = await mountSection({
update: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
})
fireEvent.click(screen.getByText(`+ ${en.add}`))
await screen.findByLabelText(en.provider)
@@ -383,11 +399,15 @@ describe('ModelsSection', () => {
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
})
it('removes a user-added provider through replace', async () => {
const { replace } = await mountSection()
it('removes a user-added provider by unsetting its path', async () => {
const { replace, mutate } = await mountSection()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', section: { providers: { zombie: {} } } })
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(replace).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'unset', path: ['providers', 'openai'] }],
})
})
it('renders the load failure with a retry control', async () => {
@@ -461,30 +481,45 @@ describe('ModelsSection', () => {
await screen.findByText('DeepSeek')
})
it('removes against a namespace with no user layer as an empty-section replace', async () => {
const { face, replace, controller } = await mountSection()
const namespace = controller.store.getSnapshot().namespaces.get('llm-plain')
it('removes by unsetting the profile path, never by rebuilding the section', async () => {
// The section rebuild is what dropped stored literal secrets: this page
// only ever holds the redacted descriptor, so the removal names the path.
const { face, mutate, replace, controller } = await mountSection()
await removeProviderProfile(
face as unknown as Parameters<typeof removeProviderProfile>[0],
controller,
{ settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] },
namespace as NonNullable<typeof namespace>,
)
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-plain', section: {} })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-plain',
ops: [{ op: 'unset', path: ['ghost-profile'] }],
})
expect(replace).not.toHaveBeenCalled()
})
it('keeps the snapshot untouched when a removal write is refused', async () => {
it('keeps the snapshot untouched and reports the message when a removal write is refused', async () => {
const { face, controller } = await mountSection({
replace: vi.fn(() => Promise.resolve(fail('read-only'))),
mutate: vi.fn(() => Promise.resolve(fail('read-only'))),
})
const namespace = controller.store.getSnapshot().namespaces.get('llm-pi-ai')
const before = controller.store.getSnapshot().rows
await removeProviderProfile(
const failure = await removeProviderProfile(
face as unknown as Parameters<typeof removeProviderProfile>[0],
controller,
{ settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
namespace as NonNullable<typeof namespace>,
)
expect(failure).toBe('read-only')
expect(controller.store.getSnapshot().rows).toBe(before)
})
it('reports a transport rejection instead of failing the removal silently', async () => {
const { face, controller } = await mountSection({
mutate: vi.fn(() => Promise.reject(new Error('connection lost'))),
})
const failure = await removeProviderProfile(
face as unknown as Parameters<typeof removeProviderProfile>[0],
controller,
{ settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
)
expect(failure).toBe('connection lost')
})
})