@basein/runner 0.3.25 → 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.
- package/README.md +27 -26
- package/dist/auth/client.d.ts +4 -1
- package/dist/auth/client.js +83 -14
- package/dist/bin/bir-hooks.js +4 -2
- package/dist/bin/bir.js +153 -37
- package/dist/bin/investigate.js +5 -0
- package/dist/bin/scenario-edit.d.ts +5 -0
- package/dist/bin/scenario-edit.js +17 -1
- package/dist/config/adapters/claude-code.d.ts +51 -7
- package/dist/config/adapters/claude-code.js +105 -18
- package/dist/config/generate.d.ts +61 -13
- package/dist/config/generate.js +108 -20
- package/dist/config/resolve.d.ts +5 -0
- package/dist/config/resolve.js +7 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +28 -0
- package/dist/control/daemon.js +62 -0
- package/dist/control/paths.d.ts +19 -1
- package/dist/control/paths.js +20 -2
- package/dist/control/pin-guard.js +8 -2
- package/dist/control/recorder-main.js +17 -5
- package/dist/control/server.d.ts +63 -9
- package/dist/control/server.js +350 -62
- package/dist/control/transcript.d.ts +64 -3
- package/dist/control/transcript.js +318 -37
- package/dist/jsonrpc/framing.d.ts +18 -3
- package/dist/jsonrpc/framing.js +57 -4
- package/dist/proxy/intercept.d.ts +4 -0
- package/dist/proxy/intercept.js +4 -0
- package/dist/proxy/relay.d.ts +13 -0
- package/dist/proxy/relay.js +12 -1
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +67 -10
- package/dist/record/recorder.d.ts +6 -1
- package/dist/record/redact.d.ts +18 -4
- package/dist/record/redact.js +538 -79
- package/dist/record/remote-recorder.d.ts +26 -5
- package/dist/record/remote-recorder.js +174 -18
- package/dist/record/truncate.js +3 -1
- package/dist/replay/bash-channel.d.ts +7 -1
- package/dist/replay/bash-channel.js +9 -3
- package/dist/replay/builtins.d.ts +14 -0
- package/dist/replay/builtins.js +460 -95
- package/dist/replay/bundle.d.ts +15 -11
- package/dist/replay/bundle.js +167 -33
- package/dist/replay/controller.d.ts +62 -0
- package/dist/replay/controller.js +218 -32
- package/dist/replay/derive.js +46 -4
- package/dist/replay/handover.d.ts +13 -0
- package/dist/replay/handover.js +18 -0
- package/dist/replay/pricing.d.ts +61 -11
- package/dist/replay/pricing.js +135 -42
- package/dist/replay/safety.js +43 -5
- package/dist/upstream/http-client.d.ts +6 -3
- package/dist/upstream/http-client.js +54 -13
- package/dist/upstream/stdio-client.d.ts +7 -0
- package/dist/upstream/stdio-client.js +1 -1
- package/dist/util/atomic-write.d.ts +9 -1
- package/dist/util/atomic-write.js +9 -6
- package/dist/util/service-health.d.ts +7 -0
- package/dist/util/service-health.js +10 -3
- package/package.json +1 -1
- package/production.md +2 -2
- package/scripts/install.ps1 +14 -2
package/dist/replay/builtins.js
CHANGED
|
@@ -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 {
|
|
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
|
|
352
|
-
const truncated = await walk(root, deadline, (rel, abs
|
|
356
|
+
const matched = [];
|
|
357
|
+
const truncated = await walk(root, deadline, (rel, abs) => {
|
|
353
358
|
if (re.test(rel))
|
|
354
|
-
|
|
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(
|
|
430
|
+
files.push(target);
|
|
410
431
|
}
|
|
411
432
|
else {
|
|
412
|
-
truncated = await walk(target, deadline, (rel, abs
|
|
433
|
+
truncated = await walk(target, deadline, (rel, abs) => {
|
|
413
434
|
if (!globRe || globRe.test(rel))
|
|
414
|
-
files.push(
|
|
435
|
+
files.push(abs);
|
|
415
436
|
return files.length < MAX_GREP_FILES;
|
|
416
437
|
});
|
|
417
438
|
}
|
|
418
|
-
const
|
|
419
|
-
|
|
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
|
-
/**
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
//
|
|
530
|
-
//
|
|
531
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
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
|
|
1058
|
+
if (!visit(rel, abs))
|
|
694
1059
|
return true;
|
|
695
1060
|
}
|
|
696
1061
|
}
|