index.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. /**
  2. * Service Definition for the subprocess capability seam (`ctx.subprocess`): execution-world executable lookup,
  3. * fully specified provider-managed process ranges with raw or
  4. * collected stdio, and one terminal-process primitive. Command defaulting,
  5. * shell semantics, deadlines, protocol framing, terminal readiness, and
  6. * presentation belong to consumers. The local implementation lives in
  7. * `@deepseek-ai/dsh-subprocess-local`.
  8. * @module @deepseek-ai/dsh-subprocess
  9. */
  10. import { Context, Service } from '@deepseek-ai/cordis'
  11. import { proxyEnvironmentForChild } from '@deepseek-ai/dsh-http-proxy'
  12. import { DSH_ENV_PREFIX } from './types.ts'
  13. import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
  14. import type { SubprocessTerminalEnvironment, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts'
  15. export { DSH_ENV_PREFIX } from './types.ts'
  16. export type {
  17. CollectedOutput,
  18. DshEnvironment,
  19. DshEnvironmentKey,
  20. SubprocessCollect,
  21. SubprocessCollectedOutputs,
  22. SubprocessHandle,
  23. SubprocessOutcome,
  24. SubprocessOutputMode,
  25. SubprocessOutputRead,
  26. SubprocessOutputReader,
  27. SubprocessSpawnSpec,
  28. SubprocessStdinMode,
  29. SubprocessStdio,
  30. SubprocessTerminalForeground,
  31. SubprocessTerminalActivity,
  32. SubprocessTerminalEnvironment,
  33. SubprocessTerminalHandle,
  34. SubprocessTerminalSignal,
  35. SubprocessTerminalSpawnSpec,
  36. } from './types.ts'
  37. /**
  38. * Credential-shaped environment names are NOT forwarded to children (the
  39. * harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a spawned
  40. * process implicitly). One heuristic for every in-repo spawner; a
  41. * deliberately supplied entry survives because explicit env layers merge
  42. * after the scrub.
  43. */
  44. export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
  45. /**
  46. * The ambient parent environment minus credential-shaped names and minus all
  47. * `DSH_*` names — the canonical base every harness child starts from. `PATH`,
  48. * `HOME`, locale, and proxy variables survive, so child CLIs run normally;
  49. * harness identity never leaks implicitly (a deliberately forwarded
  50. * credential or current `DSH_*` fact goes through the spec's explicit `env`,
  51. * which merges after this scrub). Both scrubs match case-insensitively:
  52. * Windows environment names are case-insensitive, so a parent `dsh_*` entry
  53. * would otherwise survive and read back as `$env:DSH_*` in the child;
  54. * deliberate lowercase `dsh_*` names on POSIX are implausible. Exported as a plain function so spawners
  55. * that cannot route through the service (node-pty backends, SDK-managed
  56. * transports) share the one scrub definition.
  57. *
  58. * When a proxy is active the result also carries the resolved proxy names and the flag a child Node
  59. * needs to honor them, so a child inherits the same routing as its parent.
  60. * @returns a fresh environment object safe to hand to a child spawn.
  61. */
  62. export function scrubbedParentEnv(): Record<string, string> {
  63. const env: Record<string, string> = {}
  64. for (const [key, value] of Object.entries(process.env)) {
  65. if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX)) env[key] = value
  66. }
  67. // A child Node ignores the inherited proxy variables unless the flag this adds is set, so an MCP
  68. // stdio server or subagent CLI would connect directly while its parent proxies. The same overlay
  69. // restores each proxy name to what the user exported, undoing this process's own normalization —
  70. // `undefined` removes a name the user never set.
  71. for (const [name, value] of Object.entries(proxyEnvironmentForChild())) {
  72. if (value === undefined) Reflect.deleteProperty(env, name)
  73. else env[name] = value
  74. }
  75. return env
  76. }
  77. declare module '@deepseek-ai/cordis' {
  78. interface Context {
  79. subprocess: SubprocessRuntime
  80. }
  81. }
  82. /**
  83. * Abstract subprocess service. Subclass, implement {@link spawn}, and load the
  84. * subclass as a plugin — it registers as `ctx.subprocess` (one implementation
  85. * per context; loading a second throws, which is cordis' standard
  86. * duplicate-service behavior).
  87. *
  88. * Implementations must honor these semantics:
  89. * - Executable paths belong to one execution world shared with the mounted
  90. * filesystem provider.
  91. * - {@link spawn} returns a live handle synchronously. Target identity remains
  92. * provider-private; `done` resolves with the spawned command's exit facts and
  93. * may reject for spawn or provider failures.
  94. * - Collect-mode readers are offset-based and non-consuming, so independent
  95. * readers never consume one another's output; lossy reads report truncation
  96. * and the spill file holding the complete stream when one exists. Piped
  97. * streams are handed to the caller raw and never buffered here.
  98. * - {@link SubprocessHandle.terminate} (and the spec's abort signal) starts the
  99. * provider's documented procedure against its managed range.
  100. * {@link SubprocessHandle.waitForExit} observes that same range so a
  101. * consumer-owned teardown ladder can hold each tier on real quiescence; each
  102. * provider documents its signalling and observability limits.
  103. * - Disposal of the service terminates all still-running managed processes
  104. * and awaits their exit.
  105. * - {@link spawnTerminal} owns terminal allocation, text transport,
  106. * foreground groups, signalling, and whole-session quiescence behind one
  107. * awaited termination method; readiness and persistent-shell policy stay
  108. * in the PTY consumer. Its output stream ends after queued terminal output
  109. * when the top-level process exits.
  110. */
  111. export abstract class SubprocessRuntime extends Service {
  112. constructor(ctx: Context) {
  113. super(ctx, 'subprocess')
  114. }
  115. /**
  116. * Resolve one configured executable in this provider's execution world.
  117. * Absolute paths are verified; bare names use the provider's scrubbed PATH
  118. * plus explicit environment overrides. Relative paths containing separators
  119. * are rejected: the resolution base is undefined, so providers fail loud
  120. * instead of guessing.
  121. * @param command - absolute executable path or bare PATH name.
  122. * @param env - explicit environment entries used for lookup.
  123. * @param signal - aborts remote or local lookup.
  124. * @returns a canonical executable path.
  125. */
  126. abstract resolveExecutable(
  127. command: string,
  128. env?: Readonly<Record<string, string>>,
  129. signal?: AbortSignal,
  130. ): Promise<string>
  131. /**
  132. * Inspect shell-selection facts in the provider's execution environment.
  133. * @param signal - cancellation of remote environment inspection.
  134. * @returns platform and preferred shell; executable lookup and allocation remain separate operations.
  135. */
  136. abstract terminalEnvironment(signal?: AbortSignal): Promise<SubprocessTerminalEnvironment>
  137. /**
  138. * Start one managed child process from a fully-specified spec; this seam
  139. * applies no defaults.
  140. * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.
  141. * @returns the live process handle (streams/readers, signalling, outcome promise).
  142. * @throws synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.
  143. */
  144. abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
  145. /**
  146. * Allocate a real terminal and start one owned process session. This is the
  147. * only non-pipe process primitive: implementations own terminal byte I/O,
  148. * foreground groups, signals, and whole-session quiescence.
  149. * @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.
  150. * @returns the live terminal handle after allocation succeeds.
  151. */
  152. abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>
  153. }
  154. export default SubprocessRuntime
  155. /** Executable lookup completed without finding an executable file. */
  156. export class SubprocessExecutableNotFoundError extends Error {
  157. /**
  158. * @param message - provider-specific lookup diagnostic.
  159. * @param options - original provider failure, when available.
  160. */
  161. constructor(message: string, options?: ErrorOptions) {
  162. super(message, options)
  163. this.name = 'SubprocessExecutableNotFoundError'
  164. }
  165. }