refactor(apiproxy)!: remove settings and credentials RPCs

This commit is contained in:
imccyu
2026-08-27 03:01:29 +08:00
parent 5918dd205e
commit fd7f2065b2
19 changed files with 105 additions and 839 deletions
@@ -736,6 +736,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
}, },
], ],
}, },
{
key: 'credentialsController',
summary: 'Host service backing the generated `ctx.remote.credentials` namespace.',
description: 'Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one.',
methods: [
{
signature: '@Remote async describe(refs: string[]): Promise<Record<string, CredentialInfo>>',
description: 'Describe several references for one configuration surface. Batched because a settings page describes every reference its rows name at once, and one round trip keeps those rows from settling separately.',
parameters: [{ name: 'refs', description: 'reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`.' }],
returns: 'one view per requested name, keyed by that name.',
throws: ['TypertRemoteFailure when the request is invalid or no credential provider is mounted.'],
},
{
signature: '@Remote async set(ref: string, value: string): Promise<void>',
description: 'Store one value from a configuration surface. The value crosses the wire in this direction only: no read path returns it.',
parameters: [{ name: 'ref', description: 'reference name to store under.' }, { name: 'value', description: 'the non-empty secret value.' }],
throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'],
},
{
signature: '@Remote async unset(ref: string): Promise<void>',
description: 'Remove one reference from a configuration surface.',
parameters: [{ name: 'ref', description: 'reference name to remove.' }],
throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'],
},
],
},
{ {
key: 'deepseekLlmApiExtensions', key: 'deepseekLlmApiExtensions',
summary: 'Registry of independently owned top-level fields for official DeepSeek requests.', summary: 'Registry of independently owned top-level fields for official DeepSeek requests.',
@@ -1870,6 +1896,41 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
}, },
], ],
}, },
{
key: 'settingsController',
summary: 'Host service backing the generated `ctx.remote.settings` namespace.',
description: 'Host service backing the generated `ctx.remote.settings` namespace. Every remote read uses `redactSecrets: true`, so a `role(\'secret\')` field cannot ride a response. Writes expose the settings service\'s merge, replacement, and path-addressed operations, and classify every provider refusal as `settings-conflict` or `settings-rejected` with the service\'s message.',
methods: [
{
signature: '@Remote describe(): SettingsDescribeValue',
description: 'Describe every registered namespace for a configuration page: redacted layered values plus the serialized schema the page renders its form from.',
parameters: [],
returns: 'provider writability, local-document presence, and one view per namespace.',
throws: ['TypertRemoteFailure when no settings provider is mounted.'],
},
{
signature: '@Remote update( ns: string, patch: Record<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>',
description: 'Merge a patch into one namespace\'s stored user section.',
parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'patch', description: 'fields to merge into the user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }],
returns: 'the namespace\'s redacted view after the write.',
throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'],
},
{
signature: '@Remote replace( ns: string, section: Record<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>',
description: 'Replace one namespace\'s stored user section wholesale.',
parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'section', description: 'complete replacement user section.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }],
returns: 'the namespace\'s redacted view after the write.',
throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'],
},
{
signature: '@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>',
description: 'Apply path-addressed edits to one namespace\'s user section, resolved against the section as stored rather than against whatever the caller last read, then answer with that namespace\'s new redacted view.',
parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'ops', description: 'the edits to apply, in order.' }, { name: 'expectedRevision', description: 'revision the caller read; `undefined` writes unconditionally.' }],
returns: 'the namespace\'s redacted view after the write.',
throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'],
},
],
},
{ {
key: 'shell', key: 'shell',
summary: 'Abstract bash execution service.', summary: 'Abstract bash execution service.',
@@ -4455,7 +4516,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'RpcErrorDetailsMap', name: 'RpcErrorDetailsMap',
declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}', declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}',
}, },
{ {
name: 'RpcId', name: 'RpcId',
@@ -4973,6 +5034,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SettingsDescribeOptions', name: 'SettingsDescribeOptions',
declaration: 'export interface SettingsDescribeOptions {\n redactSecrets?: boolean;\n}', declaration: 'export interface SettingsDescribeOptions {\n redactSecrets?: boolean;\n}',
}, },
{
name: 'SettingsDescribeValue',
declaration: 'export interface SettingsDescribeValue {\n writable: boolean;\n hasDocument: boolean;\n namespaces: SettingsNamespaceView[];\n}',
},
{ {
name: 'SettingsDescriptor', name: 'SettingsDescriptor',
declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n revision: number;\n base?: unknown;\n user?: unknown;\n applies: SettingsApplies;\n secrets?: RedactedSecret[];\n}', declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n revision: number;\n base?: unknown;\n user?: unknown;\n applies: SettingsApplies;\n secrets?: RedactedSecret[];\n}',
@@ -4981,14 +5046,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SettingsNamespace', name: 'SettingsNamespace',
declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;', declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;',
}, },
{
name: 'SettingsNamespaceView',
declaration: 'export interface SettingsNamespaceView {\n ns: string;\n schema: JsonValue;\n value: JsonValue;\n base?: JsonValue;\n user?: JsonValue;\n applies: \'live\' | \'restart\';\n secrets: SettingsSecretView[];\n revision: number;\n}',
},
{ {
name: 'SettingsPathOp', name: 'SettingsPathOp',
declaration: 'export type SettingsPathOp = {\n op: \'set\';\n path: readonly string[];\n value: unknown;\n} | {\n op: \'unset\';\n path: readonly string[];\n};', declaration: 'export type SettingsPathOp = {\n op: \'set\';\n path: readonly string[];\n value: unknown;\n} | {\n op: \'unset\';\n path: readonly string[];\n};',
}, },
{
name: 'SettingsPathOpView',
declaration: 'export type SettingsPathOpView = {\n op: \'set\';\n path: string[];\n value: JsonValue;\n} | {\n op: \'unset\';\n path: string[];\n};',
},
{ {
name: 'SettingsRegisterOptions', name: 'SettingsRegisterOptions',
declaration: 'export interface SettingsRegisterOptions<T> {\n base?: Partial<T>;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}', declaration: 'export interface SettingsRegisterOptions<T> {\n base?: Partial<T>;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}',
}, },
{
name: 'SettingsSecretView',
declaration: 'export interface SettingsSecretView {\n path: string[];\n set: boolean;\n}',
},
{ {
name: 'SettingsUpdateSource', name: 'SettingsUpdateSource',
declaration: 'export type SettingsUpdateSource = \'update\' | \'provider\';', declaration: 'export type SettingsUpdateSource = \'update\' | \'provider\';',
+1 -1
View File
@@ -51,7 +51,6 @@
"@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^",
@@ -74,6 +73,7 @@
"devDependencies": { "devDependencies": {
"@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^" "@deepseek-ai/dsh-typert-registry": "workspace:^"
+1 -143
View File
@@ -14,10 +14,7 @@ import {
InvalidPresetIdError, PresetExistsError, InvalidPresetIdError, PresetExistsError,
PresetNotWritableError, UnknownPresetError, PresetNotWritableError, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets' } from '@deepseek-ai/dsh-agent-presets'
import type { import type { ApiProxy, ConfigurableProviderView } from './api/index.ts'
ApiProxy, ConfigurableProviderView, CredentialView,
SettingsNamespaceView,
} from './api/index.ts'
import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller' import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import { import {
@@ -33,12 +30,6 @@ import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
// Type-only edges: resolve the command-change stream and `ctx.get('skills')`. // Type-only edges: resolve the command-change stream and `ctx.get('skills')`.
import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-commands'
import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-skill'
// The settings/credentials seams: brand guards run at this wire boundary; the
// service reads stay optional (`ctx.get`) so a composition without either
// provider still serves every other domain.
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts' import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts'
import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
@@ -187,79 +178,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return defaults.openPath !== undefined || canOpenNativePath() return defaults.openPath !== undefined || canOpenNativePath()
} }
/** Missing-service report shared by the credentials domain. */
function credentialsAbsent(): RpcError {
return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
}
/** Map one redacted settings descriptor to its wire view. */
function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
return {
ns: String(descriptor.ns),
schema: descriptor.schema,
value: descriptor.value,
...descriptor.base === undefined ? {} : { base: descriptor.base },
...descriptor.user === undefined ? {} : { user: descriptor.user },
applies: descriptor.applies,
secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
revision: descriptor.revision,
}
}
/**
* Run one settings write (merge or wholesale replace) and acknowledge with
* the namespace's new redacted view. Every seam refusal — unknown or invalid
* namespace, read-only provider, schema validation, storage — becomes one
* `settings-rejected` carrying the seam's own message.
*/
async function settingsWrite(
request: RpcRequest<unknown>,
ns: string,
mode: 'update' | 'replace' | 'mutate',
section: object,
expectedRevision?: number,
): Promise<RpcResponse<SettingsNamespaceView>> {
const settings = ctx.get('settings')
if (settings === undefined) return err(request, settingsAbsent())
const rejected = (error: unknown): RpcResponse<SettingsNamespaceView> => {
// A stale writer is its own outcome, not a malformed request: the client
// must re-read and re-apply rather than treat the write as invalid.
if (error instanceof SettingsConflictError) {
return err(request, {
code: 'settings-conflict',
message: error.message,
details: { ns, expected: error.expected, actual: error.actual },
})
}
return err(request, {
code: 'settings-rejected',
message: error instanceof Error ? error.message : String(error),
details: { ns },
})
}
let branded: SettingsNamespace
try {
branded = settingsNamespace(ns)
} catch (error: unknown) {
// A malformed name can address no registration, so it fails exactly as
// an unregistered one does.
return rejected(error)
}
try {
if (mode === 'update') await settings.update(branded, section, expectedRevision)
else if (mode === 'replace') await settings.replace(branded, section, expectedRevision)
else await settings.mutate(branded, section as SettingsPathOp[], expectedRevision)
} catch (error: unknown) {
return rejected(error)
}
const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
if (descriptor === undefined) {
// The write committed but the namespace vanished before this read: only
// a concurrent registrant disposal can produce it.
return err(request, { code: 'internal', message: `settings namespace "${ns}" was disposed after the ${mode}`, details: {} })
}
return ok(request, namespaceView(descriptor))
}
return { return {
host: { host: {
@@ -383,15 +302,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}, },
settings: { settings: {
describe(request) {
const settings = ctx.get('settings')
if (settings === undefined) return Promise.resolve(err(request, settingsAbsent()))
return Promise.resolve(ok(request, {
writable: settings.writable,
hasDocument: settings.documentPath !== undefined,
namespaces: settings.describe({ redactSecrets: true }).map(namespaceView),
}))
},
async openDocument(request, signal) { async openDocument(request, signal) {
const settings = ctx.get('settings') const settings = ctx.get('settings')
if (settings === undefined) return err(request, settingsAbsent()) if (settings === undefined) return err(request, settingsAbsent())
@@ -435,58 +345,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
} }
return openTextFile(request, path, signal) return openTextFile(request, path, signal)
}, },
update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision),
replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision),
mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision),
},
credentials: {
async describe(request) {
const credentials = ctx.get('credentials')
if (credentials === undefined) return err(request, credentialsAbsent())
const entries = await Promise.all(request.payload.refs.map(async (ref) => {
const info = await credentials.describe(credentialRef(ref))
const view: CredentialView = {
configured: info.configured,
...info.source === undefined ? {} : { source: info.source },
writable: info.writable,
}
return [ref, view] as const
}))
return ok(request, { credentials: Object.fromEntries(entries) })
},
async set(request) {
const credentials = ctx.get('credentials')
if (credentials === undefined) return err(request, credentialsAbsent())
const { ref, value } = request.payload
try {
await credentials.set(credentialRef(ref), value)
} catch (error: unknown) {
return err(request, {
code: 'credential-rejected',
message: error instanceof Error ? error.message : String(error),
details: { ref },
})
}
return ok(request, {})
},
async unset(request) {
const credentials = ctx.get('credentials')
if (credentials === undefined) return err(request, credentialsAbsent())
const { ref } = request.payload
try {
await credentials.unset(credentialRef(ref))
} catch (error: unknown) {
return err(request, {
code: 'credential-rejected',
message: error instanceof Error ? error.message : String(error),
details: { ref },
})
}
return ok(request, {})
},
}, },
llm: { llm: {
@@ -1,48 +0,0 @@
/**
* credentials domain zod schemas (names derived from map keys:
* credentialsDescribeRequestSchema / credentialsDescribeValueSchema / …).
* The reference-name pattern mirrors the seam's `credentialRef` guard so an
* invalid name fails as `bad-request` before reaching the service.
*/
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { CredentialView } from './credentials.ts'
/** POSIX-portable environment-variable name (the seam's `credentialRef` pattern). */
export const credentialRefNameSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
/** CredentialView entry of credentials.describe. */
export const credentialViewSchema = z.object({
configured: z.boolean(),
source: z.string().optional(),
writable: z.boolean(),
}) satisfies z.ZodType<Wire<CredentialView>>
/** credentials.describe request payload. */
export const credentialsDescribeRequestSchema = z.object({
refs: z.array(credentialRefNameSchema).max(64),
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.describe'>>>
/** credentials.describe response value. */
export const credentialsDescribeValueSchema = z.object({
credentials: z.record(z.string(), credentialViewSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'credentials.describe'>>>
/** credentials.set request payload: the one direction a value crosses this wire. */
export const credentialsSetRequestSchema = z.object({
ref: credentialRefNameSchema,
value: z.string().min(1),
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.set'>>>
/** credentials.set response value. */
export const credentialsSetValueSchema = z.object({}) satisfies z.ZodType<Wire<ResponseValue<'credentials.set'>>>
/** credentials.unset request payload. */
export const credentialsUnsetRequestSchema = z.object({
ref: credentialRefNameSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.unset'>>>
/** credentials.unset response value. */
export const credentialsUnsetValueSchema = z.object({}) satisfies z.ZodType<Wire<ResponseValue<'credentials.unset'>>>
@@ -1,44 +0,0 @@
/**
* credentials domain contract: the web face of the credential-reference seam
* (`ctx.credentials`). Reads are structurally value-free — a credential view
* carries configured/source/writable and has no slot for the value — and the
* value crosses the wire in exactly one direction, inside `credentials.set`.
* There is no enumeration method by design: clients learn which references
* exist from settings schemas and values (`apiKeyEnv` fields).
*/
import type { RpcRequest, RpcResponse } from './rpc.ts'
/** Wire view of one credential reference's state. */
export interface CredentialView {
/** Whether any layer currently supplies a non-empty value. */
configured: boolean
/** Winning layer when configured (`env`, `file`, …); provider vocabulary. */
source?: string
/** Whether `credentials.set`/`credentials.unset` can affect this reference. */
writable: boolean
}
/** Credentials-domain unary methods (the map keys credentials.* of RpcMethodMap). */
export interface CredentialsApi {
/**
* Describe the named references (batch): configured state, winning source,
* and writability — never values. An invalid reference name is a
* `bad-request`; an unknown-but-valid one describes as unconfigured.
*/
describe(request: RpcRequest<{ refs: string[] }>): Promise<RpcResponse<{ credentials: Record<string, CredentialView> }>>
/**
* Store one credential value in the writable layer. Rejected with
* `credential-rejected` while a read-only layer (the live environment)
* shadows the reference — the write would otherwise appear to succeed while
* resolution keeps returning the shadowing value.
*/
set(request: RpcRequest<{ ref: string; value: string }>): Promise<RpcResponse<{}>>
/**
* Remove one credential from the writable layer; same shadowing rejection
* as `set`. Unsetting an absent reference succeeds (idempotent).
*/
unset(request: RpcRequest<{ ref: string }>): Promise<RpcResponse<{}>>
}
+1 -4
View File
@@ -8,7 +8,6 @@ import type { HostApi } from './host.ts'
import type { AgentPresetsApi } from './agent-presets.ts' import type { AgentPresetsApi } from './agent-presets.ts'
import type { SkillsApi } from './skills.ts' import type { SkillsApi } from './skills.ts'
import type { SettingsApi } from './settings.ts' import type { SettingsApi } from './settings.ts'
import type { CredentialsApi } from './credentials.ts'
import type { LlmApi } from './llm.ts' import type { LlmApi } from './llm.ts'
import type { DownloadsApi } from './downloads.ts' import type { DownloadsApi } from './downloads.ts'
@@ -18,7 +17,6 @@ export interface ApiProxy {
skills: SkillsApi skills: SkillsApi
agentPresets: AgentPresetsApi agentPresets: AgentPresetsApi
settings: SettingsApi settings: SettingsApi
credentials: CredentialsApi
llm: LlmApi llm: LlmApi
/** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
downloads: DownloadsApi downloads: DownloadsApi
@@ -32,8 +30,7 @@ export type {
export type { HostApi } from './host.ts' export type { HostApi } from './host.ts'
export type { SkillsApi, SkillEntry } from './skills.ts' export type { SkillsApi, SkillEntry } from './skills.ts'
export type { AgentPresetsApi } from './agent-presets.ts' export type { AgentPresetsApi } from './agent-presets.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' export type { SettingsApi } from './settings.ts'
export type { CredentialsApi, CredentialView } from './credentials.ts'
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
export type { DownloadsApi } from './downloads.ts' export type { DownloadsApi } from './downloads.ts'
+1 -1
View File
@@ -103,7 +103,7 @@ export const llmDiscoverModelsRequestSchema = z.object({
api: z.string().min(1).optional(), api: z.string().min(1).optional(),
// Write-only at the host: used for this one interrogation, never stored and // Write-only at the host: used for this one interrogation, never stored and
// never returned. It does ride the client's outgoing envelope like every // never returned. It does ride the client's outgoing envelope like every
// other secret-bearing payload (`credentials.set`, `settings.update`), which // other secret-bearing payload (`settings/update`), which
// `subscribeEnvelopes()` observers can see — redacting that tap is a // `subscribeEnvelopes()` observers can see — redacting that tap is a
// configuration-plane-wide change, not this method's to make alone. // configuration-plane-wide change, not this method's to make alone.
apiKey: z.string().min(1).optional(), apiKey: z.string().min(1).optional(),
@@ -7,7 +7,6 @@ import type { HostApi } from './host.ts'
import type { AgentPresetsApi } from './agent-presets.ts' import type { AgentPresetsApi } from './agent-presets.ts'
import type { SkillsApi } from './skills.ts' import type { SkillsApi } from './skills.ts'
import type { SettingsApi } from './settings.ts' import type { SettingsApi } from './settings.ts'
import type { CredentialsApi } from './credentials.ts'
import type { LlmApi } from './llm.ts' import type { LlmApi } from './llm.ts'
import type { RpcResponse } from './rpc.ts' import type { RpcResponse } from './rpc.ts'
@@ -21,14 +20,7 @@ export interface RpcMethodMap {
'host.openPath': HostApi['openPath'] 'host.openPath': HostApi['openPath']
'skill.list': SkillsApi['list'] 'skill.list': SkillsApi['list']
'agentPreset.openDocument': AgentPresetsApi['openDocument'] 'agentPreset.openDocument': AgentPresetsApi['openDocument']
'settings.describe': SettingsApi['describe']
'settings.openDocument': SettingsApi['openDocument'] 'settings.openDocument': SettingsApi['openDocument']
'settings.update': SettingsApi['update']
'settings.replace': SettingsApi['replace']
'settings.mutate': SettingsApi['mutate']
'credentials.describe': CredentialsApi['describe']
'credentials.set': CredentialsApi['set']
'credentials.unset': CredentialsApi['unset']
'llm.providers': LlmApi['providers'] 'llm.providers': LlmApi['providers']
'llm.models': LlmApi['models'] 'llm.models': LlmApi['models']
'llm.discoverModels': LlmApi['discoverModels'] 'llm.discoverModels': LlmApi['discoverModels']
@@ -41,9 +41,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError> ]) as unknown as z.ZodType<RpcError>
-13
View File
@@ -36,19 +36,6 @@ export interface RpcErrorDetailsMap {
'agent-preset-not-found': { agentPreset: string; available: readonly string[] } 'agent-preset-not-found': { agentPreset: string; available: readonly string[] }
'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-preset-invalid': { agentPreset: string; reason: string }
'agent-busy': { reason: string } 'agent-busy': { reason: string }
/**
* A settings write was refused (schema validation, unknown namespace,
* read-only provider, or storage failure); the message is the seam's text.
*/
'settings-rejected': { ns: string }
/**
* A settings write carried an `expectedRevision` the namespace has already
* moved past: another writer (tab, editor, or an external file edit) landed
* first. The details carry both revisions so a client can re-read and retry.
*/
'settings-conflict': { ns: string; expected: number; actual: number }
/** A credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */
'credential-rejected': { ref: string }
/** /**
* Interrogating a draft provider endpoint did not produce a model listing: * Interrogating a draft provider endpoint did not produce a model listing:
* no adapter family serves the namespace, the protocol has no listing this * no adapter family serves the namespace, the protocol has no listing this
@@ -1,40 +1,11 @@
/** /**
* settings domain zod schemas (names derived from map keys: settingsDescribeRequestSchema / * settings domain zod schemas (names derived from map keys:
* settingsDescribeValueSchema / settingsUpdate* / settingsReplace*). * settingsOpenDocumentRequestSchema / settingsOpenDocumentValueSchema).
*/ */
import { z } from 'zod' import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts' import type { Wire } from './rpc.schema.ts'
import type { SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
/** One redacted secret slot. */
export const settingsSecretViewSchema = z.object({
path: z.array(z.string()),
set: z.boolean(),
}) satisfies z.ZodType<Wire<SettingsSecretView>>
/** SettingsNamespaceView row of settings.describe and the write responses. */
export const settingsNamespaceViewSchema = z.object({
ns: z.string().min(1),
schema: z.unknown(),
value: z.unknown(),
base: z.unknown().optional(),
user: z.unknown().optional(),
applies: z.union([z.literal('live'), z.literal('restart')]),
secrets: z.array(settingsSecretViewSchema),
revision: z.number(),
}) satisfies z.ZodType<Wire<SettingsNamespaceView>>
/** settings.describe request payload. */
export const settingsDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'settings.describe'>>>
/** settings.describe response value. */
export const settingsDescribeValueSchema = z.object({
writable: z.boolean(),
hasDocument: z.boolean(),
namespaces: z.array(settingsNamespaceViewSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'settings.describe'>>>
/** settings.openDocument request payload. */ /** settings.openDocument request payload. */
export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'settings.openDocument'>>> export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'settings.openDocument'>>>
@@ -43,39 +14,3 @@ export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodTyp
export const settingsOpenDocumentValueSchema = z.object({ export const settingsOpenDocumentValueSchema = z.object({
opened: z.literal(true), opened: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'settings.openDocument'>>> }) satisfies z.ZodType<Wire<ResponseValue<'settings.openDocument'>>>
/** settings.update request payload. */
export const settingsUpdateRequestSchema = z.object({
ns: z.string().min(1),
patch: z.record(z.string(), z.unknown()),
expectedRevision: z.number().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'settings.update'>>>
/** settings.update response value: the namespace's new redacted view. */
export const settingsUpdateValueSchema = settingsNamespaceViewSchema satisfies z.ZodType<Wire<ResponseValue<'settings.update'>>>
/** settings.replace request payload. */
export const settingsReplaceRequestSchema = z.object({
ns: z.string().min(1),
section: z.record(z.string(), z.unknown()),
expectedRevision: z.number().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'settings.replace'>>>
/** One path-addressed edit of settings.mutate. */
export const settingsPathOpSchema = z.discriminatedUnion('op', [
z.object({ op: z.literal('set'), path: z.array(z.string()), value: z.unknown() }),
z.object({ op: z.literal('unset'), path: z.array(z.string()) }),
]) as unknown as z.ZodType<Wire<SettingsPathOpView>>
/** settings.mutate request payload. */
export const settingsMutateRequestSchema = z.object({
ns: z.string().min(1),
ops: z.array(settingsPathOpSchema),
expectedRevision: z.number().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'settings.mutate'>>>
/** settings.mutate response value: the namespace's new redacted view. */
export const settingsMutateValueSchema = settingsNamespaceViewSchema satisfies z.ZodType<Wire<ResponseValue<'settings.mutate'>>>
/** settings.replace response value. */
export const settingsReplaceValueSchema = settingsNamespaceViewSchema satisfies z.ZodType<Wire<ResponseValue<'settings.replace'>>>
+5 -89
View File
@@ -1,69 +1,15 @@
/** /**
* settings domain contract: the web face of the user-settings seam * settings domain contract: what remains of the web face of the user-settings
* (`ctx.settings`). Every payload that leaves this domain is redacted by the * seam (`ctx.settings`) once the redacted read and the path-addressed write
* seam (`describe({ redactSecrets: true })` semantics): `role('secret')` * moved to the `settings` Remote namespace. Only the local-document handoff
* fields never ride a response in any layer, and the `secrets` slot list is * stays here, because opening a Host file is a platform action rather than a
* how a form learns a write-only field exists and whether it is configured. * settings read.
*/ */
import type { RpcRequest, RpcResponse } from './rpc.ts' import type { RpcRequest, RpcResponse } from './rpc.ts'
/** One schema-declared secret slot inside a redacted namespace value. */
export interface SettingsSecretView {
/** Path from the section root to the removed field. */
path: string[]
/** Whether the slot currently holds a value (the value itself never rides). */
set: boolean
}
/** Wire view of one registered settings namespace. */
export interface SettingsNamespaceView {
/** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */
ns: string
/** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */
schema: unknown
/** Redacted resolved value (schema defaults → composition base → user layer). */
value: unknown
/** Redacted composition base layer, when the registrant declared one. */
base?: unknown
/** Redacted raw user section, when one exists; a field's presence here marks it user-overridden. */
user?: unknown
/** When the owner applies changes. */
applies: 'live' | 'restart'
/** Every schema-declared secret slot with its configured state. */
secrets: SettingsSecretView[]
/**
* Monotonic revision of the raw user section this view was read at. Send it
* back as `expectedRevision` on a write so a stale editor is refused rather
* than silently overwriting a concurrent change.
*/
revision: number
}
/**
* One path-addressed edit carried by `settings.mutate`. `set` writes the
* value at the path (creating intermediate objects); `unset` removes it. The
* empty path addresses the section root.
*/
export type SettingsPathOpView =
| { op: 'set'; path: string[]; value: unknown }
| { op: 'unset'; path: string[] }
/** Settings-domain unary methods (the map keys settings.* of RpcMethodMap). */ /** Settings-domain unary methods (the map keys settings.* of RpcMethodMap). */
export interface SettingsApi { export interface SettingsApi {
/**
* Describe every registered namespace: redacted layered values plus the
* serialized schema a client renders its form from. `hasDocument` reports
* whether a file-backed provider owns a local document without exposing its
* Host path. Connection requires the browser session used by every Host API
* method; `writable: false` tells the client to disable every write control.
*/
describe(request: RpcRequest<{}>): Promise<RpcResponse<{
writable: boolean
hasDocument: boolean
namespaces: SettingsNamespaceView[]
}>>
/** /**
* Materialize the configured local document when absent and ask the Host to * Materialize the configured local document when absent and ask the Host to
* hand it to the platform text-document opener. macOS forces a text editor; * hand it to the platform text-document opener. macOS forces a text editor;
@@ -73,34 +19,4 @@ export interface SettingsApi {
openDocument( openDocument(
request: RpcRequest<{}>, signal: AbortSignal, request: RpcRequest<{}>, signal: AbortSignal,
): Promise<RpcResponse<{ opened: true }>> ): Promise<RpcResponse<{ opened: true }>>
/**
* Merge a patch into one namespace's user layer (validate → persist →
* commit). Secret-role fields may be INCLUDED in the patch (write-only
* direction); a form that leaves a secret untouched simply omits it and the
* merge preserves the stored value. Responds with the namespace's new
* redacted view; a schema or storage rejection is `settings-rejected`.
*/
update(request: RpcRequest<{ ns: string; patch: object; expectedRevision?: number }>): Promise<RpcResponse<SettingsNamespaceView>>
/**
* Replace one namespace's user section wholesale — the removal/reset path a
* merge cannot express (`section: {}` resets to composition defaults). Keys
* absent from `section` are dropped, secrets included: a client must first
* fold the descriptor's `user` layer (and re-supply any secret it wants to
* keep) or accept the reset.
*/
replace(request: RpcRequest<{ ns: string; section: object; expectedRevision?: number }>): Promise<RpcResponse<SettingsNamespaceView>>
/**
* Apply path-addressed edits to one namespace's user section, resolved
* against the section as stored — NOT against whatever the caller last
* read. This is the removal path for any client holding the redacted
* descriptor: it names the field it means, so a secret the wire never
* returned cannot be deleted as a side effect. `replace` remains the
* deliberate wholesale reset.
*/
mutate(
request: RpcRequest<{ ns: string; ops: SettingsPathOpView[]; expectedRevision?: number }>,
): Promise<RpcResponse<SettingsNamespaceView>>
} }
+1 -31
View File
@@ -20,12 +20,8 @@ import {
agentPresetOpenDocumentValueSchema, agentPresetOpenDocumentValueSchema,
} from '../api/agent-presets.schema.ts' } from '../api/agent-presets.schema.ts'
import { import {
settingsDescribeValueSchema, settingsMutateValueSchema, settingsOpenDocumentValueSchema, settingsOpenDocumentValueSchema,
settingsReplaceValueSchema, settingsUpdateValueSchema,
} from '../api/settings.schema.ts' } from '../api/settings.schema.ts'
import {
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
} from '../api/credentials.schema.ts'
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
/** /**
@@ -52,16 +48,7 @@ export interface IApiClient {
openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>> openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
} }
settings: { settings: {
describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.describe'>>>
openDocument(payload: RequestPayload<'settings.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.openDocument'>>> openDocument(payload: RequestPayload<'settings.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.openDocument'>>>
update(payload: RequestPayload<'settings.update'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.update'>>>
replace(payload: RequestPayload<'settings.replace'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.replace'>>>
mutate(payload: RequestPayload<'settings.mutate'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.mutate'>>>
}
credentials: {
describe(payload: RequestPayload<'credentials.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.describe'>>>
set(payload: RequestPayload<'credentials.set'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.set'>>>
unset(payload: RequestPayload<'credentials.unset'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.unset'>>>
} }
llm: { llm: {
providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.providers'>>> providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.providers'>>>
@@ -79,14 +66,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'host.openPath': hostOpenPathValueSchema, 'host.openPath': hostOpenPathValueSchema,
'skill.list': skillListValueSchema, 'skill.list': skillListValueSchema,
'agentPreset.openDocument': agentPresetOpenDocumentValueSchema, 'agentPreset.openDocument': agentPresetOpenDocumentValueSchema,
'settings.describe': settingsDescribeValueSchema,
'settings.openDocument': settingsOpenDocumentValueSchema, 'settings.openDocument': settingsOpenDocumentValueSchema,
'settings.update': settingsUpdateValueSchema,
'settings.replace': settingsReplaceValueSchema,
'settings.mutate': settingsMutateValueSchema,
'credentials.describe': credentialsDescribeValueSchema,
'credentials.set': credentialsSetValueSchema,
'credentials.unset': credentialsUnsetValueSchema,
'llm.providers': llmProvidersValueSchema, 'llm.providers': llmProvidersValueSchema,
'llm.models': llmModelsValueSchema, 'llm.models': llmModelsValueSchema,
'llm.discoverModels': llmDiscoverModelsValueSchema, 'llm.discoverModels': llmDiscoverModelsValueSchema,
@@ -232,17 +212,7 @@ export abstract class AbstractApiClient implements IApiClient {
} }
readonly settings: IApiClient['settings'] = { readonly settings: IApiClient['settings'] = {
describe: (payload, signal) => this.callUnary('settings.describe', payload, signal),
openDocument: (payload, signal) => this.callUnary('settings.openDocument', payload, signal), openDocument: (payload, signal) => this.callUnary('settings.openDocument', payload, signal),
update: (payload, signal) => this.callUnary('settings.update', payload, signal),
replace: (payload, signal) => this.callUnary('settings.replace', payload, signal),
mutate: (payload, signal) => this.callUnary('settings.mutate', payload, signal),
}
readonly credentials: IApiClient['credentials'] = {
describe: (payload, signal) => this.callUnary('credentials.describe', payload, signal),
set: (payload, signal) => this.callUnary('credentials.set', payload, signal),
unset: (payload, signal) => this.callUnary('credentials.unset', payload, signal),
} }
readonly llm: IApiClient['llm'] = { readonly llm: IApiClient['llm'] = {
+1 -12
View File
@@ -22,12 +22,8 @@ import {
agentPresetOpenDocumentRequestSchema, agentPresetOpenDocumentRequestSchema,
} from '../api/agent-presets.schema.ts' } from '../api/agent-presets.schema.ts'
import { import {
settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsOpenDocumentRequestSchema, settingsOpenDocumentRequestSchema,
settingsReplaceRequestSchema, settingsUpdateRequestSchema,
} from '../api/settings.schema.ts' } from '../api/settings.schema.ts'
import {
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
} from '../api/credentials.schema.ts'
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
/** /**
@@ -51,14 +47,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) }, 'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) },
'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) },
'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) }, 'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) },
'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) },
'settings.replace': { schema: settingsReplaceRequestSchema, invoke: (api, r) => api.settings.replace(r) },
'settings.mutate': { schema: settingsMutateRequestSchema, invoke: (api, r) => api.settings.mutate(r) },
'credentials.describe': { schema: credentialsDescribeRequestSchema, invoke: (api, r) => api.credentials.describe(r) },
'credentials.set': { schema: credentialsSetRequestSchema, invoke: (api, r) => api.credentials.set(r) },
'credentials.unset': { schema: credentialsUnsetRequestSchema, invoke: (api, r) => api.credentials.unset(r) },
'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) }, 'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) },
'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) }, 'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) },
'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) }, 'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) },
-2
View File
@@ -77,7 +77,6 @@ export class ApiProxyService extends Service implements ApiProxy {
readonly skills: ApiProxy['skills'] readonly skills: ApiProxy['skills']
readonly agentPresets: ApiProxy['agentPresets'] readonly agentPresets: ApiProxy['agentPresets']
readonly settings: ApiProxy['settings'] readonly settings: ApiProxy['settings']
readonly credentials: ApiProxy['credentials']
readonly llm: ApiProxy['llm'] readonly llm: ApiProxy['llm']
readonly downloads: ApiProxy['downloads'] readonly downloads: ApiProxy['downloads']
@@ -95,7 +94,6 @@ export class ApiProxyService extends Service implements ApiProxy {
this.skills = api.skills this.skills = api.skills
this.agentPresets = api.agentPresets this.agentPresets = api.agentPresets
this.settings = api.settings this.settings = api.settings
this.credentials = api.credentials
this.llm = api.llm this.llm = api.llm
this.downloads = api.downloads this.downloads = api.downloads
} }
@@ -1,8 +1,7 @@
/** /**
* Settings/credentials/llm RPC domains and their owner events over * Settings and llm RPC domains and their owner events over createApiProxy:
* createApiProxy: layered redacted describe, write-path rejection mapping, * layered redacted describe, write-path rejection mapping, the
* value-free credential views, the directory/live-route merge, and the three * directory/live-route merge, and the settings and model invalidation frames.
* invalidation frames (settings/credentials/models changed).
*/ */
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
@@ -240,18 +239,6 @@ async function captureSettingsUpdates(
} }
} }
/** 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. */ /** Count model-adapter topology commits while one API operation runs. */
async function countAdapterUpdates(ctx: Context, run: () => Promise<void>): Promise<number> { async function countAdapterUpdates(ctx: Context, run: () => Promise<void>): Promise<number> {
let updates = 0 let updates = 0
@@ -273,32 +260,11 @@ describe('settings domain', () => {
it('reports an actionable error when no settings provider is mounted', async () => { it('reports an actionable error when no settings provider is mounted', async () => {
const ctx = await harness({ settings: false }) const ctx = await harness({ settings: false })
const api = createApiProxy(ctx, DEFAULTS) const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.settings.describe(request({}))) const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal))
expect(error.code).toBe('internal') expect(error.code).toBe('internal')
expect(error.message).toContain('dsh-settings-file') expect(error.message).toContain('dsh-settings-file')
}) })
it('describes layered redacted namespaces with their secret slots', async () => {
const ctx = await harness({ settings: {
doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } },
documentPath: '/tmp/custom-settings.yaml',
} })
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)
expect(value.hasDocument).toBe(true)
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')
})
it('opens the provider-resolved document without accepting a browser path', async () => { it('opens the provider-resolved document without accepting a browser path', async () => {
const ctx = await harness({ settings: { const ctx = await harness({ settings: {
@@ -322,7 +288,7 @@ describe('settings domain', () => {
it('refuses to open settings when the provider has no local document', async () => { it('refuses to open settings when the provider has no local document', async () => {
const ctx = await harness() const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS) const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).hasDocument).toBe(false) expect(ctx.settings.documentPath).toBeUndefined()
const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal)) const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal))
expect(error.code).toBe('internal') expect(error.code).toBe('internal')
expect(error.message).toContain('no local document') expect(error.message).toContain('no local document')
@@ -356,141 +322,9 @@ describe('settings domain', () => {
expect(opened).toEqual([]) expect(opened).toEqual([])
}) })
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 browser-authenticated and secret-redacted, and which
// surface renders a namespace is the browser's decision, not this proxy's.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
ctx.settings.register(settingsNamespace('permission'), z.object({
defaultPreset: z.union(['read-only', 'workspace-write']).required(),
}), {
base: { defaultPreset: 'read-only' },
})
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}))
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'),
}))
ctx.settings.register(settingsNamespace('shell'), z.object({
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(),
}))
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.settings.describe(request({})))
expect(value.namespaces.map(view => view.ns)).toEqual([
'llm-deepseek', 'some-other-plugin', 'permission', 'ui-theme', 'locale',
'ui-conversation', 'shell', 'agent-loop', 'web-search-deepseek',
])
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' })
const theme = expectOk(await api.settings.mutate(request({
ns: 'ui-theme',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
})))
expect(theme.value).toEqual({ preference: 'dark' })
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' })
const bash = expectOk(await api.settings.mutate(request({
ns: 'shell',
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' })
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' })
})
it('serves product preference namespaces without invalidating the model catalog', async () => {
const ctx = await harness()
ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() }))
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}))
const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.toEqual(['ui-onboarding', 'ui-theme'])
const updates = await captureSettingsUpdates(ctx, async () => {
expectOk(await api.settings.mutate(request({
ns: 'ui-onboarding',
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
})))
expectOk(await api.settings.mutate(request({
ns: 'ui-theme',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
})))
})
expect(updates).toEqual([
expectedSettingsUpdate('ui-onboarding'),
expectedSettingsUpdate('ui-theme'),
])
})
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)
expectOk(await api.settings.update(request({ ns: 'agent-presets', patch: { default: 'minimal' } })))
// 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)
.toEqual({ default: 'minimal' })
})
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.
const ctx = await harness({ configurableProviders: false })
ctx.settings.register(NS, AdapterConfig)
const api = createApiProxy(ctx, DEFAULTS)
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' })
})
it('forwards a provider settings change for model-catalog consumers', async () => { it('forwards a provider settings change for model-catalog consumers', async () => {
// Editing `models` changes no route, so llm/adapters-updated never fires // Editing `models` changes no route, so llm/adapters-updated never fires
@@ -500,13 +334,12 @@ describe('settings domain', () => {
// overridden. // overridden.
const ctx = await harness() const ctx = await harness()
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
const api = createApiProxy(ctx, DEFAULTS)
const updates = await captureSettingsUpdates(ctx, async () => { const updates = await captureSettingsUpdates(ctx, async () => {
await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } })) await ctx.settings.update(settingsNamespace('llm-deepseek'), { baseURL: 'https://base' })
}) })
expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')]) expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')])
// The resolved value never moved: base already said https://base. // The resolved value never moved: base already said https://base.
expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.value) expect(ctx.settings.describe().find(view => String(view.ns) === 'llm-deepseek')?.value)
.toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' })
}) })
@@ -538,116 +371,11 @@ describe('settings domain', () => {
expect(updates).toEqual([expectedSettingsUpdate('agent-default-model')]) expect(updates).toEqual([expectedSettingsUpdate('agent-default-model')])
}) })
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' })
})
it('updates the user layer, answers with the new redacted view, and broadcasts the frame', async () => {
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
const api = createApiProxy(ctx, DEFAULTS)
const updates = await captureSettingsUpdates(ctx, async () => {
const view = expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { apiKey: 'sk-new', baseURL: 'https://next' } })))
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://next' })
expect(view.user).toEqual({ baseURL: 'https://next' })
expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }])
expect(JSON.stringify(view)).not.toContain('sk-new')
})
expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')])
})
it('replace resets the user layer wholesale', async () => {
const ctx = await harness({ settings: { doc: { 'llm-deepseek': { baseURL: 'https://user' } } } })
ctx.settings.register(NS, AdapterConfig)
const api = createApiProxy(ctx, DEFAULTS)
const view = expectOk(await api.settings.replace(request({ ns: 'llm-deepseek', section: {} })))
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY' })
expect(view.user).toEqual({})
})
it.each([
['an invalid namespace name', 'Not A Namespace', {}],
['a schema-invalid patch', 'llm-deepseek', { baseURL: 42 }],
])('rejects %s as settings-rejected', async (_case, ns, patch) => {
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.settings.update(request({ ns, patch })))
expect(error.code).toBe('settings-rejected')
expect(error.details).toEqual({ ns })
})
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.
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: {} })))
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)
})
it('maps a read-only provider refusal onto the same rejection', async () => {
const ctx = await harness({ settings: { readOnly: true } })
ctx.settings.register(NS, AdapterConfig)
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.settings.describe(request({})))
expect(value.writable).toBe(false)
const error = expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: {} })))
expect(error.code).toBe('settings-rejected')
expect(error.message).toContain('read-only')
})
})
describe('credentials domain', () => {
it('reports an actionable error when no credential provider is mounted', async () => {
const ctx = await harness({ credentials: false })
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.credentials.describe(request({ refs: ['A'] })))
expect(error.code).toBe('internal')
expect(error.message).toContain('dsh-credentials-local')
})
it('describes value-free views and flips state through set/unset with frames', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const before = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
expect(before.credentials).toEqual({ OPENAI_API_KEY: { configured: false, writable: true } })
const updates = await captureCredentialUpdates(ctx, async () => {
expectOk(await api.credentials.set(request({ ref: 'OPENAI_API_KEY', value: 'sk-secret' })))
const after = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
expect(after.credentials).toEqual({ OPENAI_API_KEY: { configured: true, source: 'file', writable: true } })
expect(JSON.stringify(after)).not.toContain('sk-secret')
expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' })))
})
expect(updates).toEqual(['OPENAI_API_KEY', 'OPENAI_API_KEY'])
})
it('maps a shadowed write onto credential-rejected for set and unset alike', async () => {
const ctx = await harness({ credentials: { shadowed: ['DEEPSEEK_API_KEY'] } })
const api = createApiProxy(ctx, DEFAULTS)
const described = expectOk(await api.credentials.describe(request({ refs: ['DEEPSEEK_API_KEY'] })))
expect(described.credentials['DEEPSEEK_API_KEY']).toEqual({ configured: true, source: 'env', writable: false })
const setError = expectErr(await api.credentials.set(request({ ref: 'DEEPSEEK_API_KEY', value: 'x' })))
expect(setError.code).toBe('credential-rejected')
expect(setError.details).toEqual({ ref: 'DEEPSEEK_API_KEY' })
const unsetError = expectErr(await api.credentials.unset(request({ ref: 'DEEPSEEK_API_KEY' })))
expect(unsetError.code).toBe('credential-rejected')
})
}) })
describe('llm domain', () => { describe('llm domain', () => {
@@ -734,8 +462,7 @@ describe('llm.discoverModels', () => {
}]) }])
// Interrogating a draft is a read: no namespace gained a section, and no // Interrogating a draft is a read: no namespace gained a section, and no
// credential reference was written. // credential reference was written.
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) expect(ctx.settings.describe().map(view => String(view.ns))).not.toContain('llm-pi-ai')
.not.toContain('llm-pi-ai')
}) })
it('carries the route being edited so an adapter can answer from its own registry', async () => { it('carries the route being edited so an adapter can answer from its own registry', async () => {
@@ -19,7 +19,6 @@ function scriptedApi(overrides: {
skills?: Partial<ApiProxy['skills']> skills?: Partial<ApiProxy['skills']>
agentPresets?: Partial<ApiProxy['agentPresets']> agentPresets?: Partial<ApiProxy['agentPresets']>
settings?: Partial<ApiProxy['settings']> settings?: Partial<ApiProxy['settings']>
credentials?: Partial<ApiProxy['credentials']>
llm?: Partial<ApiProxy['llm']> llm?: Partial<ApiProxy['llm']>
} = {}): ApiProxy { } = {}): ApiProxy {
const err = <T>(r: RpcRequest<unknown>): Promise<RpcResponse<T>> => const err = <T>(r: RpcRequest<unknown>): Promise<RpcResponse<T>> =>
@@ -38,19 +37,9 @@ function scriptedApi(overrides: {
...overrides.agentPresets, ...overrides.agentPresets,
}, },
settings: { settings: {
describe: r => ok(r, { writable: true, hasDocument: false, namespaces: [] }),
openDocument: r => ok(r, { opened: true as const }), openDocument: r => ok(r, { opened: true as const }),
update: err,
replace: err,
mutate: err,
...overrides.settings, ...overrides.settings,
}, },
credentials: {
describe: r => ok(r, { credentials: {} }),
set: err,
unset: err,
...overrides.credentials,
},
llm: { llm: {
providers: r => ok(r, { providers: [] }), providers: r => ok(r, { providers: [] }),
models: r => ok(r, { models: r => ok(r, {
@@ -291,18 +280,9 @@ describe('envelope tap', () => {
}) })
describe('config unary surface', () => { describe('config unary surface', () => {
it('round-trips every settings/credentials/llm method with its own payload and value shape', async () => { it('round-trips every settings/llm method with its own payload and value shape', async () => {
const seen: { method: string; payload: unknown }[] = [] const seen: { method: string; payload: unknown }[] = []
const record = recorderInto(seen) const record = recorderInto(seen)
const view = {
ns: 'llm-deepseek',
schema: { uid: 1, refs: { 1: { type: 'object' } } },
value: { baseURL: 'https://next' },
user: { baseURL: 'https://next' },
applies: 'live' as const,
secrets: [{ path: ['apiKey'], set: true }],
revision: 0,
}
const providerRow = { const providerRow = {
provider: 'openai', provider: 'openai',
displayName: 'openai', displayName: 'openai',
@@ -313,16 +293,7 @@ describe('config unary surface', () => {
const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] } const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
const api = scriptedApi({ const api = scriptedApi({
settings: { settings: {
describe: record('settings.describe', r => ok(r, { writable: true, hasDocument: false, namespaces: [view] })),
openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })), openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })),
update: record('settings.update', r => ok(r, view)),
replace: record('settings.replace', r => ok(r, view)),
mutate: record('settings.mutate', r => ok(r, view)),
},
credentials: {
describe: record('credentials.describe', r => ok(r, { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } })),
set: record('credentials.set', r => ok(r, {})),
unset: record('credentials.unset', r => ok(r, {})),
}, },
llm: { llm: {
providers: record('llm.providers', r => ok(r, { providers: [providerRow] })), providers: record('llm.providers', r => ok(r, { providers: [providerRow] })),
@@ -337,23 +308,7 @@ describe('config unary surface', () => {
}) })
const c = client(api) const c = client(api)
const described = await c.settings.describe({})
expect(described.result).toEqual({ ok: true, value: { writable: true, hasDocument: false, namespaces: [view] } })
expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } }) expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } })
const updated = await c.settings.update({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
expect(updated.result).toEqual({ ok: true, value: view })
const replaced = await c.settings.replace({ ns: 'llm-deepseek', section: {} })
expect(replaced.result).toEqual({ ok: true, value: view })
const mutated = await c.settings.mutate({
ns: 'llm-deepseek',
ops: [{ op: 'unset', path: ['baseURL'] }],
expectedRevision: 0,
})
expect(mutated.result).toEqual({ ok: true, value: view })
const creds = await c.credentials.describe({ refs: ['OPENAI_API_KEY'] })
expect(creds.result).toEqual({ ok: true, value: { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } } })
expect((await c.credentials.set({ ref: 'OPENAI_API_KEY', value: 'sk-x' })).result).toEqual({ ok: true, value: {} })
expect((await c.credentials.unset({ ref: 'OPENAI_API_KEY' })).result).toEqual({ ok: true, value: {} })
const providers = await c.llm.providers({}) const providers = await c.llm.providers({})
expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } }) expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } })
const models = await c.llm.models({}) const models = await c.llm.models({})
@@ -375,29 +330,16 @@ describe('config unary surface', () => {
expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } }) expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } })
expect(seen.map(call => call.method)).toEqual([ expect(seen.map(call => call.method)).toEqual([
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'settings.openDocument',
'credentials.describe', 'credentials.set', 'credentials.unset',
'llm.providers', 'llm.models', 'llm.discoverModels', 'llm.providers', 'llm.models', 'llm.discoverModels',
]) ])
expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
expect(seen[4]?.payload)
.toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 })
expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
// The draft crosses whole, credential included: the host needs it for this // The draft crosses whole, credential included: the host needs it for this
// one interrogation and stores none of it. // one interrogation and stores none of it.
expect(seen[10]?.payload).toEqual({ expect(seen[3]?.payload).toEqual({
settingsNs: 'llm-pi-ai', settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1', baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions', api: 'openai-completions',
apiKey: 'probe-key', apiKey: 'probe-key',
}) })
}) })
it('rejects an invalid credential reference name at the carrier boundary', async () => {
const api = scriptedApi()
const response = await client(api).credentials.set({ ref: 'not a var', value: 'x' })
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('bad-request')
})
}) })
@@ -33,31 +33,8 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy {
}, },
}, },
settings: { settings: {
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } } }
},
async openDocument(request) { async openDocument(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async update(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
},
async replace(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
},
async mutate(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
},
},
credentials: {
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { credentials: {} } } }
},
async set(request) {
return { rpcId: request.rpcId, result: { ok: true, value: {} } }
},
async unset(request) {
return { rpcId: request.rpcId, result: { ok: true, value: {} } }
}, },
}, },
llm: { llm: {
@@ -102,9 +79,9 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
}) })
it('carries a business error as 200 + error result', async () => { it('carries a business error as 200 + error result', async () => {
const response = await client().settings.update({ ns: 'test', patch: {} }) const response = await client().settings.openDocument({})
expect(response.result.ok).toBe(false) expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('settings-rejected') if (!response.result.ok) expect(response.result.error.code).toBe('internal')
}) })
it('round-trips the agent-preset document opener', async () => { it('round-trips the agent-preset document opener', async () => {
@@ -37,10 +37,6 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found') expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found')
expect(rpcErrorSchema.parse({ code: 'agent-preset-invalid', message: 'm', details: { agentPreset: 'p', reason: 'bad' } }).code).toBe('agent-preset-invalid') expect(rpcErrorSchema.parse({ code: 'agent-preset-invalid', message: 'm', details: { agentPreset: 'p', reason: 'bad' } }).code).toBe('agent-preset-invalid')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'settings-rejected', message: 'm', details: { ns: 'n' } }).code).toBe('settings-rejected')
expect(rpcErrorSchema.parse({ code: 'settings-conflict', message: 'm', details: { ns: 'n', expected: 1, actual: 2 } }).code).toBe('settings-conflict')
// The credentials producer still emits this code, so the branch has to stay.
expect(rpcErrorSchema.parse({ code: 'credential-rejected', message: 'm', details: { ref: 'r' } }).code).toBe('credential-rejected')
expect(rpcErrorSchema.parse({ code: 'model-discovery-failed', message: 'm', details: { settingsNs: 'n' } }).code).toBe('model-discovery-failed') expect(rpcErrorSchema.parse({ code: 'model-discovery-failed', message: 'm', details: { settingsNs: 'n' } }).code).toBe('model-discovery-failed')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
}) })