@basein/runner 0.3.24 → 0.3.26

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.
Files changed (67) hide show
  1. package/README.md +27 -26
  2. package/dist/auth/client.d.ts +4 -1
  3. package/dist/auth/client.js +83 -14
  4. package/dist/bin/bir-hooks.js +4 -2
  5. package/dist/bin/bir.js +153 -37
  6. package/dist/bin/investigate.js +5 -0
  7. package/dist/bin/scenario-edit.d.ts +5 -0
  8. package/dist/bin/scenario-edit.js +17 -1
  9. package/dist/config/adapters/claude-code.d.ts +51 -7
  10. package/dist/config/adapters/claude-code.js +105 -18
  11. package/dist/config/generate.d.ts +61 -13
  12. package/dist/config/generate.js +108 -20
  13. package/dist/config/resolve.d.ts +5 -0
  14. package/dist/config/resolve.js +7 -0
  15. package/dist/control/correlation.js +0 -0
  16. package/dist/control/daemon.d.ts +28 -0
  17. package/dist/control/daemon.js +62 -0
  18. package/dist/control/paths.d.ts +19 -1
  19. package/dist/control/paths.js +20 -2
  20. package/dist/control/pin-guard.js +8 -2
  21. package/dist/control/recorder-main.js +17 -5
  22. package/dist/control/server.d.ts +63 -9
  23. package/dist/control/server.js +350 -62
  24. package/dist/control/transcript.d.ts +64 -3
  25. package/dist/control/transcript.js +318 -37
  26. package/dist/jsonrpc/framing.d.ts +18 -3
  27. package/dist/jsonrpc/framing.js +57 -4
  28. package/dist/proxy/intercept.d.ts +4 -0
  29. package/dist/proxy/intercept.js +4 -0
  30. package/dist/proxy/relay.d.ts +13 -0
  31. package/dist/proxy/relay.js +12 -1
  32. package/dist/proxy/session.d.ts +8 -1
  33. package/dist/proxy/session.js +67 -10
  34. package/dist/record/recorder.d.ts +6 -1
  35. package/dist/record/redact.d.ts +18 -4
  36. package/dist/record/redact.js +538 -79
  37. package/dist/record/remote-recorder.d.ts +26 -5
  38. package/dist/record/remote-recorder.js +174 -18
  39. package/dist/record/truncate.js +3 -1
  40. package/dist/replay/bash-channel.d.ts +7 -1
  41. package/dist/replay/bash-channel.js +9 -3
  42. package/dist/replay/builtins.d.ts +14 -0
  43. package/dist/replay/builtins.js +460 -95
  44. package/dist/replay/bundle.d.ts +80 -0
  45. package/dist/replay/bundle.js +455 -24
  46. package/dist/replay/controller.d.ts +64 -1
  47. package/dist/replay/controller.js +238 -38
  48. package/dist/replay/derive.js +46 -4
  49. package/dist/replay/handover.d.ts +13 -0
  50. package/dist/replay/handover.js +18 -0
  51. package/dist/replay/plan.d.ts +15 -0
  52. package/dist/replay/plan.js +48 -30
  53. package/dist/replay/pricing.d.ts +61 -11
  54. package/dist/replay/pricing.js +135 -42
  55. package/dist/replay/safety.js +43 -5
  56. package/dist/upstream/http-client.d.ts +6 -3
  57. package/dist/upstream/http-client.js +54 -13
  58. package/dist/upstream/stdio-client.d.ts +7 -0
  59. package/dist/upstream/stdio-client.js +1 -1
  60. package/dist/util/atomic-write.d.ts +9 -1
  61. package/dist/util/atomic-write.js +9 -6
  62. package/dist/util/service-health.d.ts +7 -0
  63. package/dist/util/service-health.js +10 -3
  64. package/docs/calculatedReplay.md +32 -0
  65. package/package.json +1 -1
  66. package/production.md +2 -2
  67. package/scripts/install.ps1 +14 -2
@@ -46,12 +46,17 @@
46
46
  * best effort and may drift from a future host's, the same caveat the
47
47
  * hook-view threading fallback already carries ("shapes may differ").
48
48
  */
49
- import { execFile } from "node:child_process";
50
- import { randomBytes } from "node:crypto";
49
+ import { spawn, } from "node:child_process";
50
+ import { randomBytes, randomUUID } from "node:crypto";
51
51
  import { existsSync } from "node:fs";
