settings-controller.host.spec.ts 20 KB

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