@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 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
@@ -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", command], {
496
- cwd: resolve(ctx.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(stderr || stdout || "").trim().slice(0, 500);
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: String(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.sessionCwd, opts.deadline);
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, cwd, deadline) {
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, { cwd, timeoutMs, bashAllow: this.bashAllow });
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)
@@ -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.2",
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. |