返回源码地图

packages/hooks/hook-protocol/src/runner.ts

main snapshot · da00f7f5358f · 正文引用章节 12;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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