/** * Host-side ApiProxy implementation. Signature discipline: unary takes the * narrow RpcRequest

and echoes request.rpcId on the RpcResponse. */ import { homedir } from 'node:os' import { dirname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' import type { SessionId } from '@deepseek-ai/dsh-session' import { isUserInvocable } from '@deepseek-ai/dsh-skill' import { InvalidPresetIdError, PresetExistsError, PresetNotWritableError, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' import type { ApiProxy, ConfigurableProviderView, CredentialView, SettingsNamespaceView, } from './api/index.ts' import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller' import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, sessionLogExportDeps, sessionLogZipFilename, streamSessionLogZip, type SessionLogExportReady, type SessionLogCompressionLevel, } from './session-export.ts' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' // Type-only edges: resolve the command-change stream and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' // The settings/credentials seams: brand guards run at this wire boundary; the // service reads stay optional (`ctx.get`) so a composition without either // provider still serves every other domain. import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Read live abort state across awaits without treating it as synchronously immutable. */ function isAborted(signal: AbortSignal): boolean { return signal.aborted } /** Wrap an ok result echoing the request's rpcId. */ function ok(request: RpcRequest, value: T): RpcResponse { return { rpcId: request.rpcId, result: { ok: true, value } } } /** Wrap an error result echoing the request's rpcId. */ function err(request: RpcRequest, error: RpcError): RpcResponse { return { rpcId: request.rpcId, result: { ok: false, error } } } /** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */ function directoryError(error: unknown): RpcError { if (error instanceof DirectoryPickerError) { return { code: error.code, message: error.message, details: { path: error.path } } } return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } } /** Deployment metadata and Host integrations consumed by the API implementation. */ export interface ApiProxyDefaults { /** Current deployment model selection reported by `host.describe`. */ defaultModelSelection: () => ModelSelection /** Project hint reported by `host.describe`; must match Session Controller's default cwd. */ cwd: string /** Native open-with-default-application; injectable for carrier tests. */ openPath?: (path: string, signal: AbortSignal) => Promise /** Native text-editor handoff; injectable for settings-document tests. */ openTextFile?: (path: string, signal: AbortSignal) => Promise /** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */ sessionExportCompressionLevel?: SessionLogCompressionLevel /** * Whether handing a path to the native opener can work at all — the * `hasDocument` capability the preset roster reports, and the switch * between opening a preset directory and answering its path as text. * Absent, an injected `openPath` counts as openable and everything else * falls back to platform detection ({@link canOpenNativePath}). */ canOpenPath?: () => boolean } /** The roster is absent: this deployment composes no agent presets at all. */ function noRoster(agentPreset: string): RpcError { return { code: 'agent-preset-not-found', message: 'this deployment composes no agent presets', details: { agentPreset, available: [] }, } } /** Map one authoring/roster failure onto its wire code. */ function presetError(agentPreset: string, error: unknown): RpcError { if (error instanceof UnknownPresetError) { return { code: 'agent-preset-not-found', message: error.message, details: { agentPreset: error.presetId, available: [...error.available] }, } } if (error instanceof PresetNotWritableError) { return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } } } if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) { return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } } } return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} } } /** * Implement ApiProxy over a composed host context. * @param ctx - a context with the Host spine mounted. * @param defaults - host routing and project-directory defaults. * @returns the ApiProxy implementation. */ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL /** Resolve a Session's live or standing preset scope without resuming it. */ async function sessionScopeFor( sessionId: SessionId, agentPreset: string | undefined, ): Promise { const live = ctx.get('agents')?.get(sessionId) if (live !== undefined) return live const presets = ctx.get('agentPresets') if (presets === undefined) return undefined try { return await presets.standingKeyFor(agentPreset) } catch { // An unknown or unusable recorded preset falls back to the global registry. return undefined } } /** 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-file) in its composition', details: {} } } /** Open one Host-resolved target and map native failures onto the wire vocabulary. */ async function openTarget( request: RpcRequest, path: string, signal: AbortSignal, open: (path: string, signal: AbortSignal) => Promise, ): Promise> { try { await open(path, signal) return ok(request, { opened: true as const }) } catch (error: unknown) { if (signal.aborted) { return err(request, { code: 'cancelled', message: 'path open was aborted', details: {}, }) } return err(request, { code: 'internal', message: `path open failed: ${error instanceof Error ? error.message : String(error)}`, details: {}, }) } } /** Open one Host-resolved path with its default application. */ function openPath( request: RpcRequest, path: string, signal: AbortSignal, ): Promise> { const open = defaults.openPath ?? ((target: string, openSignal: AbortSignal) => openNativePath(target, openSignal)) return openTarget(request, path, signal, open) } /** Open one Host-resolved text document in a native editor. */ function openTextFile( request: RpcRequest, path: string, signal: AbortSignal, ): Promise> { const open = defaults.openTextFile ?? ((target: string, openSignal: AbortSignal) => openNativeTextFile(target, openSignal)) return openTarget(request, path, signal, open) } /** Whether this deployment can hand a path to a native opener at all. */ function canOpenPaths(): boolean { if (defaults.canOpenPath !== undefined) return defaults.canOpenPath() // An injected opener is by definition usable; otherwise ask the platform. return defaults.openPath !== undefined || canOpenNativePath() } /** Missing-service report shared by the credentials domain. */ function credentialsAbsent(): RpcError { return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} } } /** Map one redacted settings descriptor to its wire view. */ function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView { return { ns: String(descriptor.ns), schema: descriptor.schema, value: descriptor.value, ...descriptor.base === undefined ? {} : { base: descriptor.base }, ...descriptor.user === undefined ? {} : { user: descriptor.user }, applies: descriptor.applies, secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })), revision: descriptor.revision, } } /** * Run one settings write (merge or wholesale replace) and acknowledge with * the namespace's new redacted view. Every seam refusal — unknown or invalid * namespace, read-only provider, schema validation, storage — becomes one * `settings-rejected` carrying the seam's own message. */ async function settingsWrite( request: RpcRequest, ns: string, mode: 'update' | 'replace' | 'mutate', section: object, expectedRevision?: number, ): Promise> { const settings = ctx.get('settings') if (settings === undefined) return err(request, settingsAbsent()) const rejected = (error: unknown): RpcResponse => { // A stale writer is its own outcome, not a malformed request: the client // must re-read and re-apply rather than treat the write as invalid. if (error instanceof SettingsConflictError) { return err(request, { code: 'settings-conflict', message: error.message, details: { ns, expected: error.expected, actual: error.actual }, }) } return err(request, { code: 'settings-rejected', message: error instanceof Error ? error.message : String(error), details: { ns }, }) } let branded: SettingsNamespace try { branded = settingsNamespace(ns) } catch (error: unknown) { // A malformed name can address no registration, so it fails exactly as // an unregistered one does. return rejected(error) } try { if (mode === 'update') await settings.update(branded, section, expectedRevision) else if (mode === 'replace') await settings.replace(branded, section, expectedRevision) else await settings.mutate(branded, section as SettingsPathOp[], expectedRevision) } catch (error: unknown) { return rejected(error) } const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded) if (descriptor === undefined) { // The write committed but the namespace vanished before this read: only // a concurrent registrant disposal can produce it. return err(request, { code: 'internal', message: `settings namespace "${ns}" was disposed after the ${mode}`, details: {} }) } return ok(request, namespaceView(descriptor)) } return { host: { describe(request) { // TODO(apiproxy-version): read the version from apps/cli/package.json. const selection = defaults.defaultModelSelection() return Promise.resolve(ok(request, { version: '0.0.1', // This must match the default cwd supplied to Session Controller so // the UI's project hint names where a cwd-less create request lands. cwd: defaults.cwd, // Read live for the same reason: this is what the NEXT session will // start from, so a saved default has to be what it reports. provider: selection.provider, model: selection.model, attachedSessions: ctx.agents.list().length, home: homedir(), canOpenPath: canOpenPaths(), })) }, async pickDirectory(request, signal) { const capability = ctx.directoryPicker.capability() if (capability.kind !== 'native') { return err(request, { code: 'directory-picker-unavailable', message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`, details: { capability: capability.kind }, }) } try { const path = await capability.pick(signal) return ok(request, { path }) } catch (error: unknown) { if (signal.aborted) { return err(request, { code: 'cancelled', message: 'directory picker was aborted', details: {}, }) } return err(request, { code: 'internal', message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`, details: {}, }) } }, async listDirectory(request, signal) { const capability = ctx.directoryPicker.capability() if (capability.kind !== 'browse') { return err(request, { code: 'directory-picker-unavailable', message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, details: { capability: capability.kind }, }) } try { // The carrier's signal follows the caller: a disconnect or timeout // stops the backend's directory scan instead of outliving it. return ok(request, await capability.list(request.payload.path, signal)) } catch (error: unknown) { // An abort is the caller's own timeout/disconnect, not a server failure. if (signal.aborted) { return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} }) } return err(request, directoryError(error)) } }, async createDirectory(request) { const capability = ctx.directoryPicker.capability() if (capability.kind !== 'browse') { return err(request, { code: 'directory-picker-unavailable', message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, details: { capability: capability.kind }, }) } try { return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) }) } catch (error: unknown) { return err(request, directoryError(error)) } }, async openPath(request, signal) { return openPath(request, request.payload.path, signal) }, }, agentPresets: { // Only the desktop opener remains here: the roster, selection, and // authoring calls are the AgentPresets service's own Remote namespace. async openDocument(request, signal) { const { agentPreset } = request.payload const presets = ctx.get('agentPresets') if (presets === undefined) return err(request, noRoster(agentPreset)) try { const preset = await presets.resolve(agentPreset) // Same line as copy/remove draw: the shipped install is not the // user's to manage, and pointing an editor into it invites edits an // upgrade will silently overwrite. if (preset.trust !== 'user') { throw new PresetNotWritableError(preset.id, 'it ships with the deployment') } // The id resolved against the Host's own roots is what selects the // directory — no browser payload carries a path in either direction // unless the deployment has no opener to hand it to. const directory = dirname(preset.path) if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory }) return await openPath(request, directory, signal) } catch (error: unknown) { return err(request, presetError(agentPreset, error)) } }, }, skills: { // Skill lookup never creates or resumes an agent: the session address // resolves to a canonical cwd from the host-resident session header, and // the view scope is the live agent or the preset's standing key. async list(request) { const { sessionId } = request.payload let cwd: string | undefined let agentPreset: string | undefined try { using observation = await ctx.sessionQuery.observeSession(sessionId) if (observation.projections === undefined) { throw new Error('skill catalog requires a projected Session observation') } cwd = observation.header.cwd agentPreset = observation.projections.values.agentPreset ?? undefined } catch (error: unknown) { if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { return err(request, { code: 'session-not-found', message: `session "${sessionId}" not found`, details: { sessionId }, }) } return err(request, { code: 'internal', message: `session "${sessionId}" could not be inspected: ${String(error)}`, details: {}, }) } if (cwd === undefined) { // Every served session records its project at create time; a // cwd-less header is a pre-project legacy log (not served). return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) } // The host registry is layered per scope and serves every session. A // composition may still realm-mount its own registry instead; that // instance is invisible to host contexts, so address it through the // live agent (`agents.get` keeps the no-side-effect stance above). const live = ctx.agents.get(sessionId) const presets = ctx.get('agentPresets') const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') // A missing service means no composition mounts dsh-skill, not an // empty catalog. `ctx.get` also // keeps this handler independent of the gateway plugin's inject list // (an undeclared `ctx.skills` property read fails the reflect proxy). const skillRegistry = scoped ?? ctx.get('skills') if (skillRegistry === undefined) { return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} }) } // Resolve the live or recorded preset scope so the catalog matches the // Session composition without resuming its Agent. const scope = await sessionScopeFor(sessionId, agentPreset) try { const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) return ok(request, { skills: skills.map(skill => ({ name: skill.name, description: skill.description, ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }, modelInvocable: skill.invocation.modelInvocable, })), }) } catch (error: unknown) { return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} }) } }, }, settings: { describe(request) { const settings = ctx.get('settings') if (settings === undefined) return Promise.resolve(err(request, settingsAbsent())) return Promise.resolve(ok(request, { writable: settings.writable, hasDocument: settings.documentPath !== undefined, namespaces: settings.describe({ redactSecrets: true }).map(namespaceView), })) }, async openDocument(request, signal) { const settings = ctx.get('settings') if (settings === undefined) return err(request, settingsAbsent()) if (isAborted(signal)) { return err(request, { code: 'cancelled', message: 'settings document open was aborted', details: {}, }) } let path: string | undefined try { path = await settings.prepareDocument() } catch (error: unknown) { if (isAborted(signal)) { return err(request, { code: 'cancelled', message: 'settings document preparation was aborted', details: {}, }) } return err(request, { code: 'internal', message: `settings document preparation failed: ${error instanceof Error ? error.message : String(error)}`, details: {}, }) } if (path === undefined) { return err(request, { code: 'internal', message: 'settings provider has no local document to open', details: {}, }) } if (isAborted(signal)) { return err(request, { code: 'cancelled', message: 'settings document open was aborted', details: {}, }) } return openTextFile(request, path, signal) }, update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision), replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision), mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision), }, credentials: { async describe(request) { const credentials = ctx.get('credentials') if (credentials === undefined) return err(request, credentialsAbsent()) const entries = await Promise.all(request.payload.refs.map(async (ref) => { const info = await credentials.describe(credentialRef(ref)) const view: CredentialView = { configured: info.configured, ...info.source === undefined ? {} : { source: info.source }, writable: info.writable, } return [ref, view] as const })) return ok(request, { credentials: Object.fromEntries(entries) }) }, async set(request) { const credentials = ctx.get('credentials') if (credentials === undefined) return err(request, credentialsAbsent()) const { ref, value } = request.payload try { await credentials.set(credentialRef(ref), value) } catch (error: unknown) { return err(request, { code: 'credential-rejected', message: error instanceof Error ? error.message : String(error), details: { ref }, }) } return ok(request, {}) }, async unset(request) { const credentials = ctx.get('credentials') if (credentials === undefined) return err(request, credentialsAbsent()) const { ref } = request.payload try { await credentials.unset(credentialRef(ref)) } catch (error: unknown) { return err(request, { code: 'credential-rejected', message: error instanceof Error ? error.message : String(error), details: { ref }, }) } return ok(request, {}) }, }, llm: { 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: ConfigurableProviderView[] = directory.map(entry => ({ provider: entry.provider, displayName: entry.displayName, settingsNs: entry.settingsNs, settingsPath: [...entry.settingsPath], active: active.has(entry.provider), ...entry.declared === undefined ? {} : { declared: entry.declared }, })) // Routes registered without a directory declaration still appear — // they exist and serve models — just with no settings address. No // adapter claimed them, so nothing can say whether they are shipped. 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, defaults.defaultModelSelection())) }, async discoverModels(request, signal) { const { settingsNs, provider, baseURL, api, apiKey } = request.payload try { const models = await ctx.llm.discoverModels(settingsNs, { ...provider === undefined ? {} : { provider }, ...baseURL === undefined ? {} : { baseURL }, ...api === undefined ? {} : { api }, ...apiKey === undefined ? {} : { apiKey }, ...signal === undefined ? {} : { signal }, }) return ok(request, { models }) } catch (error: unknown) { // Every failure here is the user's next move, not a transport fault: // a wrong endpoint, a rejected key, or a protocol with no listing all // end at the same place — fill the models in by hand. The details // repeat only what the caller already sent, never the credential. return err(request, { code: 'model-discovery-failed', message: error instanceof Error ? error.message : String(error), details: { settingsNs, ...baseURL === undefined ? {} : { baseURL } }, }) } }, }, downloads: { async sessionLog(request, signal) { // Clean error path first: missing services answer 500 and a missing // root artifact 404 before any zip byte is produced. The root content // read here is reused as the first zip entry, so nothing is read twice. const deps = sessionLogExportDeps(ctx) if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined || deps.attachments === undefined) { return new Response( 'session log export is unavailable: missing session-query, session-persistence, or attachments service', { status: 500 }, ) } if (!deps.sessionPersistence.supportsRawArtifacts) { return new Response( 'session log export is unavailable: the persistence backend does not expose per-session raw artifacts', { status: 501 }, ) } const ready: SessionLogExportReady = { sessionQuery: deps.sessionQuery, sessionPersistence: deps.sessionPersistence, attachments: deps.attachments, sessions: deps.sessions, } let root: SessionRawArtifact | undefined try { await flushLiveSessionLog(deps, request.sessionId, signal) root = await deps.sessionPersistence.readRaw(request.sessionId, signal) signal.throwIfAborted() } catch { signal.throwIfAborted() // Root preparation failure: answer 500 without echoing the error, // which may carry absolute host paths into the browser error bar. return new Response('session log export failed to prepare the stored artifact', { status: 500 }) } if (root === undefined) { return new Response('session not found', { status: 404 }) } return new Response( streamSessionLogZip( ready, root, request.sessionId, request.includeDescendants === true, sessionExportCompressionLevel, signal, ), { headers: { 'content-type': 'application/zip', 'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`, }, }, ) }, }, } }