@basein/runner 0.3.25 → 0.3.27
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.d.ts +13 -0
- package/dist/bin/investigate.js +72 -2
- 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 +75 -11
- package/dist/control/server.js +440 -77
- 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 +92 -1
- package/dist/replay/controller.js +246 -47
- 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/plan.d.ts +41 -0
- package/dist/replay/plan.js +171 -3
- package/dist/replay/pricing.d.ts +61 -11
- package/dist/replay/pricing.js +135 -42
- package/dist/replay/reads-only.d.ts +48 -0
- package/dist/replay/reads-only.js +161 -0
- 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
|
@@ -126,13 +126,44 @@ export declare function otherHostOnly(cwd: string, sidecar: InstalledSidecar): b
|
|
|
126
126
|
/** Which file holds a given scope's `mcpServers` for this cwd. */
|
|
127
127
|
export declare function fileForScope(scope: Scope, paths: ClaudeCodePaths): string;
|
|
128
128
|
export declare function sha256(text: string): string;
|
|
129
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* A config file that is there and cannot be read: its permissions, or a lock
|
|
131
|
+
* another program holds on it. Its own class so an install can give the advice
|
|
132
|
+
* that fits it ("make it readable", not "remove the comments").
|
|
133
|
+
*/
|
|
134
|
+
export declare class UnreadableFileError extends Error {
|
|
135
|
+
constructor(message: string);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Read a file's text; null when there is no such file, and only then.
|
|
139
|
+
*
|
|
140
|
+
* A file that is there and cannot be read throws {@link UnreadableFileError}.
|
|
141
|
+
* It used to answer null as well, which {@link editJson} takes for "no file":
|
|
142
|
+
* it built a new one from `{}` plus its own edit and renamed that over the
|
|
143
|
+
* real one. A settings file that could not be read for a moment was replaced
|
|
144
|
+
* by one holding nothing but our hooks, exit 0, and its backup said "created
|
|
145
|
+
* by install".
|
|
146
|
+
*/
|
|
130
147
|
export declare function readTextOrNull(path: string): string | null;
|
|
148
|
+
/**
|
|
149
|
+
* A JSON file's value for a change to it, or to undo one: undefined when there
|
|
150
|
+
* is no such file, and only then. One that is there and cannot be read throws
|
|
151
|
+
* ({@link readTextOrNull}), and so does one that is not JSON.
|
|
152
|
+
*
|
|
153
|
+
* parseJsonFile answers undefined for an unreadable file too, which is right
|
|
154
|
+
* for reading a config and wrong for undoing one: `bir uninstall` read a locked
|
|
155
|
+
* `~/.claude.json` as one with no entry of ours, said the entry was "changed
|
|
156
|
+
* since install", dropped its record and ended "Uninstalled." with the entry
|
|
157
|
+
* still wrapped.
|
|
158
|
+
*/
|
|
159
|
+
export declare function readJsonOrUndefined(path: string): unknown;
|
|
131
160
|
/**
|
|
132
161
|
* Read-modify-write one JSON file, safely:
|
|
133
162
|
*
|
|
134
163
|
* - a file that is not plain JSON (comments, trailing commas, a torn write)
|
|
135
164
|
* is REFUSED with an error naming it, never rewritten from a guess;
|
|
165
|
+
* - so is one that is there and cannot be read ({@link readTextOrNull}):
|
|
166
|
+
* only a file that does not exist is created;
|
|
136
167
|
* - the file is re-read just before the write, and the edit is applied again
|
|
137
168
|
* to the fresh text when somebody else changed it in between (Claude Code
|
|
138
169
|
* rewrites `~/.claude.json` constantly, and a lost update there loses a
|
|
@@ -141,13 +172,18 @@ export declare function readTextOrNull(path: string): string | null;
|
|
|
141
172
|
* reads a half-written config, and a symlinked file stays a symlink.
|
|
142
173
|
*
|
|
143
174
|
* `mutate` gets a fresh parse each attempt and must not keep state between
|
|
144
|
-
* calls.
|
|
175
|
+
* calls. `opts.mode` is for a file the edit puts a credential in: created
|
|
176
|
+
* with those permission bits, and set to them when it is already there
|
|
177
|
+
* (POSIX). Returns the text written, for the sidecar's hash.
|
|
145
178
|
*/
|
|
146
|
-
export declare function editJson(path: string, mutate: (parsed: Record<string, unknown>) => void
|
|
179
|
+
export declare function editJson(path: string, mutate: (parsed: Record<string, unknown>) => void, opts?: {
|
|
180
|
+
mode?: number;
|
|
181
|
+
}): string;
|
|
147
182
|
/**
|
|
148
|
-
* Throw (naming the file) when `path` exists and is not a plain JSON object
|
|
149
|
-
* so an install can refuse up front, before it has
|
|
150
|
-
* of stopping halfway through with some files
|
|
183
|
+
* Throw (naming the file) when `path` exists and is not a plain JSON object,
|
|
184
|
+
* or cannot be read at all — so an install can refuse up front, before it has
|
|
185
|
+
* written anything, instead of stopping halfway through with some files
|
|
186
|
+
* changed and others not.
|
|
151
187
|
*/
|
|
152
188
|
export declare function assertEditableJson(path: string): void;
|
|
153
189
|
/**
|
|
@@ -267,7 +303,9 @@ export declare function buildHooksBlock(controlUrl: string, token: string, opts?
|
|
|
267
303
|
* HTTP: a loopback URL (127.0.0.1 / localhost / [::1]) whose path is exactly
|
|
268
304
|
* one of our routes, AND either our kind of bearer token or a port in
|
|
269
305
|
* the range `bir` allocates (the token-less 0.2.8 shape). A hook to
|
|
270
|
-
* `https://hooks.example.com/session/end` is somebody else's.
|
|
306
|
+
* `https://hooks.example.com/session/end` is somebody else's. One
|
|
307
|
+
* whose URL is no URL (`http://127.0.0.1:NaN/tool/pre`, from a
|
|
308
|
+
* `--port` that was not a number) needs the token as well as the route.
|
|
271
309
|
* exec: our self-starting SessionStart hook — `bir-hooks.js ensure`, or the
|
|
272
310
|
* npx form `… -p @basein/runner@<v> bir-hooks ensure`.
|
|
273
311
|
*/
|
|
@@ -277,6 +315,12 @@ export declare function isBirHook(entry: unknown): boolean;
|
|
|
277
315
|
* the new text. Our own entries are replaced rather than appended, so repeated
|
|
278
316
|
* installs stay idempotent — hook by hook: a user's own hook that happens to
|
|
279
317
|
* share a matcher group with one of ours stays exactly where it is.
|
|
318
|
+
*
|
|
319
|
+
* The file is left readable by its owner only (0600). The block carries the
|
|
320
|
+
* recorder's bearer token, which is enough to make the recorder run a
|
|
321
|
+
* scenario, and its two other copies (the discovery file, installed.json) are
|
|
322
|
+
* 0600 already: at the default 0644 any other account on the machine could
|
|
323
|
+
* read it here.
|
|
280
324
|
*/
|
|
281
325
|
export declare function installHooks(settingsPath: string, block: HooksBlock): string;
|
|
282
326
|
/** Remove only our hook entries, and drop events left empty. */
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
* clone, and never the user scope, which applies to every project.
|
|
22
22
|
*/
|
|
23
23
|
import { createHash } from "node:crypto";
|
|
24
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
25
25
|
import { dirname, join, parse as parsePath, resolve as resolvePath } from "node:path";
|
|
26
26
|
import { normalizePath } from "../../control/paths.js";
|
|
27
27
|
import { writeFileAtomic } from "../../util/atomic-write.js";
|
|
28
28
|
import { FIRST_PROJECT_PORT, PACKAGE_NAME, SCENARIO_SERVER_KEY, projectRecord, } from "../generate.js";
|
|
29
|
-
import { claudeJsonPath, parseJsonFile, parseJsonText, projectKeyFor, } from "../resolve.js";
|
|
29
|
+
import { claudeJsonPath, parseJsonFile, parseJsonText, projectKeyFor, readFailure, } from "../resolve.js";
|
|
30
30
|
export function claudeCodePaths(cwd, claudeJsonOverride) {
|
|
31
31
|
return {
|
|
32
32
|
claudeJson: claudeJsonPath(claudeJsonOverride),
|
|
@@ -162,7 +162,7 @@ export function enableMcpjsonServers(settingsPath, names) {
|
|
|
162
162
|
}
|
|
163
163
|
/** Undo {@link enableMcpjsonServers} for `names`; drops the key when empty. */
|
|
164
164
|
export function disableMcpjsonServers(settingsPath, names) {
|
|
165
|
-
const parsed =
|
|
165
|
+
const parsed = readJsonOrUndefined(settingsPath);
|
|
166
166
|
if (!parsed || !Array.isArray(parsed.enabledMcpjsonServers))
|
|
167
167
|
return undefined;
|
|
168
168
|
if (!parsed.enabledMcpjsonServers.map(String).some((n) => names.includes(n)))
|
|
@@ -300,13 +300,70 @@ export function fileForScope(scope, paths) {
|
|
|
300
300
|
export function sha256(text) {
|
|
301
301
|
return createHash("sha256").update(text).digest("hex");
|
|
302
302
|
}
|
|
303
|
-
/**
|
|
303
|
+
/**
|
|
304
|
+
* A config file that is there and cannot be read: its permissions, or a lock
|
|
305
|
+
* another program holds on it. Its own class so an install can give the advice
|
|
306
|
+
* that fits it ("make it readable", not "remove the comments").
|
|
307
|
+
*/
|
|
308
|
+
export class UnreadableFileError extends Error {
|
|
309
|
+
constructor(message) {
|
|
310
|
+
super(message);
|
|
311
|
+
this.name = "UnreadableFileError";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Read a file's text; null when there is no such file, and only then.
|
|
316
|
+
*
|
|
317
|
+
* A file that is there and cannot be read throws {@link UnreadableFileError}.
|
|
318
|
+
* It used to answer null as well, which {@link editJson} takes for "no file":
|
|
319
|
+
* it built a new one from `{}` plus its own edit and renamed that over the
|
|
320
|
+
* real one. A settings file that could not be read for a moment was replaced
|
|
321
|
+
* by one holding nothing but our hooks, exit 0, and its backup said "created
|
|
322
|
+
* by install".
|
|
323
|
+
*/
|
|
304
324
|
export function readTextOrNull(path) {
|
|
305
325
|
try {
|
|
306
|
-
return
|
|
326
|
+
return readFileSync(path, "utf8");
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
const code = err.code;
|
|
330
|
+
// ENOTDIR: a folder on the way is a file, so nothing can be there either.
|
|
331
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
332
|
+
return null;
|
|
333
|
+
throw new UnreadableFileError(`${path} exists but cannot be read (${readFailure(err)})`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* A JSON file's value for a change to it, or to undo one: undefined when there
|
|
338
|
+
* is no such file, and only then. One that is there and cannot be read throws
|
|
339
|
+
* ({@link readTextOrNull}), and so does one that is not JSON.
|
|
340
|
+
*
|
|
341
|
+
* parseJsonFile answers undefined for an unreadable file too, which is right
|
|
342
|
+
* for reading a config and wrong for undoing one: `bir uninstall` read a locked
|
|
343
|
+
* `~/.claude.json` as one with no entry of ours, said the entry was "changed
|
|
344
|
+
* since install", dropped its record and ended "Uninstalled." with the entry
|
|
345
|
+
* still wrapped.
|
|
346
|
+
*/
|
|
347
|
+
export function readJsonOrUndefined(path) {
|
|
348
|
+
const text = readTextOrNull(path);
|
|
349
|
+
return text === null ? undefined : parseJsonText(text, path);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Set a file's permission bits before it is written again. The write keeps
|
|
353
|
+
* the mode a file already has (util/atomic-write.ts), so one created 0644
|
|
354
|
+
* would stay readable by everybody with a token in it; changed first, the new
|
|
355
|
+
* text never exists under the old mode. POSIX only, and best effort: a file
|
|
356
|
+
* that is not there yet is created with the mode instead, and one whose mode
|
|
357
|
+
* is not ours to change is written all the same.
|
|
358
|
+
*/
|
|
359
|
+
function restrictMode(path, mode) {
|
|
360
|
+
if (process.platform === "win32")
|
|
361
|
+
return;
|
|
362
|
+
try {
|
|
363
|
+
chmodSync(path, mode);
|
|
307
364
|
}
|
|
308
365
|
catch {
|
|
309
|
-
|
|
366
|
+
/* not there yet, or somebody else's: never a failed install */
|
|
310
367
|
}
|
|
311
368
|
}
|
|
312
369
|
/**
|
|
@@ -314,6 +371,8 @@ export function readTextOrNull(path) {
|
|
|
314
371
|
*
|
|
315
372
|
* - a file that is not plain JSON (comments, trailing commas, a torn write)
|
|
316
373
|
* is REFUSED with an error naming it, never rewritten from a guess;
|
|
374
|
+
* - so is one that is there and cannot be read ({@link readTextOrNull}):
|
|
375
|
+
* only a file that does not exist is created;
|
|
317
376
|
* - the file is re-read just before the write, and the edit is applied again
|
|
318
377
|
* to the fresh text when somebody else changed it in between (Claude Code
|
|
319
378
|
* rewrites `~/.claude.json` constantly, and a lost update there loses a
|
|
@@ -322,9 +381,11 @@ export function readTextOrNull(path) {
|
|
|
322
381
|
* reads a half-written config, and a symlinked file stays a symlink.
|
|
323
382
|
*
|
|
324
383
|
* `mutate` gets a fresh parse each attempt and must not keep state between
|
|
325
|
-
* calls.
|
|
384
|
+
* calls. `opts.mode` is for a file the edit puts a credential in: created
|
|
385
|
+
* with those permission bits, and set to them when it is already there
|
|
386
|
+
* (POSIX). Returns the text written, for the sidecar's hash.
|
|
326
387
|
*/
|
|
327
|
-
export function editJson(path, mutate) {
|
|
388
|
+
export function editJson(path, mutate, opts = {}) {
|
|
328
389
|
for (let attempt = 0;; attempt += 1) {
|
|
329
390
|
const before = readTextOrNull(path);
|
|
330
391
|
const value = before === null ? {} : parseJsonText(before, path);
|
|
@@ -337,14 +398,22 @@ export function editJson(path, mutate) {
|
|
|
337
398
|
if (attempt < 3 && readTextOrNull(path) !== before)
|
|
338
399
|
continue;
|
|
339
400
|
mkdirSync(dirname(path), { recursive: true });
|
|
340
|
-
|
|
401
|
+
if (opts.mode === undefined)
|
|
402
|
+
writeFileAtomic(path, text);
|
|
403
|
+
else {
|
|
404
|
+
restrictMode(path, opts.mode);
|
|
405
|
+
// The mode wins over the old file's even when that chmod was refused:
|
|
406
|
+
// what is written now is ours, and holds the token.
|
|
407
|
+
writeFileAtomic(path, text, { mode: opts.mode, replaceMode: true });
|
|
408
|
+
}
|
|
341
409
|
return text;
|
|
342
410
|
}
|
|
343
411
|
}
|
|
344
412
|
/**
|
|
345
|
-
* Throw (naming the file) when `path` exists and is not a plain JSON object
|
|
346
|
-
* so an install can refuse up front, before it has
|
|
347
|
-
* of stopping halfway through with some files
|
|
413
|
+
* Throw (naming the file) when `path` exists and is not a plain JSON object,
|
|
414
|
+
* or cannot be read at all — so an install can refuse up front, before it has
|
|
415
|
+
* written anything, instead of stopping halfway through with some files
|
|
416
|
+
* changed and others not.
|
|
348
417
|
*/
|
|
349
418
|
export function assertEditableJson(path) {
|
|
350
419
|
const text = readTextOrNull(path);
|
|
@@ -535,26 +604,36 @@ const OUR_TOKEN = /^Bearer [0-9a-f]{64}$/i;
|
|
|
535
604
|
* HTTP: a loopback URL (127.0.0.1 / localhost / [::1]) whose path is exactly
|
|
536
605
|
* one of our routes, AND either our kind of bearer token or a port in
|
|
537
606
|
* the range `bir` allocates (the token-less 0.2.8 shape). A hook to
|
|
538
|
-
* `https://hooks.example.com/session/end` is somebody else's.
|
|
607
|
+
* `https://hooks.example.com/session/end` is somebody else's. One
|
|
608
|
+
* whose URL is no URL (`http://127.0.0.1:NaN/tool/pre`, from a
|
|
609
|
+
* `--port` that was not a number) needs the token as well as the route.
|
|
539
610
|
* exec: our self-starting SessionStart hook — `bir-hooks.js ensure`, or the
|
|
540
611
|
* npx form `… -p @basein/runner@<v> bir-hooks ensure`.
|
|
541
612
|
*/
|
|
542
613
|
export function isBirHook(entry) {
|
|
543
614
|
const e = (entry ?? {});
|
|
544
615
|
if (typeof e.url === "string" && e.url) {
|
|
616
|
+
const auth = e.headers?.Authorization;
|
|
617
|
+
const ourToken = typeof auth === "string" && OUR_TOKEN.test(auth);
|
|
545
618
|
let url;
|
|
546
619
|
try {
|
|
547
620
|
url = new URL(e.url);
|
|
548
621
|
}
|
|
549
622
|
catch {
|
|
550
|
-
|
|
623
|
+
// `bir install --port abc`, or `--port` with its value forgotten, once
|
|
624
|
+
// wrote http://127.0.0.1:NaN/<route>. No parser reads that, so it was
|
|
625
|
+
// nobody's hook, and neither install nor uninstall ever took it out.
|
|
626
|
+
// Ours all the same — but only as all three together: the address
|
|
627
|
+
// install writes, one of our routes, our kind of token. There is no
|
|
628
|
+
// port here to vouch for it, and no query or fragment is ever ours.
|
|
629
|
+
const broken = /^http:\/\/127\.0\.0\.1:[^/?#]*(\/[^?#]*)$/.exec(e.url);
|
|
630
|
+
return broken !== null && ourToken && HOOK_ROUTES.some(({ route }) => broken[1] === route);
|
|
551
631
|
}
|
|
552
632
|
if (!["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
|
|
553
633
|
return false;
|
|
554
634
|
if (!HOOK_ROUTES.some(({ route }) => url.pathname === route))
|
|
555
635
|
return false;
|
|
556
|
-
|
|
557
|
-
if (typeof auth === "string" && OUR_TOKEN.test(auth))
|
|
636
|
+
if (ourToken)
|
|
558
637
|
return true;
|
|
559
638
|
const port = Number(url.port);
|
|
560
639
|
return port >= FIRST_PROJECT_PORT && port < FIRST_PROJECT_PORT + 200;
|
|
@@ -574,6 +653,12 @@ export function isBirHook(entry) {
|
|
|
574
653
|
* the new text. Our own entries are replaced rather than appended, so repeated
|
|
575
654
|
* installs stay idempotent — hook by hook: a user's own hook that happens to
|
|
576
655
|
* share a matcher group with one of ours stays exactly where it is.
|
|
656
|
+
*
|
|
657
|
+
* The file is left readable by its owner only (0600). The block carries the
|
|
658
|
+
* recorder's bearer token, which is enough to make the recorder run a
|
|
659
|
+
* scenario, and its two other copies (the discovery file, installed.json) are
|
|
660
|
+
* 0600 already: at the default 0644 any other account on the machine could
|
|
661
|
+
* read it here.
|
|
577
662
|
*/
|
|
578
663
|
export function installHooks(settingsPath, block) {
|
|
579
664
|
return editJson(settingsPath, (parsed) => {
|
|
@@ -585,11 +670,13 @@ export function installHooks(settingsPath, block) {
|
|
|
585
670
|
hooks[event] = [...existing, ...matchers];
|
|
586
671
|
}
|
|
587
672
|
parsed.hooks = hooks;
|
|
588
|
-
});
|
|
673
|
+
}, { mode: 0o600 });
|
|
589
674
|
}
|
|
590
675
|
/** Remove only our hook entries, and drop events left empty. */
|
|
591
676
|
export function uninstallHooks(settingsPath) {
|
|
592
|
-
|
|
677
|
+
// Strict: a file that cannot be read throws, so the caller says the hooks
|
|
678
|
+
// may still be there, rather than nothing at all.
|
|
679
|
+
const parsed = readJsonOrUndefined(settingsPath);
|
|
593
680
|
if (!parsed?.hooks)
|
|
594
681
|
return undefined;
|
|
595
682
|
return editJson(settingsPath, (fresh) => {
|
|
@@ -262,6 +262,27 @@ export declare function redactSecrets(config: McpServerConfig): {
|
|
|
262
262
|
* would break it, so the caller leaves the entry alone instead.
|
|
263
263
|
*/
|
|
264
264
|
export declare function restoreSecrets(stash: McpServerConfig, wrapped: McpServerConfig | undefined): McpServerConfig | undefined;
|
|
265
|
+
/**
|
|
266
|
+
* True while a wrapped entry still says what its stash says. `unwrapped` is
|
|
267
|
+
* the entry read back ({@link unwrapEntry}), `stash` the original an install
|
|
268
|
+
* kept for it, values included. They agree when the entry starts the same
|
|
269
|
+
* program with the same arguments and env (or names the same URL, transport
|
|
270
|
+
* and headers), and every other field it carries has the stash's value.
|
|
271
|
+
*
|
|
272
|
+
* False means somebody changed the server inside its wrapped entry — another
|
|
273
|
+
* script after `--`, a rotated key, a `disabled` the client switched — and the
|
|
274
|
+
* stash describes a server that is no longer there.
|
|
275
|
+
*
|
|
276
|
+
* The stash is compared as a wrap of it reads back, not as it is written: a
|
|
277
|
+
* wrapped entry cannot carry `oauth`, `headersHelper` or `type: "stdio"` at
|
|
278
|
+
* all ({@link REGENERATED_FIELDS}), so set beside the stash itself it would
|
|
279
|
+
* always look changed. For the same reason a field only the stash has is no
|
|
280
|
+
* disagreement: an entry a runner before 0.3.0 wrote carries none of the
|
|
281
|
+
* client's own fields (`disabled`, `timeout`, …). Counted as changes, all of
|
|
282
|
+
* these would be dropped from the stash by the next install, and from the
|
|
283
|
+
* server by uninstall.
|
|
284
|
+
*/
|
|
285
|
+
export declare function stashStands(stash: McpServerConfig, unwrapped: McpServerConfig): boolean;
|
|
265
286
|
/**
|
|
266
287
|
* The entry for the scenario server itself. Not a proxy: it wraps nothing and
|
|
267
288
|
* spawns nothing, it just forwards one call to the control server.
|
|
@@ -343,14 +364,16 @@ export interface FileBackup {
|
|
|
343
364
|
stale?: boolean;
|
|
344
365
|
/**
|
|
345
366
|
* A `.mcp.json` whose servers carry env or header VALUES (API keys, bearer
|
|
346
|
-
* tokens)
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
367
|
+
* tokens), or a Claude Code settings file with an `env` block of its own
|
|
368
|
+
* (`{"env":{"ANTHROPIC_API_KEY":"sk-…"}}`), is not kept verbatim — that
|
|
369
|
+
* would be a second long-lived copy of them under ~/.baseinstrunner. Its
|
|
370
|
+
* text is kept with each value's literal replaced by a placeholder, and
|
|
371
|
+
* {@link FileBackup.originalText} stays null; uninstall reads the values
|
|
372
|
+
* back from the file as it is then (its servers' entries, wrapped or not,
|
|
373
|
+
* and its own `env`) and restores the file byte-for-byte only when the
|
|
374
|
+
* result hashes to `sha`, the original's. See {@link protectBackupText}. A
|
|
375
|
+
* runner older than this field (0.2.12) reads the backup as a file install
|
|
376
|
+
* created, and never writes it back whole.
|
|
354
377
|
*/
|
|
355
378
|
redacted?: RedactedText;
|
|
356
379
|
}
|
|
@@ -360,9 +383,14 @@ export interface RedactedText {
|
|
|
360
383
|
text: string;
|
|
361
384
|
/** sha256 of the original text: a restore is used only when it matches. */
|
|
362
385
|
sha: string;
|
|
363
|
-
/**
|
|
386
|
+
/**
|
|
387
|
+
* Placeholder `i` is the value of `mcpServers[server][field][key]` — or,
|
|
388
|
+
* with no `server`, of the file's own top-level `env[key]`. A runner that
|
|
389
|
+
* predates the second kind looks for it under `mcpServers`, finds nothing,
|
|
390
|
+
* cannot fill the placeholder, and repairs the file entry by entry instead.
|
|
391
|
+
*/
|
|
364
392
|
secrets: Array<{
|
|
365
|
-
server
|
|
393
|
+
server?: string;
|
|
366
394
|
field: "env" | "headers";
|
|
367
395
|
key: string;
|
|
368
396
|
}>;
|
|
@@ -380,9 +408,10 @@ export declare function protectBackupText(text: string): FileBackup;
|
|
|
380
408
|
/**
|
|
381
409
|
* The text a backup restores: its verbatim original, or the redacted one with
|
|
382
410
|
* every value read back from `currentText` — the file as it is now, whose
|
|
383
|
-
* entries (wrapped by us, or left as they were)
|
|
384
|
-
* file install created; undefined when the original
|
|
385
|
-
* (a value is gone, or the result does not hash to
|
|
411
|
+
* entries (wrapped by us, or left as they were) and own `env` block carry
|
|
412
|
+
* those values. Null for a file install created; undefined when the original
|
|
413
|
+
* cannot be rebuilt exactly (a value is gone, or the result does not hash to
|
|
414
|
+
* the original's).
|
|
386
415
|
*/
|
|
387
416
|
export declare function backupOriginalText(backup: FileBackup, currentText: string | null): string | null | undefined;
|
|
388
417
|
/** True when a backup says the file did not exist before install (nothing to restore). */
|
|
@@ -526,7 +555,23 @@ export interface InstalledSidecar {
|
|
|
526
555
|
/** Settings files whose `hooks` block we added, for a clean uninstall. */
|
|
527
556
|
hookFiles?: string[];
|
|
528
557
|
}
|
|
558
|
+
/**
|
|
559
|
+
* installed.json, or an empty sidecar when there is none, or none that can be
|
|
560
|
+
* used. A file that does not parse reads as empty and is copied aside before
|
|
561
|
+
* the next write replaces it ({@link writeSidecar}).
|
|
562
|
+
*
|
|
563
|
+
* One that is there and cannot be READ (its permissions, a lock another
|
|
564
|
+
* program holds) reads as empty too, so a recorder or `bir status` goes on —
|
|
565
|
+
* but that sidecar is never written back. Saved, it replaced every project's
|
|
566
|
+
* originals, ports and tokens with whatever one command had just added.
|
|
567
|
+
* {@link sidecarReadError} lets a command that is about to write say so first.
|
|
568
|
+
*/
|
|
529
569
|
export declare function readSidecar(): InstalledSidecar;
|
|
570
|
+
/**
|
|
571
|
+
* Why installed.json could not be read when `sidecar` was made from it
|
|
572
|
+
* ({@link readSidecar}); undefined when it was read, or is not there.
|
|
573
|
+
*/
|
|
574
|
+
export declare function sidecarReadError(sidecar: InstalledSidecar): string | undefined;
|
|
530
575
|
/**
|
|
531
576
|
* The prefix of the key a record is kept under when its stash has its
|
|
532
577
|
* secrets blanked ({@link InstalledRecord.secretsRedacted}).
|
|
@@ -560,6 +605,9 @@ export declare function normaliseRecordKeys(servers: Record<string, InstalledRec
|
|
|
560
605
|
* ports with nothing. Before that happens the unreadable file is copied aside
|
|
561
606
|
* as `installed.json.corrupt-<time>`, where a person (or support) can get the
|
|
562
607
|
* originals back.
|
|
608
|
+
*
|
|
609
|
+
* A sidecar made up for a file that could not be read at all is refused: there
|
|
610
|
+
* is nothing to copy aside, and it knows nothing of what the file holds.
|
|
563
611
|
*/
|
|
564
612
|
export declare function writeSidecar(sidecar: InstalledSidecar): void;
|
|
565
613
|
export declare function sidecarKey(cwd: string, serverName: string): string;
|
package/dist/config/generate.js
CHANGED
|
@@ -17,7 +17,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
17
17
|
import { createServer } from "node:net";
|
|
18
18
|
import { configDir, ensureDir, installedPath, normalizePath } from "../control/paths.js";
|
|
19
19
|
import { writeFileAtomic } from "../util/atomic-write.js";
|
|
20
|
-
import { isRemote } from "./resolve.js";
|
|
20
|
+
import { isRemote, readFailure } from "./resolve.js";
|
|
21
21
|
/** npm package name, as it appears in a generated `npx -p` invocation. */
|
|
22
22
|
export const PACKAGE_NAME = "@basein/runner";
|
|
23
23
|
/**
|
|
@@ -462,6 +462,38 @@ export function restoreSecrets(stash, wrapped) {
|
|
|
462
462
|
}
|
|
463
463
|
return { ...stash, env };
|
|
464
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* True while a wrapped entry still says what its stash says. `unwrapped` is
|
|
467
|
+
* the entry read back ({@link unwrapEntry}), `stash` the original an install
|
|
468
|
+
* kept for it, values included. They agree when the entry starts the same
|
|
469
|
+
* program with the same arguments and env (or names the same URL, transport
|
|
470
|
+
* and headers), and every other field it carries has the stash's value.
|
|
471
|
+
*
|
|
472
|
+
* False means somebody changed the server inside its wrapped entry — another
|
|
473
|
+
* script after `--`, a rotated key, a `disabled` the client switched — and the
|
|
474
|
+
* stash describes a server that is no longer there.
|
|
475
|
+
*
|
|
476
|
+
* The stash is compared as a wrap of it reads back, not as it is written: a
|
|
477
|
+
* wrapped entry cannot carry `oauth`, `headersHelper` or `type: "stdio"` at
|
|
478
|
+
* all ({@link REGENERATED_FIELDS}), so set beside the stash itself it would
|
|
479
|
+
* always look changed. For the same reason a field only the stash has is no
|
|
480
|
+
* disagreement: an entry a runner before 0.3.0 wrote carries none of the
|
|
481
|
+
* client's own fields (`disabled`, `timeout`, …). Counted as changes, all of
|
|
482
|
+
* these would be dropped from the stash by the next install, and from the
|
|
483
|
+
* server by uninstall.
|
|
484
|
+
*/
|
|
485
|
+
export function stashStands(stash, unwrapped) {
|
|
486
|
+
const again = unwrapEntry(wrapEntry(stash, { serverName: "stash" }));
|
|
487
|
+
if (!again || isRemote(again) !== isRemote(unwrapped))
|
|
488
|
+
return false;
|
|
489
|
+
// `?? null`: an entry with no command at all (a client's `{ "url": … }`
|
|
490
|
+
// with no `type` is read as stdio) comes back from its wrap as `command: null`.
|
|
491
|
+
const said = (c) => isRemote(c) ? [c.type, c.url, c.headers ?? {}] : [c.command ?? null, c.args ?? [], c.env ?? {}];
|
|
492
|
+
if (!sameServerConfig(said(again), said(unwrapped)))
|
|
493
|
+
return false;
|
|
494
|
+
const kept = again;
|
|
495
|
+
return Object.entries(unwrapped).every(([field, value]) => REGENERATED_FIELDS.has(field) || sameServerConfig(value, kept[field]));
|
|
496
|
+
}
|
|
465
497
|
/**
|
|
466
498
|
* The entry for the scenario server itself. Not a proxy: it wraps nothing and
|
|
467
499
|
* spawns nothing, it just forwards one call to the control server.
|
|
@@ -485,24 +517,32 @@ export function isScenarioServer(config) {
|
|
|
485
517
|
function secretPlaceholder(i) {
|
|
486
518
|
return `"@@bir-secret-${i}@@"`;
|
|
487
519
|
}
|
|
488
|
-
/**
|
|
520
|
+
/**
|
|
521
|
+
* The values a parsed config file holds that are credentials more often than
|
|
522
|
+
* not, in order: its own top-level `env` (a Claude Code settings file's), then
|
|
523
|
+
* the env and headers of every server under `mcpServers`.
|
|
524
|
+
*/
|
|
489
525
|
function secretValues(parsed) {
|
|
490
|
-
const servers = parsed?.mcpServers;
|
|
491
|
-
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
492
|
-
return [];
|
|
493
526
|
const found = [];
|
|
527
|
+
const collect = (values, where) => {
|
|
528
|
+
if (!values || typeof values !== "object" || Array.isArray(values))
|
|
529
|
+
return;
|
|
530
|
+
for (const [key, value] of Object.entries(values)) {
|
|
531
|
+
if (typeof value === "string" && value !== "")
|
|
532
|
+
found.push({ ...where, key, value });
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
536
|
+
return found;
|
|
537
|
+
collect(parsed.env, { field: "env" });
|
|
538
|
+
const servers = parsed.mcpServers;
|
|
539
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
540
|
+
return found;
|
|
494
541
|
for (const [server, config] of Object.entries(servers)) {
|
|
495
542
|
if (!config || typeof config !== "object")
|
|
496
543
|
continue;
|
|
497
|
-
for (const field of ["env", "headers"])
|
|
498
|
-
|
|
499
|
-
if (!values || typeof values !== "object" || Array.isArray(values))
|
|
500
|
-
continue;
|
|
501
|
-
for (const [key, value] of Object.entries(values)) {
|
|
502
|
-
if (typeof value === "string" && value !== "")
|
|
503
|
-
found.push({ server, field, key, value });
|
|
504
|
-
}
|
|
505
|
-
}
|
|
544
|
+
for (const field of ["env", "headers"])
|
|
545
|
+
collect(config[field], { server, field });
|
|
506
546
|
}
|
|
507
547
|
return found;
|
|
508
548
|
}
|
|
@@ -569,9 +609,10 @@ function fillSecrets(redacted, values) {
|
|
|
569
609
|
/**
|
|
570
610
|
* The text a backup restores: its verbatim original, or the redacted one with
|
|
571
611
|
* every value read back from `currentText` — the file as it is now, whose
|
|
572
|
-
* entries (wrapped by us, or left as they were)
|
|
573
|
-
* file install created; undefined when the original
|
|
574
|
-
* (a value is gone, or the result does not hash to
|
|
612
|
+
* entries (wrapped by us, or left as they were) and own `env` block carry
|
|
613
|
+
* those values. Null for a file install created; undefined when the original
|
|
614
|
+
* cannot be rebuilt exactly (a value is gone, or the result does not hash to
|
|
615
|
+
* the original's).
|
|
575
616
|
*/
|
|
576
617
|
export function backupOriginalText(backup, currentText) {
|
|
577
618
|
if (backup.originalText !== null)
|
|
@@ -588,6 +629,12 @@ export function backupOriginalText(backup, currentText) {
|
|
|
588
629
|
}
|
|
589
630
|
const servers = (parsed?.mcpServers ?? {});
|
|
590
631
|
const values = redacted.secrets.map(({ server, field, key }) => {
|
|
632
|
+
if (server === undefined) {
|
|
633
|
+
// The file's own `env` block, which nothing of ours rewrites.
|
|
634
|
+
const own = parsed?.env;
|
|
635
|
+
const value = own && typeof own === "object" ? own[key] : undefined;
|
|
636
|
+
return typeof value === "string" ? value : undefined;
|
|
637
|
+
}
|
|
591
638
|
const entry = servers[server];
|
|
592
639
|
if (!entry || typeof entry !== "object")
|
|
593
640
|
return undefined;
|
|
@@ -603,12 +650,40 @@ export function backupOriginalText(backup, currentText) {
|
|
|
603
650
|
export function backupIsCreation(backup) {
|
|
604
651
|
return backup.originalText === null && !backup.redacted;
|
|
605
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* The sidecars {@link readSidecar} had to make up because installed.json is
|
|
655
|
+
* there and could not be read, and why — so that {@link writeSidecar} refuses
|
|
656
|
+
* exactly those.
|
|
657
|
+
*/
|
|
658
|
+
const unreadSidecars = new WeakMap();
|
|
659
|
+
/**
|
|
660
|
+
* installed.json, or an empty sidecar when there is none, or none that can be
|
|
661
|
+
* used. A file that does not parse reads as empty and is copied aside before
|
|
662
|
+
* the next write replaces it ({@link writeSidecar}).
|
|
663
|
+
*
|
|
664
|
+
* One that is there and cannot be READ (its permissions, a lock another
|
|
665
|
+
* program holds) reads as empty too, so a recorder or `bir status` goes on —
|
|
666
|
+
* but that sidecar is never written back. Saved, it replaced every project's
|
|
667
|
+
* originals, ports and tokens with whatever one command had just added.
|
|
668
|
+
* {@link sidecarReadError} lets a command that is about to write say so first.
|
|
669
|
+
*/
|
|
606
670
|
export function readSidecar() {
|
|
607
671
|
const path = installedPath();
|
|
608
|
-
|
|
609
|
-
return { version: 1, servers: {} };
|
|
672
|
+
let text;
|
|
610
673
|
try {
|
|
611
|
-
|
|
674
|
+
text = readFileSync(path, "utf8");
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
const empty = { version: 1, servers: {} };
|
|
678
|
+
const code = err.code;
|
|
679
|
+
// Not there (ENOTDIR: nor could it be) is a machine nothing was installed on.
|
|
680
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
681
|
+
unreadSidecars.set(empty, `${path} exists but cannot be read (${readFailure(err)})`);
|
|
682
|
+
}
|
|
683
|
+
return empty;
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
const parsed = JSON.parse(text);
|
|
612
687
|
const sidecar = { version: 1, servers: parsed.servers ?? {}, ...parsed };
|
|
613
688
|
sidecar.servers ??= {};
|
|
614
689
|
normaliseRecordKeys(sidecar.servers);
|
|
@@ -618,6 +693,13 @@ export function readSidecar() {
|
|
|
618
693
|
return { version: 1, servers: {} };
|
|
619
694
|
}
|
|
620
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* Why installed.json could not be read when `sidecar` was made from it
|
|
698
|
+
* ({@link readSidecar}); undefined when it was read, or is not there.
|
|
699
|
+
*/
|
|
700
|
+
export function sidecarReadError(sidecar) {
|
|
701
|
+
return unreadSidecars.get(sidecar);
|
|
702
|
+
}
|
|
621
703
|
/**
|
|
622
704
|
* The prefix of the key a record is kept under when its stash has its
|
|
623
705
|
* secrets blanked ({@link InstalledRecord.secretsRedacted}).
|
|
@@ -685,8 +767,14 @@ export function normaliseRecordKeys(servers) {
|
|
|
685
767
|
* ports with nothing. Before that happens the unreadable file is copied aside
|
|
686
768
|
* as `installed.json.corrupt-<time>`, where a person (or support) can get the
|
|
687
769
|
* originals back.
|
|
770
|
+
*
|
|
771
|
+
* A sidecar made up for a file that could not be read at all is refused: there
|
|
772
|
+
* is nothing to copy aside, and it knows nothing of what the file holds.
|
|
688
773
|
*/
|
|
689
774
|
export function writeSidecar(sidecar) {
|
|
775
|
+
const unread = unreadSidecars.get(sidecar);
|
|
776
|
+
if (unread)
|
|
777
|
+
throw new Error(`${unread}; it is not written over`);
|
|
690
778
|
// Whoever built the record, it lands under the key its stash calls for.
|
|
691
779
|
normaliseRecordKeys((sidecar.servers ??= {}));
|
|
692
780
|
ensureDir(configDir());
|
package/dist/config/resolve.d.ts
CHANGED
|
@@ -59,6 +59,11 @@ export interface ResolveOptions {
|
|
|
59
59
|
export declare function isRemote(config: McpServerConfig): config is RemoteServerConfig;
|
|
60
60
|
/** Parse a JSON file. `undefined` if absent; throws if present but malformed. */
|
|
61
61
|
export declare function parseJsonFile(path: string): unknown;
|
|
62
|
+
/**
|
|
63
|
+
* Why a file could not be read, as one phrase: Node's own text without the
|
|
64
|
+
* `, open '<path>'` it ends in, since the caller names the file itself.
|
|
65
|
+
*/
|
|
66
|
+
export declare function readFailure(err: unknown): string;
|
|
62
67
|
/**
|
|
63
68
|
* Parse the text of a JSON file (BOM tolerated). Throws, naming `path`, when
|
|
64
69
|
* it is not JSON — a file with comments (JSONC) included, which is exactly the
|
package/dist/config/resolve.js
CHANGED
|
@@ -51,6 +51,13 @@ export function parseJsonFile(path) {
|
|
|
51
51
|
}
|
|
52
52
|
return parseJsonText(raw, path);
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Why a file could not be read, as one phrase: Node's own text without the
|
|
56
|
+
* `, open '<path>'` it ends in, since the caller names the file itself.
|
|
57
|
+
*/
|
|
58
|
+
export function readFailure(err) {
|
|
59
|
+
return (err instanceof Error ? err.message : String(err)).replace(/, \w+ '.*'$/, "");
|
|
60
|
+
}
|
|
54
61
|
/**
|
|
55
62
|
* Parse the text of a JSON file (BOM tolerated). Throws, naming `path`, when
|
|
56
63
|
* it is not JSON — a file with comments (JSONC) included, which is exactly the
|
|
Binary file
|