index.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. /**
  2. * Host Remote owner for the configuration surfaces over the settings-domain
  3. * seams. Two namespaces: `settings`, the redacted reads and writes of
  4. * `ctx.settings`, owned by the class below; and `credentials`, mounted from
  5. * here as its own plugin.
  6. *
  7. * @module @deepseek-ai/dsh-api-settings-controller
  8. */
  9. import { dirname } from 'node:path'
  10. import { Context } from '@deepseek-ai/cordis'
  11. import Schema from '@deepseek-ai/schemastery'
  12. // Type-only: resolves the `agentPresets` Context augmentation this controller reads.
  13. import type {} from '@deepseek-ai/dsh-agent-presets'
  14. import {
  15. canOpenNativePath,
  16. openNativePath,
  17. openNativeTextFile,
  18. } from '@deepseek-ai/dsh-native-command'
  19. import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings'
  20. import type {
  21. SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView,
  22. } from '@deepseek-ai/dsh-settings/types'
  23. import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
  24. import type { JsonValue } from '@deepseek-ai/dsh-util-values'
  25. import { z } from 'zod'
  26. import { CredentialsController } from './credentials.ts'
  27. import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts'
  28. export { CredentialsController } from './credentials.ts'
  29. export type * from './types.ts'
  30. const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1), scope: z.string().min(1).optional() })
  31. /** Native document-opening policy. */
  32. export interface Config {
  33. /** Override platform desktop-opener detection. */
  34. readonly nativeOpen?: boolean
  35. }
  36. /** Read abort state afresh after an awaited provider or opener call. */
  37. function isAborted(signal: AbortSignal): boolean {
  38. return signal.aborted
  39. }
  40. /** Host integrations replaceable by direct unit tests. */
  41. export interface SettingsControllerInternals {
  42. readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
  43. readonly openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
  44. readonly canOpenPath?: () => boolean
  45. }
  46. /**
  47. * Project one redacted descriptor onto its wire view, field by field. The
  48. * Gateway returns a business result without decoding it, so a provider whose
  49. * descriptor carried extra enumerable properties would otherwise serialize them
  50. * to the caller.
  51. * @param descriptor - one descriptor read under `redactSecrets`.
  52. * @returns the same facts with nothing else attached.
  53. */
  54. function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
  55. return {
  56. ns: String(descriptor.ns),
  57. ...descriptor.scope === undefined ? {} : { scope: String(descriptor.scope) },
  58. registered: descriptor.registered,
  59. schema: descriptor.schema as JsonValue,
  60. value: descriptor.value as JsonValue,
  61. ...descriptor.base === undefined ? {} : { base: descriptor.base as JsonValue },
  62. ...descriptor.user === undefined ? {} : { user: descriptor.user as JsonValue },
  63. ...descriptor.inherited === undefined ? {} : { inherited: descriptor.inherited as JsonValue },
  64. applies: descriptor.applies,
  65. secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
  66. revision: descriptor.revision,
  67. }
  68. }
  69. declare module '@deepseek-ai/cordis' {
  70. interface Context {
  71. /** Host owner of the `settings` Remote namespace. */
  72. settingsController: SettingsController
  73. }
  74. }
  75. /**
  76. * Host service backing the generated `ctx.remote.settings` namespace. Every
  77. * remote read uses `redactSecrets: true`, so a `role('secret')` field cannot
  78. * ride a response. Writes expose the settings service's merge, replacement,
  79. * and path-addressed operations, and classify every provider refusal as
  80. * `settings/conflict` or `settings/rejected` with the service's message.
  81. */
  82. export class SettingsController extends TypertRemoteService {
  83. static Config: Schema<Config> = Schema.object({ nativeOpen: Schema.boolean() })
  84. private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
  85. private readonly openTextFile: (path: string, signal: AbortSignal) => Promise<void>
  86. private readonly canOpenPath: () => boolean
  87. /**
  88. * Register the settings namespace and mount the credentials namespace beside
  89. * it. Both namespaces stay registered when a provider is absent so calls can
  90. * return the configuration API's actionable missing-provider diagnostic.
  91. * @param ctx - Host context where settings and credential providers may be mounted.
  92. */
  93. constructor(ctx: Context, config: Config = {}, internals: SettingsControllerInternals = {}) {
  94. super(ctx, 'settingsController', { namespace: 'settings' })
  95. this.openPath = internals.openPath ?? openNativePath
  96. this.openTextFile = internals.openTextFile ?? openNativeTextFile
  97. this.canOpenPath = internals.canOpenPath
  98. ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
  99. ctx.plugin(CredentialsController)
  100. }
  101. /**
  102. * Describe every namespace kind for a configuration page: redacted layered
  103. * values plus the serialized schema the page renders its form from, under
  104. * the global scope or one named scope (an agent preset's `preset/<id>`).
  105. * @param scope - the named scope to describe; the global scope when omitted.
  106. * @returns provider writability, local-document presence, one view per
  107. * namespace kind, and every scope some namespace is registered under.
  108. * @throws RemoteError when no settings provider is mounted or the scope id is malformed.
  109. */
  110. @Remote
  111. describe(scope?: string): SettingsDescribeValue {
  112. const settings = this.provider()
  113. let views: SettingsNamespaceView[]
  114. try {
  115. views = settings.describe({ redactSecrets: true, ...scope === undefined ? {} : { scope } }).map(namespaceView)
  116. } catch (error: unknown) {
  117. throw new RemoteError('gateway/bad-request', `invalid scope for settings.describe: ${messageOf(error)}`, {}, { cause: error })
  118. }
  119. return {
  120. writable: settings.writable,
  121. hasDocument: settings.documentPath !== undefined,
  122. namespaces: views,
  123. ...scope === undefined ? {} : { scope },
  124. scopes: settings.scopes().map(String),
  125. }
  126. }
  127. /**
  128. * Report whether this deployment can open an authored Agent preset directory natively.
  129. * @returns true when the matching open operation is available.
  130. */
  131. @Remote
  132. canOpenAgentPresetDirectory(): boolean {
  133. return this.canOpenPath()
  134. }
  135. /**
  136. * Merge a patch into one namespace's stored user section.
  137. * @param ns - namespace key to write.
  138. * @param patch - fields to merge into the user section.
  139. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
  140. * @param scope - the named scope whose section to write; the global section when omitted.
  141. * @returns the namespace's redacted view under that scope after the write.
  142. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
  143. */
  144. @Remote
  145. update(
  146. ns: string,
  147. patch: Record<string, JsonValue>,
  148. expectedRevision: number | undefined,
  149. scope?: string,
  150. ): Promise<SettingsNamespaceView> {
  151. return this.write(ns, 'update', patch, expectedRevision, scope)
  152. }
  153. /**
  154. * Replace one namespace's stored user section wholesale.
  155. * @param ns - namespace key to write.
  156. * @param section - complete replacement user section.
  157. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
  158. * @param scope - the named scope whose section to write; the global section when omitted.
  159. * @returns the namespace's redacted view under that scope after the write.
  160. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
  161. */
  162. @Remote
  163. replace(
  164. ns: string,
  165. section: Record<string, JsonValue>,
  166. expectedRevision: number | undefined,
  167. scope?: string,
  168. ): Promise<SettingsNamespaceView> {
  169. return this.write(ns, 'replace', section, expectedRevision, scope)
  170. }
  171. /**
  172. * Apply path-addressed edits to one namespace's user section, resolved against
  173. * the section as stored rather than against whatever the caller last read,
  174. * then answer with that namespace's new redacted view.
  175. * @param ns - namespace key to write.
  176. * @param ops - the edits to apply, in order.
  177. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
  178. * @param scope - the named scope whose section to write; the global section when omitted.
  179. * @returns the namespace's redacted view under that scope after the write.
  180. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
  181. */
  182. @Remote
  183. async mutate(
  184. ns: string,
  185. ops: SettingsPathOpView[],
  186. expectedRevision: number | undefined,
  187. scope?: string,
  188. ): Promise<SettingsNamespaceView> {
  189. return this.write(ns, 'mutate', ops, expectedRevision, scope)
  190. }
  191. /**
  192. * Materialize the provider-owned settings document and open it in a native text editor.
  193. * @param signal - caller lifetime; abort terminates preparation or the native command.
  194. * @returns confirmation after the native opener accepts the document.
  195. * @throws RemoteError when no document exists, preparation fails, or opening fails.
  196. */
  197. @Remote
  198. async openSettingsDocument(signal: AbortSignal): Promise<SettingsDocumentOpenValue> {
  199. const settings = this.provider()
  200. if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {})
  201. let path: string | undefined
  202. try {
  203. path = await settings.prepareDocument()
  204. } catch (error: unknown) {
  205. if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document preparation was aborted', {})
  206. throw new RemoteError('gateway/internal', `settings document preparation failed: ${messageOf(error)}`, {}, { cause: error })
  207. }
  208. if (path === undefined) {
  209. throw new RemoteError('gateway/internal', 'settings provider has no local document to open', {})
  210. }
  211. if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {})
  212. try {
  213. await this.openTextFile(path, signal)
  214. return { opened: true }
  215. } catch (error: unknown) {
  216. if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {})
  217. throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error })
  218. }
  219. }
  220. /**
  221. * Open one user-authored Agent preset directory or return its path when no native opener exists.
  222. * @param agentPreset - preset id resolved against Host-owned roots.
  223. * @param signal - caller lifetime; abort terminates the native command.
  224. * @returns an opened confirmation or the resolved directory for text display.
  225. * @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened.
  226. */
  227. @Remote
  228. async openAgentPresetDirectory(
  229. agentPreset: string,
  230. signal: AbortSignal,
  231. ): Promise<AgentPresetDirectoryOpenValue> {
  232. if (agentPreset.length === 0) {
  233. throw new RemoteError('gateway/bad-request', 'agent preset id must not be empty', {})
  234. }
  235. const presets = this.ctx.get('agentPresets')
  236. if (presets === undefined) {
  237. throw new RemoteError(
  238. 'agent-preset/not-found',
  239. 'this deployment composes no agent presets',
  240. { agentPreset, available: [] },
  241. )
  242. }
  243. const preset = await presets.resolve(agentPreset)
  244. if (preset.trust !== 'user') {
  245. throw new RemoteError(
  246. 'agent-preset/read-only',
  247. `agent-presets: preset "${preset.id}" cannot be written: it ships with the deployment`,
  248. { agentPreset: preset.id, reason: 'it ships with the deployment' },
  249. )
  250. }
  251. const directory = dirname(preset.path)
  252. if (!this.canOpenPath()) return { opened: false, path: directory }
  253. try {
  254. await this.openPath(directory, signal)
  255. return { opened: true }
  256. } catch (error: unknown) {
  257. if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {})
  258. throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error })
  259. }
  260. }
  261. private async write(
  262. ns: string,
  263. mode: 'update' | 'replace' | 'mutate',
  264. input: Record<string, JsonValue> | SettingsPathOpView[],
  265. expectedRevision: number | undefined,
  266. scope: string | undefined,
  267. ): Promise<SettingsNamespaceView> {
  268. const parsed = settingsNamespaceRequestSchema.safeParse({ ns, ...scope === undefined ? {} : { scope } })
  269. if (!parsed.success) {
  270. throw new RemoteError('gateway/bad-request', `invalid payload for settings.${mode}`, { issues: parsed.error.issues })
  271. }
  272. const settings = this.provider()
  273. const namespace = parsed.data.ns
  274. try {
  275. if (mode === 'update') await settings.update(namespace, input, expectedRevision, scope)
  276. else if (mode === 'replace') await settings.replace(namespace, input, expectedRevision, scope)
  277. else await settings.mutate(namespace, input as SettingsPathOp[], expectedRevision, scope)
  278. } catch (error: unknown) {
  279. throw rejected(ns, error)
  280. }
  281. const descriptor = settings
  282. .describe({ redactSecrets: true, ...scope === undefined ? {} : { scope } })
  283. .find(candidate => candidate.ns === namespace)
  284. if (descriptor === undefined) {
  285. // The write committed but the namespace vanished before this read: only a
  286. // concurrent registrant disposal can produce it.
  287. throw new RemoteError('gateway/internal', `settings namespace "${ns}" was disposed after the ${mode}`, {})
  288. }
  289. return namespaceView(descriptor)
  290. }
  291. /** Resolve the optional provider or report how to supply it. */
  292. private provider(): SettingsProvider {
  293. const settings = this.ctx.get('settings')
  294. if (settings === undefined) {
  295. throw new RemoteError(
  296. 'gateway/internal',
  297. 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
  298. {},
  299. )
  300. }
  301. return settings
  302. }
  303. }
  304. function messageOf(error: unknown): string {
  305. return error instanceof Error ? error.message : String(error)
  306. }
  307. interface SettingsConflict {
  308. readonly code: 'SETTINGS_CONFLICT'
  309. readonly message: string
  310. readonly expected: number
  311. readonly actual: number
  312. }
  313. function settingsConflictOf(error: unknown): SettingsConflict | undefined {
  314. if (typeof error !== 'object' || error === null) return undefined
  315. if (Reflect.get(error, 'code') !== 'SETTINGS_CONFLICT'
  316. || typeof Reflect.get(error, 'message') !== 'string'
  317. || typeof Reflect.get(error, 'expected') !== 'number'
  318. || typeof Reflect.get(error, 'actual') !== 'number') return undefined
  319. return error as SettingsConflict
  320. }
  321. /**
  322. * Classify one seam refusal. A stale writer is its own outcome, not a malformed
  323. * request: the client must re-read and re-apply rather than treat the write as
  324. * invalid.
  325. * @param ns - the namespace the write addressed.
  326. * @param error - whatever the seam threw.
  327. * @returns the failure to raise for that refusal.
  328. */
  329. function rejected(ns: string, error: unknown): RemoteError {
  330. const conflict = settingsConflictOf(error)
  331. if (conflict !== undefined) {
  332. return new RemoteError(
  333. 'settings/conflict',
  334. conflict.message,
  335. { ns, expected: conflict.expected, actual: conflict.actual },
  336. { cause: error },
  337. )
  338. }
  339. return new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error })
  340. }
  341. export default SettingsController