diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index e90a33af0b..5412e025cd 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -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>', + 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', + 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', + 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', 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, expectedRevision: number | undefined, ): Promise', + 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, expectedRevision: number | undefined, ): Promise', + 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', + 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', summary: 'Abstract bash execution service.', @@ -4455,7 +4516,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { 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', @@ -4973,6 +5034,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SettingsDescribeOptions', 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', 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', 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', 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', declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}', }, + { + name: 'SettingsSecretView', + declaration: 'export interface SettingsSecretView {\n path: string[];\n set: boolean;\n}', + }, { name: 'SettingsUpdateSource', declaration: 'export type SettingsUpdateSource = \'update\' | \'provider\';', diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 36f0ae707c..9942223158 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -51,7 +51,6 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", @@ -74,6 +73,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^" diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index fc94e58035..7aecfea3ad 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -14,10 +14,7 @@ import { InvalidPresetIdError, PresetExistsError, PresetNotWritableError, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' -import type { - ApiProxy, ConfigurableProviderView, CredentialView, - SettingsNamespaceView, -} from './api/index.ts' +import type { ApiProxy, ConfigurableProviderView } from './api/index.ts' import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller' import { SessionQueryError } from '@deepseek-ai/dsh-session-query' 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')`. import type {} from '@deepseek-ai/dsh-commands' 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 { RpcError, RpcRequest, RpcResponse } from './api/rpc.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() } - /** 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, - ns: string, - mode: 'update' | 'replace' | 'mutate', - section: object, - expectedRevision?: number, - ): Promise> { - const settings = ctx.get('settings') - if (settings === undefined) return err(request, settingsAbsent()) - const rejected = (error: unknown): RpcResponse => { - // 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 { host: { @@ -383,15 +302,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, 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) { const settings = ctx.get('settings') if (settings === undefined) return err(request, settingsAbsent()) @@ -435,58 +345,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } 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: { diff --git a/packages/host/apiproxy/src/api/credentials.schema.ts b/packages/host/apiproxy/src/api/credentials.schema.ts deleted file mode 100644 index b0ce3fe01b..0000000000 --- a/packages/host/apiproxy/src/api/credentials.schema.ts +++ /dev/null @@ -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> - -/** credentials.describe request payload. */ -export const credentialsDescribeRequestSchema = z.object({ - refs: z.array(credentialRefNameSchema).max(64), -}) satisfies z.ZodType>> - -/** credentials.describe response value. */ -export const credentialsDescribeValueSchema = z.object({ - credentials: z.record(z.string(), credentialViewSchema), -}) satisfies z.ZodType>> - -/** 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>> - -/** credentials.set response value. */ -export const credentialsSetValueSchema = z.object({}) satisfies z.ZodType>> - -/** credentials.unset request payload. */ -export const credentialsUnsetRequestSchema = z.object({ - ref: credentialRefNameSchema, -}) satisfies z.ZodType>> - -/** credentials.unset response value. */ -export const credentialsUnsetValueSchema = z.object({}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/credentials.ts b/packages/host/apiproxy/src/api/credentials.ts deleted file mode 100644 index b5b59ca059..0000000000 --- a/packages/host/apiproxy/src/api/credentials.ts +++ /dev/null @@ -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 }>> - - /** - * 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> - - /** - * Remove one credential from the writable layer; same shadowing rejection - * as `set`. Unsetting an absent reference succeeds (idempotent). - */ - unset(request: RpcRequest<{ ref: string }>): Promise> -} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index b151677137..8e33371141 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -8,7 +8,6 @@ import type { HostApi } from './host.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SettingsApi } from './settings.ts' -import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' import type { DownloadsApi } from './downloads.ts' @@ -18,7 +17,6 @@ export interface ApiProxy { skills: SkillsApi agentPresets: AgentPresetsApi settings: SettingsApi - credentials: CredentialsApi llm: LlmApi /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ downloads: DownloadsApi @@ -32,8 +30,7 @@ export type { export type { HostApi } from './host.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi } from './agent-presets.ts' -export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' -export type { CredentialsApi, CredentialView } from './credentials.ts' +export type { SettingsApi } from './settings.ts' export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' export type { DownloadsApi } from './downloads.ts' diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts index ff7c63a822..2c1619c8df 100644 --- a/packages/host/apiproxy/src/api/llm.schema.ts +++ b/packages/host/apiproxy/src/api/llm.schema.ts @@ -103,7 +103,7 @@ export const llmDiscoverModelsRequestSchema = z.object({ api: z.string().min(1).optional(), // 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 - // 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 // configuration-plane-wide change, not this method's to make alone. apiKey: z.string().min(1).optional(), diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 2590b4cec7..99c8c7e580 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -7,7 +7,6 @@ import type { HostApi } from './host.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SettingsApi } from './settings.ts' -import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' import type { RpcResponse } from './rpc.ts' @@ -21,14 +20,7 @@ export interface RpcMethodMap { 'host.openPath': HostApi['openPath'] 'skill.list': SkillsApi['list'] 'agentPreset.openDocument': AgentPresetsApi['openDocument'] - 'settings.describe': SettingsApi['describe'] '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.models': LlmApi['models'] 'llm.discoverModels': LlmApi['discoverModels'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 57ca87bcf3..434bbb24dc 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -41,9 +41,6 @@ export const rpcErrorSchema: z.ZodType = 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-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('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('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 7448ef669c..0981944824 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -36,19 +36,6 @@ export interface RpcErrorDetailsMap { 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } 'agent-preset-invalid': { agentPreset: string; 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: * no adapter family serves the namespace, the protocol has no listing this diff --git a/packages/host/apiproxy/src/api/settings.schema.ts b/packages/host/apiproxy/src/api/settings.schema.ts index 19fa2c012e..fcfcb75cea 100644 --- a/packages/host/apiproxy/src/api/settings.schema.ts +++ b/packages/host/apiproxy/src/api/settings.schema.ts @@ -1,40 +1,11 @@ /** - * settings domain zod schemas (names derived from map keys: settingsDescribeRequestSchema / - * settingsDescribeValueSchema / settingsUpdate* / settingsReplace*). + * settings domain zod schemas (names derived from map keys: + * settingsOpenDocumentRequestSchema / settingsOpenDocumentValueSchema). */ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.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> - -/** 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> - -/** settings.describe request payload. */ -export const settingsDescribeRequestSchema = z.object({}) satisfies z.ZodType>> - -/** settings.describe response value. */ -export const settingsDescribeValueSchema = z.object({ - writable: z.boolean(), - hasDocument: z.boolean(), - namespaces: z.array(settingsNamespaceViewSchema), -}) satisfies z.ZodType>> /** settings.openDocument request payload. */ export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodType>> @@ -43,39 +14,3 @@ export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodTyp export const settingsOpenDocumentValueSchema = z.object({ opened: z.literal(true), }) satisfies z.ZodType>> - -/** 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>> - -/** settings.update response value: the namespace's new redacted view. */ -export const settingsUpdateValueSchema = settingsNamespaceViewSchema satisfies z.ZodType>> - -/** 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>> - -/** 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> - -/** 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>> - -/** settings.mutate response value: the namespace's new redacted view. */ -export const settingsMutateValueSchema = settingsNamespaceViewSchema satisfies z.ZodType>> - -/** settings.replace response value. */ -export const settingsReplaceValueSchema = settingsNamespaceViewSchema satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/settings.ts b/packages/host/apiproxy/src/api/settings.ts index ec7017eef7..07fc0e72be 100644 --- a/packages/host/apiproxy/src/api/settings.ts +++ b/packages/host/apiproxy/src/api/settings.ts @@ -1,69 +1,15 @@ /** - * settings domain contract: the web face of the user-settings seam - * (`ctx.settings`). Every payload that leaves this domain is redacted by the - * seam (`describe({ redactSecrets: true })` semantics): `role('secret')` - * fields never ride a response in any layer, and the `secrets` slot list is - * how a form learns a write-only field exists and whether it is configured. + * settings domain contract: what remains of the web face of the user-settings + * seam (`ctx.settings`) once the redacted read and the path-addressed write + * moved to the `settings` Remote namespace. Only the local-document handoff + * stays here, because opening a Host file is a platform action rather than a + * settings read. */ 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). */ 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> - /** * 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; @@ -73,34 +19,4 @@ export interface SettingsApi { openDocument( request: RpcRequest<{}>, signal: AbortSignal, ): Promise> - - /** - * 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> - - /** - * 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> - - /** - * 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> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 4a567f8638..c33313ece2 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -20,12 +20,8 @@ import { agentPresetOpenDocumentValueSchema, } from '../api/agent-presets.schema.ts' import { - settingsDescribeValueSchema, settingsMutateValueSchema, settingsOpenDocumentValueSchema, - settingsReplaceValueSchema, settingsUpdateValueSchema, + settingsOpenDocumentValueSchema, } from '../api/settings.schema.ts' -import { - credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema, -} from '../api/credentials.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>> } settings: { - describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise>> openDocument(payload: RequestPayload<'settings.openDocument'>, signal?: AbortSignal): Promise>> - update(payload: RequestPayload<'settings.update'>, signal?: AbortSignal): Promise>> - replace(payload: RequestPayload<'settings.replace'>, signal?: AbortSignal): Promise>> - mutate(payload: RequestPayload<'settings.mutate'>, signal?: AbortSignal): Promise>> - } - credentials: { - describe(payload: RequestPayload<'credentials.describe'>, signal?: AbortSignal): Promise>> - set(payload: RequestPayload<'credentials.set'>, signal?: AbortSignal): Promise>> - unset(payload: RequestPayload<'credentials.unset'>, signal?: AbortSignal): Promise>> } llm: { providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise>> @@ -79,14 +66,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('settings.describe', 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'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d4f3ea58ed..d5fb1d0e29 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -22,12 +22,8 @@ import { agentPresetOpenDocumentRequestSchema, } from '../api/agent-presets.schema.ts' import { - settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsOpenDocumentRequestSchema, - settingsReplaceRequestSchema, settingsUpdateRequestSchema, + settingsOpenDocumentRequestSchema, } from '../api/settings.schema.ts' -import { - credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema, -} from '../api/credentials.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) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, '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.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.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) }, 'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 98a0e091c8..e8816f99ce 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -77,7 +77,6 @@ export class ApiProxyService extends Service implements ApiProxy { readonly skills: ApiProxy['skills'] readonly agentPresets: ApiProxy['agentPresets'] readonly settings: ApiProxy['settings'] - readonly credentials: ApiProxy['credentials'] readonly llm: ApiProxy['llm'] readonly downloads: ApiProxy['downloads'] @@ -95,7 +94,6 @@ export class ApiProxyService extends Service implements ApiProxy { this.skills = api.skills this.agentPresets = api.agentPresets this.settings = api.settings - this.credentials = api.credentials this.llm = api.llm this.downloads = api.downloads } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 9eb2b09c85..8152fa262e 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -1,8 +1,7 @@ /** - * Settings/credentials/llm RPC domains and their owner events over - * createApiProxy: layered redacted describe, write-path rejection mapping, - * value-free credential views, the directory/live-route merge, and the three - * invalidation frames (settings/credentials/models changed). + * Settings and llm RPC domains and their owner events over createApiProxy: + * layered redacted describe, write-path rejection mapping, the + * directory/live-route merge, and the settings and model invalidation frames. */ 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): Promise { - const updates: CredentialRef[] = [] - const dispose = ctx.on('credentials/reference-updated', (ref) => { updates.push(ref) }) - try { - await run() - return updates - } finally { - dispose() - } -} - /** Count model-adapter topology commits while one API operation runs. */ async function countAdapterUpdates(ctx: Context, run: () => Promise): Promise { let updates = 0 @@ -273,32 +260,11 @@ describe('settings domain', () => { it('reports an actionable error when no settings provider is mounted', async () => { const ctx = await harness({ settings: false }) const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.settings.describe(request({}))) + const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal)) expect(error.code).toBe('internal') 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 () => { 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 () => { const ctx = await harness() 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)) expect(error.code).toBe('internal') expect(error.message).toContain('no local document') @@ -356,141 +322,9 @@ describe('settings domain', () => { 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 () => { // Editing `models` changes no route, so llm/adapters-updated never fires @@ -500,13 +334,12 @@ describe('settings domain', () => { // overridden. const ctx = await harness() ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) - const api = createApiProxy(ctx, DEFAULTS) 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')]) // 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' }) }) @@ -538,116 +371,11 @@ describe('settings domain', () => { 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', () => { @@ -734,8 +462,7 @@ describe('llm.discoverModels', () => { }]) // Interrogating a draft is a read: no namespace gained a section, and no // credential reference was written. - expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) - .not.toContain('llm-pi-ai') + expect(ctx.settings.describe().map(view => String(view.ns))).not.toContain('llm-pi-ai') }) it('carries the route being edited so an adapter can answer from its own registry', async () => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 45b6d153ce..b570fbeec7 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -19,7 +19,6 @@ function scriptedApi(overrides: { skills?: Partial agentPresets?: Partial settings?: Partial - credentials?: Partial llm?: Partial } = {}): ApiProxy { const err = (r: RpcRequest): Promise> => @@ -38,19 +37,9 @@ function scriptedApi(overrides: { ...overrides.agentPresets, }, settings: { - describe: r => ok(r, { writable: true, hasDocument: false, namespaces: [] }), openDocument: r => ok(r, { opened: true as const }), - update: err, - replace: err, - mutate: err, ...overrides.settings, }, - credentials: { - describe: r => ok(r, { credentials: {} }), - set: err, - unset: err, - ...overrides.credentials, - }, llm: { providers: r => ok(r, { providers: [] }), models: r => ok(r, { @@ -291,18 +280,9 @@ describe('envelope tap', () => { }) 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 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 = { provider: '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 api = scriptedApi({ 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 })), - 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: { providers: record('llm.providers', r => ok(r, { providers: [providerRow] })), @@ -337,23 +308,7 @@ describe('config unary surface', () => { }) 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 } }) - 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({}) expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } }) 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(seen.map(call => call.method)).toEqual([ - 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', - 'credentials.describe', 'credentials.set', 'credentials.unset', + 'settings.openDocument', '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 // one interrogation and stores none of it. - expect(seen[10]?.payload).toEqual({ + expect(seen[3]?.payload).toEqual({ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.acme.example/v1', api: 'openai-completions', 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') - }) }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 9bbd19640b..284a973cb4 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -33,31 +33,8 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { }, }, settings: { - async describe(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } } } - }, async openDocument(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } - }, - 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: {} } } + return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } }, }, llm: { @@ -102,9 +79,9 @@ describe('unary round trip (handler ⇄ client, no network)', () => { }) 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) - 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 () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 429e31361f..3f863b7817 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -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-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: '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: 'internal', message: 'm', details: {} }).code).toBe('internal') })