settings-controller.host.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import z from '@deepseek-ai/schemastery'
  4. import {
  5. InvalidPresetIdError,
  6. PresetExistsError,
  7. UnknownPresetError,
  8. } from '@deepseek-ai/dsh-agent-presets'
  9. import { settingsNamespace } from '@deepseek-ai/dsh-settings'
  10. import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings'
  11. import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
  12. import SettingsController from '../src/index.ts'
  13. import { MemorySettings } from '../../../settings/settings/tests/memory.ts'
  14. const NS = settingsNamespace('ui-test')
  15. const Profile = z.object({
  16. preference: z.union(['light', 'dark']).default('light'),
  17. apiKey: z.string().role('secret'),
  18. })
  19. /** A provider that reports a local document, for the `hasDocument` fact. */
  20. class DocumentSettings extends MemorySettings {
  21. override get documentPath(): string | undefined {
  22. return '/deployment/settings.yaml'
  23. }
  24. }
  25. /** A provider whose read forgets the namespace its write just committed. */
  26. class VanishingSettings extends MemorySettings {
  27. override describe(): SettingsDescriptor[] {
  28. return []
  29. }
  30. }
  31. /**
  32. * A provider whose descriptor omits the secret-slot list. `secrets` is optional
  33. * on the descriptor, so a foreign provider may leave it out even under
  34. * `redactSecrets`, and the view still has to declare an empty list.
  35. */
  36. class SlotlessSettings extends MemorySettings {
  37. override describe(): SettingsDescriptor[] {
  38. return [{
  39. ns: NS,
  40. schema: Profile.toJSON(),
  41. value: { preference: 'light' },
  42. applies: 'live',
  43. revision: 0,
  44. } as unknown as SettingsDescriptor]
  45. }
  46. }
  47. /** A provider that refuses every write the way a read-only backing store would. */
  48. class RefusingSettings extends MemorySettings {
  49. override mutate(ns: SettingsNamespace): Promise<void> {
  50. return Promise.reject(new Error(`settings "${ns}" is read-only in this deployment`))
  51. }
  52. }
  53. /** A provider that refuses with a bare string, the way some storage clients do. */
  54. class LiteralRefusingSettings extends MemorySettings {
  55. override async mutate(): Promise<void> {
  56. throw 'the document is locked'
  57. }
  58. }
  59. async function boot(
  60. provider: typeof MemorySettings = MemorySettings,
  61. options: { doc?: Record<string, unknown>; base?: { preference: 'light' | 'dark' } } = {},
  62. ): Promise<{ controller: SettingsController; ctx: Context }> {
  63. const ctx = new Context()
  64. await ctx.plugin(provider, options.doc === undefined ? {} : { doc: options.doc })
  65. ctx.settings.register(NS, Profile, options.base === undefined ? {} : { base: options.base })
  66. await ctx.plugin(SettingsController)
  67. return { controller: ctx.settingsController, ctx }
  68. }
  69. describe('the settings Remote namespace a configuration page calls', () => {
  70. it('publishes the settings namespace from its own service key', async () => {
  71. const { controller } = await boot()
  72. expect(controller.typertRemote.serviceKey).toBe('settingsController')
  73. expect(controller.typertRemote.namespace).toBe('settings')
  74. expect(remoteMethods(controller)).toEqual([
  75. { method: 'describe', invocation: { kind: 'direct' } },
  76. { method: 'canOpenAgentPresetDirectory', invocation: { kind: 'direct' } },
  77. { method: 'update', invocation: { kind: 'direct' } },
  78. { method: 'replace', invocation: { kind: 'direct' } },
  79. { method: 'mutate', invocation: { kind: 'direct' } },
  80. { method: 'openSettingsDocument', invocation: { kind: 'direct' } },
  81. { method: 'openAgentPresetDirectory', invocation: { kind: 'direct' } },
  82. ])
  83. })
  84. it('reports the actionable configuration error while no settings provider is mounted', async () => {
  85. const ctx = new Context()
  86. await ctx.plugin(SettingsController)
  87. const calls: Array<() => unknown> = [
  88. () => ctx.settingsController.describe(),
  89. () => ctx.settingsController.update('ui-test', {}, undefined),
  90. () => ctx.settingsController.replace('ui-test', {}, undefined),
  91. () => ctx.settingsController.mutate('ui-test', [], undefined),
  92. () => ctx.settingsController.openSettingsDocument(new AbortController().signal),
  93. ]
  94. for (const call of calls) {
  95. const failure = await Promise.resolve().then(call).catch((error: unknown) => error)
  96. expect(failure).toBeInstanceOf(TypertRemoteFailure)
  97. expect((failure as TypertRemoteFailure).failure).toEqual({
  98. code: 'internal',
  99. message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
  100. details: {},
  101. })
  102. }
  103. })
  104. it('mounts the credentials namespace beside its own', async () => {
  105. const ctx = new Context()
  106. await ctx.plugin(MemorySettings)
  107. ctx.settings.register(NS, Profile)
  108. const fiber = ctx.plugin(SettingsController)
  109. await fiber.await()
  110. expect(ctx.get('credentialsController')).toBeDefined()
  111. await fiber.dispose()
  112. expect(ctx.get('settingsController')).toBeUndefined()
  113. expect(ctx.get('credentialsController')).toBeUndefined()
  114. })
  115. it('describes every namespace redacted, with the deployment facts around them', async () => {
  116. const { controller } = await boot(DocumentSettings, { doc: { 'ui-test': { apiKey: 'sk-stored' } } })
  117. const value = controller.describe()
  118. expect(value).toMatchObject({ writable: true, hasDocument: true })
  119. const [view] = value.namespaces
  120. expect(view?.ns).toBe('ui-test')
  121. // The secret never rides; its slot reports only that one is stored.
  122. expect(JSON.stringify(value)).not.toContain('sk-stored')
  123. expect(view?.secrets).toEqual([{ path: ['apiKey'], set: true }])
  124. // Redaction removes the field rather than replacing it, so the layer that
  125. // stored a secret comes back empty instead of carrying a placeholder.
  126. expect(view?.user).toEqual({})
  127. })
  128. it('reports a read-only provider and omits the layers it has none of', async () => {
  129. const { controller } = await boot(class extends MemorySettings {
  130. override get writable(): boolean {
  131. return false
  132. }
  133. })
  134. const value = controller.describe()
  135. expect(value).toMatchObject({ writable: false, hasDocument: false })
  136. const [view] = value.namespaces
  137. // No composition base was declared and no user section is stored, so
  138. // neither optional layer appears at all.
  139. expect(view && 'base' in view).toBe(false)
  140. expect(view && 'user' in view).toBe(false)
  141. })
  142. it('declares an empty slot list when the provider names no secrets', async () => {
  143. const { controller } = await boot(SlotlessSettings)
  144. const [view] = controller.describe().namespaces
  145. expect(view?.secrets).toEqual([])
  146. })
  147. it('carries the composition base layer when the registrant declared one', async () => {
  148. const { controller } = await boot(MemorySettings, { base: { preference: 'dark' } })
  149. const [view] = controller.describe().namespaces
  150. expect(view?.base).toEqual({ preference: 'dark' })
  151. })
  152. it('applies path-addressed edits and answers with the namespace it just wrote', async () => {
  153. const { controller } = await boot()
  154. const view = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined)
  155. expect(view).toMatchObject({ ns: 'ui-test', user: { preference: 'dark' } })
  156. expect(view.revision).toBeGreaterThan(0)
  157. })
  158. it('supports merge updates and wholesale replacement on the Remote namespace', async () => {
  159. const { controller } = await boot(MemorySettings, {
  160. doc: { 'ui-test': { preference: 'dark', apiKey: 'sk-stored' } },
  161. })
  162. const updated = await controller.update('ui-test', { preference: 'light' }, undefined)
  163. expect(updated.user).toEqual({ preference: 'light' })
  164. expect(updated.secrets).toEqual([{ path: ['apiKey'], set: true }])
  165. const replaced = await controller.replace('ui-test', {}, updated.revision)
  166. expect(replaced.value).toEqual({ preference: 'light' })
  167. expect(replaced.user).toEqual({})
  168. expect(replaced.secrets).toEqual([{ path: ['apiKey'], set: false }])
  169. })
  170. it('refuses a stale write as settings-conflict carrying both revisions', async () => {
  171. const { controller } = await boot()
  172. const held = controller.describe().namespaces[0]!.revision
  173. await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], held)
  174. const failure = await controller
  175. .mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'light' }], held)
  176. .catch((error: unknown) => error)
  177. expect(failure).toBeInstanceOf(TypertRemoteFailure)
  178. const { code, details } = (failure as TypertRemoteFailure).failure
  179. expect(code).toBe('settings-conflict')
  180. expect(details).toMatchObject({ ns: 'ui-test', expected: held })
  181. })
  182. it('answers a malformed namespace exactly as an unregistered one', async () => {
  183. const { controller } = await boot()
  184. for (const ns of ['Not A Namespace', 'unregistered']) {
  185. const failure = await controller.mutate(ns, [{ op: 'unset', path: ['preference'] }], undefined)
  186. .catch((error: unknown) => error)
  187. expect((failure as TypertRemoteFailure).failure).toMatchObject({
  188. code: 'settings-rejected',
  189. details: { ns },
  190. })
  191. }
  192. })
  193. it('reports an empty namespace as bad-request', async () => {
  194. const { controller } = await boot()
  195. for (const call of [
  196. () => controller.update('', {}, undefined),
  197. () => controller.replace('', {}, undefined),
  198. () => controller.mutate('', [], undefined),
  199. ]) {
  200. const failure = await call().catch((error: unknown) => error)
  201. expect(failure).toBeInstanceOf(TypertRemoteFailure)
  202. expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
  203. }
  204. })
  205. it('reports a refused write as settings-rejected carrying the seam message', async () => {
  206. const { controller } = await boot(RefusingSettings)
  207. const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined)
  208. .catch((error: unknown) => error)
  209. const { code, message } = (failure as TypertRemoteFailure).failure
  210. expect(code).toBe('settings-rejected')
  211. expect(message).toContain('read-only in this deployment')
  212. })
  213. it('stringifies a refusal that is not an Error', async () => {
  214. const { controller } = await boot(LiteralRefusingSettings)
  215. const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined)
  216. .catch((error: unknown) => error)
  217. expect((failure as TypertRemoteFailure).failure.message).toBe('the document is locked')
  218. })
  219. it('reports a namespace disposed between the write and its read-back', async () => {
  220. const { controller } = await boot(VanishingSettings)
  221. const failure = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined)
  222. .catch((error: unknown) => error)
  223. const { code, message } = (failure as TypertRemoteFailure).failure
  224. expect(code).toBe('internal')
  225. expect(message).toContain('was disposed after the mutate')
  226. })
  227. it('prepares and opens the provider-owned settings document', async () => {
  228. const ctx = new Context()
  229. await ctx.plugin(DocumentSettings)
  230. const prepare = vi.spyOn(ctx.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml')
  231. const openTextFile = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
  232. const controller = new SettingsController(ctx, {}, { openTextFile })
  233. const signal = new AbortController().signal
  234. await expect(controller.openSettingsDocument(signal)).resolves.toEqual({ opened: true })
  235. expect(prepare).toHaveBeenCalledOnce()
  236. expect(openTextFile).toHaveBeenCalledWith('/tmp/settings.yaml', signal)
  237. })
  238. it('preserves settings-document absence, failure, and cancellation', async () => {
  239. const absent = await boot()
  240. const missingDocument = absent.controller.openSettingsDocument(new AbortController().signal)
  241. await expect(missingDocument).rejects.toMatchObject({ failure: { code: 'internal' } })
  242. await expect(missingDocument).rejects.toThrow('no local document')
  243. const failed = await boot(DocumentSettings)
  244. vi.spyOn(failed.ctx.settings, 'prepareDocument').mockRejectedValue(new Error('read failed'))
  245. const failedRead = failed.controller.openSettingsDocument(new AbortController().signal)
  246. await expect(failedRead).rejects.toMatchObject({ failure: { code: 'internal' } })
  247. await expect(failedRead).rejects.toThrow('read failed')
  248. const cancelled = new AbortController()
  249. cancelled.abort(new Error('cancelled'))
  250. const prepare = vi.spyOn(failed.ctx.settings, 'prepareDocument')
  251. prepare.mockClear()
  252. await expect(failed.controller.openSettingsDocument(cancelled.signal))
  253. .rejects.toMatchObject({ failure: { code: 'cancelled' } })
  254. expect(prepare).not.toHaveBeenCalled()
  255. })
  256. it('does not open a settings document cancelled during preparation', async () => {
  257. const ctx = new Context()
  258. await ctx.plugin(DocumentSettings)
  259. const prepared = Promise.withResolvers<string | undefined>()
  260. vi.spyOn(ctx.settings, 'prepareDocument').mockReturnValue(prepared.promise)
  261. const openTextFile = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
  262. const controller = new SettingsController(ctx, {}, { openTextFile })
  263. const abort = new AbortController()
  264. const opening = controller.openSettingsDocument(abort.signal)
  265. abort.abort(new Error('cancelled'))
  266. prepared.resolve('/tmp/settings.yaml')
  267. await expect(opening).rejects.toMatchObject({ failure: { code: 'cancelled' } })
  268. expect(openTextFile).not.toHaveBeenCalled()
  269. })
  270. it('maps native settings-document opener failures', async () => {
  271. const ctx = new Context()
  272. await ctx.plugin(DocumentSettings)
  273. vi.spyOn(ctx.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml')
  274. const controller = new SettingsController(ctx, {}, {
  275. openTextFile: () => Promise.reject(new Error('no default editor')),
  276. })
  277. await expect(controller.openSettingsDocument(new AbortController().signal))
  278. .rejects.toMatchObject({
  279. failure: { code: 'internal', message: 'path open failed: no default editor' },
  280. })
  281. })
  282. it('classifies cancellation while preparing or opening the settings document', async () => {
  283. const preparing = new Context()
  284. await preparing.plugin(DocumentSettings)
  285. const prepareAbort = new AbortController()
  286. vi.spyOn(preparing.settings, 'prepareDocument').mockImplementation(async () => {
  287. prepareAbort.abort(new Error('cancelled'))
  288. throw new Error('preparation stopped')
  289. })
  290. const preparingController = new SettingsController(preparing)
  291. await expect(preparingController.openSettingsDocument(prepareAbort.signal))
  292. .rejects.toMatchObject({ failure: { code: 'cancelled' } })
  293. const opening = new Context()
  294. await opening.plugin(DocumentSettings)
  295. vi.spyOn(opening.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml')
  296. const openAbort = new AbortController()
  297. const openingController = new SettingsController(opening, {}, {
  298. openTextFile: async () => {
  299. openAbort.abort(new Error('cancelled'))
  300. throw new Error('opening stopped')
  301. },
  302. })
  303. await expect(openingController.openSettingsDocument(openAbort.signal))
  304. .rejects.toMatchObject({ failure: { code: 'cancelled' } })
  305. })
  306. it('opens a user Agent preset directory or returns its path without a native opener', async () => {
  307. const ctx = new Context()
  308. ctx.provide('agentPresets', {
  309. resolve: (id: string) => Promise.resolve({
  310. id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`,
  311. }),
  312. } as never)
  313. const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
  314. const openable = new SettingsController(ctx, { nativeOpen: true }, { openPath })
  315. expect(openable.canOpenAgentPresetDirectory()).toBe(true)
  316. const signal = new AbortController().signal
  317. await expect(openable.openAgentPresetDirectory('mine', signal))
  318. .resolves.toEqual({ opened: true })
  319. expect(openPath).toHaveBeenCalledWith('/presets/mine', signal)
  320. const headless = new Context()
  321. headless.provide('agentPresets', {
  322. resolve: (id: string) => Promise.resolve({
  323. id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`,
  324. }),
  325. } as never)
  326. const reveal = new SettingsController(headless, { nativeOpen: false })
  327. expect(reveal.canOpenAgentPresetDirectory()).toBe(false)
  328. await expect(reveal.openAgentPresetDirectory('mine', new AbortController().signal))
  329. .resolves.toEqual({ opened: false, path: '/presets/mine' })
  330. })
  331. it('covers native-open detection defaults and explicit overrides', () => {
  332. const fromInjectedOpener = new SettingsController(new Context(), {}, {
  333. openPath: () => Promise.resolve(),
  334. })
  335. expect((fromInjectedOpener as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe(true)
  336. const detected = new SettingsController(new Context())
  337. expect(typeof (detected as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe('boolean')
  338. const override = vi.fn(() => false)
  339. const overridden = new SettingsController(new Context(), {}, { canOpenPath: override })
  340. expect((overridden as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe(false)
  341. expect(override).toHaveBeenCalledOnce()
  342. })
  343. it('refuses a shipped Agent preset and a missing preset provider', async () => {
  344. const ctx = new Context()
  345. ctx.provide('agentPresets', {
  346. resolve: (id: string) => Promise.resolve({
  347. id, trust: 'system', path: `/presets/${id}/agent.cordis.yml`,
  348. }),
  349. } as never)
  350. const controller = new SettingsController(ctx)
  351. await expect(controller.openAgentPresetDirectory('standard', new AbortController().signal))
  352. .rejects.toMatchObject({ failure: { code: 'agent-preset-read-only' } })
  353. const missing = new SettingsController(new Context())
  354. await expect(missing.openAgentPresetDirectory('mine', new AbortController().signal))
  355. .rejects.toMatchObject({ failure: { code: 'agent-preset-not-found' } })
  356. })
  357. it('rejects an empty Agent preset id before resolving a provider', async () => {
  358. const resolve = vi.fn()
  359. const ctx = new Context()
  360. ctx.provide('agentPresets', { resolve } as never)
  361. const controller = new SettingsController(ctx)
  362. await expect(controller.openAgentPresetDirectory('', new AbortController().signal))
  363. .rejects.toMatchObject({ failure: { code: 'bad-request' } })
  364. expect(resolve).not.toHaveBeenCalled()
  365. })
  366. it.each([
  367. [new UnknownPresetError('missing', ['standard']), 'agent-preset-not-found'],
  368. [new InvalidPresetIdError('../bad'), 'agent-preset-invalid'],
  369. [new PresetExistsError('taken'), 'agent-preset-invalid'],
  370. [new TypertRemoteFailure({ code: 'cancelled', message: 'cancelled', details: {} }), 'cancelled'],
  371. ['unexpected preset failure', 'internal'],
  372. ] as const)('maps Agent preset resolution failure %#', async (error, code) => {
  373. const ctx = new Context()
  374. ctx.provide('agentPresets', { resolve: async () => { throw error } } as never)
  375. const controller = new SettingsController(ctx)
  376. await expect(controller.openAgentPresetDirectory('mine', new AbortController().signal))
  377. .rejects.toMatchObject({ failure: { code } })
  378. })
  379. it('classifies cancellation and non-Error failures from the preset opener', async () => {
  380. const ctx = new Context()
  381. ctx.provide('agentPresets', {
  382. resolve: (id: string) => Promise.resolve({
  383. id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`,
  384. }),
  385. } as never)
  386. const abort = new AbortController()
  387. const openPath = vi.fn()
  388. .mockImplementationOnce(async () => {
  389. abort.abort(new Error('cancelled'))
  390. throw new Error('opening stopped')
  391. })
  392. .mockRejectedValueOnce('desktop unavailable')
  393. const controller = new SettingsController(ctx, { nativeOpen: true }, { openPath })
  394. await expect(controller.openAgentPresetDirectory('first', abort.signal))
  395. .rejects.toMatchObject({ failure: { code: 'cancelled' } })
  396. await expect(controller.openAgentPresetDirectory('second', new AbortController().signal))
  397. .rejects.toMatchObject({
  398. failure: { code: 'internal', message: 'path open failed: desktop unavailable' },
  399. })
  400. })
  401. })