index.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. /**
  2. * The bash executor seam (`ctx.bash`): an abstract service defining WHAT a
  3. * bash backend does — run commands, manage background tasks — without saying
  4. * HOW. Implementations subclass {@link BashExecutor} and register themselves
  5. * as the `bash` service; `@deepseek-ai/dsh-bash-local` (local subprocesses)
  6. * is the first. Future implementations swap in sandboxes, containers, or
  7. * remote exec servers without touching the tool schemas that consume them
  8. * (`@deepseek-ai/dsh-tool-bash`).
  9. *
  10. * The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the
  11. * surveyed agents: pi hides execution behind a `BashOperations` interface
  12. * (local shell / SSH / VM backends), Codex behind an exec-server protocol.
  13. *
  14. * @module @deepseek-ai/dsh-bash
  15. */
  16. import { Context, Service } from 'cordis'
  17. import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
  18. import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
  19. export { BashTaskId, OwnerToken } from './types.ts'
  20. export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
  21. export type {
  22. BashExecRequest,
  23. BashExecSpec,
  24. BashRunResult,
  25. BashSandboxInfo,
  26. BashTask,
  27. BashTaskListener,
  28. BashTaskRead,
  29. BashTaskStatus,
  30. CollectedOutput,
  31. } from './types.ts'
  32. declare module 'cordis' {
  33. interface Context {
  34. bash: BashExecutor
  35. }
  36. }
  37. /**
  38. * Abstract bash execution service. Subclass, implement the abstract methods,
  39. * and load the subclass as a plugin — it registers as `ctx.bash` (one
  40. * implementation per context; loading a second throws, which is cordis'
  41. * standard duplicate-service behavior).
  42. *
  43. * Semantics every implementation must honor:
  44. * - {@link run} REJECTS only for infrastructure failures (unusable workdir,
  45. * missing shell, pre-aborted signal). Nonzero exits, timeout kills, and
  46. * abort kills RESOLVE with a descriptive {@link BashRunResult} — reporting
  47. * a failed command is the tool layer's job, not an exception.
  48. * - {@link start} returns immediately; no timeout applies to background
  49. * tasks (callers stop them via {@link kill} or the spec's AbortSignal).
  50. * Completion must fire the {@link onTaskDone} listeners exactly once per
  51. * task, and must NOT fire after the service is disposed.
  52. * - {@link readOutput} is incremental: consecutive reads never re-deliver
  53. * output. Implementations bound their buffers; reads that lost data flag
  54. * `lossy` and point at full-stream spill files when available.
  55. * - Disposal kills every running task and awaits their exit (no orphan
  56. * processes survive `fiber.dispose()`).
  57. */
  58. export abstract class BashExecutor extends Service {
  59. private listeners = new Set<BashTaskListener>()
  60. private listenersClosed = false
  61. constructor(ctx: Context) {
  62. super(ctx, 'bash')
  63. ctx.effect(() => () => {
  64. // Close the listener registry before subclass teardown so late task
  65. // completions (e.g. from kills issued during dispose) stay silent.
  66. this.listenersClosed = true
  67. this.listeners.clear()
  68. }, 'bash listener teardown')
  69. }
  70. /**
  71. * The sandbox mode this executor confines commands under BY DEFAULT, or
  72. * `undefined` when it does not sandbox at all — the capability fact the
  73. * tool and ACP layers read to advertise sandbox controls honestly. The
  74. * getter proves a sandboxing executor is mounted and supplies its fallback
  75. * mode; a session override may make the effective mode narrower or wider,
  76. * so strict escalation widening is checked per call rather than encoded in
  77. * this default-relative capability fact. The base class reports
  78. * `undefined`; a sandboxing implementation overrides the getter.
  79. * @returns the configured default mode of a sandboxing executor;
  80. * `undefined` for an executor that never confines.
  81. */
  82. get sandboxMode(): SandboxMode | undefined {
  83. return undefined
  84. }
  85. /**
  86. * Resolve a caller's {@link BashExecRequest} into a fully-specified
  87. * {@link BashExecSpec}, applying this implementation's config defaults and
  88. * caps (working directory, default/max timeout). Consumers (tool layer)
  89. * call this, then pass the result to {@link run}/{@link start} — keeping
  90. * defaulting in the implementation that owns the config while the seam type
  91. * stays explicit (no hidden `?? default` inside run/start).
  92. * @param request - the caller's request; omitted fields get this
  93. * implementation's defaults, capped fields are clamped.
  94. * @returns the fully-specified spec to hand to {@link run}/{@link start}.
  95. */
  96. abstract resolve(request: BashExecRequest): BashExecSpec
  97. /**
  98. * Run a command in the foreground; resolves when it finishes.
  99. * @param spec - a resolved spec from {@link resolve}, never a raw request.
  100. * @returns the outcome; nonzero exits, timeout kills, and abort kills
  101. * resolve with a descriptive result rather than reject.
  102. */
  103. abstract run(spec: BashExecSpec): Promise<BashRunResult>
  104. /**
  105. * Start a background task and return its handle immediately.
  106. * @param spec - a resolved spec from {@link resolve}, never a raw request.
  107. * @returns the live task handle; completion fires {@link onTaskDone}.
  108. */
  109. abstract start(spec: BashExecSpec): BashTask
  110. /**
  111. * Look up a background task by id.
  112. * @param id - the task id to look up.
  113. * @returns the tracked task, or undefined for an id this executor never issued.
  114. */
  115. abstract get(id: BashTaskId): BashTask | undefined
  116. /**
  117. * The opaque OWNER token recorded for a background task at {@link start}
  118. * (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id
  119. * OR a known-but-ownerless task. The executor stores and returns the token
  120. * verbatim — it never interprets it; the access POLICY (who may read/kill a
  121. * task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares
  122. * `ownerOf(id)` to the caller's token. Collapsing unknown-id and
  123. * known-but-unowned into the same `undefined` is fine: the consumer's access
  124. * gate treats `undefined` as "open", and a genuinely unknown id then fails
  125. * loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
  126. * Storing ownership in the executor (disposed with ITS fiber) — not in the
  127. * tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
  128. * @param id - the background task id to look up ownership for.
  129. * @returns the token recorded at start, verbatim; undefined for an unknown
  130. * id or a known-but-ownerless task.
  131. */
  132. abstract ownerOf(id: BashTaskId): OwnerToken | undefined
  133. /**
  134. * All tracked background tasks (insertion order).
  135. * @returns every task this executor started, running or finished.
  136. */
  137. abstract list(): BashTask[]
  138. /**
  139. * Read output produced since the previous read. Throws for unknown ids.
  140. * @param id - the task to read from.
  141. * @returns the incremental read; consecutive reads never re-deliver output.
  142. */
  143. abstract readOutput(id: BashTaskId): BashTaskRead
  144. /**
  145. * Kill a running background task. Returns false when it had already
  146. * finished (no-op). Throws for unknown ids.
  147. * @param id - the task to kill.
  148. * @returns true when this call killed it, false when it had already finished.
  149. */
  150. abstract kill(id: BashTaskId): boolean
  151. /**
  152. * Register a background-task completion listener (disposed with the
  153. * calling fiber). Listeners never fire after this service is disposed.
  154. * @param listener - called exactly once per task completion.
  155. * @returns the disposer that unregisters the listener.
  156. */
  157. onTaskDone(listener: BashTaskListener): () => void {
  158. const dispose = this.ctx.effect(() => {
  159. this.listeners.add(listener)
  160. return () => this.listeners.delete(listener)
  161. }, 'bash.onTaskDone()')
  162. return () => void dispose()
  163. }
  164. /** For implementations: notify listeners that `task` completed. Listener
  165. * exceptions are contained (logged) — one bad listener must not reject
  166. * `BashTask.done` or starve the listeners after it. */
  167. protected notifyTaskDone(task: BashTask): void {
  168. if (this.listenersClosed) return
  169. for (const listener of this.listeners) {
  170. try {
  171. listener(task)
  172. } catch (error: unknown) {
  173. // Listener bugs are reported, never propagated into task.done.
  174. console.error('bash onTaskDone listener threw:', error)
  175. }
  176. }
  177. }
  178. }
  179. export default BashExecutor