host-update-qualification.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /** Built Host qualification; the profile and all session data belong to the caller's private directory. */
  2. import assert from 'node:assert/strict'
  3. import { writeFile } from 'node:fs/promises'
  4. import { spawn } from 'node:child_process'
  5. import { once } from 'node:events'
  6. import { createRequire } from 'node:module'
  7. import { join } from 'node:path'
  8. import { pathToFileURL } from 'node:url'
  9. import { installDesktopUpdateTaskControl } from '../../../desktop-host/lib/types/update-tasks.js'
  10. import { ready } from './host-update-control.mjs'
  11. const root = process.argv[2]
  12. assert.ok(root)
  13. const project = join(root, 'project')
  14. const require = createRequire(join(project, 'package.json'))
  15. const { LlmAdapter, createUserMessage } = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh-llm')).href)
  16. const observed = []
  17. const { runProfile } = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh/profile-boot')).href)
  18. const { loadLayeredEnv, loadProfileDirectory } = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh-app-boot')).href)
  19. const installAnchor = require.resolve('@deepseek-ai/dsh/package.json')
  20. const running = await runProfile({ environment: loadLayeredEnv('dsh'), profile: 'desktop',
  21. resolvedProfile: { profile: loadProfileDirectory('dsh', project, installAnchor), installAnchor },
  22. patchFiles: [], args: ['--no-open', '--port', '0'] })
  23. const host = { updateTasks: installDesktopUpdateTaskControl(running.ctx), dispose: () => running.shutdown.shutdown(0) }
  24. const applicationUrl = running.ctx.connection.authenticatedUrl(`http://127.0.0.1:${running.ctx.webServer.port}`)
  25. const exchange = await fetch(applicationUrl, { redirect: 'manual' })
  26. const cookie = exchange.headers.get('set-cookie')?.split(';', 1)[0]
  27. assert.ok(cookie)
  28. const apiUrl = new URL('/api/qualification', applicationUrl)
  29. try {
  30. const ctx = await ready.promise
  31. const entered = Promise.withResolvers()
  32. class HeldModel extends LlmAdapter {
  33. nextTool
  34. toolIssued = false
  35. async *stream(options) {
  36. if (this.nextTool !== undefined) {
  37. const tool = this.nextTool
  38. this.nextTool = undefined
  39. this.toolIssued = true
  40. yield { type: 'block-start', index: 0, blockType: 'tool-call' }
  41. yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: `qualification-${tool.name}`,
  42. name: tool.name, arguments: JSON.stringify(tool.arguments) } }
  43. yield { type: 'finish', reason: { kind: 'tool-calls' } }
  44. return
  45. }
  46. if (this.toolIssued) {
  47. yield { type: 'finish', reason: { kind: 'stop' } }
  48. return
  49. }
  50. entered.resolve(options)
  51. await new Promise(resolve => {
  52. if (options.signal.aborted) resolve()
  53. else options.signal.addEventListener('abort', resolve, { once: true })
  54. })
  55. yield { type: 'finish', reason: { kind: 'aborted' } }
  56. }
  57. }
  58. const model = new HeldModel()
  59. ctx.effect(() => ctx.llm.registerAdapter(['update-qualification'], model))
  60. assert.equal(await host.updateTasks('inspect'), false)
  61. const owned = await ctx.agents.create({ sessionId: 'desktop-update-qualification', meta: { cwd: root },
  62. agentOptions: { provider: 'update-qualification', model: 'held' },
  63. setup: async agentCtx => { await ctx.agentPresets.mount(agentCtx, 'standard') } })
  64. try {
  65. const agent = owned.agent
  66. assert.equal(ctx.agents.get(agent.id), agent)
  67. assert.equal(await host.updateTasks('inspect'), false)
  68. for (const createsTask of [false, true]) {
  69. const entered = Promise.withResolvers()
  70. const finish = Promise.withResolvers()
  71. const remove = ctx.connection.fetch.register({ path: '/api/update-qualification', methods: ['POST'], requestBody: 'buffered',
  72. async fetch() {
  73. entered.resolve()
  74. await finish.promise
  75. if (createsTask) agent.send(createUserMessage({ content: [{ type: 'text', text: 'Admitted write creates queued work' }],
  76. source: { kind: 'user' } }), 'next-turn', false)
  77. return new Response('finished')
  78. } })
  79. const pending = fetch(new URL('/api/update-qualification', applicationUrl), { method: 'POST', headers: { cookie } })
  80. .then(async response => ({ status: response.status, body: await response.text() }))
  81. try {
  82. await entered.promise
  83. assert.equal(await host.updateTasks('inspect'), false)
  84. let drained = false
  85. const locking = host.updateTasks('lock').then(active => { drained = true; return active })
  86. assert.equal((await fetch(apiUrl, { headers: { cookie } })).status, 503)
  87. assert.equal(drained, false)
  88. finish.resolve()
  89. assert.equal(await locking, createsTask)
  90. assert.deepEqual(await pending, { status: 200, body: 'finished' })
  91. assert.equal(agent.inbox.nextTurn.length, createsTask ? 1 : 0)
  92. agent.inbox.clear()
  93. await host.updateTasks('unlock')
  94. observed.push(createsTask ? 'admitted-write-task-rechecked-after-drain' : 'read-request-drained-without-task-warning')
  95. } finally { finish.resolve(); await pending; await remove() }
  96. }
  97. for (const [target, field] of [['next-turn', 'nextTurn'], ['next-step', 'nextStep']]) {
  98. agent.send(createUserMessage({ content: [{ type: 'text', text: 'Queued qualification input' }], source: { kind: 'user' } }), target, false)
  99. assert.equal(agent.status, 'idle')
  100. assert.equal(agent.inbox[field].length, 1)
  101. assert.equal(await host.updateTasks('inspect'), true)
  102. agent.inbox.clear()
  103. assert.equal(await host.updateTasks('inspect'), false)
  104. observed.push(target)
  105. }
  106. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Hold the qualification model request' }], source: { kind: 'user' } }))
  107. const request = await entered.promise
  108. assert.equal(agent.status, 'running')
  109. assert.equal(await host.updateTasks('inspect'), true)
  110. assert.equal(request.signal.aborted, false)
  111. assert.equal(await host.updateTasks('lock'), true)
  112. assert.equal((await fetch(apiUrl, { headers: { cookie } })).status, 503)
  113. assert.equal(request.signal.aborted, false)
  114. assert.equal(agent.status, 'running')
  115. assert.equal(await host.updateTasks('unlock'), true)
  116. assert.equal((await fetch(apiUrl, { headers: { cookie } })).status, 404)
  117. agent.cancel({ kind: 'user' })
  118. await agent.whenIdle()
  119. assert.equal(request.signal.aborted, true)
  120. assert.equal(await host.updateTasks('inspect'), false)
  121. observed.push('running-model', 'admission-lock-preserves-running-work', 'unlock-restores-requests')
  122. for (const event of ['user-questions/request', 'approval/request']) {
  123. const asked = Promise.withResolvers()
  124. // Hold human input before the Remote answerer, which otherwise waits for a connected Client.
  125. const remove = agent.ctx.on(event, (question, next) => {
  126. if (question.agent !== agent) return next()
  127. asked.resolve(question)
  128. return new Promise((resolve, reject) => {
  129. const abort = () => event === 'approval/request' ? resolve('cancelled') : reject(question.signal.reason)
  130. if (question.signal.aborted) abort()
  131. else question.signal.addEventListener('abort', abort, { once: true })
  132. })
  133. }, { prepend: true })
  134. try {
  135. model.nextTool = event === 'user-questions/request'
  136. ? { name: 'ask_user_question', arguments: { questions: [{ id: 'update', question: 'Keep waiting for qualification?' }] } }
  137. : { name: process.platform === 'win32' ? 'pwsh' : 'bash', arguments: {
  138. command: process.platform === 'win32' ? "Write-Output 'qualification'" : "printf qualification",
  139. description: 'Hold a harmless command pending approval',
  140. sandbox_permissions: 'danger-full-access', justification: 'Qualification holds this approval without running the command',
  141. } }
  142. agent.followup(createUserMessage({ content: [{ type: 'text', text: `Exercise ${event}` }], source: { kind: 'user' } }))
  143. const question = await Promise.race([asked.promise, agent.whenIdle().then(() => {
  144. throw new Error(`Agent finished without reaching ${event}`)
  145. })])
  146. assert.equal(question.signal.aborted, false)
  147. assert.equal(agent.status, 'running')
  148. assert.equal(await host.updateTasks('inspect'), true)
  149. assert.equal(await host.updateTasks('lock'), true)
  150. assert.equal(question.signal.aborted, false)
  151. await host.updateTasks('unlock')
  152. agent.cancel({ kind: 'user' })
  153. await agent.whenIdle()
  154. assert.equal(question.signal.aborted, true)
  155. assert.equal(await host.updateTasks('inspect'), false)
  156. observed.push(event)
  157. } finally {
  158. agent.cancel({ kind: 'user' })
  159. await agent.whenIdle()
  160. remove()
  161. }
  162. }
  163. ctx.effect(() => ctx.jobs.attachController('update-qualification'))
  164. for (const owner of [undefined, agent]) {
  165. let child
  166. let done
  167. const id = ctx.jobs.start({ kind: 'update-qualification', label: 'Private Node process waiting for stdin EOF', owner,
  168. run() {
  169. child = spawn(process.execPath, ['-e', "process.stdout.write('ready');process.stdin.resume()"], {
  170. cwd: root, env: process.env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
  171. })
  172. done = new Promise((resolve, reject) => {
  173. child.once('error', reject)
  174. child.once('close', (code, signal) => resolve({ code, signal }))
  175. })
  176. return { cancel: () => { child.stdin.end() },
  177. done: done.then(result => ({ status: result.code === 0 && result.signal === null ? 'killed' : 'failed' })) }
  178. } })
  179. try {
  180. await once(child.stdout, 'data', { signal: AbortSignal.timeout(15_000) })
  181. assert.equal(ctx.jobs.get(id, owner).status, 'running')
  182. assert.equal(await host.updateTasks('inspect'), true)
  183. assert.equal(child.exitCode, null)
  184. assert.equal(ctx.jobs.kill(id, owner, 'Explicit qualification stop'), 'requested')
  185. assert.equal(ctx.jobs.get(id, owner).status, 'stopping')
  186. assert.equal(await host.updateTasks('inspect'), true)
  187. assert.equal((await ctx.jobs.wait(id, 15_000, owner)).status, 'killed')
  188. assert.deepEqual(await done, { code: 0, signal: null })
  189. assert.equal(await host.updateTasks('inspect'), false)
  190. observed.push(owner === undefined ? 'global-node-job' : 'agent-node-job')
  191. } finally {
  192. child.stdin.end()
  193. await done
  194. }
  195. }
  196. } finally { await owned.dispose() }
  197. } finally { await host.dispose() }
  198. await assert.rejects(host.updateTasks('inspect'), /Host is stopping/)
  199. await assert.rejects(host.updateTasks('lock'), /Host is stopping/)
  200. observed.push('disposed-host-refuses-task-inspection')
  201. await writeFile(join(root, 'result.json'), JSON.stringify({ realHost: true, realAgent: true, observed,
  202. model: 'scripted/held adapter', humanAnswers: 'held answerers', jobs: 'actual Node subprocesses',
  203. hostDisposed: true, installerExecuted: false }, null, 2) + '\n')