index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. /** Session-scoped browser terminals over the composed subprocess and sandbox providers. */
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import z from '@deepseek-ai/schemastery'
  4. import type { Agent } from '@deepseek-ai/dsh-agent'
  5. import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
  6. import type {} from '@deepseek-ai/dsh-sandbox-policy'
  7. import type {} from '@deepseek-ai/dsh-sandbox'
  8. import type {} from '@deepseek-ai/dsh-session-projection'
  9. import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
  10. import { discoverShells, resolveShell } from './shells.ts'
  11. import { BrowserTerminal } from './terminal.ts'
  12. import { TerminalRetention } from './retention.ts'
  13. import type {
  14. TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, TerminalRetentionFrame,
  15. WebTerminalId, WebTerminalInfo,
  16. } from './types.ts'
  17. export type * from './types.ts'
  18. declare module '@deepseek-ai/cordis' {
  19. interface Context {
  20. /** Interactive user terminals, separate from the Agent terminal tool registry. */
  21. terminalController: TerminalController
  22. }
  23. }
  24. /** Deployment limits and an optional shell profile. */
  25. export interface Config {
  26. /** Explicit shell profile; omission uses the execution environment's default shell. */
  27. readonly shell?: {
  28. /** Executable path or PATH name, verified by the subprocess provider. */
  29. path: string
  30. /** User-visible profile name. */
  31. name: string
  32. /** Arguments passed to the interactive shell. */
  33. args: string[]
  34. } | undefined
  35. /** Executable names or paths checked for the new-terminal shell selector. */
  36. readonly shellCandidates: string[]
  37. /** Maximum retained terminals and pending allocations per Session. */
  38. readonly maxTerminals: number
  39. /** Maximum terminal width in columns. */
  40. readonly maxCols: number
  41. /** Maximum terminal height in rows. */
  42. readonly maxRows: number
  43. /** Screen history rows retained for reconnecting clients. */
  44. readonly scrollback: number
  45. /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */
  46. readonly maxBufferedBytes: number
  47. /** Maximum UTF-8 bytes in one input request. */
  48. readonly maxInputBytes: number
  49. /** Provider process-termination grace period in milliseconds. */
  50. readonly disposeGraceMs: number
  51. /** Continuous confirmed idle time without window holds before reclamation; zero disables reclamation. */
  52. readonly unattendedTimeoutMs: number
  53. /** Interval between unattended shell and process observations. */
  54. readonly activityPollIntervalMs: number
  55. /** Delay before retrying failed owned terminal cleanup. */
  56. readonly cleanupRetryMs: number
  57. }
  58. interface TerminalAllocation {
  59. info: WebTerminalInfo
  60. readonly cleanup: TerminalRetention
  61. }
  62. interface OwnedSession {
  63. readonly lifetime: AbortController
  64. readonly closedIds: Set<WebTerminalId>
  65. cleanup?: Promise<void>
  66. readonly terminals: Map<WebTerminalId, BrowserTerminal>
  67. readonly pending: Map<WebTerminalId, Promise<BrowserTerminal>>
  68. readonly allocations: Map<WebTerminalId, TerminalAllocation>
  69. }
  70. /** Typed Remote control of transient Session-owned terminal processes. */
  71. export class TerminalController extends TypertRemoteService {
  72. static inject = ['subprocess', 'sandboxPolicy', 'sessionProjections', 'typert']
  73. static Config: z<Config> = z.object({
  74. shell: z.union([z.object({
  75. path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]),
  76. }), z.const(undefined)]),
  77. shellCandidates: z.array(z.string().min(1)).default(['zsh', 'bash', 'fish', 'pwsh', 'powershell', 'cmd']),
  78. maxTerminals: z.number().step(1).min(1).default(8),
  79. maxCols: z.number().step(1).min(2).default(500),
  80. maxRows: z.number().step(1).min(1).default(200),
  81. scrollback: z.number().step(1).min(0).default(1000),
  82. maxBufferedBytes: z.number().step(1).min(1024).default(2 * 1024 * 1024),
  83. maxInputBytes: z.number().step(1).min(1).default(64 * 1024),
  84. disposeGraceMs: z.number().step(1).min(1).default(1000),
  85. unattendedTimeoutMs: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(7_200_000),
  86. activityPollIntervalMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(30_000),
  87. cleanupRetryMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(60_000),
  88. })
  89. private readonly owners = new Map<SessionId, OwnedSession>()
  90. private readonly lifetime = new AbortController()
  91. /**
  92. * @param ctx - Host context carrying typed Remote and execution providers.
  93. * @param config - validated terminal limits and optional shell profile.
  94. */
  95. constructor(ctx: Context, private readonly config: Config) {
  96. super(ctx, 'terminalController', { namespace: 'terminal' })
  97. ctx.on('internal/dispatch', (_mode, eventName, args) => {
  98. if (eventName !== 'session/event') return
  99. const [session, event] = args as [Session, SessionEvent]
  100. if (event.type !== 'sandbox/mode') return
  101. const owner = this.owners.get(session.id)
  102. if (owner === undefined || owner.terminals.size + owner.pending.size + owner.allocations.size === 0) return
  103. const current = ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? ctx.sandboxPolicy.defaultMode
  104. if (event.data.mode !== current) throw new Error('Close browser terminals before changing the Session sandbox mode')
  105. }, { global: true })
  106. ctx.effect(() => async () => {
  107. this.lifetime.abort(new Error('Terminal controller disposed'))
  108. const results = await Promise.allSettled([...this.owners].map(([id, owner]) => this.disposeOwner(id, owner)))
  109. const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
  110. if (errors.length > 0) throw new AggregateError(errors, 'Browser terminal cleanup failed')
  111. }, 'terminal-controller.processes')
  112. }
  113. /**
  114. * Read the Session working directory and terminal limits without resolving a shell.
  115. * @param agent - Session owner supplied by the Gateway.
  116. * @param signal - request cancellation.
  117. * @returns the Session workspace directory and terminal limits.
  118. */
  119. @Remote
  120. environment(agent: Agent, signal: AbortSignal): TerminalEnvironment {
  121. signal.throwIfAborted()
  122. const { sandboxPolicy } = this.execution(agent)
  123. return { cwd: sandboxPolicy.resolve({ session: agent.session }).workspaceRoot,
  124. maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols,
  125. maxRows: this.config.maxRows, scrollback: this.config.scrollback }
  126. }
  127. /**
  128. * Discover installed shells in the Session's execution environment.
  129. * @param agent - Session owner supplied by the Gateway.
  130. * @param signal - request cancellation.
  131. * @returns verified profiles, with the configured or system default first.
  132. */
  133. @Remote
  134. shells(agent: Agent, signal: AbortSignal): Promise<TerminalShell[]> {
  135. signal.throwIfAborted()
  136. return discoverShells(this.execution(agent).subprocess, this.config.shell, this.config.shellCandidates, signal)
  137. }
  138. /**
  139. * List retained terminals without resolving or activating an Agent.
  140. * @param sessionId - displayed Session identity, including offline history.
  141. * @returns terminals retained for this Host lifetime.
  142. */
  143. @Remote
  144. list(sessionId: SessionId): WebTerminalInfo[] {
  145. const owner = this.owners.get(sessionId)
  146. if (owner === undefined) return []
  147. return [...owner.terminals.values(), ...owner.allocations.values()].map(terminal => terminal.info)
  148. }
  149. /**
  150. * Allocate an interactive shell once for a caller-generated identity.
  151. * @param agent - Session owner supplied by the Gateway.
  152. * @param request - initial dimensions and idempotency identity.
  153. * @param signal - allocation cancellation; committed terminals survive disconnection.
  154. * @returns the existing or newly committed terminal.
  155. */
  156. @Remote
  157. async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<WebTerminalInfo> {
  158. this.lifetime.signal.throwIfAborted()
  159. if (!/^[\w-]{1,128}$/u.test(request.id)) throw new Error('Invalid terminal identity')
  160. this.dimensions(request.cols, request.rows)
  161. const owner = this.owner(agent)
  162. owner.lifetime.signal.throwIfAborted()
  163. this.requireOpen(owner, request.id)
  164. const existing = owner.terminals.get(request.id)
  165. if (existing !== undefined) return existing.info
  166. const pending = owner.pending.get(request.id)
  167. if (pending !== undefined) {
  168. const terminal = await pending
  169. this.requireOpen(owner, request.id)
  170. return terminal.info
  171. }
  172. if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: this.config.maxTerminals })
  173. const allocation = this.spawn(agent, owner, request, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal]))
  174. owner.pending.set(request.id, allocation)
  175. try {
  176. const terminal = await allocation
  177. owner.terminals.set(request.id, terminal)
  178. owner.allocations.delete(request.id)
  179. terminal.monitor(this.config, () => { owner.closedIds.add(request.id) }, () => {
  180. owner.terminals.delete(request.id)
  181. }, (error) => { this.ctx.logger.error('Browser terminal cleanup failed', error) })
  182. this.requireOpen(owner, request.id)
  183. return terminal.info
  184. } finally {
  185. owner.pending.delete(request.id)
  186. }
  187. }
  188. /**
  189. * Retain an existing terminal for a window without activating its Agent or taking input control.
  190. * @param sessionId - owning Session identity, including an inactive saved layout.
  191. * @param id - retained Host terminal identity.
  192. * @param signal - physical Remote stream cancellation.
  193. * @returns a hold acknowledgement followed by an open lifetime stream.
  194. */
  195. @Remote({ mode: 'stream' })
  196. retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable<TerminalRetentionFrame> {
  197. const owner = this.owners.get(sessionId)
  198. const terminal = owner?.terminals.get(id)
  199. if (terminal === undefined || owner?.closedIds.has(id) === true || owner?.lifetime.signal.aborted === true) {
  200. throw new RemoteError('terminal/unavailable', 'Terminal is closing or unavailable', {})
  201. }
  202. return terminal.retain(signal)
  203. }
  204. /**
  205. * Attach to a terminal without binding its process lifetime to the transport.
  206. * @param agent - Session owner supplied by the Gateway.
  207. * @param id - terminal identity.
  208. * @param attachmentId - new exclusive input attachment.
  209. * @param signal - physical stream cancellation.
  210. * @returns screen recovery followed by output and metadata changes.
  211. */
  212. @Remote({ mode: 'stream' })
  213. follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable<TerminalFrame> {
  214. if (!/^[\w-]{1,128}$/u.test(attachmentId)) throw new Error('Invalid terminal attachment identity')
  215. return this.terminal(agent, id).follow(attachmentId, signal)
  216. }
  217. /**
  218. * Deliver raw input, including Tab completion and control characters.
  219. * @param agent - Session owner supplied by the Gateway.
  220. * @param id - terminal identity.
  221. * @param attachmentId - current writable attachment.
  222. * @param data - input bytes represented as UTF-8 text.
  223. * @returns after provider input acceptance.
  224. */
  225. @Remote
  226. async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise<void> {
  227. if (Buffer.byteLength(data, 'utf8') > this.config.maxInputBytes) throw new Error('Terminal input exceeds the configured limit')
  228. await this.terminal(agent, id).write(attachmentId, data)
  229. }
  230. /**
  231. * Update the dimensions of the PTY and recovery screen.
  232. * @param agent - Session owner supplied by the Gateway.
  233. * @param id - terminal identity.
  234. * @param attachmentId - current writable attachment.
  235. * @param cols - column count.
  236. * @param rows - row count.
  237. * @returns after the resize completes.
  238. */
  239. @Remote
  240. async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise<void> {
  241. this.dimensions(cols, rows)
  242. await this.terminal(agent, id).resize(attachmentId, cols, rows)
  243. }
  244. /**
  245. * Rename a terminal without changing its shell.
  246. * @param agent - Session owner supplied by the Gateway.
  247. * @param id - terminal identity.
  248. * @param title - nonempty display title, at most 120 characters.
  249. */
  250. @Remote
  251. rename(agent: Agent, id: WebTerminalId, title: string): void {
  252. if (title.trim().length === 0 || title.length > 120) throw new Error('Terminal title must contain 1–120 characters')
  253. this.terminal(agent, id).rename(title.trim())
  254. }
  255. /**
  256. * Close an identity to future creation and kill its process range; repeated closes succeed.
  257. * @param agent - Session owner supplied by the Gateway.
  258. * @param id - terminal identity.
  259. * @returns after provider cleanup succeeds. A failure retains the terminal for retry.
  260. */
  261. @Remote
  262. async close(agent: Agent, id: WebTerminalId): Promise<void> {
  263. const owner = this.owner(agent)
  264. owner.closedIds.add(id)
  265. // create publishes the allocation before this wait settles; close owns it even if create then rejects.
  266. await owner.pending.get(id)?.catch(() => { /* Creation reports its failure; close still owns any allocated process. */ })
  267. const terminal = owner.terminals.get(id)
  268. if (terminal !== undefined) {
  269. await terminal.close()
  270. owner.terminals.delete(id)
  271. } else {
  272. const allocation = owner.allocations.get(id)
  273. if (allocation === undefined) return
  274. await allocation.cleanup.close()
  275. owner.allocations.delete(id)
  276. }
  277. }
  278. private owner(agent: Agent): OwnedSession {
  279. let owner = this.owners.get(agent.id)
  280. if (owner === undefined) {
  281. owner = { terminals: new Map(), pending: new Map(), allocations: new Map(), closedIds: new Set(), lifetime: new AbortController() }
  282. this.owners.set(agent.id, owner)
  283. const owned = owner
  284. agent.ctx.effect(() => async () => { await this.disposeOwner(agent.id, owned) }, 'terminal-controller.owner')
  285. }
  286. return owner
  287. }
  288. private disposeOwner(id: SessionId, owner: OwnedSession): Promise<void> {
  289. if (owner.cleanup !== undefined) return owner.cleanup
  290. owner.lifetime.abort(new Error('Terminal Session owner disposed'))
  291. owner.cleanup = (async () => {
  292. await Promise.allSettled(owner.pending.values())
  293. const results = await Promise.allSettled([
  294. ...[...owner.terminals.values()].map(terminal => terminal.dispose()),
  295. ...[...owner.allocations.values()].map(allocation => allocation.cleanup.dispose()),
  296. ])
  297. const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
  298. if (errors.length > 0) throw new AggregateError(errors, 'Session terminal cleanup failed')
  299. owner.terminals.clear()
  300. owner.allocations.clear()
  301. this.owners.delete(id)
  302. })().catch((error: unknown) => { delete owner.cleanup; throw error })
  303. return owner.cleanup
  304. }
  305. private terminal(agent: Agent, id: WebTerminalId): BrowserTerminal {
  306. const terminal = this.owners.get(agent.id)?.terminals.get(id)
  307. if (terminal === undefined) throw new RemoteError('terminal/unavailable', 'Terminal no longer exists in this Session', {})
  308. this.requireOpen(this.owners.get(agent.id) as OwnedSession, id)
  309. return terminal
  310. }
  311. private requireOpen(owner: OwnedSession, id: WebTerminalId): void {
  312. if (owner.closedIds.has(id)) throw new RemoteError('terminal/unavailable', 'Terminal was closed in this Session', {})
  313. }
  314. private dimensions(cols: number, rows: number): void {
  315. if (!Number.isSafeInteger(cols) || cols < 2 || cols > this.config.maxCols
  316. || !Number.isSafeInteger(rows) || rows < 1 || rows > this.config.maxRows) throw new Error('Terminal dimensions exceed the configured limits')
  317. }
  318. private execution(agent: Agent): { subprocess: Context['subprocess']; sandboxPolicy: Context['sandboxPolicy'] } {
  319. // The Agent context selects execution providers but does not inject consumer services.
  320. const subprocess = agent.ctx.get('subprocess')
  321. const sandboxPolicy = agent.ctx.get('sandboxPolicy')
  322. if (subprocess === undefined || sandboxPolicy === undefined) throw new Error('The Session execution environment requires subprocess and sandbox policy providers')
  323. return { subprocess, sandboxPolicy }
  324. }
  325. private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise<BrowserTerminal> {
  326. const environment = this.environment(agent, signal)
  327. const { subprocess, sandboxPolicy } = this.execution(agent)
  328. const shell = request.shellPath === undefined
  329. ? await resolveShell(subprocess, this.config.shell, signal)
  330. : (await this.shells(agent, signal)).find(candidate => candidate.path === request.shellPath)
  331. if (shell === undefined) throw new Error('Selected shell is not available in this execution environment')
  332. const policy = sandboxPolicy.resolve({ session: agent.session })
  333. let argv = [shell.path, ...shell.args]
  334. if (policy.mode !== 'danger-full-access') {
  335. const sandbox = agent.ctx.get('sandbox')
  336. if (sandbox === undefined) throw new Error('The Session sandbox mode requires an execution sandbox provider')
  337. argv = (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv
  338. }
  339. const handle = await subprocess.spawnTerminal({
  340. argv, cwd: environment.cwd, cols: request.cols, rows: request.rows,
  341. terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id },
  342. shellActivity: true,
  343. graceMs: this.config.disposeGraceMs, signal,
  344. })
  345. const info: WebTerminalInfo = {
  346. id: request.id, shell, title: shell.name, cwd: environment.cwd,
  347. cols: request.cols, rows: request.rows, state: 'running', exitCode: null,
  348. }
  349. try {
  350. signal.throwIfAborted()
  351. return new BrowserTerminal(handle, info, this.config.scrollback, this.config.maxBufferedBytes)
  352. } catch (error) {
  353. const cleanup = new TerminalRetention(this.config, handle.inspectActivity.bind(handle), async () => {
  354. owner.closedIds.add(request.id)
  355. await handle.terminate()
  356. owner.allocations.delete(request.id)
  357. }, (cleanupError) => { this.ctx.logger.error('Browser terminal allocation cleanup failed', cleanupError) })
  358. owner.allocations.set(request.id, {
  359. info: { ...info, state: 'failed', error: error instanceof Error ? error.message : String(error) }, cleanup,
  360. })
  361. try {
  362. await cleanup.close()
  363. } catch (cleanupError) {
  364. throw new AggregateError([error, cleanupError], 'Terminal allocation cleanup failed')
  365. }
  366. throw error
  367. }
  368. }
  369. }
  370. /** Browser terminal service plugin. */
  371. export default TerminalController