index.ts 14 KB

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