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