@basein/runner 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,674 @@
1
+ /**
2
+ * builtins — running a scenario's built-in steps (`Read`, `Glob`, `Grep`,
3
+ * `Edit`, `Write`, `Bash`) on the runner itself (docs/calculatedReplay.md §5).
4
+ *
5
+ * A built-in lives inside the host session, not behind any MCP server, so there
6
+ * is no proxy to relay it to: historically every built-in step threw `not_run`
7
+ * and the plan handed the task back to the model. This module is the other
8
+ * option: execute the step here, against the *session's own project directory*,
9
+ * so a chain that starts with a `Read` no longer costs a hand-over.
10
+ *
11
+ * THE RULES, because this bypasses the host's permission prompts entirely:
12
+ *
13
+ * - **Default deny beyond read-only.** Which built-ins may run at all is
14
+ * `BIR_BUILTIN_TOOLS` ({@link parseBuiltinTools}), and its default is the
15
+ * read-only set (`Read,Glob,Grep`). `Edit`, `Write` and `Bash` are an
16
+ * explicit, per-environment opt-in — a recorded `rm` must never run
17
+ * because a prompt looked similar.
18
+ * - **Path jail.** Every path is resolved against, and confined to, the
19
+ * session's project directory ({@link jailed}), symlinks included. The
20
+ * daemon's own cwd is never used: it is `~/.baseinstrunner`, not the
21
+ * project (§5.1 of the change plan).
22
+ * - **Bash runs nothing unless `BIR_BASH_ALLOW` says so.** A command must
23
+ * match one of its prefixes ({@link bashAllowed}); unset means `Bash`
24
+ * never executes here, even when allow-listed.
25
+ * - **Same failure contract as an MCP step.** A step that *cannot* run here
26
+ * throws {@link BuiltinNotRunnable} (→ `not_run`: the session can still do
27
+ * it, or a recorded output may stand in under
28
+ * `BIR_REPLAY_RECORDED_OUTPUTS=1`). One that ran out of time throws
29
+ * {@link BuiltinTimeout} (→ `uncertain`). Any other throw is a step that
30
+ * ran and failed (→ `failed`), never silently threaded as a success.
31
+ *
32
+ * Outputs mimic the **hook view** of each tool — Claude Code's `tool_response`
33
+ * as `PostToolUse` reports it — because that is exactly what the recorder
34
+ * stored for built-in steps (control/server.ts, `onToolPost`) and therefore
35
+ * what each step's `toolOutputLogic` was authored against. The shapes are a
36
+ * best effort and may drift from a future host's, the same caveat the
37
+ * hook-view threading fallback already carries ("shapes may differ").
38
+ */
39
+ import { execFile } from "node:child_process";
40
+ import { randomBytes } from "node:crypto";
41
+ import { existsSync } from "node:fs";
42
+ import { readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
43
+ import { dirname, join, resolve, sep } from "node:path";
44
+ // ── configuration ────────────────────────────────────────────────────────────
45
+ /**
46
+ * The built-ins bir may stand in for or execute when `BIR_BUILTIN_TOOLS` is
47
+ * unset: the read-only set. Reading a file the session itself would read
48
+ * without a prompt is the risk floor; anything that writes or runs is not.
49
+ */
50
+ export const DEFAULT_BUILTIN_TOOLS = ["Read", "Glob", "Grep"];
51
+ /** The built-ins {@link runBuiltin} actually implements. */
52
+ export const EXECUTABLE_BUILTINS = new Set([
53
+ "Read",
54
+ "Glob",
55
+ "Grep",
56
+ "Edit",
57
+ "Write",
58
+ "Bash",
59
+ ]);
60
+ /** `read` → `Read`: the vocabulary is small enough to forgive casing. */
61
+ const CANONICAL_BUILTIN = new Map([...EXECUTABLE_BUILTINS].map((name) => [name.toLowerCase(), name]));
62
+ /**
63
+ * Parse `BIR_BUILTIN_TOOLS`. Unset means {@link DEFAULT_BUILTIN_TOOLS}; set,
64
+ * it is the whole list — `none` (or an empty value) turns built-ins off
65
+ * entirely. Unlike `BIR_REPLAY_ALLOW_SERVERS`, an explicit empty list is
66
+ * honoured: there is a non-empty default to fall back from, so emptiness here
67
+ * is a choice, not an accident.
68
+ */
69
+ export function parseBuiltinTools(value) {
70
+ if (value === undefined)
71
+ return new Set(DEFAULT_BUILTIN_TOOLS);
72
+ const names = value
73
+ .split(",")
74
+ .map((s) => s.trim())
75
+ .filter((s) => s.length > 0 && !["none", "off", "0"].includes(s.toLowerCase()))
76
+ .map((s) => CANONICAL_BUILTIN.get(s.toLowerCase()) ?? s);
77
+ return new Set(names);
78
+ }
79
+ /**
80
+ * Parse `BIR_BASH_ALLOW`: comma-separated command prefixes, `*` for
81
+ * everything. Undefined (unset or blank) means `Bash` never executes on the
82
+ * runner — the flag is the opt-in, exactly like the tool list itself.
83
+ */
84
+ export function parseBashAllow(value) {
85
+ const prefixes = (value ?? "")
86
+ .split(",")
87
+ .map((s) => s.trim())
88
+ .filter(Boolean);
89
+ return prefixes.length > 0 ? prefixes : undefined;
90
+ }
91
+ /**
92
+ * Whether `command` matches the `BIR_BASH_ALLOW` prefixes. A prefix that ends
93
+ * mid-word must end the command's word too: `git` allows `git status` and
94
+ * `git`, never `gitk` — an operator who wrote `git` meant git, and the
95
+ * trimming in {@link parseBashAllow} must not widen that.
96
+ */
97
+ export function bashAllowed(command, allow) {
98
+ if (!allow)
99
+ return false;
100
+ const trimmed = command.trimStart();
101
+ const word = /[A-Za-z0-9_-]/;
102
+ return allow.some((prefix) => {
103
+ if (prefix === "*")
104
+ return true;
105
+ if (!trimmed.startsWith(prefix))
106
+ return false;
107
+ const next = trimmed.charAt(prefix.length);
108
+ return next === "" || !word.test(next) || !word.test(prefix.charAt(prefix.length - 1));
109
+ });
110
+ }
111
+ // ── the failure contract ─────────────────────────────────────────────────────
112
+ /**
113
+ * The step cannot run here — policy, not breakage: the tool is not
114
+ * implemented, its path leaves the project, `Bash` is not allowed, or the
115
+ * input is not even the right shape. Maps to `not_run`: nothing happened, the
116
+ * session may still do it, a recorded output may stand in.
117
+ */
118
+ export class BuiltinNotRunnable extends Error {
119
+ constructor(message) {
120
+ super(message);
121
+ this.name = "BuiltinNotRunnable";
122
+ }
123
+ }
124
+ /**
125
+ * The step started and was cut off by its clock. For `Bash` the command may
126
+ * have done part of its work; for a walk there is simply no result. Either
127
+ * way nothing may be asserted, which is `uncertain` — the same word an MCP
128
+ * step's timeout gets.
129
+ */
130
+ export class BuiltinTimeout extends Error {
131
+ constructor(message) {
132
+ super(message);
133
+ this.name = "BuiltinTimeout";
134
+ }
135
+ }
136
+ // ── size caps ────────────────────────────────────────────────────────────────
137
+ // The results flow through the same `serializeCapped(redact(...))` as every
138
+ // recorded payload, so these caps are about the daemon's memory and time, not
139
+ // about what reaches the service.
140
+ /** The most of one file a `Read`/`Edit` loads. */
141
+ const MAX_FILE_BYTES = 8 * 1024 * 1024;
142
+ /** `Read` line clamp, matching the host's own rendering limit. */
143
+ const MAX_LINE_CHARS = 2_000;
144
+ /** `Read` default line count, matching the host's. */
145
+ const DEFAULT_READ_LINES = 2_000;
146
+ /** Bash stdout/stderr cap. */
147
+ const MAX_BASH_BUFFER = 8 * 1024 * 1024;
148
+ /** The most files a Glob answers, oldest dropped first (mtime order). */
149
+ const MAX_GLOB_RESULTS = 100;
150
+ /** The most directory entries a walk visits before it reports `truncated`. */
151
+ const MAX_WALK_ENTRIES = 50_000;
152
+ /** The most files a Grep opens, and the most of each it reads. */
153
+ const MAX_GREP_FILES = 5_000;
154
+ const MAX_GREP_FILE_BYTES = 4 * 1024 * 1024;
155
+ /** The most matching lines a Grep's `content` mode returns. */
156
+ const DEFAULT_GREP_HEAD = 250;
157
+ // ── the executor ─────────────────────────────────────────────────────────────
158
+ /**
159
+ * Execute one built-in step and return its hook-shaped result (an object; the
160
+ * caller serializes). Throws per the failure contract above.
161
+ */
162
+ export async function runBuiltin(toolName, input, ctx) {
163
+ const deadline = ctx.timeoutMs !== undefined ? Date.now() + ctx.timeoutMs : undefined;
164
+ switch (toolName) {
165
+ case "Read":
166
+ return runRead(input, ctx);
167
+ case "Glob":
168
+ return runGlob(input, ctx, deadline);
169
+ case "Grep":
170
+ return runGrep(input, ctx, deadline);
171
+ case "Edit":
172
+ return runEdit(input, ctx);
173
+ case "Write":
174
+ return runWrite(input, ctx);
175
+ case "Bash":
176
+ return runBash(input, ctx);
177
+ default:
178
+ // Allow-listed but not implemented (`TodoWrite`, a tool this version
179
+ // does not know): the session runs it, or its recording stands in.
180
+ throw new BuiltinNotRunnable(`no runner-side implementation for ${toolName}`);
181
+ }
182
+ }
183
+ // ── the path jail ────────────────────────────────────────────────────────────
184
+ /** Case-blind on Windows, where `C:\Repo` and `c:\repo` are one directory. */
185
+ function samePath(a, b) {
186
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
187
+ }
188
+ function underRoot(root, abs) {
189
+ const prefix = root.endsWith(sep) ? root : root + sep;
190
+ return (samePath(abs, root) ||
191
+ (process.platform === "win32"
192
+ ? abs.toLowerCase().startsWith(prefix.toLowerCase())
193
+ : abs.startsWith(prefix)));
194
+ }
195
+ /**
196
+ * Resolve `p` against the session root and refuse anything outside it — first
197
+ * textually, then through `realpath` so a symlink inside the project cannot
198
+ * point the step at a file beyond it. The nearest existing ancestor is what
199
+ * gets resolved, so a `Write` to a new file is judged by its real directory.
200
+ */
201
+ async function jailed(root, p, what = "file_path") {
202
+ if (typeof p !== "string" || p.trim() === "") {
203
+ throw new BuiltinNotRunnable(`${what} is required`);
204
+ }
205
+ const r = resolve(root);
206
+ const abs = resolve(r, p);
207
+ const outside = () => {
208
+ throw new BuiltinNotRunnable(`${p} is outside the session's project directory, so bir does not touch it unattended`);
209
+ };
210
+ if (!underRoot(r, abs))
211
+ outside();
212
+ // The textual check passed; now the physical one. Walk up to what exists,
213
+ // realpath it, and require *that* to sit under the root's own realpath.
214
+ let probe = abs;
215
+ let real;
216
+ for (;;) {
217
+ try {
218
+ real = await realpath(probe);
219
+ break;
220
+ }
221
+ catch {
222
+ const parent = dirname(probe);
223
+ if (samePath(parent, probe))
224
+ break; // hit the filesystem root: nothing exists
225
+ probe = parent;
226
+ }
227
+ }
228
+ if (real !== undefined) {
229
+ let realRoot;
230
+ try {
231
+ realRoot = await realpath(r);
232
+ }
233
+ catch {
234
+ throw new BuiltinNotRunnable(`the session's project directory does not exist: ${root}`);
235
+ }
236
+ if (!underRoot(realRoot, real))
237
+ outside();
238
+ }
239
+ return abs;
240
+ }
241
+ // ── Read ─────────────────────────────────────────────────────────────────────
242
+ async function runRead(input, ctx) {
243
+ const filePath = await jailed(ctx.cwd, input.file_path);
244
+ const size = (await stat(filePath)).size;
245
+ if (size > MAX_FILE_BYTES) {
246
+ throw new Error(`file is ${size} bytes, more than the ${MAX_FILE_BYTES} this runner reads`);
247
+ }
248
+ const text = await readFile(filePath, "utf8");
249
+ const all = text.split(/\r?\n/);
250
+ // `offset` is 1-based, as the host's Read documents it; 0 reads as 1.
251
+ const offset = Math.max(1, numberOr(input.offset, 1));
252
+ const limit = Math.max(1, numberOr(input.limit, DEFAULT_READ_LINES));
253
+ const lines = all
254
+ .slice(offset - 1, offset - 1 + limit)
255
+ .map((l) => (l.length > MAX_LINE_CHARS ? l.slice(0, MAX_LINE_CHARS) : l));
256
+ return {
257
+ type: "text",
258
+ file: {
259
+ filePath,
260
+ content: lines.join("\n"),
261
+ numLines: lines.length,
262
+ startLine: offset,
263
+ totalLines: all.length,
264
+ },
265
+ };
266
+ }
267
+ // ── Glob ─────────────────────────────────────────────────────────────────────
268
+ async function runGlob(input, ctx, deadline) {
269
+ const pattern = input.pattern;
270
+ if (typeof pattern !== "string" || pattern.trim() === "") {
271
+ throw new BuiltinNotRunnable("pattern is required");
272
+ }
273
+ const root = await jailed(ctx.cwd, typeof input.path === "string" ? input.path : ".", "path");
274
+ const startedAt = Date.now();
275
+ const re = globToRegExp(pattern);
276
+ const found = [];
277
+ const truncated = await walk(root, deadline, (rel, abs, mtime) => {
278
+ if (re.test(rel))
279
+ found.push({ path: abs, mtime });
280
+ return true;
281
+ });
282
+ found.sort((a, b) => b.mtime - a.mtime);
283
+ const filenames = found.slice(0, MAX_GLOB_RESULTS).map((f) => f.path);
284
+ return {
285
+ filenames,
286
+ durationMs: Date.now() - startedAt,
287
+ numFiles: filenames.length,
288
+ truncated: truncated || found.length > MAX_GLOB_RESULTS,
289
+ };
290
+ }
291
+ // ── Grep ─────────────────────────────────────────────────────────────────────
292
+ /** Grep inputs this executor honours; anything else is the session's job. */
293
+ const GREP_KNOWN_KEYS = new Set([
294
+ "pattern",
295
+ "path",
296
+ "glob",
297
+ "output_mode",
298
+ "-i",
299
+ "-n",
300
+ "head_limit",
301
+ ]);
302
+ async function runGrep(input, ctx, deadline) {
303
+ const pattern = input.pattern;
304
+ if (typeof pattern !== "string" || pattern === "") {
305
+ throw new BuiltinNotRunnable("pattern is required");
306
+ }
307
+ // Context lines, multiline mode, type filters, offsets: a stand-in that
308
+ // silently ignored them would thread the *wrong* output. Decline instead —
309
+ // the step still runs in the session, or its recording stands in.
310
+ const exotic = Object.keys(input).filter((k) => !GREP_KNOWN_KEYS.has(k) && input[k] !== undefined && input[k] !== null);
311
+ if (exotic.length > 0) {
312
+ throw new BuiltinNotRunnable(`this runner does not support Grep's ${exotic.join(", ")}`);
313
+ }
314
+ let re;
315
+ try {
316
+ re = new RegExp(pattern, input["-i"] === true ? "i" : "");
317
+ }
318
+ catch (err) {
319
+ throw new BuiltinNotRunnable(`the pattern is not a regular expression this runner can run: ${err.message}`);
320
+ }
321
+ const target = await jailed(ctx.cwd, typeof input.path === "string" ? input.path : ".", "path");
322
+ // ripgrep's `-g '*.ts'` matches the basename at any depth; only a pattern
323
+ // with a `/` in it speaks about the path.
324
+ const globRe = typeof input.glob === "string" && input.glob
325
+ ? globToRegExp(input.glob.includes("/") ? input.glob : `**/${input.glob}`)
326
+ : undefined;
327
+ const mode = typeof input.output_mode === "string" ? input.output_mode : "files_with_matches";
328
+ const headLimit = Math.max(1, numberOr(input.head_limit, DEFAULT_GREP_HEAD));
329
+ // One file, or a walk — the host's Grep takes both.
330
+ const files = [];
331
+ const st = await stat(target).catch(() => undefined);
332
+ let truncated = false;
333
+ if (st?.isFile()) {
334
+ files.push({ path: target, mtime: st.mtimeMs });
335
+ }
336
+ else {
337
+ truncated = await walk(target, deadline, (rel, abs, mtime) => {
338
+ if (!globRe || globRe.test(rel))
339
+ files.push({ path: abs, mtime });
340
+ return files.length < MAX_GREP_FILES;
341
+ });
342
+ }
343
+ const mtimes = new Map(files.map((f) => [f.path, f.mtime]));
344
+ const matches = [];
345
+ for (const f of files) {
346
+ if (deadline !== undefined && Date.now() > deadline) {
347
+ throw new BuiltinTimeout(`Grep ran out of time after ${files.length} candidate files`);
348
+ }
349
+ const lines = await matchFile(f.path, re, mode === "files_with_matches");
350
+ if (lines)
351
+ matches.push({ path: f.path, lines });
352
+ }
353
+ matches.sort((a, b) => (mtimes.get(b.path) ?? 0) - (mtimes.get(a.path) ?? 0));
354
+ if (mode === "count") {
355
+ const shown = matches.slice(0, headLimit);
356
+ return {
357
+ mode: "count",
358
+ content: shown.map((m) => `${m.path}:${m.lines.length}`).join("\n"),
359
+ numFiles: matches.length,
360
+ numMatches: matches.reduce((n, m) => n + m.lines.length, 0),
361
+ truncated: truncated || matches.length > shown.length,
362
+ };
363
+ }
364
+ if (mode === "content") {
365
+ const numbered = input["-n"] !== false;
366
+ const out = [];
367
+ for (const m of matches) {
368
+ for (const line of m.lines) {
369
+ out.push(numbered ? `${m.path}:${line.n}:${line.text}` : `${m.path}:${line.text}`);
370
+ if (out.length >= headLimit)
371
+ break;
372
+ }
373
+ if (out.length >= headLimit)
374
+ break;
375
+ }
376
+ return { mode: "content", content: out.join("\n"), numLines: out.length, truncated };
377
+ }
378
+ const filenames = matches.slice(0, headLimit).map((m) => m.path);
379
+ return {
380
+ mode: "files_with_matches",
381
+ filenames,
382
+ numFiles: filenames.length,
383
+ truncated: truncated || matches.length > filenames.length,
384
+ };
385
+ }
386
+ /** The matching lines of one file, or undefined when none (or not text). */
387
+ async function matchFile(path, re, firstOnly) {
388
+ let buf;
389
+ try {
390
+ const st = await stat(path);
391
+ if (!st.isFile() || st.size > MAX_GREP_FILE_BYTES)
392
+ return undefined;
393
+ buf = await readFile(path);
394
+ }
395
+ catch {
396
+ return undefined; // unreadable is not a match
397
+ }
398
+ // A NUL near the start is the classic binary sniff, same as grep's own.
399
+ if (buf.subarray(0, 4096).includes(0))
400
+ return undefined;
401
+ const lines = buf.toString("utf8").split(/\r?\n/);
402
+ const out = [];
403
+ for (let i = 0; i < lines.length; i += 1) {
404
+ if (re.test(lines[i])) {
405
+ out.push({ n: i + 1, text: lines[i].slice(0, MAX_LINE_CHARS) });
406
+ if (firstOnly)
407
+ return out;
408
+ }
409
+ }
410
+ return out.length > 0 ? out : undefined;
411
+ }
412
+ // ── Edit / Write ─────────────────────────────────────────────────────────────
413
+ async function runEdit(input, ctx) {
414
+ const filePath = await jailed(ctx.cwd, input.file_path);
415
+ const oldString = input.old_string;
416
+ const newString = input.new_string;
417
+ if (typeof oldString !== "string" || oldString === "") {
418
+ throw new BuiltinNotRunnable("old_string is required");
419
+ }
420
+ if (typeof newString !== "string")
421
+ throw new BuiltinNotRunnable("new_string is required");
422
+ const replaceAll = input.replace_all === true;
423
+ const size = (await stat(filePath)).size;
424
+ if (size > MAX_FILE_BYTES) {
425
+ throw new Error(`file is ${size} bytes, more than the ${MAX_FILE_BYTES} this runner edits`);
426
+ }
427
+ const current = await readFile(filePath, "utf8");
428
+ const occurrences = current.split(oldString).length - 1;
429
+ if (occurrences === 0)
430
+ throw new Error(`old_string was not found in ${filePath}`);
431
+ if (!replaceAll && occurrences > 1) {
432
+ throw new Error(`old_string appears ${occurrences} times in ${filePath}; it must be unique`);
433
+ }
434
+ const next = replaceAll
435
+ ? current.split(oldString).join(newString)
436
+ : current.replace(oldString, newString);
437
+ await writeFile(filePath, next, "utf8");
438
+ return {
439
+ filePath,
440
+ oldString,
441
+ newString,
442
+ originalFile: current,
443
+ structuredPatch: [],
444
+ userModified: false,
445
+ replaceAll,
446
+ };
447
+ }
448
+ async function runWrite(input, ctx) {
449
+ const filePath = await jailed(ctx.cwd, input.file_path);
450
+ const content = input.content;
451
+ if (typeof content !== "string")
452
+ throw new BuiltinNotRunnable("content is required");
453
+ const existed = await stat(filePath).then((s) => s.isFile(), () => false);
454
+ // No mkdir: the host's Write does not invent directories, so neither does
455
+ // the stand-in — a missing parent is a divergence, not a wish.
456
+ await writeFile(filePath, content, "utf8");
457
+ return {
458
+ type: existed ? "update" : "create",
459
+ filePath,
460
+ content,
461
+ structuredPatch: [],
462
+ };
463
+ }
464
+ // ── Bash ─────────────────────────────────────────────────────────────────────
465
+ /**
466
+ * Which `bash` a runner-side step uses. On Windows the host's own Bash tool
467
+ * is Git Bash, which is rarely on the daemon's PATH — so the standard
468
+ * Git-for-Windows locations are preferred over PATH, where `bash.exe` may
469
+ * even be the WSL stub (a different filesystem view entirely). Resolved once.
470
+ */
471
+ export function bashExecutable() {
472
+ if (process.platform !== "win32")
473
+ return "bash";
474
+ for (const candidate of [
475
+ join(process.env.ProgramFiles ?? "C:\\Program Files", "Git", "bin", "bash.exe"),
476
+ join(process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", "Git", "bin", "bash.exe"),
477
+ ]) {
478
+ if (existsSync(candidate))
479
+ return candidate;
480
+ }
481
+ return "bash.exe";
482
+ }
483
+ async function runBash(input, ctx) {
484
+ const command = input.command;
485
+ if (typeof command !== "string" || command.trim() === "") {
486
+ throw new BuiltinNotRunnable("command is required");
487
+ }
488
+ if (!ctx.bashAllow) {
489
+ throw new BuiltinNotRunnable("BIR_BASH_ALLOW is not set, so bir runs no Bash command unattended");
490
+ }
491
+ if (!bashAllowed(command, ctx.bashAllow)) {
492
+ throw new BuiltinNotRunnable("the command does not match BIR_BASH_ALLOW, so bir does not run it unattended");
493
+ }
494
+ const timeoutMs = ctx.timeoutMs ?? 120_000;
495
+ // The host's Bash semantics, which the recorded chain was written against:
496
+ // the working directory carries over from the plan's previous Bash step,
497
+ // variables and functions do not (a fresh `-l` shell each call).
498
+ const startIn = ctx.bashCwd && existsSync(ctx.bashCwd) ? ctx.bashCwd : resolve(ctx.cwd);
499
+ // Where the shell *ends up* is asked of the shell itself, behind a marker
500
+ // no real output contains (random per call), printed to stderr and
501
+ // stripped before the output is threaded — the recording never carried it.
502
+ // `pwd -W` is Git Bash's Windows-path form; elsewhere it fails quietly and
503
+ // plain `pwd` answers. An explicit `exit` skips the marker, and the
504
+ // previous directory simply stands.
505
+ const mark = `__BIR_PWD_${randomBytes(8).toString("hex")}__`;
506
+ const script = `${command}\n__bir_rc=$?\nprintf '\\n%s%s%s' '${mark}' "$(pwd -W 2>/dev/null || pwd)" '${mark}' 1>&2\nexit "$__bir_rc"`;
507
+ return new Promise((resolvePromise, rejectPromise) => {
508
+ execFile(bashExecutable(), ["-lc", script], {
509
+ cwd: startIn,
510
+ timeout: timeoutMs,
511
+ maxBuffer: MAX_BASH_BUFFER,
512
+ windowsHide: true,
513
+ }, (err, stdout, stderr) => {
514
+ const cleaned = takePwdMarker(String(stderr ?? ""), mark);
515
+ if (cleaned.pwd)
516
+ ctx.onBashCwd?.(cleaned.pwd);
517
+ if (err) {
518
+ const e = err;
519
+ if (e.code === "ENOENT") {
520
+ // No bash on this machine (a Windows runner without Git Bash):
521
+ // the step is the session's, exactly as before.
522
+ rejectPromise(new BuiltinNotRunnable("no `bash` on the runner's PATH"));
523
+ return;
524
+ }
525
+ if (e.killed || e.signal === "SIGTERM") {
526
+ rejectPromise(new BuiltinTimeout(`the command was stopped after ${timeoutMs}ms and may have done part of its work`));
527
+ return;
528
+ }
529
+ const code = typeof e.code === "number" ? e.code : 1;
530
+ const detail = String(cleaned.text || stdout || "").trim().slice(0, 500);
531
+ rejectPromise(new Error(`the command exited with code ${code}${detail ? `: ${detail}` : ""}`));
532
+ return;
533
+ }
534
+ resolvePromise({
535
+ stdout: String(stdout ?? ""),
536
+ stderr: cleaned.text,
537
+ interrupted: false,
538
+ isImage: false,
539
+ });
540
+ });
541
+ });
542
+ }
543
+ /**
544
+ * Split the shell's final `$PWD` out of stderr again: the marked chunk (and
545
+ * the newline the wrapper added before it) is removed, whatever else the
546
+ * command wrote stays byte-for-byte.
547
+ */
548
+ function takePwdMarker(stderr, mark) {
549
+ const i = stderr.indexOf(mark);
550
+ if (i < 0)
551
+ return { text: stderr };
552
+ const j = stderr.indexOf(mark, i + mark.length);
553
+ if (j < 0)
554
+ return { text: stderr };
555
+ const pwd = stderr.slice(i + mark.length, j).trim();
556
+ const before = stderr.slice(0, i).replace(/\n$/, "");
557
+ const after = stderr.slice(j + mark.length);
558
+ return { text: before + after, pwd: pwd || undefined };
559
+ }
560
+ // ── shared walking and matching ─────────────────────────────────────────────
561
+ /** Directories no scenario's step means to scan. */
562
+ const SKIP_DIRS = new Set([".git", "node_modules", ".hg", ".svn"]);
563
+ /**
564
+ * Walk `root`, calling `visit(relativePath, absolutePath, mtimeMs)` for every
565
+ * regular file. Never follows directory symlinks — the jail was checked at
566
+ * the root, and a link out of it must not re-open the door. Returns whether
567
+ * the walk was cut short (entry cap, deadline, or `visit` saying stop).
568
+ */
569
+ async function walk(root, deadline, visit) {
570
+ let seen = 0;
571
+ const queue = [root];
572
+ while (queue.length > 0) {
573
+ if (deadline !== undefined && Date.now() > deadline) {
574
+ throw new BuiltinTimeout(`the walk ran out of time after ${seen} entries`);
575
+ }
576
+ const dir = queue.shift();
577
+ let entries;
578
+ try {
579
+ entries = await readdir(dir, { withFileTypes: true });
580
+ }
581
+ catch {
582
+ continue; // unreadable directory: skip, as the host's own tools do
583
+ }
584
+ for (const entry of entries) {
585
+ seen += 1;
586
+ if (seen > MAX_WALK_ENTRIES)
587
+ return true;
588
+ const abs = resolve(dir, entry.name);
589
+ if (entry.isDirectory()) {
590
+ if (!SKIP_DIRS.has(entry.name))
591
+ queue.push(abs);
592
+ continue;
593
+ }
594
+ if (!entry.isFile())
595
+ continue; // symlinks and specials stay unfollowed
596
+ let mtime = 0;
597
+ try {
598
+ mtime = (await stat(abs)).mtimeMs;
599
+ }
600
+ catch {
601
+ continue;
602
+ }
603
+ const rel = abs.slice(resolve(root).length + 1).split(sep).join("/");
604
+ if (!visit(rel, abs, mtime))
605
+ return true;
606
+ }
607
+ }
608
+ return false;
609
+ }
610
+ /**
611
+ * A glob pattern as a RegExp over `/`-separated relative paths: `**` crosses
612
+ * directories, `*` and `?` stay inside one, `{a,b}` alternates, `[…]` passes
613
+ * through as a character class. Matched against the whole relative path, as
614
+ * the host's Glob does — a bare `*.ts` is the top level only (Grep's filter
615
+ * widens a bare pattern itself, ripgrep-style). Enough for the patterns
616
+ * Claude Code writes; a pattern beyond it simply matches nothing it should
617
+ * not.
618
+ */
619
+ export function globToRegExp(pattern) {
620
+ let p = pattern.replace(/\\/g, "/");
621
+ if (p.startsWith("./"))
622
+ p = p.slice(2);
623
+ let out = "";
624
+ for (let i = 0; i < p.length; i += 1) {
625
+ const c = p[i];
626
+ if (c === "*") {
627
+ if (p[i + 1] === "*") {
628
+ // `**/` may match nothing at all; a trailing `**` matches the rest.
629
+ out += p[i + 2] === "/" ? "(?:[^/]+/)*" : ".*";
630
+ i += p[i + 2] === "/" ? 2 : 1;
631
+ }
632
+ else {
633
+ out += "[^/]*";
634
+ }
635
+ }
636
+ else if (c === "?") {
637
+ out += "[^/]";
638
+ }
639
+ else if (c === "{") {
640
+ const end = p.indexOf("}", i);
641
+ if (end === -1) {
642
+ out += "\\{";
643
+ }
644
+ else {
645
+ out += `(?:${p
646
+ .slice(i + 1, end)
647
+ .split(",")
648
+ .map((s) => s.replace(/[.+^$()|\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]"))
649
+ .join("|")})`;
650
+ i = end;
651
+ }
652
+ }
653
+ else if (c === "[") {
654
+ const end = p.indexOf("]", i + (p[i + 1] === "!" || p[i + 1] === "^" ? 2 : 1) + 1);
655
+ if (end === -1) {
656
+ out += "\\[";
657
+ }
658
+ else {
659
+ const body = p.slice(i + 1, end).replace(/^!/, "^");
660
+ out += `[${body}]`;
661
+ i = end;
662
+ }
663
+ }
664
+ else {
665
+ out += /[.+^$()|\\]/.test(c) ? `\\${c}` : c;
666
+ }
667
+ }
668
+ return new RegExp(`^${out}$`);
669
+ }
670
+ function numberOr(value, fallback) {
671
+ const n = typeof value === "string" ? Number(value) : value;
672
+ return typeof n === "number" && Number.isFinite(n) ? Math.floor(n) : fallback;
673
+ }
674
+ //# sourceMappingURL=builtins.js.map