runner.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. /**
  2. * Execute command hooks through `ctx.bash`, using its credential scrub,
  3. * process-group cancellation, and timeout machinery. The bridge supplies the
  4. * trusted stdin payload and dialect environment, then this module decodes the
  5. * captured outcome.
  6. * @module @deepseek-ai/dsh-hook-protocol/runner
  7. */
  8. import type { BashExecutor } from '@deepseek-ai/dsh-bash'
  9. import { parseHookOutput } from './codec.ts'
  10. import type { CommandHook, HookOutput } from './types.ts'
  11. /**
  12. * The reference default per-hook timeout, in ms (10 minutes) — the value both
  13. * Claude Code and Codex apply to a hook whose config sets no `timeout`. It
  14. * lives here, once, as the protocol's default; the bridges' `defaultTimeoutMs`
  15. * config defaults to it, and a per-hook {@link CommandHook.timeoutSec} is the
  16. * override API.
  17. */
  18. export const DEFAULT_HOOK_TIMEOUT_MS = 600_000
  19. /** Everything a single hook invocation needs beyond its command line. */
  20. export interface RunHookOptions {
  21. /** The JSON payload object written to the hook's stdin (the bridge builds it). */
  22. payload: unknown
  23. /** Extra env vars for the hook process (`CLAUDE_PROJECT_DIR`, …); the bridge builds these. */
  24. env?: Record<string, string>
  25. /** Working directory for the hook (defaults to the executor's own default when omitted). */
  26. cwd?: string
  27. /** Explicit owning-operation signal; firing it cancels the hook run. */
  28. readonly signal: AbortSignal
  29. /** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */
  30. trailingNewline: boolean
  31. /**
  32. * Timeout applied when the hook's config sets no `timeout` of its own. The
  33. * bridge owns the default (its `defaultTimeoutMs` config, reference default
  34. * {@link DEFAULT_HOOK_TIMEOUT_MS}) and passes it in explicitly.
  35. */
  36. defaultTimeoutMs: number
  37. /**
  38. * The event this hook is firing for (e.g. `'PreToolUse'`). When set, a
  39. * structured `hookSpecificOutput` block whose `hookEventName` names a DIFFERENT
  40. * event is treated as malformed and its event-scoped fields are discarded (see
  41. * {@link parseHookOutput}). Omit it to apply any block as-is.
  42. */
  43. expectedEventName?: string
  44. }
  45. /** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */
  46. export interface RunHookResult {
  47. output: HookOutput
  48. /** Wall-clock duration of the run, from `now` — durable on the `hook/result` event. */
  49. durationMs: number
  50. }
  51. /**
  52. * Run `hook` with serialized stdin and decode its outcome. A hook-specific
  53. * timeout in seconds overrides the default; trusted environment entries merge
  54. * after the executor scrub. Infrastructure rejection becomes an outcome with
  55. * no exit code, so this function never throws or crashes the calling turn.
  56. * @param bash - The executor service the command runs through.
  57. * @param hook - the configured command; its `timeoutSec` (wire unit: seconds) overrides the default timeout.
  58. * @param options - the invocation's payload, env, cwd, signal, stdin framing, and default timeout.
  59. * @param now - millisecond clock used for the reported duration.
  60. * @returns the decoded output plus the run's wall-clock duration.
  61. */
  62. export async function runHook(
  63. bash: BashExecutor,
  64. hook: CommandHook,
  65. options: RunHookOptions,
  66. now: () => number,
  67. ): Promise<RunHookResult> {
  68. const started = now()
  69. const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : options.defaultTimeoutMs
  70. const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '')
  71. const request = {
  72. command: hook.command,
  73. timeoutMs,
  74. stdin,
  75. signal: options.signal,
  76. ...options.cwd !== undefined ? { workdir: options.cwd } : {},
  77. ...options.env !== undefined ? { env: options.env } : {},
  78. }
  79. try {
  80. const result = await bash.run(bash.resolve(request))
  81. // BashRunResult.exitCode is `number | null` (null = died by signal); the
  82. // protocol's exit-code contract is numeric, so a signal death maps to
  83. // `undefined` (a non-blocking error — no clean exit code to act on).
  84. const exitCode = result.exitCode ?? undefined
  85. return {
  86. output: parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName),
  87. durationMs: now() - started,
  88. }
  89. } catch (error: unknown) {
  90. // The executor rejects only on infrastructure faults (unusable workdir,
  91. // missing shell). A hook that cannot run is a non-blocking error: no exit
  92. // code, the failure on stderr for the record. The turn proceeds.
  93. const message = error instanceof Error ? error.message : String(error)
  94. return {
  95. output: parseHookOutput(undefined, '', message),
  96. durationMs: now() - started,
  97. }
  98. }
  99. }