feat(apiproxy): settings/credentials/llm wire domains, frames, and write guard

Eight compiler-locked methods: settings.describe/update/replace serve
redacted layered namespace views (secrets structurally absent from every
layer, write-only in the update direction) and fold seam refusals into
settings-rejected; credentials.describe/set/unset expose value-free views
with credential-rejected on shadowed writes; llm.providers merges the
configurable directory with live routes and llm.models claims the
host-scoped catalog reservation through the buildModelCatalog extraction
session.models now shares. Three HostFrame invalidations bridge the seam
events (host/settings-changed, host/credentials-changed,
host/models-changed), and the connection route generalizes the native-
dialog check into a privileged-method set covering all four writes. The
fixture and both fake clients grow the same face.
This commit is contained in:
Yichen Jiang
2026-07-30 00:13:12 +08:00
parent a5c8136cb3
commit 191067559e
30 changed files with 1349 additions and 102 deletions
@@ -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 {
@@ -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<string, string>()
const nextTurn = new Map<SessionId, number>([[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<RpcReceipt> {
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)
}
}
@@ -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'
+18 -2
View File
@@ -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')
@@ -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
@@ -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<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
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()
})
})
+17
View File
@@ -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
+3 -1
View File
@@ -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.
+2
View File
@@ -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:^",
+254 -67
View File
@@ -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<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
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<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
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<unknown>,
ns: string,
mode: 'update' | 'replace',
section: object,
): Promise<RpcResponse<SettingsNamespaceView>> {
const settings = ctx.get('settings')
if (settings === undefined) return err(request, settingsAbsent())
const rejected = (error: unknown): RpcResponse<SettingsNamespaceView> => 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<RpcRequest<MuxFrame>>()
@@ -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() })
},
@@ -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<Wire<CredentialView>>
/** credentials.describe request payload. */
export const credentialsDescribeRequestSchema = z.object({
refs: z.array(credentialRefNameSchema).max(64),
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.describe'>>>
/** credentials.describe response value. */
export const credentialsDescribeValueSchema = z.object({
credentials: z.record(z.string(), credentialViewSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'credentials.describe'>>>
/** credentials.set request payload: the one direction a value crosses this wire. */
export const credentialsSetRequestSchema = z.object({
ref: credentialRefNameSchema,
value: z.string().min(1),
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.set'>>>
/** credentials.set response value. */
export const credentialsSetValueSchema = z.object({}) satisfies z.ZodType<Wire<ResponseValue<'credentials.set'>>>
/** credentials.unset request payload. */
export const credentialsUnsetRequestSchema = z.object({
ref: credentialRefNameSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'credentials.unset'>>>
/** credentials.unset response value. */
export const credentialsUnsetValueSchema = z.object({}) satisfies z.ZodType<Wire<ResponseValue<'credentials.unset'>>>
@@ -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<RpcResponse<{ credentials: Record<string, CredentialView> }>>
/**
* Store one credential value in the writable layer. Rejected with
* `credential-rejected` while a read-only layer (the live environment)
* shadows the reference — the write would otherwise appear to succeed while
* resolution keeps returning the shadowing value.
*/
set(request: RpcRequest<{ ref: string; value: string }>): Promise<RpcResponse<{}>>
/**
* Remove one credential from the writable layer; same shadowing rejection
* as `set`. Unsetting an absent reference succeeds (idempotent).
*/
unset(request: RpcRequest<{ ref: string }>): Promise<RpcResponse<{}>>
}
@@ -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<HostFrame>
+19
View File
@@ -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 }
+9
View File
@@ -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<RpcReceipt>
}
@@ -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'
@@ -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<Wire<ConfigurableProviderView>>
/** llm.providers request payload. */
export const llmProvidersRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'llm.providers'>>>
/** llm.providers response value. */
export const llmProvidersValueSchema = z.object({
providers: z.array(configurableProviderViewSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'llm.providers'>>>
/** llm.models request payload. */
export const llmModelsRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'llm.models'>>>
/** llm.models response value. */
export const llmModelsValueSchema = z.object({
groups: z.array(modelProviderGroupSchema),
failures: z.array(modelCatalogFailureSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'llm.models'>>>
+43
View File
@@ -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<RpcResponse<{ providers: ConfigurableProviderView[] }>>
/**
* 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<RpcResponse<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }>>
}
+11
View File
@@ -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). */
@@ -45,6 +45,8 @@ export const rpcErrorSchema: z.ZodType<RpcError> = 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<RpcError>
+7
View File
@@ -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': {}
}
@@ -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<Wire<SettingsSecretView>>
/** SettingsNamespaceView row of settings.describe and the write responses. */
export const settingsNamespaceViewSchema = z.object({
ns: z.string().min(1),
schema: z.unknown(),
value: z.unknown(),
base: z.unknown().optional(),
user: z.unknown().optional(),
applies: z.union([z.literal('live'), z.literal('restart')]),
secrets: z.array(settingsSecretViewSchema),
}) satisfies z.ZodType<Wire<SettingsNamespaceView>>
/** settings.describe request payload. */
export const settingsDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'settings.describe'>>>
/** settings.describe response value. */
export const settingsDescribeValueSchema = z.object({
writable: z.boolean(),
namespaces: z.array(settingsNamespaceViewSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'settings.describe'>>>
/** settings.update request payload. */
export const settingsUpdateRequestSchema = z.object({
ns: z.string().min(1),
patch: z.record(z.string(), z.unknown()),
}) satisfies z.ZodType<Wire<RequestPayload<'settings.update'>>>
/** settings.update response value: the namespace's new redacted view. */
export const settingsUpdateValueSchema = settingsNamespaceViewSchema satisfies z.ZodType<Wire<ResponseValue<'settings.update'>>>
/** settings.replace request payload. */
export const settingsReplaceRequestSchema = z.object({
ns: z.string().min(1),
section: z.record(z.string(), z.unknown()),
}) satisfies z.ZodType<Wire<RequestPayload<'settings.replace'>>>
/** settings.replace response value. */
export const settingsReplaceValueSchema = settingsNamespaceViewSchema satisfies z.ZodType<Wire<ResponseValue<'settings.replace'>>>
@@ -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<RpcResponse<{ writable: boolean; namespaces: SettingsNamespaceView[] }>>
/**
* 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<RpcResponse<SettingsNamespaceView>>
/**
* Replace one namespace's user section wholesale — the removal/reset path a
* merge cannot express (`section: {}` resets to composition defaults). Keys
* absent from `section` are dropped, secrets included: a client must first
* fold the descriptor's `user` layer (and re-supply any secret it wants to
* keep) or accept the reset.
*/
replace(request: RpcRequest<{ ns: string; section: object }>): Promise<RpcResponse<SettingsNamespaceView>>
}
@@ -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<RpcResponse<ResponseValue<'goal.complete'>>>
clear(payload: RequestPayload<'goal.clear'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.clear'>>>
}
settings: {
describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.describe'>>>
update(payload: RequestPayload<'settings.update'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.update'>>>
replace(payload: RequestPayload<'settings.replace'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.replace'>>>
}
credentials: {
describe(payload: RequestPayload<'credentials.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.describe'>>>
set(payload: RequestPayload<'credentials.set'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.set'>>>
unset(payload: RequestPayload<'credentials.unset'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'credentials.unset'>>>
}
llm: {
providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.providers'>>>
models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.models'>>>
}
/** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
}
@@ -132,6 +153,14 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'goal.resume': goalResumeValueSchema,
'goal.complete': goalCompleteValueSchema,
'goal.clear': goalClearValueSchema,
'settings.describe': settingsDescribeValueSchema,
'settings.update': settingsUpdateValueSchema,
'settings.replace': settingsReplaceValueSchema,
'credentials.describe': credentialsDescribeValueSchema,
'credentials.set': credentialsSetValueSchema,
'credentials.unset': credentialsUnsetValueSchema,
'llm.providers': llmProvidersValueSchema,
'llm.models': llmModelsValueSchema,
}
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
@@ -360,6 +389,23 @@ export abstract class AbstractApiClient implements IApiClient {
clear: (payload, signal) => 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),
@@ -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). */
+6
View File
@@ -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.
@@ -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<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
}
function expectOk<T>(response: RpcResponse<T>): T {
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
return response.result.value
}
function expectErr<T>(response: RpcResponse<T>): { code: string; message: string; details: unknown } {
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
return response.result.error
}
/** In-memory settings provider: the seam base class owns all tested behavior. */
class MemorySettings extends Settings {
doc: Record<string, unknown>
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { doc?: Record<string, unknown>; 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<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc[ns] = structuredClone(section)
return Promise.resolve()
}
}
/** In-memory credential provider with an env-shadow double for the rejection path. */
class MemoryCredentials extends Credentials {
private readonly values = new Map<string, string>()
constructor(ctx: ConstructorParameters<typeof Credentials>[0], options?: { shadowed?: string[] }) {
super(ctx)
this.shadowed = new Set(options?.shadowed ?? [])
}
private readonly shadowed: Set<string>
resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
if (this.shadowed.has(ref)) return Promise.resolve({ value: 'from-env', source: 'env' })
const value = this.values.get(ref)
return Promise.resolve(value === undefined ? undefined : { value, source: 'file' })
}
describe(ref: CredentialRef): Promise<CredentialInfo> {
if (this.shadowed.has(ref)) return Promise.resolve({ configured: true, source: 'env', writable: false })
const configured = this.values.has(ref)
return Promise.resolve({ configured, ...configured ? { source: 'file' } : {}, writable: true })
}
set(ref: CredentialRef, value: string): Promise<void> {
if (this.shadowed.has(ref)) {
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
}
this.values.set(ref, value)
this.ctx.emit('credentials/updated', ref)
return Promise.resolve()
}
unset(ref: CredentialRef): Promise<void> {
if (this.shadowed.has(ref)) {
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
}
this.values.delete(ref)
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<readonly LlmModelInfo[]> {
return Promise.resolve(this.models.map(id => ({ provider, id, name: id })))
}
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
throw new Error('not exercised')
}
}
class BrokenCatalogAdapter extends CatalogAdapter {
override listModels(): Promise<readonly LlmModelInfo[]> {
return Promise.reject(new Error('catalog backend down'))
}
}
const NS = settingsNamespace('llm-deepseek')
const AdapterConfig = z.object({
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().default('DEEPSEEK_API_KEY'),
baseURL: z.string(),
})
async function harness(options?: {
settings?: false | { doc?: Record<string, unknown>; readOnly?: boolean }
credentials?: false | { shadowed?: string[] }
}): Promise<Context> {
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<typeof createApiProxy>,
types: string[],
count: number,
run: () => Promise<void>,
): Promise<HostFrame[]> {
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' }])
})
})
@@ -24,6 +24,9 @@ function scriptedApi(overrides: {
skills?: Partial<ApiProxy['skills']>
events?: Partial<ApiProxy['events']>
goals?: Partial<ApiProxy['goals']>
settings?: Partial<ApiProxy['settings']>
credentials?: Partial<ApiProxy['credentials']>
llm?: Partial<ApiProxy['llm']>
respond?: ApiProxy['respond']
} = {}): ApiProxy {
async function *empty<F>(): AsyncGenerator<RpcRequest<F>> { /* 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<MuxFrame>(), host: () => empty<HostFrame>(), ...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 <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
(r: RpcRequest<P>): Promise<RpcResponse<V>> => {
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 = <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
(r: RpcRequest<P>): Promise<RpcResponse<V>> => {
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')
})
})
@@ -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),
+6
View File
@@ -11,6 +11,12 @@
{
"path": "../../goal/goal"
},
{
"path": "../../settings/settings"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../../vendor/cordis"
},
+6
View File
@@ -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