controller.spec.ts 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. /** Session identity, allocation races, confinement and real PTY behavior. */
  2. import { mkdtemp, rm } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { PassThrough } from 'node:stream'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import type { Agent } from '@deepseek-ai/dsh-agent'
  8. import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
  9. import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
  10. import { SubprocessExecutableNotFoundError, type SubprocessRuntime, type SubprocessTerminalEnvironment, type SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
  11. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  12. import { afterEach, describe, expect, it, vi } from 'vitest'
  13. import { TerminalController, type Config } from '../src/index.ts'
  14. import { resolveShell } from '../src/shells.ts'
  15. import type { TerminalAttachmentId, WebTerminalId } from '../src/types.ts'
  16. const roots: Context[] = []
  17. afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) })
  18. const config: Config = { shellCandidates: ['zsh', 'bash', 'sh'], shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100, unattendedTimeoutMs: 7_200_000, activityPollIntervalMs: 30_000, cleanupRetryMs: 60_000 }
  19. const id = 'test-terminal' as WebTerminalId
  20. const request = { id, cols: 80, rows: 24 }
  21. const signal = (): AbortSignal => new AbortController().signal
  22. function owner(ctx: Context, id = 'session'): Agent {
  23. return { id: id as SessionId, ctx, session: { id: id as SessionId } } as unknown as Agent
  24. }
  25. function fixture(overrides: Partial<Config> = {}) {
  26. const ctx = new Context()
  27. roots.push(ctx)
  28. const effects = vi.spyOn(ctx.fiber, 'effect')
  29. const sandboxPolicy = { defaultMode: 'danger-full-access', resolve: vi.fn((): SandboxExecutionPolicy => ({ mode: 'danger-full-access', workspaceRoot: '/workspace' })) }
  30. const projections = { stateOf: vi.fn((): SandboxMode | null => null) }
  31. ctx.provide('sandboxPolicy', sandboxPolicy as never)
  32. ctx.provide('sessionProjections', projections as never)
  33. const output = new PassThrough()
  34. const done = Promise.withResolvers<{ exitCode: number; signal: null }>()
  35. const handle = {
  36. pid: 123, output, done: done.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}),
  37. inspectActivity: vi.fn<SubprocessTerminalHandle['inspectActivity']>(async () => ({ state: 'unknown', revision: 0 })),
  38. inspectForeground: async () => undefined, signalForeground: async () => 123,
  39. terminate: vi.fn(async () => { output.end(); done.resolve({ exitCode: 0, signal: null }) }) }
  40. const checked: SubprocessTerminalHandle = handle
  41. const subprocess = { terminalEnvironment: vi.fn(async (): Promise<SubprocessTerminalEnvironment> => ({ platform: 'posix', defaultShell: '/bin/bash' })), resolveExecutable: vi.fn(async (path: string) => path), spawnTerminal: vi.fn(async () => checked) }
  42. ctx.provide('subprocess', subprocess as never)
  43. const controller = new TerminalController(ctx, { ...config, ...overrides })
  44. const disposeEffect = (label: string): Promise<void> => {
  45. const index = effects.mock.calls.findIndex(call => call[1] === label)
  46. const result = effects.mock.results[index]
  47. if (result?.type !== 'return' || typeof result.value !== 'function') throw new Error(`Missing effect: ${label}`)
  48. return result.value()
  49. }
  50. return { ctx, agent: owner(ctx), controller, subprocess, handle, sandboxPolicy, projections, disposeEffect }
  51. }
  52. describe('TerminalController', () => {
  53. it('resolves execution services from an Agent plugin context without consumer injections', async () => {
  54. const { ctx, controller } = fixture()
  55. let agent: Agent | undefined
  56. await ctx.plugin((child) => { agent = owner(child) })
  57. if (agent === undefined) throw new Error('Agent plugin did not load')
  58. expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace' })
  59. await controller.create(agent, request, signal())
  60. await controller.close(agent, id)
  61. })
  62. it('creates the environment default shell and keeps an existing identity when that default changes', async () => {
  63. const { controller, agent, subprocess } = fixture({ shell: undefined })
  64. subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/usr/local/bin/zsh' })
  65. expect(controller.environment(agent, signal())).toEqual({ cwd: '/workspace', maxCols: 200, maxRows: 100, maxInputBytes: 1000, scrollback: 100 })
  66. expect(subprocess.terminalEnvironment).not.toHaveBeenCalled()
  67. const created = await controller.create(agent, request, signal())
  68. expect(created.shell.path).toBe('/usr/local/bin/zsh')
  69. expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/usr/local/bin/zsh', '-i'] }))
  70. subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/bin/sh' })
  71. expect(await controller.create(agent, request, signal())).toBe(created)
  72. expect(subprocess.terminalEnvironment).toHaveBeenCalledOnce()
  73. expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
  74. })
  75. it('restores a running terminal after its default executable becomes unavailable', async () => {
  76. const { controller, agent, subprocess } = fixture({ shell: undefined })
  77. await controller.create(agent, request, signal())
  78. subprocess.resolveExecutable.mockRejectedValue(new SubprocessExecutableNotFoundError('default shell was removed'))
  79. expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace', maxInputBytes: 1000 })
  80. expect(controller.list(agent.id)).toMatchObject([{ id, state: 'running' }])
  81. const stream = controller.follow(agent, id, 'restored' as TerminalAttachmentId, signal())[Symbol.asyncIterator]()
  82. try {
  83. expect(await stream.next()).toMatchObject({ done: false, value: { type: 'snapshot', info: { id, state: 'running' } } })
  84. expect(subprocess.resolveExecutable).toHaveBeenCalledOnce()
  85. await expect(controller.create(agent, { ...request, id: 'new-terminal' as WebTerminalId }, signal())).rejects.toThrow('default shell was removed')
  86. expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
  87. } finally { await stream.return?.() }
  88. })
  89. it('honors cancellation before reading environment limits', () => {
  90. const { controller, agent, subprocess, sandboxPolicy } = fixture()
  91. const abort = new AbortController()
  92. abort.abort(new Error('request cancelled'))
  93. expect(() => controller.environment(agent, abort.signal)).toThrow('request cancelled')
  94. expect(subprocess.terminalEnvironment).not.toHaveBeenCalled()
  95. expect(sandboxPolicy.resolve).not.toHaveBeenCalled()
  96. })
  97. it('deduplicates concurrent creates, scopes access by Session, and preserves terminals when a stream aborts', async () => {
  98. const { controller, agent, ctx, subprocess, handle } = fixture()
  99. const [first, second] = await Promise.all([controller.create(agent, request, signal()), controller.create(agent, request, signal())])
  100. expect(first.id).toBe(second.id)
  101. expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
  102. expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ terminalType: 'xterm-256color', cwd: '/workspace' }))
  103. expect(controller.list('other' as SessionId)).toEqual([])
  104. expect(() => controller.follow(owner(ctx, 'other'), id, 'writer' as TerminalAttachmentId, signal())).toThrow('no longer exists')
  105. const abort = new AbortController()
  106. const stream = controller.follow(agent, id, 'writer' as TerminalAttachmentId, abort.signal)[Symbol.asyncIterator]()
  107. const firstFrame = await stream.next()
  108. if (firstFrame.done === true) throw new Error('Terminal stream ended before its snapshot')
  109. expect(firstFrame.value.type).toBe('snapshot')
  110. abort.abort()
  111. await stream.return?.()
  112. expect(handle.terminate).not.toHaveBeenCalled()
  113. await controller.close(agent, id)
  114. expect(handle.terminate).toHaveBeenCalledOnce()
  115. expect(controller.list(agent.id)).toEqual([])
  116. await controller.close(agent, id)
  117. })
  118. it('waits for pending allocation when explicitly closing and rolls back cancellation before publication', async () => {
  119. const { controller, agent, subprocess, handle } = fixture()
  120. const allocation = Promise.withResolvers<SubprocessTerminalHandle>()
  121. subprocess.spawnTerminal.mockImplementationOnce(() => allocation.promise)
  122. const creating = controller.create(agent, request, signal())
  123. const rejected = expect(creating).rejects.toThrow('closed in this Session')
  124. const closing = controller.close(agent, id)
  125. allocation.resolve(handle)
  126. await rejected
  127. await closing
  128. expect(controller.list(agent.id)).toEqual([])
  129. expect(handle.terminate).toHaveBeenCalledOnce()
  130. })
  131. it('rejects a delayed create after close even when that identity has never been allocated', async () => {
  132. const { controller, agent, subprocess } = fixture()
  133. await controller.close(agent, id)
  134. await controller.close(agent, id)
  135. await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session')
  136. expect(subprocess.spawnTerminal).not.toHaveBeenCalled()
  137. const nextId = 'next-terminal' as WebTerminalId
  138. await expect(controller.create(agent, { ...request, id: nextId }, signal())).resolves.toMatchObject({ id: nextId })
  139. expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
  140. })
  141. it('rejects original, duplicate and later creates when close arrives during allocation', async () => {
  142. const { controller, agent, subprocess, handle } = fixture()
  143. const allocation = Promise.withResolvers<SubprocessTerminalHandle>()
  144. const spawning = Promise.withResolvers<undefined>()
  145. subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise })
  146. const creating = controller.create(agent, request, signal())
  147. const duplicate = controller.create(agent, request, signal())
  148. const rejected = expect(creating).rejects.toThrow('closed in this Session')
  149. const duplicateRejected = expect(duplicate).rejects.toThrow('closed in this Session')
  150. try {
  151. await spawning.promise
  152. const closing = controller.close(agent, id)
  153. await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session')
  154. allocation.resolve(handle)
  155. await Promise.all([rejected, duplicateRejected, closing])
  156. await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session')
  157. expect(handle.terminate).toHaveBeenCalledOnce()
  158. expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
  159. expect(controller.list(agent.id)).toEqual([])
  160. } finally { allocation.resolve(handle) }
  161. })
  162. it('retains failed allocation cleanup for a later explicit close', async () => {
  163. const { controller, agent, subprocess, handle } = fixture()
  164. const abort = new AbortController()
  165. subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('request disconnected')); return handle })
  166. vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('cleanup failed'))
  167. await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed')
  168. expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed' }])
  169. await controller.close(agent, id)
  170. expect(handle.terminate).toHaveBeenCalledTimes(2)
  171. expect(controller.list(agent.id)).toEqual([])
  172. })
  173. it('retains a terminal when process cleanup fails so closing can be retried', async () => {
  174. const { controller, agent, handle } = fixture()
  175. await controller.create(agent, request, signal())
  176. vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('process still alive'))
  177. await expect(controller.close(agent, id)).rejects.toThrow('still alive')
  178. expect(controller.list(agent.id)).toHaveLength(1)
  179. await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session')
  180. await controller.close(agent, id)
  181. expect(controller.list(agent.id)).toHaveLength(0)
  182. })
  183. it('rejects invalid dimensions, unavailable shells and oversized input before executing them', async () => {
  184. const { controller, agent, subprocess } = fixture()
  185. await expect(controller.create(agent, { ...request, cols: 1 }, signal())).rejects.toThrow('dimensions')
  186. subprocess.resolveExecutable.mockRejectedValueOnce(new SubprocessExecutableNotFoundError('shell unavailable'))
  187. await expect(controller.create(agent, request, signal())).rejects.toThrow('unavailable')
  188. expect(subprocess.spawnTerminal).not.toHaveBeenCalled()
  189. await expect(controller.write(agent, id, 'writer' as TerminalAttachmentId, 'x'.repeat(1001))).rejects.toThrow('input')
  190. })
  191. it('keeps the committed identity on repeated creation and counts pending reservations toward the Session limit', async () => {
  192. const { controller, agent, subprocess, handle } = fixture({ maxTerminals: 1 })
  193. const allocation = Promise.withResolvers<SubprocessTerminalHandle>()
  194. const spawning = Promise.withResolvers<undefined>()
  195. subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise })
  196. const creating = controller.create(agent, request, signal())
  197. try {
  198. await spawning.promise
  199. await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } })
  200. } finally { allocation.resolve(handle) }
  201. const first = await creating
  202. expect(await controller.create(agent, request, signal())).toBe(first)
  203. expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
  204. })
  205. it('keeps failed allocations reserved until their cleanup succeeds', async () => {
  206. const { controller, agent, subprocess, handle } = fixture({ maxTerminals: 1 })
  207. const abort = new AbortController()
  208. subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort('lost request'); return handle })
  209. handle.terminate.mockRejectedValueOnce(new Error('still alive'))
  210. await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed')
  211. expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed', error: 'lost request' }])
  212. await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session')
  213. await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } })
  214. await controller.close(agent, id)
  215. expect(controller.list(agent.id)).toEqual([])
  216. })
  217. it('lets close reclaim an allocation whose request failed while close was waiting', async () => {
  218. const { controller, agent, subprocess, handle } = fixture()
  219. const allocation = Promise.withResolvers<SubprocessTerminalHandle>()
  220. const abort = new AbortController()
  221. subprocess.spawnTerminal.mockImplementationOnce(() => allocation.promise)
  222. const creating = controller.create(agent, request, abort.signal)
  223. const rejected = expect(creating).rejects.toThrow('cancelled')
  224. const closing = controller.close(agent, id)
  225. abort.abort(new Error('cancelled'))
  226. allocation.resolve(handle)
  227. await rejected
  228. await closing
  229. expect(handle.terminate).toHaveBeenCalledOnce()
  230. expect(controller.list(agent.id)).toEqual([])
  231. })
  232. it('routes input, resize and trimmed names through the active attachment', async () => {
  233. const { controller, agent, handle } = fixture({ maxInputBytes: 6 })
  234. await controller.create(agent, request, signal())
  235. const attachmentId = 'writer' as TerminalAttachmentId
  236. const stream = controller.follow(agent, id, attachmentId, signal())[Symbol.asyncIterator]()
  237. await stream.next()
  238. try {
  239. await controller.write(agent, id, attachmentId, '终端')
  240. expect(handle.write).toHaveBeenCalledWith('终端')
  241. await expect(controller.write(agent, id, attachmentId, '终端!')).rejects.toThrow('input exceeds')
  242. await controller.resize(agent, id, attachmentId, 200, 100)
  243. expect(handle.resize).toHaveBeenCalledWith(200, 100)
  244. controller.rename(agent, id, ' server logs ')
  245. expect(controller.list(agent.id)).toMatchObject([{ title: 'server logs', cols: 200, rows: 100 }])
  246. } finally { await stream.return?.() }
  247. })
  248. it.each([{ cols: 1.5, rows: 24 }, { cols: 201, rows: 24 }, { cols: 80, rows: 1.5 }, { cols: 80, rows: 0 }, { cols: 80, rows: 101 }])('rejects dimensions $cols × $rows before allocation or resize', async (dimensions) => {
  249. const { controller, agent, subprocess, handle } = fixture()
  250. await expect(controller.create(agent, { ...request, ...dimensions }, signal())).rejects.toThrow('dimensions')
  251. await expect(controller.resize(agent, id, 'writer' as TerminalAttachmentId, dimensions.cols, dimensions.rows)).rejects.toThrow('dimensions')
  252. expect(subprocess.spawnTerminal).not.toHaveBeenCalled()
  253. expect(handle.resize).not.toHaveBeenCalled()
  254. })
  255. it('rejects invalid wire identities and names without changing retained terminals', async () => {
  256. const { controller, agent } = fixture()
  257. await expect(controller.create(agent, { ...request, id: '../terminal' as WebTerminalId }, signal())).rejects.toThrow('Invalid terminal identity')
  258. expect(() => controller.follow(agent, id, '' as TerminalAttachmentId, signal())).toThrow('attachment identity')
  259. expect(() => { controller.rename(agent, id, ' ') }).toThrow('1–120')
  260. expect(() => { controller.rename(agent, id, 'x'.repeat(121)) }).toThrow('1–120')
  261. await controller.close(agent, id)
  262. expect(controller.list(agent.id)).toEqual([])
  263. })
  264. it('uses the Session sandbox policy to confine its selected shell', async () => {
  265. const { controller, agent, ctx, sandboxPolicy, subprocess } = fixture()
  266. const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: '/workspace', sessionId: agent.id }
  267. sandboxPolicy.resolve.mockReturnValue(policy)
  268. const confine = vi.fn((argv: readonly string[]) => ({ argv: ['sandbox-runner', ...argv] }))
  269. ctx.provide('sandbox', { confine } as never)
  270. await controller.create(agent, request, signal())
  271. expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy, expect.any(AbortSignal))
  272. expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['sandbox-runner', '/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 }))
  273. })
  274. it('rejects a confined Session without a sandbox provider before spawning', async () => {
  275. const { controller, agent, sandboxPolicy, subprocess } = fixture()
  276. sandboxPolicy.resolve.mockReturnValue({ mode: 'read-only', workspaceRoot: '/workspace' })
  277. await expect(controller.create(agent, request, signal())).rejects.toThrow('requires an execution sandbox provider')
  278. expect(subprocess.spawnTerminal).not.toHaveBeenCalled()
  279. })
  280. it.each(['subprocess', 'sandboxPolicy'] as const)('fails clearly when the Session lacks %s', (missing) => {
  281. const { controller, subprocess, sandboxPolicy } = fixture()
  282. const isolated = new Context()
  283. roots.push(isolated)
  284. if (missing !== 'subprocess') isolated.provide('subprocess', subprocess as never)
  285. if (missing !== 'sandboxPolicy') isolated.provide('sandboxPolicy', sandboxPolicy as never)
  286. expect(() => controller.environment(owner(isolated), signal())).toThrow('requires subprocess and sandbox policy providers')
  287. })
  288. it('blocks sandbox-mode changes only while that Session retains a terminal', async () => {
  289. const { controller, agent, ctx, projections } = fixture()
  290. const mode = (mode: SandboxMode): void => { ctx.emit('session/event', agent.session, { type: 'sandbox/mode', data: { mode } } as SessionEvent) }
  291. ctx.emit('session/disposed', agent.session)
  292. ctx.emit('session/event', agent.session, { type: 'turn/start', data: { turn: 1 } } as SessionEvent)
  293. expect(() => { mode('workspace-write') }).not.toThrow()
  294. await controller.create(agent, request, signal())
  295. expect(() => { mode('danger-full-access') }).not.toThrow()
  296. expect(() => { mode('workspace-write') }).toThrow('Close browser terminals')
  297. projections.stateOf.mockReturnValue('read-only')
  298. expect(() => { mode('read-only') }).not.toThrow()
  299. expect(() => { mode('danger-full-access') }).toThrow('Close browser terminals')
  300. await controller.close(agent, id)
  301. expect(() => { mode('workspace-write') }).not.toThrow()
  302. })
  303. it('terminates committed processes when the Session effect ends', async () => {
  304. const { controller, agent, handle, disposeEffect } = fixture()
  305. await controller.create(agent, request, signal())
  306. await disposeEffect('terminal-controller.owner')
  307. expect(handle.terminate).toHaveBeenCalledOnce()
  308. expect(controller.list(agent.id)).toEqual([])
  309. })
  310. it('cleans up a Session when its real Agent plugin fiber is disposed', async () => {
  311. const { controller, ctx, handle } = fixture()
  312. let agent: Agent | undefined
  313. const fiber = await ctx.plugin((child) => { agent = owner(child) })
  314. if (agent === undefined) throw new Error('Agent plugin did not load')
  315. await controller.create(agent, request, signal())
  316. await fiber.dispose()
  317. expect(handle.terminate).toHaveBeenCalledOnce()
  318. expect(controller.list(agent.id)).toEqual([])
  319. })
  320. it('waits for pending creation during Host disposal and rolls back its process before resolving', async () => {
  321. const { controller, agent, subprocess, handle, disposeEffect } = fixture()
  322. const allocation = Promise.withResolvers<SubprocessTerminalHandle>()
  323. const spawning = Promise.withResolvers<undefined>()
  324. subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise })
  325. const creating = controller.create(agent, request, signal())
  326. const rejected = expect(creating).rejects.toThrow('disposed')
  327. await spawning.promise
  328. const disposing = disposeEffect('terminal-controller.processes')
  329. allocation.resolve(handle)
  330. await rejected
  331. await disposing
  332. expect(handle.terminate).toHaveBeenCalledOnce()
  333. expect(controller.list(agent.id)).toEqual([])
  334. await expect(controller.create(agent, request, signal())).rejects.toThrow('controller disposed')
  335. })
  336. it('shares cleanup between the Session and Host effects while awaiting process termination', async () => {
  337. const { controller, agent, handle, disposeEffect } = fixture()
  338. await controller.create(agent, request, signal())
  339. const stopping = Promise.withResolvers<undefined>()
  340. const stopped = Promise.withResolvers<undefined>()
  341. const terminate = handle.terminate.getMockImplementation()
  342. if (terminate === undefined) throw new Error('Missing process cleanup fixture')
  343. handle.terminate.mockImplementationOnce(async () => { stopping.resolve(undefined); await stopped.promise; await terminate() })
  344. const ownerDisposal = disposeEffect('terminal-controller.owner')
  345. try {
  346. await stopping.promise
  347. const hostDisposal = disposeEffect('terminal-controller.processes')
  348. stopped.resolve(undefined)
  349. await Promise.all([ownerDisposal, hostDisposal])
  350. expect(handle.terminate).toHaveBeenCalledOnce()
  351. expect(controller.list(agent.id)).toEqual([])
  352. } finally { stopped.resolve(undefined) }
  353. })
  354. it('retains cleanup failures from both Session and Host disposal for explicit retry', async () => {
  355. const { controller, agent, handle, disposeEffect } = fixture()
  356. await controller.create(agent, request, signal())
  357. handle.terminate.mockRejectedValueOnce(new Error('still alive'))
  358. await expect(disposeEffect('terminal-controller.owner')).rejects.toThrow('Session terminal cleanup failed')
  359. await expect(controller.create(agent, request, signal())).rejects.toThrow('Session owner disposed')
  360. handle.terminate.mockRejectedValueOnce(new Error('still alive again'))
  361. await expect(disposeEffect('terminal-controller.processes')).rejects.toThrow('Browser terminal cleanup failed')
  362. expect(controller.list(agent.id)).toHaveLength(1)
  363. await controller.close(agent, id)
  364. expect(controller.list(agent.id)).toEqual([])
  365. expect(handle.terminate).toHaveBeenCalledTimes(3)
  366. })
  367. it('reclaims retained failed allocations when the owning Session ends', async () => {
  368. const { controller, agent, subprocess, handle, disposeEffect } = fixture()
  369. const abort = new AbortController()
  370. subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('disconnected')); return handle })
  371. handle.terminate.mockRejectedValueOnce(new Error('first cleanup failed'))
  372. await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed')
  373. await disposeEffect('terminal-controller.owner')
  374. expect(handle.terminate).toHaveBeenCalledTimes(2)
  375. expect(controller.list(agent.id)).toEqual([])
  376. })
  377. })
  378. describe('shell resolution', () => {
  379. it('accepts an omitted shell profile and defaults configured arguments to an empty list', () => {
  380. // Loader input is unvalidated; the schema's declared type describes its normalized output.
  381. const parse = (input: unknown): Config => TerminalController.Config(input as Config)
  382. expect(parse({}).shell).toBeUndefined()
  383. expect(parse({})).toMatchObject({ unattendedTimeoutMs: 7_200_000, activityPollIntervalMs: 30_000, cleanupRetryMs: 60_000 })
  384. expect(parse({ unattendedTimeoutMs: 0 }).unattendedTimeoutMs).toBe(0)
  385. for (const key of ['unattendedTimeoutMs', 'activityPollIntervalMs', 'cleanupRetryMs']) {
  386. for (const value of [-1, 0.5, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1]) {
  387. expect(() => parse({ [key]: value })).toThrow()
  388. }
  389. }
  390. for (const key of ['activityPollIntervalMs', 'cleanupRetryMs']) expect(() => parse({ [key]: 0 })).toThrow()
  391. expect(parse({ shell: { path: 'custom', name: 'Project shell' } }).shell).toEqual({ path: 'custom', name: 'Project shell', args: [] })
  392. expect(() => parse({ shell: { name: 'Project shell' } })).toThrow()
  393. })
  394. it.each([
  395. { platform: 'posix' as const, path: '/bin/sh', name: 'sh', args: ['-i'] },
  396. { platform: 'windows' as const, path: 'cmd.exe', name: 'cmd.exe', args: [] },
  397. ])('uses the conservative $platform fallback only when the provider omits its default shell', async ({ platform, path, name, args }) => {
  398. const { subprocess } = fixture()
  399. subprocess.terminalEnvironment.mockResolvedValue({ platform })
  400. const runtime = subprocess as unknown as SubprocessRuntime
  401. const requestSignal = signal()
  402. expect(await resolveShell(runtime, undefined, requestSignal)).toEqual({ path, name, args })
  403. expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith(path, undefined, requestSignal)
  404. })
  405. it.each([
  406. { platform: 'posix' as const, defaultShell: '/usr/local/bin/fish', name: 'fish', args: ['-i'] },
  407. { platform: 'windows' as const, defaultShell: 'C:\\Windows\\CMD.EXE', name: 'CMD.EXE', args: [] },
  408. { platform: 'windows' as const, defaultShell: 'C:\\PowerShell\\powershell.exe', name: 'powershell.exe', args: ['-NoLogo'] },
  409. { platform: 'posix' as const, defaultShell: '/usr/local/bin/pwsh', name: 'pwsh', args: ['-NoLogo'] },
  410. ])('resolves only the declared default $defaultShell with its interactive arguments', async ({ platform, defaultShell, name, args }) => {
  411. const { subprocess } = fixture()
  412. subprocess.terminalEnvironment.mockResolvedValue({ platform, defaultShell })
  413. subprocess.resolveExecutable.mockResolvedValue('/resolved/shell')
  414. const runtime = subprocess as unknown as SubprocessRuntime
  415. const requestSignal = signal()
  416. expect(await resolveShell(runtime, undefined, requestSignal)).toEqual({ path: '/resolved/shell', name, args })
  417. expect(subprocess.terminalEnvironment).toHaveBeenCalledExactlyOnceWith(requestSignal)
  418. expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith(defaultShell, undefined, requestSignal)
  419. })
  420. it('uses an explicit profile without querying the environment and preserves its name and arguments', async () => {
  421. const { subprocess } = fixture()
  422. subprocess.resolveExecutable.mockResolvedValue('/resolved/custom')
  423. const runtime = subprocess as unknown as SubprocessRuntime
  424. const requestSignal = signal()
  425. const configured = { path: 'custom', name: 'Project shell', args: ['--project'] }
  426. expect(await resolveShell(runtime, configured, requestSignal)).toEqual({ ...configured, path: '/resolved/custom' })
  427. expect(subprocess.terminalEnvironment).not.toHaveBeenCalled()
  428. expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith('custom', undefined, requestSignal)
  429. })
  430. it.each([new SubprocessExecutableNotFoundError('missing default'), new Error('transport unavailable')])('reports default resolution failure without attempting fallback: %s', async (failure) => {
  431. const { subprocess } = fixture()
  432. subprocess.resolveExecutable.mockRejectedValue(failure)
  433. const runtime = subprocess as unknown as SubprocessRuntime
  434. await expect(resolveShell(runtime, undefined, signal())).rejects.toBe(failure)
  435. expect(subprocess.resolveExecutable).toHaveBeenCalledOnce()
  436. })
  437. it('reports an unavailable configured shell without consulting the provider default', async () => {
  438. const { subprocess } = fixture()
  439. const failure = new SubprocessExecutableNotFoundError('missing configured shell')
  440. subprocess.resolveExecutable.mockRejectedValue(failure)
  441. const runtime = subprocess as unknown as SubprocessRuntime
  442. await expect(resolveShell(runtime, { path: '/missing', name: 'missing', args: [] }, signal())).rejects.toBe(failure)
  443. expect(subprocess.resolveExecutable).toHaveBeenCalledOnce()
  444. expect(subprocess.terminalEnvironment).not.toHaveBeenCalled()
  445. })
  446. it('propagates environment cancellation before verifying an executable', async () => {
  447. const { subprocess } = fixture()
  448. const cancelled = new Error('request disconnected')
  449. subprocess.terminalEnvironment.mockRejectedValue(cancelled)
  450. const runtime = subprocess as unknown as SubprocessRuntime
  451. await expect(resolveShell(runtime, undefined, signal())).rejects.toBe(cancelled)
  452. expect(subprocess.resolveExecutable).not.toHaveBeenCalled()
  453. })
  454. })
  455. it.skipIf(process.platform === 'win32')('runs a real interactive shell with completion, TERM and live window dimensions', async () => {
  456. const cwd = await mkdtemp(join(tmpdir(), 'dsh-web-terminal-'))
  457. const ctx = new Context()
  458. const runtime = await ctx.plugin(LocalSubprocessRuntime)
  459. try {
  460. const handle = await ctx.subprocess.spawnTerminal({ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd, rows: 24, cols: 80, terminalType: 'xterm-256color', graceMs: 100, env: { PS1: 'READY> ', PS2: '' } })
  461. let output = ''
  462. handle.output.on('data', (data: Buffer) => { output += data.toString('utf8') })
  463. try {
  464. await expect.poll(() => output).toContain('READY>')
  465. await handle.resize(100, 30)
  466. await handle.write("printf 'TERM:%s\\n' \"$TERM\"; stty size\r")
  467. await expect.poll(() => output).toContain('TERM:xterm-256color')
  468. await expect.poll(() => output).toContain('30 100')
  469. // Bash's builtin completion expands this unambiguous command before Enter.
  470. await handle.write('histor\t')
  471. await expect.poll(() => output).toContain('history')
  472. } finally { await handle.terminate() }
  473. await expect(handle.done).resolves.toBeDefined()
  474. } finally {
  475. await runtime.dispose()
  476. await ctx.fiber.dispose()
  477. await rm(cwd, { recursive: true, force: true })
  478. }
  479. })
  480. it('discovers installed shells once per path, preserves default arguments, and refuses unlisted paths', async () => {
  481. const h = fixture({ shellCandidates: ['bash', 'zsh', 'missing'] })
  482. h.subprocess.resolveExecutable.mockImplementation(async (path) => {
  483. if (path === 'missing') throw new SubprocessExecutableNotFoundError('absent')
  484. return path.startsWith('/') ? path : `/bin/${path}`
  485. })
  486. const shells = await h.controller.shells(h.agent, signal())
  487. expect(shells).toEqual([config.shell, { path: '/bin/zsh', name: 'zsh', args: ['-i'] }])
  488. expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled()
  489. await expect(h.controller.create(h.agent, { ...request, shellPath: '/bin/unlisted' }, signal())).rejects.toThrow('Selected shell is not available')
  490. expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled()
  491. const created = await h.controller.create(h.agent, { ...request, shellPath: '/bin/zsh' }, signal())
  492. expect(created.shell.path).toBe('/bin/zsh')
  493. expect(h.subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/bin/zsh', '-i'] }))
  494. h.subprocess.resolveExecutable.mockRejectedValue(new Error('SSH disconnected'))
  495. await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('SSH disconnected')
  496. expect(await h.controller.create(h.agent, { ...request, shellPath: '/bin/bash' }, signal())).toBe(created)
  497. })
  498. it('propagates optional-shell discovery transport errors and cancellation', async () => {
  499. const h = fixture({ shellCandidates: ['fish'] })
  500. h.subprocess.resolveExecutable.mockImplementation(async (path) => {
  501. if (path === 'fish') throw new Error('lookup transport failed')
  502. return path
  503. })
  504. await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('lookup transport failed')
  505. const cancelled = AbortSignal.abort(new Error('cancelled discovery'))
  506. expect(() => h.controller.shells(h.agent, cancelled)).toThrow('cancelled discovery')
  507. })
  508. it('deduplicates Windows executable paths regardless of letter case', async () => {
  509. const h = fixture({ shell: { path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }, shellCandidates: ['c:\\windows\\cmd.exe'] })
  510. expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }])
  511. })
  512. it('retains a known terminal without Agent resolution and fences stream admission after explicit close', async () => {
  513. const h = fixture()
  514. await h.controller.create(h.agent, request, signal())
  515. const abort = new AbortController()
  516. const held = h.controller.retain(h.agent.id, id, abort.signal)[Symbol.asyncIterator]()
  517. expect(await held.next()).toMatchObject({ value: { type: 'retained' } })
  518. expect(h.subprocess.spawnTerminal).toHaveBeenCalledOnce()
  519. expect(() => h.controller.retain('missing-session' as SessionId, id, signal())).toThrow('unavailable')
  520. const ended = held.next()
  521. await h.controller.close(h.agent, id)
  522. expect(await ended).toMatchObject({ done: true })
  523. expect(() => h.controller.retain(h.agent.id, id, signal())).toThrow('unavailable')
  524. await expect(h.controller.create(h.agent, request, signal())).rejects.toThrow('closed in this Session')
  525. })
  526. it('reclaims confirmed idle processes and preserves closed identity exclusion after removing their screens', async () => {
  527. vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] })
  528. const h = fixture({ unattendedTimeoutMs: 100, activityPollIntervalMs: 10 })
  529. try {
  530. h.handle.inspectActivity.mockResolvedValue({ state: 'busy', revision: 0 })
  531. await h.controller.create(h.agent, request, signal())
  532. await vi.advanceTimersByTimeAsync(500)
  533. expect(h.handle.terminate).not.toHaveBeenCalled()
  534. h.handle.inspectActivity.mockResolvedValue({ state: 'idle', revision: 1 })
  535. await vi.advanceTimersByTimeAsync(110)
  536. expect(h.handle.terminate).toHaveBeenCalledOnce()
  537. expect(h.controller.list(h.agent.id)).toEqual([])
  538. await expect(h.controller.create(h.agent, request, signal())).rejects.toThrow('closed in this Session')
  539. expect(h.subprocess.spawnTerminal).toHaveBeenCalledOnce()
  540. } finally {
  541. await h.ctx.fiber.dispose()
  542. vi.useRealTimers()
  543. }
  544. })
  545. it.each(['allocation', 'committed'] as const)('retains failed %s cleanup, reports scheduled failures, and releases resources after retry', async (phase) => {
  546. vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] })
  547. const h = fixture({ unattendedTimeoutMs: 100, activityPollIntervalMs: 10, cleanupRetryMs: 20 })
  548. h.handle.terminate.mockRejectedValueOnce(new Error('still alive')).mockRejectedValueOnce(new Error('still alive again'))
  549. try {
  550. if (phase === 'allocation') {
  551. const abort = new AbortController()
  552. h.subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('disconnected during spawn')); return h.handle })
  553. await expect(h.controller.create(h.agent, request, abort.signal)).rejects.toThrow('cleanup failed')
  554. } else {
  555. h.handle.inspectActivity.mockResolvedValue({ state: 'idle', revision: 1 })
  556. await h.controller.create(h.agent, request, signal())
  557. await vi.advanceTimersByTimeAsync(100)
  558. }
  559. expect(h.controller.list(h.agent.id)).toHaveLength(1)
  560. expect(() => h.controller.retain(h.agent.id, id, signal())).toThrow('unavailable')
  561. await vi.advanceTimersByTimeAsync(20)
  562. expect(h.handle.terminate).toHaveBeenCalledTimes(2)
  563. expect(h.controller.list(h.agent.id)).toHaveLength(1)
  564. await vi.advanceTimersByTimeAsync(20)
  565. expect(h.handle.terminate).toHaveBeenCalledTimes(3)
  566. expect(h.controller.list(h.agent.id)).toEqual([])
  567. } finally {
  568. await h.ctx.fiber.dispose()
  569. vi.useRealTimers()
  570. }
  571. })
  572. it('classifies missing and closing terminal identities for localized recovery actions', async () => {
  573. const h = fixture()
  574. expect(() => h.controller.follow(h.agent, id, 'view' as TerminalAttachmentId, signal()))
  575. .toThrow(expect.objectContaining({ code: 'terminal/unavailable' }))
  576. await h.controller.create(h.agent, request, signal())
  577. h.handle.terminate.mockRejectedValueOnce(new Error('cleanup pending'))
  578. await expect(h.controller.close(h.agent, id)).rejects.toThrow('cleanup pending')
  579. expect(() => h.controller.follow(h.agent, id, 'view' as TerminalAttachmentId, signal()))
  580. .toThrow(expect.objectContaining({ code: 'terminal/unavailable' }))
  581. await expect(h.controller.create(h.agent, request, signal())).rejects.toMatchObject({ code: 'terminal/unavailable' })
  582. })