52
- import { readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
52
+ import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
53
53
  import { homedir } from "node:os";
54
54
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
55
+ import { setTimeout as delay } from "node:timers/promises";
56
+ import { Worker } from "node:worker_threads";
57
+ import { ensureDir } from "../control/paths.js";
58
+ import { killTree } from "../upstream/stdio-client.js";
59
+ import { deliveryDir } from "./bash-channel.js";
55
60
  // ── configuration ────────────────────────────────────────────────────────────
56
61
  /** The built-ins {@link runBuiltin} actually implements. */
57
62
  export const EXECUTABLE_BUILTINS = new Set([
@@ -348,12 +353,28 @@ async function runGlob(input, ctx, deadline) {
348
353
  const root = await allowedPath(ctx, typeof input.path === "string" ? input.path : ".", "path");
349
354
  const startedAt = Date.now();
350
355
  const re = globToRegExp(pattern);
351
- const found = [];
352
- const truncated = await walk(root, deadline, (rel, abs, mtime) => {
356
+ const matched = [];
357
+ const truncated = await walk(root, deadline, (rel, abs) => {
353
358
  if (re.test(rel))
354
- found.push({ path: abs, mtime });
359
+ matched.push(abs);
355
360
  return true;
356
361
  });
362
+ // The time is asked of the files that matched, after the walk, and not of
363
+ // every file the walk passes: on a tree of 9,576 files that stat was most of
364
+ // what a Glob took. A file gone since the walk is left out, as a file the
365
+ // walk could not stat always was.
366
+ const found = [];
367
+ for (const path of matched) {
368
+ if (deadline !== undefined && Date.now() > deadline) {
369
+ throw new BuiltinTimeout(`Glob ran out of time after ${matched.length} matching files`);
370
+ }
371
+ try {
372
+ found.push({ path, mtime: (await stat(path)).mtimeMs });
373
+ }
374
+ catch {
375
+ continue;
376
+ }
377
+ }
357
378
  found.sort((a, b) => b.mtime - a.mtime);
358
379
  const filenames = found.slice(0, MAX_GLOB_RESULTS).map((f) => f.path);
359
380
  return {
@@ -406,26 +427,17 @@ async function runGrep(input, ctx, deadline) {
406
427
  const st = await stat(target).catch(() => undefined);
407
428
  let truncated = false;
408
429
  if (st?.isFile()) {
409
- files.push({ path: target, mtime: st.mtimeMs });
430
+ files.push(target);
410
431
  }
411
432
  else {
412
- truncated = await walk(target, deadline, (rel, abs, mtime) => {
433
+ truncated = await walk(target, deadline, (rel, abs) => {
413
434
  if (!globRe || globRe.test(rel))
414
- files.push({ path: abs, mtime });
435
+ files.push(abs);
415
436
  return files.length < MAX_GREP_FILES;
416
437
  });
417
438
  }
418
- const mtimes = new Map(files.map((f) => [f.path, f.mtime]));
419
- const matches = [];
420
- for (const f of files) {
421
- if (deadline !== undefined && Date.now() > deadline) {
422
- throw new BuiltinTimeout(`Grep ran out of time after ${files.length} candidate files`);
423
- }
424
- const lines = await matchFile(f.path, re, mode === "files_with_matches");
425
- if (lines)
426
- matches.push({ path: f.path, lines });
427
- }
428
- matches.sort((a, b) => (mtimes.get(b.path) ?? 0) - (mtimes.get(a.path) ?? 0));
439
+ const matches = await matchFiles(files, re, mode === "files_with_matches", deadline);
440
+ matches.sort((a, b) => b.mtime - a.mtime);
429
441
  if (mode === "count") {
430
442
  const shown = matches.slice(0, headLimit);
431
443
  return {
@@ -458,31 +470,100 @@ async function runGrep(input, ctx, deadline) {
458
470
  truncated: truncated || matches.length > filenames.length,
459
471
  };
460
472
  }
461
- /** The matching lines of one file, or undefined when none (or not text). */
462
- async function matchFile(path, re, firstOnly) {
463
- let buf;
473
+ /**
474
+ * The matching of a Grep, run in a thread of its own. The recorded pattern
475
+ * runs on JavaScript's backtracking engine, and `(a+)+$` over one line of 26
476
+ * letters and a `b` takes a second or two, twice that for every letter more:
477
+ * a call that no clock checked between files can interrupt. Run on the thread
478
+ * that answers every hook of the user's agent it held them all, and the step's
479
+ * time limit never came into it. In a worker the limit ends the call and the
480
+ * thread with it ({@link matchFiles}).
481
+ *
482
+ * Source text, not a file of its own: a worker started from a path has to find
483
+ * that file again from wherever this module is installed, and from `dist/`
484
+ * that is the path that goes wrong. The worker reads the files itself, so only
485
+ * their names cross over, and its one `stat` gives both the size to refuse and
486
+ * the time to sort by.
487
+ */
488
+ const GREP_WORKER = String.raw `
489
+ const { parentPort, workerData } = require("node:worker_threads");
490
+ const { readFileSync, statSync } = require("node:fs");
491
+ const { source, flags, files, firstOnly, maxBytes, maxLine } = workerData;
492
+ const re = new RegExp(source, flags);
493
+ const hits = [];
494
+ for (const path of files) {
495
+ let st;
496
+ let buf;
497
+ try {
498
+ st = statSync(path);
499
+ if (!st.isFile() || st.size > maxBytes) continue;
500
+ buf = readFileSync(path);
501
+ } catch {
502
+ continue; // unreadable is not a match
503
+ }
504
+ // A NUL near the start is the classic binary sniff, same as grep's own.
505
+ if (buf.subarray(0, 4096).includes(0)) continue;
506
+ const lines = buf.toString("utf8").split(/\r?\n/);
507
+ const found = [];
508
+ for (let i = 0; i < lines.length; i += 1) {
509
+ if (re.test(lines[i])) {
510
+ found.push({ n: i + 1, text: lines[i].slice(0, maxLine) });
511
+ if (firstOnly) break;
512
+ }
513
+ }
514
+ if (found.length > 0) hits.push({ path, mtime: st.mtimeMs, lines: found });
515
+ }
516
+ parentPort.postMessage(hits);
517
+ `;
518
+ /**
519
+ * Match `re` against `files` in {@link GREP_WORKER}, within the step's clock.
520
+ * Out of time the answer is the time-out error at that moment, whatever the
521
+ * thread is doing, and the thread is told to stop: a pattern that explodes
522
+ * ends there instead of holding the process.
523
+ */
524
+ async function matchFiles(files, re, firstOnly, deadline) {
525
+ if (files.length === 0)
526
+ return [];
527
+ const left = deadline === undefined ? undefined : deadline - Date.now();
528
+ const outOfTime = () => new BuiltinTimeout(`Grep ran out of time after ${files.length} candidate files`);
529
+ if (left !== undefined && left <= 0)
530
+ throw outOfTime();
531
+ let worker;
464
532
  try {
465
- const st = await stat(path);
466
- if (!st.isFile() || st.size > MAX_GREP_FILE_BYTES)
467
- return undefined;
468
- buf = await readFile(path);
469
- }
470
- catch {
471
- return undefined; // unreadable is not a match
472
- }
473
- // A NUL near the start is the classic binary sniff, same as grep's own.
474
- if (buf.subarray(0, 4096).includes(0))
475
- return undefined;
476
- const lines = buf.toString("utf8").split(/\r?\n/);
477
- const out = [];
478
- for (let i = 0; i < lines.length; i += 1) {
479
- if (re.test(lines[i])) {
480
- out.push({ n: i + 1, text: lines[i].slice(0, MAX_LINE_CHARS) });
481
- if (firstOnly)
482
- return out;
483
- }
533
+ worker = new Worker(GREP_WORKER, {
534
+ eval: true,
535
+ workerData: {
536
+ source: re.source,
537
+ flags: re.flags,
538
+ files,
539
+ firstOnly,
540
+ maxBytes: MAX_GREP_FILE_BYTES,
541
+ maxLine: MAX_LINE_CHARS,
542
+ },
543
+ });
544
+ }
545
+ catch (err) {
546
+ // Nothing has run, so the session can still do the search.
547
+ throw new BuiltinNotRunnable(`a thread for the search could not be started (${err.message}), so the session runs it`);
548
+ }
549
+ let clock;
550
+ try {
551
+ return await new Promise((resolvePromise, rejectPromise) => {
552
+ if (left !== undefined)
553
+ clock = setTimeout(() => rejectPromise(outOfTime()), left);
554
+ // The first of these to happen is the answer; the rest are no-ops. The
555
+ // worker's messages reach this side before its exit does.
556
+ worker.on("message", resolvePromise);
557
+ worker.on("error", rejectPromise);
558
+ worker.on("exit", () => rejectPromise(new Error("the search ended without an answer")));
559
+ });
560
+ }
561
+ finally {
562
+ clearTimeout(clock);
563
+ // Not awaited: the answer never waits for a thread that is stuck in a
564
+ // pattern or in a read.
565
+ worker.terminate().catch(() => undefined);
484
566
  }
485
- return out.length > 0 ? out : undefined;
486
567
  }
487
568
  // ── Edit / Write ─────────────────────────────────────────────────────────────
488
569
  async function runEdit(input, ctx) {
@@ -500,16 +581,40 @@ async function runEdit(input, ctx) {
500
581
  throw new Error(`file is ${size} bytes, more than the ${MAX_FILE_BYTES} this runner edits`);
501
582
  }
502
583
  const current = await readFile(filePath, "utf8");
503
- const occurrences = current.split(oldString).length - 1;
584
+ // The model writes its strings with LF whatever the file has, and the host's
585
+ // Edit finds them in a CRLF file all the same and writes CRLF back. So a
586
+ // CRLF file is edited on LF copies — of its text and of both strings — and
587
+ // every line gets its CRLF again. Matched as it lay on disk, a two-line
588
+ // `old_string` was never found in such a file, and a two-line `new_string`
589
+ // left LF lines in it. A file with both endings is edited as it lies on disk
590
+ // while neither string holds a line break, since no ending is written then,
591
+ // and left to the session when one does: there is no one ending to write
592
+ // back, and choosing one would change lines the edit never named. Refused
593
+ // whole, every file an earlier runner left mixed handed over on each replay,
594
+ // one-line edits included.
595
+ const crlf = current.split("\r\n").length - 1;
596
+ const lf = current.split("\n").length - 1 - crlf;
597
+ const mixed = crlf > 0 && lf > 0;
598
+ if (mixed && /[\r\n]/.test(oldString + newString)) {
599
+ throw new BuiltinNotRunnable("the file mixes CRLF and LF line endings and the edit spans lines, so the session edits it");
600
+ }
601
+ const onLf = crlf > 0 && !mixed;
602
+ const asLf = (s) => (onLf ? s.replace(/\r\n/g, "\n") : s);
603
+ const text = asLf(current);
604
+ const find = asLf(oldString);
605
+ const put = asLf(newString);
606
+ const occurrences = text.split(find).length - 1;
504
607
  if (occurrences === 0)
505
608
  throw new Error(`old_string was not found in ${filePath}`);
506
609
  if (!replaceAll && occurrences > 1) {
507
610
  throw new Error(`old_string appears ${occurrences} times in ${filePath}; it must be unique`);
508
611
  }
509
- const next = replaceAll
510
- ? current.split(oldString).join(newString)
511
- : current.replace(oldString, newString);
512
- await writeFile(filePath, next, "utf8");
612
+ // A function, never the string itself: `replace` reads `$$`, `$&`, `` $` ``
613
+ // and `$'` in a replacement string as commands, so `const re = '^[a-z]+$';`
614
+ // was written with the rest of the file pasted in where `$'` stood, and the
615
+ // step reported success.
616
+ const edited = replaceAll ? text.split(find).join(put) : text.replace(find, () => put);
617
+ await writeFile(filePath, onLf ? edited.replace(/\n/g, "\r\n") : edited, "utf8");
513
618
  return {
514
619
  filePath,
515
620
  oldString,
@@ -526,9 +631,21 @@ async function runWrite(input, ctx) {
526
631
  if (typeof content !== "string")
527
632
  throw new BuiltinNotRunnable("content is required");
528
633
  const existed = await stat(filePath).then((s) => s.isFile(), () => false);
529
- // No mkdir: the host's Write does not invent directories, so neither does
530
- // the stand-in — a missing parent is a divergence, not a wish.
531
- await writeFile(filePath, content, "utf8");
634
+ // The host's Write makes the folders a new file needs, so the stand-in does
635
+ // too: a plan whose path holds a value (reports/2026-11/close.md) failed on
636
+ // every new value without them. Only when the write finds them missing — a
637
+ // folder that is there is never touched — and only where the file itself may
638
+ // go: `allowedPath` judged the whole path above by its real, existing
639
+ // folder, and every folder made here lies between that one and the file.
640
+ try {
641
+ await writeFile(filePath, content, "utf8");
642
+ }
643
+ catch (err) {
644
+ if (err.code !== "ENOENT")
645
+ throw err;
646
+ await mkdir(dirname(filePath), { recursive: true });
647
+ await writeFile(filePath, content, "utf8");
648
+ }
532
649
  return {
533
650
  type: existed ? "update" : "create",
534
651
  filePath,
@@ -588,44 +705,297 @@ async function runBash(input, ctx) {
588
705
  // `pwd -W` is Git Bash's Windows-path form; elsewhere it fails quietly and
589
706
  // plain `pwd` answers. An explicit `exit` skips the marker, and the
590
707
  // previous directory simply stands.
708
+ //
709
+ // A blank line after the command: one whose last line ends in `\` ran on
710
+ // into the wrapper's first line, and `echo hi \` printed `hi __bir_rc=0` and
711
+ // failed on an `exit` with no number.
591
712
  const mark = `__BIR_PWD_${randomBytes(8).toString("hex")}__`;
592
- const script = `${command}\n__bir_rc=$?\nprintf '\\n%s%s%s' '${mark}' "$(pwd -W 2>/dev/null || pwd)" '${mark}' 1>&2\nexit "$__bir_rc"`;
713
+ const script = `${command}\n\n__bir_rc=$?\nprintf '\\n%s%s%s' '${mark}' "$(pwd -W 2>/dev/null || pwd)" '${mark}' 1>&2\nexit "$__bir_rc"`;
714
+ const file = await scriptFile(script);
715
+ const unread = `__BIR_UNREAD_${randomBytes(8).toString("hex")}__`;
716
+ let ran;
717
+ try {
718
+ ran = await runShell(readsScript(file, unread), startIn, timeoutMs);
719
+ }
720
+ finally {
721
+ // Whatever became of the command. A file a crash leaves behind goes with
722
+ // bash-channel.ts's sweep, within the hour.
723
+ await rm(file, { force: true }).catch(() => undefined);
724
+ }
725
+ if (ran.code === 126 && ran.stderr.includes(unread)) {
726
+ // bash never had the command: it could not read the file it was handed in
727
+ // (a `bash.exe` that is WSL's sees no C:/ path). Nothing of it ran, so the
728
+ // session can still run it, where a 126 counted as a failed run.
729
+ const why = ran.stderr.split(unread).join("").trim().slice(0, 300);
730
+ throw new BuiltinNotRunnable(`bash could not read the command from its file${why ? ` (${why})` : ""}, so the session runs it`);
731
+ }
732
+ const cleaned = takePwdMarker(ran.stderr, mark);
733
+ if (cleaned.pwd)
734
+ ctx.onBashCwd?.(cleaned.pwd);
735
+ if (ran.signal) {
736
+ // Not the clock's doing (that is `runShell`'s own error): something else
737
+ // ended the shell, and nobody knows how far the command had got.
738
+ throw new BuiltinTimeout(`the command's shell was ended by ${ran.signal} and the command may have done part of its work`);
739
+ }
740
+ if (ran.code !== 0) {
741
+ const detail = String(cleaned.text || ran.stdout || "").trim().slice(0, 500);
742
+ throw new Error(`the command exited with code ${ran.code ?? 1}${detail ? `: ${detail}` : ""}`);
743
+ }
744
+ return {
745
+ stdout: ran.stdout,
746
+ stderr: cleaned.text,
747
+ interrupted: false,
748
+ isImage: false,
749
+ };
750
+ }
751
+ /**
752
+ * Put the script in a private file for bash to read ({@link readsScript}),
753
+ * because a command line cannot be trusted to carry it. Through Git Bash on
754
+ * Windows only the first 8,186 characters of a `-c` argument ran, and the step
755
+ * still resolved as a success: a 9,072-byte heredoc was written cut mid-line
756
+ * and the `echo` after it never ran. Just under that length the command ran
757
+ * whole and was reported failed, because what was cut were the wrapper's own
758
+ * last lines; at 40,000 characters nothing started at all (ENAMETOOLONG), and
759
+ * that was reported as a failed run too.
760
+ *
761
+ * The file lives where the runner keeps the texts it hands to a shell
762
+ * (bash-channel.ts, under `BIR_HOME`, mode 0700) and is named as those are, so
763
+ * that module's sweep also removes one a crash left behind. Its path is
764
+ * absolute: bash starts in the step's folder, not in this process's, and a
765
+ * relative `BIR_HOME` named a file there that did not exist.
766
+ */
767
+ async function scriptFile(script) {
768
+ // bash drops a NUL from what it reads and runs the rest: another command
769
+ // than the one recorded. No command line could carry one either.
770
+ if (script.includes("\u0000")) {
771
+ throw new BuiltinNotRunnable("the command holds a NUL character, so the session runs it");
772
+ }
773
+ const dir = resolve(deliveryDir());
774
+ ensureDir(dir);
775
+ const file = join(dir, `replay-bash-${randomUUID()}.txt`);
776
+ try {
777
+ await writeFile(file, script, { encoding: "utf8", mode: 0o600, flag: "wx" });
778
+ }
779
+ catch (err) {
780
+ throw new BuiltinNotRunnable(`the command could not be handed to bash (${err.message}), so the session runs it`);
781
+ }
782
+ return file;
783
+ }
784
+ /**
785
+ * The one short line bash is started with: read the script's file and `eval`
786
+ * it — as the host evals the command it is given. Run that way `$0`, the line
787
+ * numbers and the shell's own error text stay what they were when the script
788
+ * was the `-c` argument (`/usr/bin/bash: line 3: jq: command not found`); run
789
+ * as `bash <file>` every one of them would carry this file's name instead. A
790
+ * file that cannot be read is exit 126: evaluated without the check it is an
791
+ * empty script, which succeeds. And `unread`, a marker unique to the call, goes
792
+ * to stderr first: a command's own 126 (a file that is not executable) is a
793
+ * command that ran, and only the marker tells the two apart ({@link runBash}).
794
+ *
795
+ * Exported for the tests.
796
+ */
797
+ export function readsScript(file, unread) {
798
+ // Forward slashes on Windows, which Git Bash reads as they are.
799
+ const path = process.platform === "win32" ? file.replace(/\\/g, "/") : file;
800
+ const quoted = `'${path.replace(/'/g, `'\\''`)}'`;
801
+ return `__bir_s=$(<${quoted}) || { printf '%s' '${unread}' 1>&2; exit 126; }; eval -- "$__bir_s"`;
802
+ }
803
+ /** After the shell has exited, how long its pipes may stay open before the step stops listening. */
804
+ const BASH_PIPE_GRACE_MS = 100;
805
+ /** How long the processes of a command that is cut off get to end by themselves. */
806
+ const BASH_TERM_GRACE_MS = 300;
807
+ /**
808
+ * The longest the step waits for a command it cut off to be gone. Ending it is
809
+ * bounded already ({@link killTree}), but the step's answer must not rest on
810
+ * that: a step that never reports its time-out is worse than one that reports
811
+ * it while a process is still dying.
812
+ */
813
+ const BASH_KILL_WAIT_MS = 5_000;
814
+ /** The longest a Node timer waits; asked for more, it fires at once. */
815
+ const MAX_TIMER_MS = 2 ** 31 - 1;
816
+ /**
817
+ * Start `bash -lc <line>` in `cwd` and collect what it writes, under the
818
+ * step's own clock. Resolves when the shell has ended by itself, whatever its
819
+ * exit code. Rejects {@link BuiltinNotRunnable} when it never started, and
820
+ * {@link BuiltinTimeout} when it was cut off: at its clock, or at the most
821
+ * output the runner takes.
822
+ */
823
+ function runShell(line, cwd, timeoutMs) {
593
824
  return new Promise((resolvePromise, rejectPromise) => {
594
- execFile(bashExecutable(), ["-lc", script], {
595
- cwd: startIn,
596
- timeout: timeoutMs,
597
- maxBuffer: MAX_BASH_BUFFER,
598
- windowsHide: true,
599
- }, (err, stdout, stderr) => {
600
- const cleaned = takePwdMarker(String(stderr ?? ""), mark);
601
- if (cleaned.pwd)
602
- ctx.onBashCwd?.(cleaned.pwd);
603
- if (err) {
604
- const e = err;
605
- if (e.code === "ENOENT") {
606
- // No bash on this machine (a Windows runner without Git Bash):
607
- // the step is the session's, exactly as before.
608
- rejectPromise(new BuiltinNotRunnable("no `bash` on the runner's PATH"));
609
- return;
610
- }
611
- if (e.killed || e.signal === "SIGTERM") {
612
- rejectPromise(new BuiltinTimeout(`the command was stopped after ${timeoutMs}ms and may have done part of its work`));
613
- return;
614
- }
615
- const code = typeof e.code === "number" ? e.code : 1;
616
- const detail = String(cleaned.text || stdout || "").trim().slice(0, 500);
617
- rejectPromise(new Error(`the command exited with code ${code}${detail ? `: ${detail}` : ""}`));
825
+ // Nothing ran, so the session can still run it. A missing bash (a Windows
826
+ // runner without Git Bash) says so in the words it always had.
827
+ const notStarted = (err) => err?.code === "ENOENT"
828
+ ? new BuiltinNotRunnable("no `bash` on the runner's PATH")
829
+ : new BuiltinNotRunnable(`bash could not be started (${err?.message ?? String(err)}), so the session runs the command`);
830
+ let child;
831
+ try {
832
+ child = spawn(bashExecutable(), ["-lc", line], {
833
+ cwd,
834
+ // An empty input, as the host gives its own commands. Left open, a
835
+ // command that reads it (`cat`) held the step for its whole clock.
836
+ stdio: ["ignore", "pipe", "pipe"],
837
+ windowsHide: true,
838
+ // The leader of its own process group where there are such things, so
839
+ // one signal reaches everything the command started ({@link endCommand}).
840
+ detached: process.platform !== "win32",
841
+ });
842
+ }
843
+ catch (err) {
844
+ rejectPromise(notStarted(err));
845
+ return;
846
+ }
847
+ let stdout = "";
848
+ let stderr = "";
849
+ let stdoutBytes = 0;
850
+ let stderrBytes = 0;
851
+ let started = false;
852
+ let exited;
853
+ let cutOff = false;
854
+ let settled = false;
855
+ let grace;
856
+ const settle = (act) => {
857
+ if (settled)
618
858
  return;
619
- }
620
- resolvePromise({
621
- stdout: String(stdout ?? ""),
622
- stderr: cleaned.text,
623
- interrupted: false,
624
- isImage: false,
859
+ settled = true;
860
+ clearTimeout(clock);
861
+ clearTimeout(grace);
862
+ act();
863
+ };
864
+ // The step's own timer and its own cap, not `execFile`'s: those end one
865
+ // process — on Windows, Git's launcher — and the command went on behind a
866
+ // step already reported as stopped. `(sleep 3; echo late > f)` on a
867
+ // one-second clock still wrote `f`; and past the cap the next command of
868
+ // the line ran too, under an error that said "exited with code 1".
869
+ const cutOffBecause = (why) => {
870
+ if (settled || cutOff)
871
+ return;
872
+ cutOff = true;
873
+ clearTimeout(clock);
874
+ clearTimeout(grace);
875
+ // Stop reading, and keep the pipes open until the command is gone. Read
876
+ // on, a writer finishes and the line's next command starts; closed, the
877
+ // writer dies of it and the next command starts just the same.
878
+ child.stdout.pause();
879
+ child.stderr.pause();
880
+ void Promise.race([
881
+ endCommand(child).catch(() => undefined),
882
+ delay(BASH_KILL_WAIT_MS, undefined, { ref: false }),
883
+ ]).then(() => {
884
+ child.stdout.destroy();
885
+ child.stderr.destroy();
886
+ settle(() => rejectPromise(new BuiltinTimeout(`${why}; it may have done part of its work, and a program it started may still be running`)));
625
887
  });
888
+ };
889
+ const clock = setTimeout(() => cutOffBecause(`the command was cut off at its limit of ${timeoutMs}ms`), Math.min(timeoutMs, MAX_TIMER_MS));
890
+ // The shell has ended and its output is in: the step's answer.
891
+ const finish = () => {
892
+ if (!exited || cutOff)
893
+ return;
894
+ const { code, signal } = exited;
895
+ settle(() => {
896
+ letGo(child.stdout);
897
+ letGo(child.stderr);
898
+ resolvePromise({ code, signal, stdout, stderr });
899
+ });
900
+ };
901
+ const tooMuch = () => cutOffBecause(`the command was cut off after writing more than ${MAX_BASH_BUFFER / (1024 * 1024)} MiB of output`);
902
+ child.stdout.setEncoding("utf8");
903
+ child.stderr.setEncoding("utf8");
904
+ // A pipe broken by a forced end is not news: the shell's own exit, or the
905
+ // clock, is the answer, and an 'error' nobody listens to would end this
906
+ // process instead.
907
+ child.stdout.on("error", () => undefined);
908
+ child.stderr.on("error", () => undefined);
909
+ child.stdout.on("data", (chunk) => {
910
+ stdoutBytes += Buffer.byteLength(chunk);
911
+ if (stdoutBytes > MAX_BASH_BUFFER)
912
+ tooMuch();
913
+ else
914
+ stdout += chunk;
915
+ });
916
+ child.stderr.on("data", (chunk) => {
917
+ stderrBytes += Buffer.byteLength(chunk);
918
+ if (stderrBytes > MAX_BASH_BUFFER)
919
+ tooMuch();
920
+ else
921
+ stderr += chunk;
922
+ });
923
+ child.once("spawn", () => {
924
+ started = true;
626
925
  });
926
+ child.on("error", (err) => {
927
+ // Once the shell runs, an error here is a signal that could not be
928
+ // sent: the shell's exit, or the clock, still ends the step.
929
+ if (!started)
930
+ settle(() => rejectPromise(notStarted(err)));
931
+ });
932
+ // The step ends when the shell does, not when its pipes close: a job the
933
+ // command left behind (`sleep 6 &`) holds them open, and the step waited
934
+ // out its whole clock for it where the host answers at once. What the
935
+ // shell itself wrote is already in the pipes, and is read before the
936
+ // answer: the pipes close first, or the grace and one more turn of the
937
+ // loop pass.
938
+ child.once("exit", (code, signal) => {
939
+ exited = { code, signal };
940
+ if (cutOff)
941
+ return;
942
+ clearTimeout(clock);
943
+ grace = setTimeout(() => setImmediate(finish), BASH_PIPE_GRACE_MS);
944
+ });
945
+ child.once("close", finish);
627
946
  });
628
947
  }
948
+ /**
949
+ * Stop listening to a pipe the shell's own exit left open, without closing
950
+ * it: a job the command left running still writes there, and would be ended by
951
+ * its first write to a closed pipe — under the host it runs on. What it writes
952
+ * is read and dropped, and the pipe no longer keeps this process alive.
953
+ */
954
+ function letGo(pipe) {
955
+ if (pipe.destroyed)
956
+ return;
957
+ pipe.removeAllListeners("data");
958
+ pipe.resume();
959
+ pipe.unref?.();
960
+ }
961
+ /**
962
+ * End a command that is still running, with what it started. Away from
963
+ * Windows the shell leads its own process group, so the group is told to end
964
+ * and, after {@link BASH_TERM_GRACE_MS}, made to; {@link killTree} then sees
965
+ * to the shell itself, and on Windows does the whole job (`taskkill /T`).
966
+ *
967
+ * Not everything is reached. On Windows a program Git Bash started by `exec`
968
+ * (`sleep`, `ssh`, `find`: its own tools) is no child of the shell as Windows
969
+ * sees it, and runs on; anywhere, a program that left the group does. So the
970
+ * error says the command may still be running in part, never that it stopped.
971
+ */
972
+ async function endCommand(child) {
973
+ // A shell that has ended has no tree to find by its id, and the id may be
974
+ // another program's by now.
975
+ if (child.exitCode !== null || child.signalCode !== null)
976
+ return;
977
+ const pid = child.pid;
978
+ if (process.platform !== "win32" && pid !== undefined) {
979
+ const signalGroup = (signal) => {
980
+ try {
981
+ process.kill(-pid, signal);
982
+ return true;
983
+ }
984
+ catch {
985
+ return false; // no such group any more, or none of it ours to signal
986
+ }
987
+ };
988
+ if (signalGroup("SIGTERM")) {
989
+ const until = Date.now() + BASH_TERM_GRACE_MS;
990
+ while (signalGroup(0) && Date.now() < until)
991
+ await delay(20);
992
+ signalGroup("SIGKILL");
993
+ }
994
+ }
995
+ // `killTree` reads only the id, the exit state and `kill`; its type names a
996
+ // child whose stdin is a pipe, which this one (an empty input) is not.
997
+ await killTree(child);
998
+ }
629
999
  /**
630
1000
  * Split the shell's final `$PWD` out of stderr again: the marked chunk (and
631
1001
  * the newline the wrapper added before it) is removed, whatever else the
@@ -647,11 +1017,13 @@ function takePwdMarker(stderr, mark) {
647
1017
  /** Directories no scenario's step means to scan. */
648
1018
  const SKIP_DIRS = new Set([".git", "node_modules", ".hg", ".svn"]);
649
1019
  /**
650
- * Walk `root`, calling `visit(relativePath, absolutePath, mtimeMs)` for every
651
- * regular file. Never follows directory symlinks — the folder was checked at
652
- * the root, and under a `BIR_PATH_ALLOW` list a link out of it must not
653
- * re-open the door (nor may a link loop the walk). Returns whether the walk
654
- * was cut short (entry cap, deadline, or `visit` saying stop).
1020
+ * Walk `root`, calling `visit(relativePath, absolutePath)` for every regular
1021
+ * file. Never follows directory symlinks — the folder was checked at the
1022
+ * root, and under a `BIR_PATH_ALLOW` list a link out of it must not re-open
1023
+ * the door (nor may a link loop the walk). Returns whether the walk was cut
1024
+ * short (entry cap, deadline, or `visit` saying stop). It asks the disk
1025
+ * nothing about a file but its name: Glob wants a time for the files that
1026
+ * matched, Grep one for the files it opens, and neither wants it for the rest.
655
1027
  */
656
1028
  async function walk(root, deadline, visit) {
657
1029
  let seen = 0;
@@ -680,17 +1052,10 @@ async function walk(root, deadline, visit) {
680
1052
  }
681
1053
  if (!entry.isFile())
682
1054
  continue; // symlinks and specials stay unfollowed
683
- let mtime = 0;
684
- try {
685
- mtime = (await stat(abs)).mtimeMs;
686
- }
687
- catch {
688
- continue;
689
- }
690
1055
  // `relative`, not a slice: a walk may now start at a drive's or the
691
1056
  // disk's root (`C:\`, `/`), which already ends in a separator.
692
1057
  const rel = relative(root, abs).split(sep).join("/");
693
- if (!visit(rel, abs, mtime))
1058
+ if (!visit(rel, abs))
694
1059
  return true;
695
1060
  }
696
1061
  }