diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json new file mode 100644 index 0000000000..ed518756fb --- /dev/null +++ b/packages/api/settings-controller/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-api-settings-controller", + "description": "Remote owner for the configuration surfaces over the settings-domain seams", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/settings-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + } +} diff --git a/packages/api/settings-controller/src/credentials.ts b/packages/api/settings-controller/src/credentials.ts new file mode 100644 index 0000000000..a9db113e35 --- /dev/null +++ b/packages/api/settings-controller/src/credentials.ts @@ -0,0 +1,157 @@ +/** + * Host owner of the `credentials` Remote namespace: the reference half of + * `ctx.credentials` as a browser configuration page reads and writes it. + * + * @module @deepseek-ai/dsh-api-settings-controller/src/credentials.ts + */ + +import { Context } from '@deepseek-ai/cordis' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type { CredentialProvider } from '@deepseek-ai/dsh-credentials' +import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { z } from 'zod' + +/** + * Fan-out bound on one remote `describe` batch. A settings page asks about the + * references its own rows name, so this is far above any real page and still + * keeps one authenticated request from starting unbounded provider work. + */ +const MAX_DESCRIBE_REFS = 64 + +const credentialRefSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/) +const describeRequestSchema = z.object({ + refs: z.array(credentialRefSchema).max(MAX_DESCRIBE_REFS), +}) +const setRequestSchema = z.object({ ref: credentialRefSchema, value: z.string().min(1) }) +const unsetRequestSchema = z.object({ ref: credentialRefSchema }) + +/** Parse the domain constraints that are more specific than generated TypeScript codecs. */ +function parseRequest(method: string, schema: z.ZodType, value: unknown): T { + const parsed = schema.safeParse(value) + if (!parsed.success) { + throw new TypertRemoteFailure({ + code: 'bad-request', + message: `invalid payload for ${method}`, + details: { issues: parsed.error.issues }, + }) + } + return parsed.data +} + +/** + * Copy exactly the fields {@link CredentialInfo} declares. The Gateway returns + * a business result without decoding it, so a provider whose `describe` carried + * extra enumerable properties would otherwise serialize them to the caller. + * @param info - the provider's answer for one reference. + * @returns the same facts with nothing else attached. + */ +function projectCredentialInfo(info: CredentialInfo): CredentialInfo { + return { + configured: info.configured, + ...info.source === undefined ? {} : { source: info.source }, + writable: info.writable, + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host owner of the `credentials` Remote namespace. */ + credentialsController: CredentialsController + } +} + +/** + * Host service backing the generated `ctx.remote.credentials` namespace. It + * carries every wire obligation the credential seam itself does not: the batch + * fan-out bound, the field-by-field view projection, the reference-grammar + * guard, and the refusal mapping. Secret values cross in one direction only — + * no method here returns one. + */ +export class CredentialsController extends TypertRemoteService { + /** @param ctx - Host context where a credential provider may be mounted. */ + constructor(ctx: Context) { + super(ctx, 'credentialsController', { namespace: 'credentials' }) + } + + /** + * Describe several references for one configuration surface. Batched because + * a settings page describes every reference its rows name at once, and one + * round trip keeps those rows from settling separately. + * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`. + * @returns one view per requested name, keyed by that name. + * @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted. + */ + @Remote + async describe(refs: string[]): Promise> { + const request = parseRequest('credentials.describe', describeRequestSchema, { refs }) + const branded = request.refs.map(ref => [ref, credentialRef(ref)] as const) + const credentials = this.provider() + const entries = await Promise.all(branded.map(async ([ref, key]) => + [ref, projectCredentialInfo(await credentials.describe(key))] as const)) + return Object.fromEntries(entries) + } + + /** + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. + * @param ref - reference name to store under. + * @param value - the non-empty secret value. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async set(ref: string, value: string): Promise { + const request = parseRequest('credentials.set', setRequestSchema, { ref, value }) + const branded = credentialRef(request.ref) + const credentials = this.provider() + await this.write(request.ref, () => credentials.set(branded, request.value)) + } + + /** + * Remove one reference from a configuration surface. + * @param ref - reference name to remove. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async unset(ref: string): Promise { + const request = parseRequest('credentials.unset', unsetRequestSchema, { ref }) + const branded = credentialRef(request.ref) + const credentials = this.provider() + await this.write(request.ref, () => credentials.unset(branded)) + } + + /** Resolve the optional provider or report how to supply it. */ + private provider(): CredentialProvider { + const credentials = this.ctx.get('credentials') + if (credentials === undefined) { + throw new TypertRemoteFailure({ + 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: {}, + }) + } + return credentials + } + + /** + * Run one remote write and report every refusal as `credential-rejected` + * carrying the seam's own message: a read-only source shadowing the reference + * is what a configuration surface must show verbatim. Callers brand the + * reference before entering, so a name outside the grammar never reaches this + * path and fails the same way it does on the read side. The details name only + * the reference, so no failure path can carry the value back out. + */ + private async write(ref: string, write: () => Promise): Promise { + try { + await write() + } catch (error: unknown) { + throw new TypertRemoteFailure({ + code: 'credential-rejected', + message: error instanceof Error ? error.message : String(error), + details: { ref }, + }) + } + } +} + +export default CredentialsController diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts new file mode 100644 index 0000000000..e893d06649 --- /dev/null +++ b/packages/api/settings-controller/src/index.ts @@ -0,0 +1,222 @@ +/** + * Host Remote owner for the configuration surfaces over the settings-domain + * seams. Two namespaces: `settings`, the redacted reads and writes of + * `ctx.settings`, owned by the class below; and `credentials`, mounted from + * here as its own plugin. + * + * @module @deepseek-ai/dsh-api-settings-controller + */ + +import { Context } from '@deepseek-ai/cordis' +import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings' +import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings' +import type { + SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView, +} from '@deepseek-ai/dsh-settings/types' +import type { JsonValue } from '@deepseek-ai/dsh-session/types' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { z } from 'zod' +import { CredentialsController } from './credentials.ts' + +export { CredentialsController } from './credentials.ts' +export type * from './types.ts' + +const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) }) + +/** + * Project one redacted descriptor onto its wire view, field by field. The + * Gateway returns a business result without decoding it, so a provider whose + * descriptor carried extra enumerable properties would otherwise serialize them + * to the caller. + * @param descriptor - one descriptor read under `redactSecrets`. + * @returns the same facts with nothing else attached. + */ +function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView { + return { + ns: String(descriptor.ns), + schema: descriptor.schema as JsonValue, + value: descriptor.value as JsonValue, + ...descriptor.base === undefined ? {} : { base: descriptor.base as JsonValue }, + ...descriptor.user === undefined ? {} : { user: descriptor.user as JsonValue }, + applies: descriptor.applies, + secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })), + revision: descriptor.revision, + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host owner of the `settings` Remote namespace. */ + settingsController: SettingsController + } +} + +/** + * Host service backing the generated `ctx.remote.settings` namespace. Every + * remote read uses `redactSecrets: true`, so a `role('secret')` field cannot + * ride a response. Writes expose the settings service's merge, replacement, + * and path-addressed operations, and classify every provider refusal as + * `settings-conflict` or `settings-rejected` with the service's message. + */ +export class SettingsController extends TypertRemoteService { + /** + * Register the settings namespace and mount the credentials namespace beside + * it. Both namespaces stay registered when a provider is absent so calls can + * return the configuration API's actionable missing-provider diagnostic. + * @param ctx - Host context where settings and credential providers may be mounted. + */ + constructor(ctx: Context) { + super(ctx, 'settingsController', { namespace: 'settings' }) + ctx.plugin(CredentialsController) + } + + /** + * Describe every registered namespace for a configuration page: redacted + * layered values plus the serialized schema the page renders its form from. + * @returns provider writability, local-document presence, and one view per namespace. + * @throws TypertRemoteFailure when no settings provider is mounted. + */ + @Remote + describe(): SettingsDescribeValue { + const settings = this.provider() + return { + writable: settings.writable, + hasDocument: settings.documentPath !== undefined, + namespaces: settings.describe({ redactSecrets: true }).map(namespaceView), + } + } + + /** + * Merge a patch into one namespace's stored user section. + * @param ns - namespace key to write. + * @param patch - fields to merge into the user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + update( + ns: string, + patch: Record, + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, 'update', patch, expectedRevision) + } + + /** + * Replace one namespace's stored user section wholesale. + * @param ns - namespace key to write. + * @param section - complete replacement user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + replace( + ns: string, + section: Record, + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, 'replace', section, expectedRevision) + } + + /** + * Apply path-addressed edits to one namespace's user section, resolved against + * the section as stored rather than against whatever the caller last read, + * then answer with that namespace's new redacted view. + * @param ns - namespace key to write. + * @param ops - the edits to apply, in order. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async mutate( + ns: string, + ops: SettingsPathOpView[], + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, 'mutate', ops, expectedRevision) + } + + private async write( + ns: string, + mode: 'update' | 'replace' | 'mutate', + input: Record | SettingsPathOpView[], + expectedRevision: number | undefined, + ): Promise { + const parsed = settingsNamespaceRequestSchema.safeParse({ ns }) + if (!parsed.success) { + throw new TypertRemoteFailure({ + code: 'bad-request', + message: `invalid payload for settings.${mode}`, + details: { issues: parsed.error.issues }, + }) + } + const settings = this.provider() + let branded + try { + // A malformed name can address no registration, so it fails exactly as an + // unregistered one does. + branded = settingsNamespace(parsed.data.ns) + } catch (error: unknown) { + throw rejected(ns, error) + } + try { + if (mode === 'update') await settings.update(branded, input, expectedRevision) + else if (mode === 'replace') await settings.replace(branded, input, expectedRevision) + else await settings.mutate(branded, input as SettingsPathOp[], expectedRevision) + } catch (error: unknown) { + throw rejected(ns, 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. + throw new TypertRemoteFailure({ + code: 'internal', + message: `settings namespace "${ns}" was disposed after the ${mode}`, + details: {}, + }) + } + return namespaceView(descriptor) + } + + /** Resolve the optional provider or report how to supply it. */ + private provider(): SettingsProvider { + const settings = this.ctx.get('settings') + if (settings === undefined) { + throw new TypertRemoteFailure({ + code: 'internal', + message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', + details: {}, + }) + } + return settings + } +} + +/** + * Classify one seam refusal. A stale writer is its own outcome, not a malformed + * request: the client must re-read and re-apply rather than treat the write as + * invalid. + * @param ns - the namespace the write addressed. + * @param error - whatever the seam threw. + * @returns the failure to raise for that refusal. + */ +function rejected(ns: string, error: unknown): TypertRemoteFailure { + if (error instanceof SettingsConflictError) { + return new TypertRemoteFailure({ + code: 'settings-conflict', + message: error.message, + details: { ns, expected: error.expected, actual: error.actual }, + }) + } + return new TypertRemoteFailure({ + code: 'settings-rejected', + message: error instanceof Error ? error.message : String(error), + details: { ns }, + }) +} + +export default SettingsController diff --git a/packages/api/settings-controller/src/invariant.ts b/packages/api/settings-controller/src/invariant.ts new file mode 100644 index 0000000000..a4da3cb426 --- /dev/null +++ b/packages/api/settings-controller/src/invariant.ts @@ -0,0 +1,23 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-settings-controller/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-api-settings-controller' + +/** Cordis companion plugin name. */ +export const name = 'api-settings-controller-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the settings and credential seams own storage and + * update events, while this package only projects their methods onto the wire. + */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts new file mode 100644 index 0000000000..81249e3065 --- /dev/null +++ b/packages/api/settings-controller/src/types.ts @@ -0,0 +1,49 @@ +/** + * Browser-safe failure vocabulary of the configuration surfaces this package + * serves. The redacted views themselves live with their seam in + * `@deepseek-ai/dsh-settings/types`, whose Cordis event declarations already + * register that file for the Client compilation face. + * + * @module @deepseek-ai/dsh-api-settings-controller/types + */ + +/** Stable settings failure details returned by the `settings` namespace. */ +export interface SettingsErrorDetailsMap { + /** + * Every seam refusal that is not a stale write: an unregistered or malformed + * namespace, a read-only provider, schema validation, storage. + */ + 'settings-rejected': { readonly ns: string } + /** + * The stored revision moved after the caller read it. Its own outcome rather + * than an invalid request: the caller must re-read and re-apply. + */ + 'settings-conflict': { readonly ns: string; readonly expected: number; readonly actual: number } +} + +/** Settings business failure carried by a rejected Remote call. */ +export type SettingsError = { + [Code in keyof SettingsErrorDetailsMap]: { + readonly code: Code + readonly message: string + readonly details: SettingsErrorDetailsMap[Code] + } +}[keyof SettingsErrorDetailsMap] + +/** Stable credential failure details returned by the `credentials` namespace. */ +export interface CredentialErrorDetailsMap { + /** + * The provider refused a valid write, for example because a read-only source + * shadows the reference. The details name only the reference, never the value. + */ + 'credential-rejected': { readonly ref: string } +} + +/** Credential business failure carried by a rejected Remote call. */ +export type CredentialError = { + [Code in keyof CredentialErrorDetailsMap]: { + readonly code: Code + readonly message: string + readonly details: CredentialErrorDetailsMap[Code] + } +}[keyof CredentialErrorDetailsMap] diff --git a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts new file mode 100644 index 0000000000..a27af7687c --- /dev/null +++ b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' +import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import CredentialsController from '../src/credentials.ts' +import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' + +/** A store whose `describe` carries more than the view declares, as a foreign provider might. */ +class LeakyCredentials extends MemoryCredentials { + override describe(): Promise { + return Promise.resolve( + { configured: true, source: 'memory', writable: true, value: 'sk-leaked' } as CredentialInfo, + ) + } +} + +/** A store whose write rejects with a bare string, the way some client libraries do. */ +class LiteralRejectingCredentials extends MemoryCredentials { + override async set(): Promise { + throw 'the store refused' + } +} + +/** A store whose provider-owned policy rejects an otherwise valid write. */ +class RejectingCredentials extends MemoryCredentials { + override set(): Promise { + return Promise.reject(new Error('a read-only source shadows this reference')) + } +} + +async function boot( + seed: Record = {}, + provider: typeof MemoryCredentials = MemoryCredentials, +): Promise { + const ctx = new Context() + await ctx.plugin(provider, seed) + await ctx.plugin(CredentialsController) + return ctx.credentialsController +} + +describe('the credentials Remote namespace a configuration surface calls', () => { + it('publishes the credentials namespace from its own service key', async () => { + const controller = await boot() + const binding = controller.typertRemote + expect(binding.serviceKey).toBe('credentialsController') + expect(binding.namespace).toBe('credentials') + expect(remoteMethods(controller)).toEqual([ + { method: 'describe', invocation: { kind: 'direct' } }, + { method: 'set', invocation: { kind: 'direct' } }, + { method: 'unset', invocation: { kind: 'direct' } }, + ]) + }) + + it('reports the actionable configuration error while no credential provider is mounted', async () => { + const ctx = new Context() + await ctx.plugin(CredentialsController) + for (const call of [ + () => ctx.credentialsController.describe(['DEEPSEEK_API_KEY']), + () => ctx.credentialsController.set('DEEPSEEK_API_KEY', 'sk-live'), + () => ctx.credentialsController.unset('DEEPSEEK_API_KEY'), + ]) { + const failure = await call().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + expect((failure as TypertRemoteFailure).failure).toEqual({ + 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: {}, + }) + } + }) + + it('describes a batch of references as one map, values excluded', async () => { + const controller = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' }) + const described = await controller.describe(['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']) + expect(described).toEqual({ + DEEPSEEK_API_KEY: { configured: true, source: 'memory', writable: true }, + OPENAI_API_KEY: { configured: false, writable: true }, + }) + expect(JSON.stringify(described)).not.toContain('sk-seeded') + }) + + it('reports an invalid reference as bad-request', async () => { + const controller = await boot() + for (const call of [ + () => controller.describe(['DEEPSEEK_API_KEY', 'not a var']), + () => controller.set('not a var', 'sk-live'), + () => controller.unset('not a var'), + ]) { + const failure = await call().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + } + }) + + it('answers the largest batch it accepts and reports one reference more as bad-request', async () => { + const controller = await boot() + const accepted = Array.from({ length: 64 }, (_unused, index) => `REF_${String(index)}`) + expect(Object.keys(await controller.describe(accepted))).toHaveLength(64) + const failure = await controller.describe([...accepted, 'REF_64']).catch((error: unknown) => error) + expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + }) + + it('answers only the fields the view declares, whatever a provider returns', async () => { + const controller = await boot({}, LeakyCredentials) + const described = await controller.describe(['DEEPSEEK_API_KEY']) + expect(described.DEEPSEEK_API_KEY).toEqual({ configured: true, source: 'memory', writable: true }) + expect(JSON.stringify(described)).not.toContain('sk-leaked') + }) + + it('stores and removes through the same references the batch describes', async () => { + const controller = await boot() + await controller.set('DEEPSEEK_API_KEY', 'sk-live') + expect(await controller.describe(['DEEPSEEK_API_KEY'])) + .toEqual({ DEEPSEEK_API_KEY: { configured: true, source: 'memory', writable: true } }) + await controller.unset('DEEPSEEK_API_KEY') + expect(await controller.describe(['DEEPSEEK_API_KEY'])) + .toEqual({ DEEPSEEK_API_KEY: { configured: false, writable: true } }) + }) + + it('reports a refused write as credential-rejected naming only the reference', async () => { + const controller = await boot({}, RejectingCredentials) + const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + const { code, message, details } = (failure as TypertRemoteFailure).failure + expect(code).toBe('credential-rejected') + expect(message).toContain('read-only source') + expect(details).toEqual({ ref: 'DEEPSEEK_API_KEY' }) + }) + + it('reports an empty value as bad-request', async () => { + const controller = await boot() + const failure = await controller.set('DEEPSEEK_API_KEY', '').catch((error: unknown) => error) + expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + }) + + it('stringifies a refusal that is not an Error', async () => { + const controller = await boot({}, LiteralRejectingCredentials) + const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) + expect((failure as TypertRemoteFailure).failure.message).toBe('the store refused') + }) +}) diff --git a/packages/api/settings-controller/tests/invariant.spec.ts b/packages/api/settings-controller/tests/invariant.spec.ts new file mode 100644 index 0000000000..6454249191 --- /dev/null +++ b/packages/api/settings-controller/tests/invariant.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import * as SettingsControllerInvariant from '../src/invariant.ts' + +describe('api-settings-controller invariant companion', () => { + it('reserves the package name against duplicate registration', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry) + await ctx.plugin(SettingsControllerInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-api-settings-controller', () => {}) + }).toThrow(/already registered/) + }) +}) diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts new file mode 100644 index 0000000000..ed6e9325fd --- /dev/null +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import SettingsController from '../src/index.ts' +import { MemorySettings } from '../../../settings/settings/tests/memory.ts' + +const NS = settingsNamespace('ui-test') + +const Profile = z.object({ + preference: z.union(['light', 'dark']).default('light'), + apiKey: z.string().role('secret'), +}) + +/** A provider that reports a local document, for the `hasDocument` fact. */ +class DocumentSettings extends MemorySettings { + override get documentPath(): string | undefined { + return '/deployment/settings.yaml' + } +} + +/** A provider whose read forgets the namespace its write just committed. */ +class VanishingSettings extends MemorySettings { + override describe(): SettingsDescriptor[] { + return [] + } +} + +/** + * A provider whose descriptor omits the secret-slot list. `secrets` is optional + * on the descriptor, so a foreign provider may leave it out even under + * `redactSecrets`, and the view still has to declare an empty list. + */ +class SlotlessSettings extends MemorySettings { + override describe(): SettingsDescriptor[] { + return [{ + ns: NS, + schema: Profile.toJSON(), + value: { preference: 'light' }, + applies: 'live', + revision: 0, + } as unknown as SettingsDescriptor] + } +} + +/** A provider that refuses every write the way a read-only backing store would. */ +class RefusingSettings extends MemorySettings { + override mutate(ns: SettingsNamespace): Promise { + return Promise.reject(new Error(`settings "${ns}" is read-only in this deployment`)) + } +} + +/** A provider that refuses with a bare string, the way some storage clients do. */ +class LiteralRefusingSettings extends MemorySettings { + override async mutate(): Promise { + throw 'the document is locked' + } +} + +async function boot( + provider: typeof MemorySettings = MemorySettings, + options: { doc?: Record; base?: { preference: 'light' | 'dark' } } = {}, +): Promise<{ controller: SettingsController; ctx: Context }> { + const ctx = new Context() + await ctx.plugin(provider, options.doc === undefined ? {} : { doc: options.doc }) + ctx.settings.register(NS, Profile, options.base === undefined ? {} : { base: options.base }) + await ctx.plugin(SettingsController) + return { controller: ctx.settingsController, ctx } +} + +describe('the settings Remote namespace a configuration page calls', () => { + it('publishes the settings namespace from its own service key', async () => { + const { controller } = await boot() + expect(controller.typertRemote.serviceKey).toBe('settingsController') + expect(controller.typertRemote.namespace).toBe('settings') + expect(remoteMethods(controller)).toEqual([ + { method: 'describe', invocation: { kind: 'direct' } }, + { method: 'update', invocation: { kind: 'direct' } }, + { method: 'replace', invocation: { kind: 'direct' } }, + { method: 'mutate', invocation: { kind: 'direct' } }, + ]) + }) + + it('reports the actionable configuration error while no settings provider is mounted', async () => { + const ctx = new Context() + await ctx.plugin(SettingsController) + for (const call of [ + () => ctx.settingsController.describe(), + () => ctx.settingsController.update('ui-test', {}, undefined), + () => ctx.settingsController.replace('ui-test', {}, undefined), + () => ctx.settingsController.mutate('ui-test', [], undefined), + ]) { + const failure = await Promise.resolve().then(call).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + expect((failure as TypertRemoteFailure).failure).toEqual({ + code: 'internal', + message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', + details: {}, + }) + } + }) + + it('mounts the credentials namespace beside its own', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings) + ctx.settings.register(NS, Profile) + const fiber = ctx.plugin(SettingsController) + await fiber.await() + expect(ctx.get('credentialsController')).toBeDefined() + await fiber.dispose() + expect(ctx.get('settingsController')).toBeUndefined() + expect(ctx.get('credentialsController')).toBeUndefined() + }) + + it('describes every namespace redacted, with the deployment facts around them', async () => { + const { controller } = await boot(DocumentSettings, { doc: { 'ui-test': { apiKey: 'sk-stored' } } }) + const value = controller.describe() + expect(value).toMatchObject({ writable: true, hasDocument: true }) + const [view] = value.namespaces + expect(view?.ns).toBe('ui-test') + // The secret never rides; its slot reports only that one is stored. + expect(JSON.stringify(value)).not.toContain('sk-stored') + expect(view?.secrets).toEqual([{ path: ['apiKey'], set: true }]) + // Redaction removes the field rather than replacing it, so the layer that + // stored a secret comes back empty instead of carrying a placeholder. + expect(view?.user).toEqual({}) + }) + + it('reports a read-only provider and omits the layers it has none of', async () => { + const { controller } = await boot(class extends MemorySettings { + override get writable(): boolean { + return false + } + }) + const value = controller.describe() + expect(value).toMatchObject({ writable: false, hasDocument: false }) + const [view] = value.namespaces + // No composition base was declared and no user section is stored, so + // neither optional layer appears at all. + expect(view && 'base' in view).toBe(false) + expect(view && 'user' in view).toBe(false) + }) + + it('declares an empty slot list when the provider names no secrets', async () => { + const { controller } = await boot(SlotlessSettings) + const [view] = controller.describe().namespaces + expect(view?.secrets).toEqual([]) + }) + + it('carries the composition base layer when the registrant declared one', async () => { + const { controller } = await boot(MemorySettings, { base: { preference: 'dark' } }) + const [view] = controller.describe().namespaces + expect(view?.base).toEqual({ preference: 'dark' }) + }) + + it('applies path-addressed edits and answers with the namespace it just wrote', async () => { + const { controller } = await boot() + const view = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined) + expect(view).toMatchObject({ ns: 'ui-test', user: { preference: 'dark' } }) + expect(view.revision).toBeGreaterThan(0) + }) + + it('supports merge updates and wholesale replacement on the Remote namespace', async () => { + const { controller } = await boot(MemorySettings, { + doc: { 'ui-test': { preference: 'dark', apiKey: 'sk-stored' } }, + }) + const updated = await controller.update('ui-test', { preference: 'light' }, undefined) + expect(updated.user).toEqual({ preference: 'light' }) + expect(updated.secrets).toEqual([{ path: ['apiKey'], set: true }]) + + const replaced = await controller.replace('ui-test', {}, updated.revision) + expect(replaced.value).toEqual({ preference: 'light' }) + expect(replaced.user).toEqual({}) + expect(replaced.secrets).toEqual([{ path: ['apiKey'], set: false }]) + }) + + it('refuses a stale write as settings-conflict carrying both revisions', async () => { + const { controller } = await boot() + const held = controller.describe().namespaces[0]!.revision + await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], held) + const failure = await controller + .mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'light' }], held) + .catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + const { code, details } = (failure as TypertRemoteFailure).failure + expect(code).toBe('settings-conflict') + expect(details).toMatchObject({ ns: 'ui-test', expected: held }) + }) + + it('answers a malformed namespace exactly as an unregistered one', async () => { + const { controller } = await boot() + for (const ns of ['Not A Namespace', 'unregistered']) { + const failure = await controller.mutate(ns, [{ op: 'unset', path: ['preference'] }], undefined) + .catch((error: unknown) => error) + expect((failure as TypertRemoteFailure).failure).toMatchObject({ + code: 'settings-rejected', + details: { ns }, + }) + } + }) + + it('reports an empty namespace as bad-request', async () => { + const { controller } = await boot() + for (const call of [ + () => controller.update('', {}, undefined), + () => controller.replace('', {}, undefined), + () => controller.mutate('', [], undefined), + ]) { + const failure = await call().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(TypertRemoteFailure) + expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + } + }) + + it('reports a refused write as settings-rejected carrying the seam message', async () => { + const { controller } = await boot(RefusingSettings) + const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined) + .catch((error: unknown) => error) + const { code, message } = (failure as TypertRemoteFailure).failure + expect(code).toBe('settings-rejected') + expect(message).toContain('read-only in this deployment') + }) + + it('stringifies a refusal that is not an Error', async () => { + const { controller } = await boot(LiteralRefusingSettings) + const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined) + .catch((error: unknown) => error) + expect((failure as TypertRemoteFailure).failure.message).toBe('the document is locked') + }) + + it('reports a namespace disposed between the write and its read-back', async () => { + const { controller } = await boot(VanishingSettings) + const failure = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined) + .catch((error: unknown) => error) + const { code, message } = (failure as TypertRemoteFailure).failure + expect(code).toBe('internal') + expect(message).toContain('was disposed after the mutate') + }) +}) diff --git a/packages/api/settings-controller/tsconfig.json b/packages/api/settings-controller/tsconfig.json new file mode 100644 index 0000000000..5b854dbe64 --- /dev/null +++ b/packages/api/settings-controller/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../core/session" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../settings/settings" + }, + { + "path": "../../typert/protocol" + } + ] +} diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 9e4f71923f..3f889dd8b4 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -86,6 +86,11 @@ - id: session-controller name: '@deepseek-ai/dsh-api-session-controller' + # Configuration-surface reads and writes over Typert Remote. Each method + # reports an actionable error when its settings-domain provider is absent. + - id: settings-controller + name: '@deepseek-ai/dsh-api-settings-controller' + # Workspace commands and reconnect-safe projection over Typert Remote. - id: workspace-controller name: '@deepseek-ai/dsh-api-workspace-controller' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index dfd0b68de3..0f216073de 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -108,6 +108,7 @@ "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index 9d80f4ae79..8ddcd99883 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -9,9 +9,11 @@ */ import { Context, Service } from '@deepseek-ai/cordis' -import type { CredentialKey, CredentialRecord, CredentialRef } from './types.ts' +import type { CredentialInfo, CredentialKey, CredentialRecord, CredentialRef } from './types.ts' -export type { ApiKeyRecord, CredentialKey, CredentialRecord, CredentialRef, GrantRecord } from './types.ts' +export type { + ApiKeyRecord, CredentialInfo, CredentialKey, CredentialRecord, CredentialRef, GrantRecord, +} from './types.ts' const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ @@ -119,16 +121,6 @@ export interface ResolvedCredential { source: string } -/** Source and writability facts for one reference, safe for configuration UIs — never the value. */ -export interface CredentialInfo { - /** Whether {@link CredentialProvider.resolve} would currently return a value. */ - configured: boolean - /** Source layer currently supplying the value; absent while unconfigured. */ - source?: string - /** Whether {@link CredentialProvider.set} would currently succeed for this reference. */ - writable: boolean -} - /** Presence and writability facts for one record, safe for configuration UIs — never the value. */ export interface CredentialRecordInfo { /** diff --git a/packages/credentials/credentials/src/types.ts b/packages/credentials/credentials/src/types.ts index 2a76ae5c61..57e0f99706 100644 --- a/packages/credentials/credentials/src/types.ts +++ b/packages/credentials/credentials/src/types.ts @@ -1,8 +1,9 @@ /** * Client-safe type surface of the credential seam: the two key brands, the - * stored-record union, and the seam's Cordis event declarations. Types only — - * no runtime code, and nothing here reaches a Host-only symbol, so a Client - * compilation face reads exactly the signature the Host emits. + * stored-record union, the reference view crossing the Remote wire, and the + * seam's Cordis event declarations. Types only — no runtime code, and nothing + * here reaches a Host-only symbol, so a Client compilation face reads exactly + * the signature the Host emits. * * @module @deepseek-ai/dsh-credentials/types */ @@ -58,6 +59,20 @@ export interface GrantRecord { /** One durable credential record, tagged by what the seam may do with it. */ export type CredentialRecord = ApiKeyRecord | GrantRecord +/** + * Source and writability facts for one reference, safe for configuration UIs — + * never the value. The view has no slot a value could ride in, which is what + * lets the whole read half cross the Remote wire. + */ +export interface CredentialInfo { + /** Whether resolving the reference would currently return a value. */ + configured: boolean + /** Source layer currently supplying the value; absent while unconfigured. */ + source?: string + /** Whether the active provider can write this reference. */ + writable: boolean +} + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 5d3f0b8838..9316515b1b 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -37,15 +37,17 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/settings/settings/src/types.ts b/packages/settings/settings/src/types.ts index 0b3e3b0e10..5c00b877eb 100644 --- a/packages/settings/settings/src/types.ts +++ b/packages/settings/settings/src/types.ts @@ -1,13 +1,15 @@ /** * Client-safe type surface of the user-settings seam: the namespace brand, the - * commit-origin union, and the seam's Cordis event declarations. Types only — - * no runtime code, and nothing here reaches a Host-only symbol, so a Client + * commit-origin union, the redacted views a configuration surface reads over + * the Remote wire, and the seam's Cordis event declarations. Types only — no + * runtime code, and nothing here reaches a Host-only symbol, so a Client * compilation face reads exactly the signatures the Host emits. * * @module @deepseek-ai/dsh-settings/types */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { JsonValue } from '@deepseek-ai/dsh-session/types' /** Nominal id of one registered settings namespace. */ export type SettingsNamespace = Branded<'SettingsNamespace'> @@ -15,6 +17,61 @@ export type SettingsNamespace = Branded<'SettingsNamespace'> /** Origin of one committed settings change. */ export type SettingsUpdateSource = 'update' | 'provider' +/** 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 namespace, always read under `redactSecrets`. The + * JSON-valued fields are `JsonValue` rather than the descriptor's `unknown` + * because the Remote boundary admits no unconstrained data. + */ +export interface SettingsNamespaceView { + /** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */ + ns: string + /** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */ + schema: JsonValue + /** Redacted resolved value (schema defaults → composition base → user layer). */ + value: JsonValue + /** Redacted composition base layer, when the registrant declared one. */ + base?: JsonValue + /** Redacted raw user section, when one exists; a field's presence here marks it user-overridden. */ + user?: JsonValue + /** When the owner applies changes. */ + applies: 'live' | 'restart' + /** Every schema-declared secret slot with its configured state. */ + secrets: SettingsSecretView[] + /** + * Monotonic revision of the raw user section this view was read at. Send it + * back as `expectedRevision` on a write so a stale editor is refused rather + * than silently overwriting a concurrent change. + */ + revision: number +} + +/** + * One path-addressed edit carried by a remote settings write. `set` writes the + * value at the path, creating intermediate objects; `unset` removes it. The + * empty path addresses the section root. + */ +export type SettingsPathOpView = + | { op: 'set'; path: string[]; value: JsonValue } + | { op: 'unset'; path: string[] } + +/** Every registered namespace with the deployment facts a configuration page renders around them. */ +export interface SettingsDescribeValue { + /** Whether the provider accepts writes; `false` disables every write control. */ + writable: boolean + /** Whether a file-backed provider owns a local document, without exposing its Host path. */ + hasDocument: boolean + /** One view per registered namespace. */ + namespaces: SettingsNamespaceView[] +} + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/settings/settings/tsconfig.json b/packages/settings/settings/tsconfig.json index 916ba5a4b9..60ca039d1c 100644 --- a/packages/settings/settings/tsconfig.json +++ b/packages/settings/settings/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../util/brand" }, + { + "path": "../../core/session" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11f1d8f5d3..f271832de3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -683,6 +683,9 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../session-controller + '@deepseek-ai/dsh-api-settings-controller': + specifier: workspace:^ + version: link:../settings-controller '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../workspace-controller @@ -820,6 +823,31 @@ importers: specifier: workspace:^ version: link:../../workspace/workspace + packages/api/settings-controller: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + packages/api/workspace-controller: dependencies: zod: @@ -1376,6 +1404,9 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller + '@deepseek-ai/dsh-api-settings-controller': + specifier: workspace:^ + version: link:../../api/settings-controller '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../../api/workspace-controller @@ -1630,6 +1661,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo @@ -2644,9 +2678,6 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3096,9 +3127,6 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -5703,9 +5731,6 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-credentials': - specifier: workspace:^ - version: link:../../credentials/credentials '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker @@ -5752,6 +5777,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -7531,6 +7559,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session packages/settings/settings-file: dependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 3d789bb894..3332b24350 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -70,6 +70,8 @@ export const SERVICE_PAGE: Record = { cordisInspect: 'extensions.md', authorization: 'credentials.md', credentials: 'credentials.md', + credentialsController: 'credentials.md', + settingsController: 'settings.md', directoryPicker: 'workspace.md', deepseekLlmApiExtensions: 'llm-streaming.md', dynamicCordisRunner: 'extensions.md', @@ -526,6 +528,10 @@ export const LINK_MAP: Readonly> = { SettingsRegisterOptions: 'settings.md', SettingsScope: 'settings.md', SettingsDescriptor: 'settings.md', + SettingsDescribeValue: 'settings.md', + SettingsNamespaceView: 'settings.md', + SettingsPathOpView: 'settings.md', + SettingsSecretView: 'settings.md', SettingsPathOp: 'settings.md', SettingsDescribeOptions: 'settings.md', SettingsUpdateSource: 'settings.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 22ded5918c..00af95b674 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -157,6 +157,20 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['host-apiproxy'], note: 'Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains.', }, + { + key: 'credentialsController', + pkg: 'api-settings-controller', + title: 'Host credential-surface Remote controller', + mode: 'core', + note: 'Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition.', + }, + { + key: 'settingsController', + pkg: 'api-settings-controller', + title: 'Host settings-surface Remote controller', + mode: 'core', + note: 'Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition.', + }, { key: 'workspaceController', pkg: 'api-workspace-controller', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 60ee4b020d..b54672eb87 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1664,7 +1664,7 @@ { "doc": "docs/subsystems/credentials.md", "symbol": "CredentialInfo", - "source": "packages/credentials/credentials/src/index.ts" + "source": "packages/credentials/credentials/src/types.ts" }, { "doc": "docs/subsystems/settings.md", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 7a7e0e9b73..9e88355ec9 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -162,6 +162,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, 'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, 'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' }, + 'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' }, 'packages/api/workspace-controller': { kind: 'none', reason: 'Workspace API and state projection owner; it registers no prompt, tool, or session event.' }, 'packages/typert/protocol': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers nothing model-facing.' }, 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index f591eaed91..83c2432053 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -52,6 +52,9 @@ "@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"], "@deepseek-ai/dsh-api-session-controller/types": ["./packages/api/session-controller/src/types.ts"], "@deepseek-ai/dsh-api-session-controller/remote-events": ["./packages/api/session-controller/src/remote-events.ts"], + "@deepseek-ai/dsh-api-settings-controller": ["./packages/api/settings-controller/src/index.ts"], + "@deepseek-ai/dsh-api-settings-controller/invariant": ["./packages/api/settings-controller/src/invariant.ts"], + "@deepseek-ai/dsh-api-settings-controller/types": ["./packages/api/settings-controller/src/types.ts"], "@deepseek-ai/dsh-api-workspace-controller": ["./packages/api/workspace-controller/src/index.ts"], "@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-workspace-controller/invariant": ["./packages/api/workspace-controller/src/invariant.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index d19ff00848..f5dc588982 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -146,6 +146,7 @@ { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/api/session-controller/tsconfig.host.json" }, + { "path": "./packages/api/settings-controller" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session/session-persistence" },