index.ts 14 KB

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