diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 978d4c3378..c65788365c 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -13,6 +13,8 @@ export type { ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, SessionModels, GoalsApi, GoalRef, + SettingsApi, SettingsNamespaceView, SettingsSecretView, + CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 5a72fdc19f..1c90718aa3 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -28,7 +28,7 @@ import type { import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, - ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, + ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -99,6 +99,35 @@ const OPENAI_REASONING = { defaultEffort: 'medium', } +/** Catalog served by `session.models` and `llm.models` alike (fresh copies per call). */ +function fixtureModelGroups(): ModelProviderGroup[] { + return [ + { + id: 'deepseek-official', + name: 'DeepSeek', + models: [ + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: '快速响应', + reasoning: DEEPSEEK_REASONING, + }, + { + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: '复杂任务', + reasoning: DEEPSEEK_REASONING, + }, + ], + }, + { + id: 'openai', + name: 'OpenAI', + models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }], + }, + ] +} + function sid(id: string): SessionId { return id as SessionId } @@ -558,6 +587,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { session.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, ])) + /** Credential store double: set/unset flip the describe badge, values never read back. */ + const fixtureCredentials = new Map() const nextTurn = new Map([[sid('fx-alpha'), 60]]) let nextSession = 1 let nextRpc = 1 @@ -879,31 +910,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { models: request => ok(request, { current: modelTargets.get(request.payload.sessionId) ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - groups: [ - { - id: 'deepseek-official', - name: 'DeepSeek', - models: [ - { - id: 'deepseek-v4-flash', - name: 'DeepSeek-V4-Flash', - description: '快速响应', - reasoning: DEEPSEEK_REASONING, - }, - { - id: 'deepseek-v4-pro', - name: 'DeepSeek-V4-Pro', - description: '复杂任务', - reasoning: DEEPSEEK_REASONING, - }, - ], - }, - { - id: 'openai', - name: 'OpenAI', - models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }], - }, - ], + groups: fixtureModelGroups(), failures: [], }), selectModel: (request) => { @@ -1276,6 +1283,50 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } }, }, + settings: { + // The fixture registers no namespaces yet: the Models surface renders + // its provider list from llm.providers alone, and a real settings form + // rides the HTTP transport (a hand-written schema envelope here would + // drift from schemastery's real serialization). + describe: request => ok(request, { writable: true, namespaces: [] }), + update: request => err(request, { + code: 'settings-rejected', + message: 'fixture: no settings namespaces are registered', + details: { ns: request.payload.ns }, + }), + replace: request => err(request, { + code: 'settings-rejected', + message: 'fixture: no settings namespaces are registered', + details: { ns: request.payload.ns }, + }), + }, + credentials: { + describe: request => ok(request, { + credentials: Object.fromEntries(request.payload.refs.map(ref => [ref, { + configured: fixtureCredentials.has(ref), + ...fixtureCredentials.has(ref) ? { source: 'file' } : {}, + writable: true, + }])), + }), + set: (request) => { + fixtureCredentials.set(request.payload.ref, request.payload.value) + return ok(request, {}) + }, + unset: (request) => { + fixtureCredentials.delete(request.payload.ref) + return ok(request, {}) + }, + }, + llm: { + providers: request => ok(request, { + providers: [ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, + { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false }, + ], + }), + models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }), + }, respond(message: ClientResponse): Promise { if (!questionPending || message.rpcId !== pendingQuestionRpcId) { return Promise.resolve({ accepted: false, reason: 'not-pending' }) @@ -1351,6 +1402,14 @@ export class FixtureApiClient extends AbstractApiClient { case 'goal.resume': return this.api.goals.resume(request) case 'goal.complete': return this.api.goals.complete(request) case 'goal.clear': return this.api.goals.clear(request) + case 'settings.describe': return this.api.settings.describe(request) + case 'settings.update': return this.api.settings.update(request) + case 'settings.replace': return this.api.settings.replace(request) + case 'credentials.describe': return this.api.credentials.describe(request) + case 'credentials.set': return this.api.credentials.set(request) + case 'credentials.unset': return this.api.credentials.unset(request) + case 'llm.providers': return this.api.llm.providers(request) + case 'llm.models': return this.api.llm.models(request) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 53e5b2bc3f..d4a8a5de7b 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -21,6 +21,8 @@ export type { ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, GoalsApi, GoalRef, + SettingsApi, SettingsNamespaceView, SettingsSecretView, + CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi, } from './api.ts' export { RpcId, AbstractApiClient, transportError } from './api.ts' diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 33f6d0cc41..0886f3d1b3 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -15,6 +15,22 @@ export const name = 'client-connection' /** Services required before mounting the route. */ export const inject = ['httpServer', 'apiProxy'] +/** + * Methods gated on the trusted same-origin loopback check. Native dialogs act + * on the host machine; settings and credential writes mutate the user's + * configuration and secret store. Under `--host 0.0.0.0` every other method + * is reachable LAN-wide, but these stay browser-same-origin-on-loopback until + * a real authentication layer exists. + */ +const PRIVILEGED_METHODS = new Set([ + 'host.pickDirectory', + 'host.openPath', + 'settings.update', + 'settings.replace', + 'credentials.set', + 'credentials.unset', +]) + /** * Mounts the API gateway under the browser transport prefix. * @param ctx - Host plugin context. @@ -26,8 +42,8 @@ export function apply(ctx: Context): void { path: API_PATH, handler: async (req, res) => { const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - if ((pathname === `${API_PATH}/host.pickDirectory` - || pathname === `${API_PATH}/host.openPath`) + if (pathname.startsWith(`${API_PATH}/`) + && PRIVILEGED_METHODS.has(pathname.slice(API_PATH.length + 1)) && !isTrustedNativeDialogRequest(req)) { res.writeHead(403) res.end('forbidden') diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 162f824b0e..63268d0a53 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -136,6 +136,23 @@ export class FakeApiClient implements IApiClient { clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))), } + readonly settings: IApiClient['settings'] = { + describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), + update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))), + replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))), + } + + readonly credentials: IApiClient['credentials'] = { + describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))), + set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))), + unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))), + } + + readonly llm: IApiClient['llm'] = { + providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), + models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 2c90cd8b7a..ee5f34cef6 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -28,7 +28,13 @@ describe('connection node half', () => { expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) - for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) { + // The privileged set: native dialogs plus every settings/credential write. + // A non-loopback peer is denied even with same-origin headers. + for (const url of [ + '/api/host.pickDirectory', '/api/host.openPath', + '/api/settings.update', '/api/settings.replace', + '/api/credentials.set', '/api/credentials.unset', + ]) { let status: number | undefined let body: unknown const deniedRequest = { @@ -50,4 +56,49 @@ describe('connection node half', () => { await fiber.dispose() expect(routes).toHaveLength(0) }) + + it('leaves reads and unprivileged methods to the bridge under the same untrusted peer', async () => { + const ctx = new Context() + const routes: WebRoute[] = [] + const httpServer: Pick = { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } + ctx.provide('httpServer', httpServer as HttpServerService) + // The bridge parses the request before the (empty) impl is consulted; a + // carrier-level 404/parse outcome proves the guard did not intercept. + ctx.provide('apiProxy', {} as unknown as ApiProxy) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + + let status: number | undefined + const request = { + url: '/api/settings.describe', + method: 'POST', + headers: { + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }, + socket: { remoteAddress: '192.168.1.8' }, + // Minimal async-iterable face for the bridge's body assembly. + async *[Symbol.asyncIterator]() { + yield Buffer.from('not json') + }, + } as unknown as IncomingMessage + const response = { + writeHead(value: number) { status = value; return this }, + setHeader() { return this }, + end() { return this }, + write() { return true }, + on() { return this }, + } as unknown as ServerResponse + await routes[0]!.handler(request, response) + // 400 (body is not JSON) comes from the carrier, not the 403 guard: the + // read passed the privileged check and reached the fetch handler. + expect(status).toBe(400) + await fiber.dispose() + }) }) diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 39e587c484..e109640dde 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -162,6 +162,23 @@ export class FakeApiClient implements IApiClient { clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))), } + readonly settings: IApiClient['settings'] = { + describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), + update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))), + replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [] }))), + } + + readonly credentials: IApiClient['credentials'] = { + describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))), + set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))), + unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))), + } + + readonly llm: IApiClient['llm'] = { + providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), + models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index c20887b73b..9d628a6c11 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -24,6 +24,8 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create` The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. `settings.describe` serves every registered namespace with its serialized schemastery schema plus redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden) and the `secrets` slot list; `settings.update`/`settings.replace` write the user layer and answer with the namespace's new redacted view, folding every seam refusal into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update` patch or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/updated` passthrough — RPC writes and external `settings.yaml` edits alike), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` (`llm/adapters-updated` passthrough). The browser carrier restricts the four write methods (`settings.update`/`settings.replace`/`credentials.set`/`credentials.unset`) to loopback, same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. + ## Carrier layer (`/client` + root) `AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. @@ -39,6 +41,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals). -- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. +- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations (the former `host.listModels` reservation shipped as `llm.models`); an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **Linux native picker requires desktop tooling** — `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; it does not fall back to a custom or typed-path browser. diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 0db9568f7b..1557ef8222 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -43,12 +43,14 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 56d4df78a3..9d9b32be3a 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -24,9 +24,9 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, - MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, ToolEventView, - WorkspaceId, WorkspaceView, + ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, + ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, + SettingsNamespaceView, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. import type {} from '@deepseek-ai/dsh-session-projection' @@ -38,6 +38,12 @@ import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, 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 { settingsNamespace } from '@deepseek-ai/dsh-settings' +import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { credentialRef } from '@deepseek-ai/dsh-credentials' import { questionResponsePayloadSchema } from './api/questions.schema.ts' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts' import { RpcId } from './api/rpc.ts' @@ -88,6 +94,82 @@ function ok(request: RpcRequest, value: T): RpcResponse { return { rpcId: request.rpcId, result: { ok: true, value } } } +/** + * Build the provider/model catalog over every registered route. Shared by the + * session-scoped `session.models` (which passes the session's current target + * so an unlisted current model still renders selectable) and the host-scoped + * `llm.models` (no current). Per-provider failures ride `failures` without + * failing the sound groups; groups that advertise nothing are dropped. + */ +async function buildModelCatalog( + ctx: Context, + current?: { provider: string; model: string }, +): Promise<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }> { + const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => { + try { + const advertised = await ctx.llm.listModels(provider.id) + const models = [...advertised] + if ( + current !== undefined + && provider.id === current.provider + && !models.some(model => model.id === current.model) + ) { + models.push({ + provider: provider.id, + id: current.model, + name: current.model, + }) + } + const entries = await Promise.all(models.map(async (model) => { + const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id) + const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined + ? undefined + : { + efforts: resolved.reasoning.efforts.map(effort => ({ + id: effort.id, + name: effort.name, + ...effort.description === undefined + ? {} + : { description: effort.description }, + })), + ...resolved.reasoning.defaultEffort === undefined + ? {} + : { defaultEffort: resolved.reasoning.defaultEffort }, + } + return { + id: model.id, + name: model.name, + ...model.description === undefined ? {} : { description: model.description }, + ...current !== undefined + && provider.id === current.provider + && model.id === current.model + && !advertised.some(candidate => candidate.id === current.model) + ? { unlisted: true as const } + : {}, + ...reasoning === undefined ? {} : { reasoning }, + } + })) + const group: ModelProviderGroup = { + id: provider.id, + name: provider.name, + models: entries, + } + return { kind: 'group' as const, group } + } catch (error: unknown) { + const failure: ModelCatalogFailure = { + id: provider.id, + name: provider.name, + message: error instanceof Error ? error.message : String(error), + } + return { kind: 'failure' as const, failure } + } + })) + return { + groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []).filter(group => group.models.length > 0), + failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), + } +} + /** Wrap an error result echoing the request's rpcId. */ function err(request: RpcRequest, error: RpcError): RpcResponse { return { rpcId: request.rpcId, result: { ok: false, error } } @@ -716,6 +798,69 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } + /** Missing-service report shared by the settings domain (skills-domain stance). */ + function settingsAbsent(): RpcError { + return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} } + } + + /** 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 seam 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 })), + } + } + + /** + * 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', + section: object, + ): Promise> { + const settings = ctx.get('settings') + if (settings === undefined) return err(request, settingsAbsent()) + const rejected = (error: unknown): RpcResponse => 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) { + return rejected(error) + } + try { + if (mode === 'update') await settings.update(branded, section) + else await settings.replace(branded, section) + } 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 { sessions: { // Attached sessions summarize from memory; persisted-but-unattached (cold) @@ -826,70 +971,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) const current = targetFor(found.agent).current - const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => { - try { - const advertised = await ctx.llm.listModels(provider.id) - const models = [...advertised] - if ( - provider.id === current.provider - && !models.some(model => model.id === current.model) - ) { - models.push({ - provider: provider.id, - id: current.model, - name: current.model, - }) - } - const entries = await Promise.all(models.map(async (model) => { - const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id) - const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined - ? undefined - : { - efforts: resolved.reasoning.efforts.map(effort => ({ - id: effort.id, - name: effort.name, - ...effort.description === undefined - ? {} - : { description: effort.description }, - })), - ...resolved.reasoning.defaultEffort === undefined - ? {} - : { defaultEffort: resolved.reasoning.defaultEffort }, - } - return { - id: model.id, - name: model.name, - ...model.description === undefined ? {} : { description: model.description }, - ...provider.id === current.provider - && model.id === current.model - && !advertised.some(candidate => candidate.id === current.model) - ? { unlisted: true as const } - : {}, - ...reasoning === undefined ? {} : { reasoning }, - } - })) - const group: ModelProviderGroup = { - id: provider.id, - name: provider.name, - models: entries, - } - return { kind: 'group' as const, group } - } catch (error: unknown) { - const failure: ModelCatalogFailure = { - id: provider.id, - name: provider.name, - message: error instanceof Error ? error.message : String(error), - } - return { kind: 'failure' as const, failure } - } - })) - const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) - const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []) - return ok(request, { - current: { ...current }, - groups: groups.filter(group => group.models.length > 0), - failures, - }) + const { groups, failures } = await buildModelCatalog(ctx, current) + return ok(request, { current: { ...current }, groups, failures }) }, async selectModel(request) { @@ -1265,6 +1348,101 @@ 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, + namespaces: settings.describe({ redactSecrets: true }).map(namespaceView), + })) + }, + update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch), + replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section), + }, + + 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: { + providers(request) { + const registered = ctx.llm.listProviders() + const active = new Set(registered.map(provider => provider.id)) + const directory = ctx.llm.listConfigurableProviders() + const declared = new Set(directory.map(entry => entry.provider)) + const views = directory.map(entry => ({ + provider: entry.provider, + displayName: entry.displayName, + settingsNs: entry.settingsNs, + settingsPath: [...entry.settingsPath], + active: active.has(entry.provider), + })) + // Routes registered without a directory declaration still appear — + // they exist and serve models — just with no settings address. + for (const provider of registered) { + if (declared.has(provider.id)) continue + views.push({ + provider: provider.id, + displayName: provider.name, + settingsNs: '', + settingsPath: [], + active: true, + }) + } + return Promise.resolve(ok(request, { providers: views })) + }, + + async models(request) { + return ok(request, await buildModelCatalog(ctx)) + }, + }, + events: { mux(_request, signal) { const queue = new FrameQueue>() @@ -1392,6 +1570,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ctx.on('commands/change', () => { queue.push(frame({ type: 'host/commands-changed' })) }), + ctx.on('settings/updated', (ns) => { + queue.push(frame({ type: 'host/settings-changed', ns: String(ns) })) + }), + ctx.on('credentials/updated', (ref) => { + queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) })) + }), + ctx.on('llm/adapters-updated', () => { + queue.push(frame({ type: 'host/models-changed' })) + }), ] return queue.iterate(signal, () => { for (const dispose of disposers) dispose() }) }, diff --git a/packages/host/apiproxy/src/api/credentials.schema.ts b/packages/host/apiproxy/src/api/credentials.schema.ts new file mode 100644 index 0000000000..b0ce3fe01b --- /dev/null +++ b/packages/host/apiproxy/src/api/credentials.schema.ts @@ -0,0 +1,48 @@ +/** + * 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 new file mode 100644 index 0000000000..b5b59ca059 --- /dev/null +++ b/packages/host/apiproxy/src/api/credentials.ts @@ -0,0 +1,44 @@ +/** + * 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/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 4e729b4c41..584f2b88f6 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -58,5 +58,8 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), z.object({ type: z.literal('host/commands-changed') }), + z.object({ type: z.literal('host/settings-changed'), ns: z.string() }), + z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }), + z.object({ type: z.literal('host/models-changed') }), z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 9f56fc1dd5..cd1407bd4d 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -112,4 +112,23 @@ export type HostFrame = * background rather than diffing. */ | { type: 'host/commands-changed' } + /** + * One settings namespace's resolved value changed (`settings/updated` + * passthrough) — an RPC write, an external `settings.yaml` edit, or a + * provider reload all converge here. Clients refetch `settings.describe`; + * values never ride the frame (they would need redaction and can go stale). + */ + | { type: 'host/settings-changed'; ns: string } + /** + * One credential reference's state changed (`credentials/updated` + * passthrough): a set/unset over this wire or an external `.env` edit. + * The ref is an environment-variable NAME — never a value. + */ + | { type: 'host/credentials-changed'; ref: string } + /** + * The provider topology changed (`llm/adapters-updated` passthrough): + * routes registered or dropped, or the configurable directory moved. Pure + * invalidation: clients refetch `llm.providers`/`llm.models`/`session.models`. + */ + | { type: 'host/models-changed' } | { type: 'stream/error'; error: RpcError } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 451655d114..54fe1eec01 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -11,6 +11,9 @@ import type { CommandsApi } from './commands.ts' import type { SkillsApi } from './skills.ts' import type { EventsApi } from './events.ts' import type { GoalsApi } from './goals.ts' +import type { SettingsApi } from './settings.ts' +import type { CredentialsApi } from './credentials.ts' +import type { LlmApi } from './llm.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' /** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */ @@ -22,6 +25,9 @@ export interface ApiProxy { skills: SkillsApi events: EventsApi goals: GoalsApi + settings: SettingsApi + credentials: CredentialsApi + llm: LlmApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise } @@ -37,6 +43,9 @@ export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { GoalsApi, GoalId, GoalRef } from './goals.ts' +export type { SettingsApi, SettingsNamespaceView, SettingsSecretView } from './settings.ts' +export type { CredentialsApi, CredentialView } from './credentials.ts' +export type { ConfigurableProviderView, LlmApi } from './llm.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts new file mode 100644 index 0000000000..4d86302c9f --- /dev/null +++ b/packages/host/apiproxy/src/api/llm.schema.ts @@ -0,0 +1,36 @@ +/** + * llm domain zod schemas (names derived from map keys: llmProvidersRequestSchema / + * llmProvidersValueSchema / llmModelsRequestSchema / llmModelsValueSchema). + */ + +import { z } from 'zod' +import type { RequestPayload, ResponseValue } from './rpc-map.ts' +import type { Wire } from './rpc.schema.ts' +import type { ConfigurableProviderView } from './llm.ts' +import { modelCatalogFailureSchema, modelProviderGroupSchema } from './sessions.schema.ts' + +/** ConfigurableProviderView row of llm.providers. */ +export const configurableProviderViewSchema = z.object({ + provider: z.string().min(1), + displayName: z.string().min(1), + settingsNs: z.string(), + settingsPath: z.array(z.string()), + active: z.boolean(), +}) satisfies z.ZodType> + +/** llm.providers request payload. */ +export const llmProvidersRequestSchema = z.object({}) satisfies z.ZodType>> + +/** llm.providers response value. */ +export const llmProvidersValueSchema = z.object({ + providers: z.array(configurableProviderViewSchema), +}) satisfies z.ZodType>> + +/** llm.models request payload. */ +export const llmModelsRequestSchema = z.object({}) satisfies z.ZodType>> + +/** llm.models response value. */ +export const llmModelsValueSchema = z.object({ + groups: z.array(modelProviderGroupSchema), + failures: z.array(modelCatalogFailureSchema), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts new file mode 100644 index 0000000000..59a21cf12a --- /dev/null +++ b/packages/host/apiproxy/src/api/llm.ts @@ -0,0 +1,43 @@ +/** + * llm domain contract: host-scoped provider topology for configuration + * surfaces. `llm.providers` merges the configurable-provider directory + * (which providers CAN be configured, and where their settings live) with the + * live route registry; `llm.models` is the session-independent model catalog + * (`session.models` minus the per-session current/unlisted logic). Both + * invalidate on the `host/models-changed` frame. + */ + +import type { RpcRequest, RpcResponse } from './rpc.ts' +import type { ModelCatalogFailure, ModelProviderGroup } from './sessions.ts' + +/** Wire view of one configurable provider. */ +export interface ConfigurableProviderView { + /** Provider route key (`deepseek-official`, `openai`, …). */ + provider: string + /** Human-readable name for configuration surfaces. */ + displayName: string + /** Settings namespace whose section configures this provider. */ + settingsNs: string + /** Path from that section's root to the provider's profile object (empty = whole section). */ + settingsPath: string[] + /** Whether the route is currently registered (its models are requestable). */ + active: boolean +} + +/** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */ +export interface LlmApi { + /** + * List every configurable provider with its live/dormant state, in + * directory declaration order. Routes registered outside the directory + * (an adapter that never declared configurability) are appended with their + * registration identity and no settings address. + */ + providers(request: RpcRequest<{}>): Promise> + + /** + * Host-scoped model catalog over every registered provider route: the + * settings surface's models view, needing no session. Per-provider listing + * failures ride `failures` without failing the sound groups. + */ + models(request: RpcRequest<{}>): Promise> +} diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index bedd6f4b1f..d771b8c16f 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -10,6 +10,9 @@ import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' import type { SkillsApi } from './skills.ts' import type { GoalsApi } from './goals.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' /** @@ -42,6 +45,14 @@ export interface RpcMethodMap { 'goal.resume': GoalsApi['resume'] 'goal.complete': GoalsApi['complete'] 'goal.clear': GoalsApi['clear'] + 'settings.describe': SettingsApi['describe'] + 'settings.update': SettingsApi['update'] + 'settings.replace': SettingsApi['replace'] + 'credentials.describe': CredentialsApi['describe'] + 'credentials.set': CredentialsApi['set'] + 'credentials.unset': CredentialsApi['unset'] + 'llm.providers': LlmApi['providers'] + 'llm.models': LlmApi['models'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 1e13645eae..77dac7de50 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -45,6 +45,8 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), + z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), + z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), 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 ce6b8186d3..33ac538337 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -44,6 +44,13 @@ export interface RpcErrorDetailsMap { 'command-error': {} /** A leading-/ prompt named no registered command; the message names the token. */ 'unknown-command': {} + /** + * 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 credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */ + 'credential-rejected': { ref: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/settings.schema.ts b/packages/host/apiproxy/src/api/settings.schema.ts new file mode 100644 index 0000000000..105573b109 --- /dev/null +++ b/packages/host/apiproxy/src/api/settings.schema.ts @@ -0,0 +1,53 @@ +/** + * settings domain zod schemas (names derived from map keys: settingsDescribeRequestSchema / + * settingsDescribeValueSchema / settingsUpdate* / settingsReplace*). + */ + +import { z } from 'zod' +import type { RequestPayload, ResponseValue } from './rpc-map.ts' +import type { Wire } from './rpc.schema.ts' +import type { SettingsNamespaceView, 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), +}) 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(), + namespaces: z.array(settingsNamespaceViewSchema), +}) satisfies z.ZodType>> + +/** settings.update request payload. */ +export const settingsUpdateRequestSchema = z.object({ + ns: z.string().min(1), + patch: z.record(z.string(), z.unknown()), +}) 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()), +}) 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 new file mode 100644 index 0000000000..27e4d11156 --- /dev/null +++ b/packages/host/apiproxy/src/api/settings.ts @@ -0,0 +1,63 @@ +/** + * 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. + */ + +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[] +} + +/** 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. `writable: false` + * (read-only provider) tells the client to disable every write control. + */ + describe(request: RpcRequest<{}>): 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 }>): 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 }>): Promise> +} diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index fab8166c3f..ca74ec6550 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -42,6 +42,13 @@ import { goalCompleteValueSchema, goalClearValueSchema, } from '../api/goals.schema.ts' +import { + settingsDescribeValueSchema, settingsReplaceValueSchema, settingsUpdateValueSchema, +} from '../api/settings.schema.ts' +import { + credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema, +} from '../api/credentials.schema.ts' +import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' /** * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary @@ -99,6 +106,20 @@ export interface IApiClient { complete(payload: RequestPayload<'goal.complete'>, signal?: AbortSignal): Promise>> clear(payload: RequestPayload<'goal.clear'>, signal?: AbortSignal): Promise>> } + settings: { + describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise>> + update(payload: RequestPayload<'settings.update'>, signal?: AbortSignal): Promise>> + replace(payload: RequestPayload<'settings.replace'>, 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>> + models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise>> + } /** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */ respond(message: ClientResponse, signal?: AbortSignal): Promise } @@ -132,6 +153,14 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('goal.clear', payload, signal), } + readonly settings: IApiClient['settings'] = { + describe: (payload, signal) => this.callUnary('settings.describe', payload, signal), + update: (payload, signal) => this.callUnary('settings.update', payload, signal), + replace: (payload, signal) => this.callUnary('settings.replace', 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'] = { + providers: (payload, signal) => this.callUnary('llm.providers', payload, signal), + models: (payload, signal) => this.callUnary('llm.models', payload, signal), + } + readonly events: IApiClient['events'] = { mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen), host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 31ed3a8dea..dd32fb4351 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -43,6 +43,13 @@ import { goalCompleteRequestSchema, goalClearRequestSchema, } from '../api/goals.schema.ts' +import { + settingsDescribeRequestSchema, settingsReplaceRequestSchema, settingsUpdateRequestSchema, +} from '../api/settings.schema.ts' +import { + credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema, +} from '../api/credentials.schema.ts' +import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' /** * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a @@ -85,6 +92,14 @@ const UNARY_ROUTES: UnaryRoutes = { 'goal.resume': { schema: goalResumeRequestSchema, invoke: (api, r) => api.goals.resume(r) }, 'goal.complete': { schema: goalCompleteRequestSchema, invoke: (api, r) => api.goals.complete(r) }, 'goal.clear': { schema: goalClearRequestSchema, invoke: (api, r) => api.goals.clear(r) }, + 'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) }, + 'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) }, + 'settings.replace': { schema: settingsReplaceRequestSchema, invoke: (api, r) => api.settings.replace(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) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index c9a4e3afb9..fdf944c161 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -59,6 +59,9 @@ export class ApiProxyService extends Service implements ApiProxy { readonly commands: ApiProxy['commands'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] + readonly settings: ApiProxy['settings'] + readonly credentials: ApiProxy['credentials'] + readonly llm: ApiProxy['llm'] readonly events: ApiProxy['events'] readonly respond: ApiProxy['respond'] @@ -77,6 +80,9 @@ export class ApiProxyService extends Service implements ApiProxy { this.commands = api.commands this.goals = api.goals this.skills = api.skills + this.settings = api.settings + this.credentials = api.credentials + this.llm = api.llm this.events = api.events // createApiProxy returns closures (no `this` capture); bind only satisfies // the unbound-method lint without changing behavior. diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts new file mode 100644 index 0000000000..2562de5785 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -0,0 +1,349 @@ +/** + * Settings/credentials/llm RPC domains and their host-stream frames over + * createApiProxy: layered redacted describe, write-path rejection mapping, + * value-free credential views, the directory/live-route merge, and the three + * invalidation frames (settings/credentials/models changed). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import { Settings, settingsNamespace } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { Credentials } from '@deepseek-ai/dsh-credentials' +import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials' +import type { HostFrame } from '../src/api/index.ts' +import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' +import { RpcId } from '../src/api/rpc.ts' +import { createApiProxy } from '../src/api-proxy.ts' + +const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } + +let nextRpc = 1 +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } +} + +function expectOk(response: RpcResponse): T { + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + return response.result.value +} + +function expectErr(response: RpcResponse): { code: string; message: string; details: unknown } { + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + return response.result.error +} + +/** In-memory settings provider: the seam base class owns all tested behavior. */ +class MemorySettings extends Settings { + doc: Record + + constructor(ctx: ConstructorParameters[0], options?: { doc?: Record; readOnly?: boolean }) { + super(ctx) + this.doc = structuredClone(options?.doc ?? {}) + this.readOnly = options?.readOnly ?? false + } + + private readonly readOnly: boolean + + get writable(): boolean { + return !this.readOnly + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc[ns] = structuredClone(section) + return Promise.resolve() + } +} + +/** In-memory credential provider with an env-shadow double for the rejection path. */ +class MemoryCredentials extends Credentials { + private readonly values = new Map() + + constructor(ctx: ConstructorParameters[0], options?: { shadowed?: string[] }) { + super(ctx) + this.shadowed = new Set(options?.shadowed ?? []) + } + + private readonly shadowed: Set + + resolve(ref: CredentialRef): Promise { + if (this.shadowed.has(ref)) return Promise.resolve({ value: 'from-env', source: 'env' }) + const value = this.values.get(ref) + return Promise.resolve(value === undefined ? undefined : { value, source: 'file' }) + } + + describe(ref: CredentialRef): Promise { + if (this.shadowed.has(ref)) return Promise.resolve({ configured: true, source: 'env', writable: false }) + const configured = this.values.has(ref) + return Promise.resolve({ configured, ...configured ? { source: 'file' } : {}, writable: true }) + } + + set(ref: CredentialRef, value: string): Promise { + if (this.shadowed.has(ref)) { + return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`)) + } + this.values.set(ref, value) + this.ctx.emit('credentials/updated', ref) + return Promise.resolve() + } + + unset(ref: CredentialRef): Promise { + if (this.shadowed.has(ref)) { + return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`)) + } + this.values.delete(ref) + this.ctx.emit('credentials/updated', ref) + return Promise.resolve() + } +} + +/** Catalog-serving adapter stub for the llm.models path. */ +class CatalogAdapter extends LlmAdapter { + constructor(private readonly name: string, private readonly models: readonly string[]) { + super() + } + + override providerInfo(provider: string): LlmProviderInfo { + return { id: provider, name: this.name } + } + + override listModels(provider: string): Promise { + return Promise.resolve(this.models.map(id => ({ provider, id, name: id }))) + } + + + async * stream(_options: GenerateOptions): AsyncIterable { + throw new Error('not exercised') + } +} + +class BrokenCatalogAdapter extends CatalogAdapter { + override listModels(): Promise { + return Promise.reject(new Error('catalog backend down')) + } +} + +const NS = settingsNamespace('llm-deepseek') + +const AdapterConfig = z.object({ + apiKey: z.string().role('secret'), + apiKeyEnv: z.string().default('DEEPSEEK_API_KEY'), + baseURL: z.string(), +}) + +async function harness(options?: { + settings?: false | { doc?: Record; readOnly?: boolean } + credentials?: false | { shadowed?: string[] } +}): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LlmService) + if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings) + if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials) + // Host-stream opener reads the committed-workspace baseline; the stub + // suffices — the real workspace composition is api-proxy-workspace.spec's. + ctx.provide('workspace', { list: () => [] } as never) + return ctx +} + +/** Drain `count` host frames matching `types`, then abort the stream. */ +async function collectHost( + api: ReturnType, + types: string[], + count: number, + run: () => Promise, +): Promise { + const abort = new AbortController() + const frames: HostFrame[] = [] + const stream = api.events.host(request({}), abort.signal) + const consume = (async () => { + for await (const frame of stream) { + if (!types.includes(frame.payload.type)) continue + frames.push(frame.payload) + if (frames.length >= count) abort.abort() + } + })() + await run() + await consume + return frames +} + +describe('settings domain', () => { + it('reports an actionable error when no settings provider is mounted', async () => { + const ctx = await harness({ settings: false }) + const api = createApiProxy(ctx, DEFAULTS) + const error = expectErr(await api.settings.describe(request({}))) + expect(error.code).toBe('internal') + expect(error.message).toContain('dsh-settings-local') + }) + + it('describes layered redacted namespaces with their secret slots', async () => { + const ctx = await harness({ settings: { doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } } } }) + 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.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('updates the user layer, answers with the new redacted view, and broadcasts the frame', async () => { + const ctx = await harness() + ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) + const api = createApiProxy(ctx, DEFAULTS) + const frames = await collectHost(api, ['host/settings-changed'], 1, async () => { + const view = expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { apiKey: 'sk-new', baseURL: 'https://next' } }))) + expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://next' }) + expect(view.user).toEqual({ baseURL: 'https://next' }) + expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }]) + expect(JSON.stringify(view)).not.toContain('sk-new') + }) + expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'llm-deepseek' }]) + }) + + it('replace resets the user layer wholesale', async () => { + const ctx = await harness({ settings: { doc: { 'llm-deepseek': { baseURL: 'https://user' } } } }) + ctx.settings.register(NS, AdapterConfig) + const api = createApiProxy(ctx, DEFAULTS) + const view = expectOk(await api.settings.replace(request({ ns: 'llm-deepseek', section: {} }))) + expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY' }) + expect(view.user).toEqual({}) + }) + + it.each([ + ['an invalid namespace name', 'Not A Namespace', {}], + ['an unregistered namespace', 'unknown-ns', {}], + ['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('maps a read-only provider refusal onto the same rejection', async () => { + const ctx = await harness({ settings: { readOnly: true } }) + ctx.settings.register(NS, AdapterConfig) + const api = createApiProxy(ctx, DEFAULTS) + const value = expectOk(await api.settings.describe(request({}))) + expect(value.writable).toBe(false) + const error = expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: {} }))) + expect(error.code).toBe('settings-rejected') + expect(error.message).toContain('read-only') + }) +}) + +describe('credentials domain', () => { + it('reports an actionable error when no credential provider is mounted', async () => { + const ctx = await harness({ credentials: false }) + const api = createApiProxy(ctx, DEFAULTS) + const error = expectErr(await api.credentials.describe(request({ refs: ['A'] }))) + expect(error.code).toBe('internal') + expect(error.message).toContain('dsh-credentials-local') + }) + + it('describes value-free views and flips state through set/unset with frames', async () => { + const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) + const before = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] }))) + expect(before.credentials).toEqual({ OPENAI_API_KEY: { configured: false, writable: true } }) + const frames = await collectHost(api, ['host/credentials-changed'], 2, async () => { + expectOk(await api.credentials.set(request({ ref: 'OPENAI_API_KEY', value: 'sk-secret' }))) + const after = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] }))) + expect(after.credentials).toEqual({ OPENAI_API_KEY: { configured: true, source: 'file', writable: true } }) + expect(JSON.stringify(after)).not.toContain('sk-secret') + expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' }))) + }) + expect(frames).toEqual([ + { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' }, + { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' }, + ]) + }) + + it('maps a shadowed write onto credential-rejected for set and unset alike', async () => { + const ctx = await harness({ credentials: { shadowed: ['DEEPSEEK_API_KEY'] } }) + const api = createApiProxy(ctx, DEFAULTS) + const described = expectOk(await api.credentials.describe(request({ refs: ['DEEPSEEK_API_KEY'] }))) + expect(described.credentials['DEEPSEEK_API_KEY']).toEqual({ configured: true, source: 'env', writable: false }) + const setError = expectErr(await api.credentials.set(request({ ref: 'DEEPSEEK_API_KEY', value: 'x' }))) + expect(setError.code).toBe('credential-rejected') + expect(setError.details).toEqual({ ref: 'DEEPSEEK_API_KEY' }) + const unsetError = expectErr(await api.credentials.unset(request({ ref: 'DEEPSEEK_API_KEY' }))) + expect(unsetError.code).toBe('credential-rejected') + }) +}) + +describe('llm domain', () => { + it('merges the configurable directory with live routes and appends undeclared ones', async () => { + const ctx = await harness() + ctx.llm.registerConfigurableProviders([ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, + ]) + ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash'])) + ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1'])) + const api = createApiProxy(ctx, DEFAULTS) + const value = expectOk(await api.llm.providers(request({}))) + expect(value.providers).toEqual([ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false }, + { provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true }, + ]) + }) + + it('serves the host-scoped catalog with per-provider failures contained', async () => { + const ctx = await harness() + ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash', 'deepseek-v4-pro'])) + ctx.llm.registerAdapter(['broken'], new BrokenCatalogAdapter('Broken', [])) + const api = createApiProxy(ctx, DEFAULTS) + const value = expectOk(await api.llm.models(request({}))) + expect(value.groups).toEqual([{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [ + { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' }, + { id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, + ], + }]) + expect(value.failures).toEqual([{ id: 'broken', name: 'Broken', message: 'catalog backend down' }]) + }) + + it('broadcasts host/models-changed at every topology commit point', async () => { + const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) + const frames = await collectHost(api, ['host/models-changed'], 2, async () => { + const dispose = ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [])) + dispose() + return Promise.resolve() + }) + expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }]) + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index d90593e415..4bfb70a55b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -24,6 +24,9 @@ function scriptedApi(overrides: { skills?: Partial events?: Partial goals?: Partial + settings?: Partial + credentials?: Partial + llm?: Partial respond?: ApiProxy['respond'] } = {}): ApiProxy { async function *empty(): AsyncGenerator> { /* no frames */ } @@ -78,6 +81,23 @@ function scriptedApi(overrides: { clear: err, ...overrides.goals, }, + settings: { + describe: r => ok(r, { writable: true, namespaces: [] }), + update: err, + replace: 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, { groups: [], failures: [] }), + ...overrides.llm, + }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), } @@ -87,6 +107,15 @@ function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient { return new InProcessApiClient(toFetchHandler(api), timeoutMs) } +/** Wrap one scripted method to record its invocation into `seen` before responding. */ +function recorderInto(seen: { method: string; payload: unknown }[]) { + return (method: string, respond: (r: RpcRequest

) => Promise>) => + (r: RpcRequest

): Promise> => { + seen.push({ method, payload: r.payload }) + return respond(r) + } +} + describe('unary round trip', () => { it('carries payload out and value back through the full wire form', async () => { let seen: RpcRequest<{ cursor?: string }> | undefined @@ -424,11 +453,7 @@ describe('goals unary surface', () => { it('round-trips every goal method with its own payload and value shape', async () => { const seen: { method: string; payload: unknown }[] = [] - const record = (method: string, respond: (r: RpcRequest

) => Promise>) => - (r: RpcRequest

): Promise> => { - seen.push({ method, payload: r.payload }) - return respond(r) - } + const record = recorderInto(seen) const api = scriptedApi({ goals: { create: record('goal.create', r => ok(r, ack)), @@ -542,3 +567,74 @@ describe('envelope tap', () => { expect(batches).toEqual([]) }) }) + +describe('config unary surface', () => { + it('round-trips every settings/credentials/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 }], + } + const providerRow = { + provider: 'openai', + displayName: 'openai', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'openai'], + active: false, + } + 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, namespaces: [view] })), + update: record('settings.update', r => ok(r, view)), + replace: record('settings.replace', 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] })), + models: record('llm.models', r => ok(r, { groups: [group], failures: [] })), + }, + }) + const c = client(api) + + const described = await c.settings.describe({}) + expect(described.result).toEqual({ ok: true, value: { writable: true, namespaces: [view] } }) + 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 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({}) + expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } }) + + expect(seen.map(call => call.method)).toEqual([ + 'settings.describe', 'settings.update', 'settings.replace', + 'credentials.describe', 'credentials.set', 'credentials.unset', + 'llm.providers', 'llm.models', + ]) + expect(seen[1]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } }) + expect(seen[4]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' }) + }) + + 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 149fe0231a..aca3035186 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -155,6 +155,36 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } }, }, + settings: { + async describe(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, namespaces: [] } } } + }, + 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 } } } } + }, + }, + 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: { + async providers(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { providers: [] } } } + }, + async models(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { groups: [], failures: [] } } } + }, + }, events: { mux: (_request, signal) => stream(muxFrames, signal), host: (_request, signal) => stream(hostFrames, signal), diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 26a0af3636..5617d2e1e2 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -11,6 +11,12 @@ { "path": "../../goal/goal" }, + { + "path": "../../settings/settings" + }, + { + "path": "../../credentials/credentials" + }, { "path": "../../../vendor/cordis" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a6c4b7544..210b112588 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2861,6 +2861,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../ui/commands + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal @@ -2879,6 +2882,9 @@ importers: '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session-projection/session-projection-cache + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill