1
0
Эх сурвалжийг харах

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.
Yichen Jiang 1 сар өмнө
parent
commit
191067559e
30 өөрчлөгдсөн 1349 нэмэгдсэн , 102 устгасан
  1. 2 0
      packages/client/connection/src/client/api.ts
  2. 85 26
      packages/client/connection/src/client/fixture.ts
  3. 2 0
      packages/client/connection/src/client/index.ts
  4. 18 2
      packages/client/connection/src/index.ts
  5. 17 0
      packages/client/connection/tests/fake-api.ts
  6. 52 1
      packages/client/connection/tests/node-half.spec.ts
  7. 17 0
      packages/client/runtime/tests/fake-api.ts
  8. 3 1
      packages/host/apiproxy/README.md
  9. 2 0
      packages/host/apiproxy/package.json
  10. 254 67
      packages/host/apiproxy/src/api-proxy.ts
  11. 48 0
      packages/host/apiproxy/src/api/credentials.schema.ts
  12. 44 0
      packages/host/apiproxy/src/api/credentials.ts
  13. 3 0
      packages/host/apiproxy/src/api/events.schema.ts
  14. 19 0
      packages/host/apiproxy/src/api/events.ts
  15. 9 0
      packages/host/apiproxy/src/api/index.ts
  16. 36 0
      packages/host/apiproxy/src/api/llm.schema.ts
  17. 43 0
      packages/host/apiproxy/src/api/llm.ts
  18. 11 0
      packages/host/apiproxy/src/api/rpc-map.ts
  19. 2 0
      packages/host/apiproxy/src/api/rpc.schema.ts
  20. 7 0
      packages/host/apiproxy/src/api/rpc.ts
  21. 53 0
      packages/host/apiproxy/src/api/settings.schema.ts
  22. 63 0
      packages/host/apiproxy/src/api/settings.ts
  23. 46 0
      packages/host/apiproxy/src/fetch/client.ts
  24. 15 0
      packages/host/apiproxy/src/fetch/handler.ts
  25. 6 0
      packages/host/apiproxy/src/index.ts
  26. 349 0
      packages/host/apiproxy/tests/api-proxy-config.spec.ts
  27. 101 5
      packages/host/apiproxy/tests/client-handler.spec.ts
  28. 30 0
      packages/host/apiproxy/tests/fetch-carrier.spec.ts
  29. 6 0
      packages/host/apiproxy/tsconfig.json
  30. 6 0
      pnpm-lock.yaml

+ 2 - 0
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 {

+ 85 - 26
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<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)
     }
   }
 

+ 2 - 0
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'
 

+ 18 - 2
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')

+ 17 - 0
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
 

+ 52 - 1
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<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 - 0
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
 

+ 3 - 1
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.

+ 2 - 0
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:^",

+ 254 - 67
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<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() })
       },

+ 48 - 0
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<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'>>>

+ 44 - 0
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<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<{}>>
+}

+ 3 - 0
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<HostFrame>

+ 19 - 0
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 }

+ 9 - 0
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<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'
 

+ 36 - 0
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<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 - 0
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<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 - 0
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). */

+ 2 - 0
packages/host/apiproxy/src/api/rpc.schema.ts

@@ -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 - 0
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': {}
 }
 

+ 53 - 0
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<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'>>>

+ 63 - 0
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<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>>
+}

+ 46 - 0
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<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),

+ 15 - 0
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). */

+ 6 - 0
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.

+ 349 - 0
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<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' }])
+  })
+})

+ 101 - 5
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -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')
+  })
+})

+ 30 - 0
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),

+ 6 - 0
packages/host/apiproxy/tsconfig.json

@@ -11,6 +11,12 @@
     {
       "path": "../../goal/goal"
     },
+    {
+      "path": "../../settings/settings"
+    },
+    {
+      "path": "../../credentials/credentials"
+    },
     {
       "path": "../../../vendor/cordis"
     },

+ 6 - 0
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