@basein/runner 0.3.2 → 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.
- package/README.md +1 -1
- package/dist/replay/builtins.d.ts +15 -0
- package/dist/replay/builtins.js +37 -4
- package/dist/replay/controller.d.ts +9 -0
- package/dist/replay/controller.js +13 -3
- package/docs/calculatedReplay.md +4 -1
- package/docs/calculatedReplayGuide.md +1 -1
- package/package.json +1 -1
- package/production.md +1 -1
package/README.md
CHANGED
|
@@ -414,7 +414,7 @@ what to do when they register, so there is one switch and no second copy of it:
|
|
|
414
414
|
| `BIR_REPLAY` | `0` turns replay off and any other value on, over the project's stored switch (`bir replay on` / `off`; a first plain `bir install` stores off). Nothing below matters while it is off. |
|
|
415
415
|
| `BIR_REPLAY_ALLOW_SERVERS` | Server keys bir may call unattended: **direct** execution, and a steered plan's recovery. Unset means the list `bir replay allow` stored for the project, else every wrapped server; narrowing it is the recommendation. |
|
|
416
416
|
| `BIR_BUILTIN_TOOLS` | Built-in tools bir may execute itself during a replay — in the session's own project directory, and confined to it. Default `Read,Glob,Grep` (read-only). Add `Edit,Write,Bash` only deliberately: a runner-side step never reaches Claude Code's permission prompts. `none` turns it off, and a built-in step hands the turn back as before. |
|
|
417
|
-
| `BIR_BASH_ALLOW` | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it. |
|
|
417
|
+
| `BIR_BASH_ALLOW` | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it. A plan's Bash steps share a working directory, as Claude Code's do (variables and functions do not persist, also as in Claude Code). |
|
|
418
418
|
| `BIR_MIN_STEER_SIMILARITY` | Minimum match similarity to replay (default `0.92`, above the service's own `0.9` detection threshold). |
|
|
419
419
|
| `BIR_DERIVE_API_KEY` | **Optional.** Working out what a new request is about — which fleet, which file, which date — is done for you by the service on its own key, as long as you are signed in. Set this (an Anthropic key) only to keep that reading on your machine, on your key. Signed out *and* unset, a scenario whose values change between requests is declined rather than replayed on stale ones. |
|
|
420
420
|
| `ANTHROPIC_API_KEY` | Not used by the background recorder, which has Claude Code's environment and would bill your own key — unless `BIR_USE_ANTHROPIC_API_KEY=1`. A `bir-hooks` run by hand in a terminal uses that terminal's key. |
|
|
@@ -94,6 +94,21 @@ export interface BuiltinContext {
|
|
|
94
94
|
timeoutMs?: number;
|
|
95
95
|
/** Parsed `BIR_BASH_ALLOW` ({@link parseBashAllow}). */
|
|
96
96
|
bashAllow?: readonly string[];
|
|
97
|
+
/**
|
|
98
|
+
* Where this plan's *previous* `Bash` step left its shell — the host's own
|
|
99
|
+
* semantics, which a recorded chain was written against: the working
|
|
100
|
+
* directory persists between Bash calls, while variables and functions do
|
|
101
|
+
* not (each call is a fresh shell off the profile). Absent, or pointing at
|
|
102
|
+
* a directory that no longer exists, the step starts in {@link cwd}.
|
|
103
|
+
*/
|
|
104
|
+
bashCwd?: string;
|
|
105
|
+
/**
|
|
106
|
+
* Told where a `Bash` step's shell ended up (`$PWD`, as a path the next
|
|
107
|
+
* spawn can use), so the caller can carry it into the plan's next Bash
|
|
108
|
+
* step. Best effort: a command that `exit`s explicitly, or is cut off by
|
|
109
|
+
* its clock, reports nothing and the previous directory stands.
|
|
110
|
+
*/
|
|
111
|
+
onBashCwd?: (pwd: string) => void;
|
|
97
112
|
}
|
|
98
113
|
/**
|
|
99
114
|
* Execute one built-in step and return its hook-shaped result (an object; the
|
package/dist/replay/builtins.js
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
* hook-view threading fallback already carries ("shapes may differ").
|
|
38
38
|
*/
|
|
39
39
|
import { execFile } from "node:child_process";
|
|
40
|
+
import { randomBytes } from "node:crypto";
|
|
40
41
|
import { existsSync } from "node:fs";
|
|
41
42
|
import { readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
42
43
|
import { dirname, join, resolve, sep } from "node:path";
|
|
@@ -491,13 +492,28 @@ async function runBash(input, ctx) {
|
|
|
491
492
|
throw new BuiltinNotRunnable("the command does not match BIR_BASH_ALLOW, so bir does not run it unattended");
|
|
492
493
|
}
|
|
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"`;
|
|
494
507
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
495
|
-
execFile(bashExecutable(), ["-lc",
|
|
496
|
-
cwd:
|
|
508
|
+
execFile(bashExecutable(), ["-lc", script], {
|
|
509
|
+
cwd: startIn,
|
|
497
510
|
timeout: timeoutMs,
|
|
498
511
|
maxBuffer: MAX_BASH_BUFFER,
|
|
499
512
|
windowsHide: true,
|
|
500
513
|
}, (err, stdout, stderr) => {
|
|
514
|
+
const cleaned = takePwdMarker(String(stderr ?? ""), mark);
|
|
515
|
+
if (cleaned.pwd)
|
|
516
|
+
ctx.onBashCwd?.(cleaned.pwd);
|
|
501
517
|
if (err) {
|
|
502
518
|
const e = err;
|
|
503
519
|
if (e.code === "ENOENT") {
|
|
@@ -511,19 +527,36 @@ async function runBash(input, ctx) {
|
|
|
511
527
|
return;
|
|
512
528
|
}
|
|
513
529
|
const code = typeof e.code === "number" ? e.code : 1;
|
|
514
|
-
const detail = String(
|
|
530
|
+
const detail = String(cleaned.text || stdout || "").trim().slice(0, 500);
|
|
515
531
|
rejectPromise(new Error(`the command exited with code ${code}${detail ? `: ${detail}` : ""}`));
|
|
516
532
|
return;
|
|
517
533
|
}
|
|
518
534
|
resolvePromise({
|
|
519
535
|
stdout: String(stdout ?? ""),
|
|
520
|
-
stderr:
|
|
536
|
+
stderr: cleaned.text,
|
|
521
537
|
interrupted: false,
|
|
522
538
|
isImage: false,
|
|
523
539
|
});
|
|
524
540
|
});
|
|
525
541
|
});
|
|
526
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
|
+
}
|
|
527
560
|
// ── shared walking and matching ─────────────────────────────────────────────
|
|
528
561
|
/** Directories no scenario's step means to scan. */
|
|
529
562
|
const SKIP_DIRS = new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
@@ -356,6 +356,15 @@ export interface ReplayState {
|
|
|
356
356
|
* no built-in executes: the step is `not_run`, exactly as before.
|
|
357
357
|
*/
|
|
358
358
|
sessionCwd?: string;
|
|
359
|
+
/**
|
|
360
|
+
* Where this plan's last runner-side `Bash` step left its shell. The
|
|
361
|
+
* host's own semantics, which the recorded chain was written against: the
|
|
362
|
+
* working directory persists between Bash calls (variables and functions
|
|
363
|
+
* do not), so a chain of `cd sub` then `cat file` replays as it ran.
|
|
364
|
+
* Starts at {@link sessionCwd}; never read for `Read`/`Edit`/`Write`
|
|
365
|
+
* paths, which resolve against the project directory as the host's do.
|
|
366
|
+
*/
|
|
367
|
+
bashCwd?: string;
|
|
359
368
|
/**
|
|
360
369
|
* Which kind of row was handed out (segmented.md R-OUT-7). A `segment` is a
|
|
361
370
|
* named sub-task of a recording rather than a whole task, and it is judged
|
|
@@ -2084,7 +2084,7 @@ export class ReplayController {
|
|
|
2084
2084
|
// the session can do the step itself or its recorded output can
|
|
2085
2085
|
// stand in (`BIR_REPLAY_RECORDED_OUTPUTS=1`).
|
|
2086
2086
|
if (this.builtinTools.has(name) && state.sessionCwd) {
|
|
2087
|
-
return this.executeBuiltin(name, input, state
|
|
2087
|
+
return this.executeBuiltin(name, input, state, opts.deadline);
|
|
2088
2088
|
}
|
|
2089
2089
|
throw new StepRunError(`${name || "this step"} is a built-in tool, which only runs in the session`, "not_run");
|
|
2090
2090
|
}
|
|
@@ -2130,14 +2130,24 @@ export class ReplayController {
|
|
|
2130
2130
|
* Failures keep {@link ExecuteStep}'s contract: policy (`not_run`), a step
|
|
2131
2131
|
* cut off by its clock (`uncertain`), a step that ran and broke (`failed`).
|
|
2132
2132
|
*/
|
|
2133
|
-
async executeBuiltin(name, input,
|
|
2133
|
+
async executeBuiltin(name, input, state, deadline) {
|
|
2134
|
+
const cwd = state.sessionCwd; // the caller executes only with one
|
|
2134
2135
|
const left = deadline !== undefined ? deadline - Date.now() : Number.POSITIVE_INFINITY;
|
|
2135
2136
|
const timeoutMs = Math.max(MIN_STEP_MS, Math.min(this.budgets.stepMs, left));
|
|
2136
2137
|
// The audit line precedes the action, always (§13.2, mitigation 4).
|
|
2137
2138
|
logDetail("replay.builtin", { tool: name, cwd });
|
|
2138
2139
|
let result;
|
|
2139
2140
|
try {
|
|
2140
|
-
result = await runBuiltin(name, input, {
|
|
2141
|
+
result = await runBuiltin(name, input, {
|
|
2142
|
+
cwd,
|
|
2143
|
+
timeoutMs,
|
|
2144
|
+
bashAllow: this.bashAllow,
|
|
2145
|
+
// The plan's Bash steps share a working directory, as the host's do.
|
|
2146
|
+
bashCwd: state.bashCwd,
|
|
2147
|
+
onBashCwd: (pwd) => {
|
|
2148
|
+
state.bashCwd = pwd;
|
|
2149
|
+
},
|
|
2150
|
+
});
|
|
2141
2151
|
}
|
|
2142
2152
|
catch (err) {
|
|
2143
2153
|
if (err instanceof BuiltinNotRunnable)
|
package/docs/calculatedReplay.md
CHANGED
|
@@ -259,7 +259,10 @@ step is `not_run`, the clean hand-over. `Bash` needs a second opt-in on top (`BI
|
|
|
259
259
|
command prefixes), because a runner-side step never reaches the host's permission prompts. The
|
|
260
260
|
results mimic the hook's view of each tool, which is what the recorder stored for built-in steps
|
|
261
261
|
and what their `toolOutputLogic` was authored against; they pass through the same
|
|
262
|
-
`serializeCapped(redact(…))` as every recorded payload.
|
|
262
|
+
`serializeCapped(redact(…))` as every recorded payload. Bash keeps the host's own semantics a
|
|
263
|
+
recorded chain was written against: each call is a fresh `-l` shell (variables and functions do
|
|
264
|
+
not persist), while the **working directory carries over** from the plan's previous Bash step —
|
|
265
|
+
`cd sub` then `cat file` replays as it ran.
|
|
263
266
|
|
|
264
267
|
A **mixed** scenario in `steer` mode still gets direct execution for its wrapped steps once it
|
|
265
268
|
diverges (§8), so coverage is not all-or-nothing at the divergence boundary — only at the arm-time
|
|
@@ -354,7 +354,7 @@ so there is exactly one switch and it cannot get out of step with itself.
|
|
|
354
354
|
| `BIR_LOGIC_TIMEOUT_MS` | `1000` | How long one scenario logic body may run before it is stopped. At most 10 000 |
|
|
355
355
|
| `BIR_REPLAY_RECORDED_OUTPUTS` | *(unset)* | `1` lets a step that could not be sent (a built-in off `BIR_BUILTIN_TOOLS`, no proxy, a server off the allowlist) stand on its recorded output instead of stopping the plan. Never for a timeout or an upstream error |
|
|
356
356
|
| `BIR_BUILTIN_TOOLS` | `Read,Glob,Grep` | Built-ins the runner executes itself during a replay, in the session's own project directory and confined to it. A chain whose only non-MCP steps are on this list arms `direct`. Add `Edit,Write,Bash` only deliberately — a runner-side step never reaches the host's permission prompts. `none` restores the old behaviour: every built-in hands the turn back |
|
|
357
|
-
| `BIR_BASH_ALLOW` | *(unset)* | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it |
|
|
357
|
+
| `BIR_BASH_ALLOW` | *(unset)* | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner, even when `BIR_BUILTIN_TOOLS` lists it. A plan's Bash steps share a working directory, exactly as the host's own Bash tool does — variables and functions do not persist, also as the host's |
|
|
358
358
|
| `BIR_INTENT_ARM` | *(unset)* | `1` lets a match found mid-turn arm a plan in the calling tool's place, which means waiting for it (`BIR_INTENT_ARM_BUDGET_MS`, 500); `0` never waits; unset follows `BIR_SEGMENT_ARM` |
|
|
359
359
|
| `BIR_VERBOSE` | *(unset)* | `1` adds per-step `replay.*` detail lines |
|
|
360
360
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basein/runner",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/production.md
CHANGED
|
@@ -993,7 +993,7 @@ Defaults were checked in `src/`.
|
|
|
993
993
|
| `BIR_LOGIC_TIMEOUT_MS` | recorder | 1000 (capped at 10000) | Synchronous time limit for one scenario logic body. |
|
|
994
994
|
| `BIR_REPLAY_RECORDED_OUTPUTS` | recorder | unset | `1`: a step that could not be sent may use its recorded output, labelled as such. |
|
|
995
995
|
| `BIR_BUILTIN_TOOLS` | recorder | `Read,Glob,Grep` | Built-ins the runner executes itself during a replay, in the session's project directory and confined to it. Add `Edit,Write,Bash` deliberately — runner-side steps bypass the host's permission prompts. `none` turns it off. |
|
|
996
|
-
| `BIR_BASH_ALLOW` | recorder | unset | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner even when listed. |
|
|
996
|
+
| `BIR_BASH_ALLOW` | recorder | unset | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner even when listed. A plan's Bash steps share a working directory, as the host's do; variables do not persist, also as the host's. |
|
|
997
997
|
| `BIR_DERIVE_RECENT_RESULTS` | recorder | 5 | How many of this prompt's tool results derivation may read (`0`: none). Never for plans with a command sink. |
|
|
998
998
|
| `BIR_INTENT_MATCH` | recorder | on | `0` turns mid-turn intent probes off. |
|
|
999
999
|
| `BIR_INTENT_MATCH_BUDGET_MS` | recorder | 4000 | One probe's own ceiling. It is not a hook wait. |
|