@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.
Files changed (69) hide show
  1. package/README.md +27 -26
  2. package/dist/auth/client.d.ts +4 -1
  3. package/dist/auth/client.js +83 -14
  4. package/dist/bin/bir-hooks.js +4 -2
  5. package/dist/bin/bir.js +153 -37
  6. package/dist/bin/investigate.d.ts +13 -0
  7. package/dist/bin/investigate.js +72 -2
  8. package/dist/bin/scenario-edit.d.ts +5 -0
  9. package/dist/bin/scenario-edit.js +17 -1
  10. package/dist/config/adapters/claude-code.d.ts +51 -7
  11. package/dist/config/adapters/claude-code.js +105 -18
  12. package/dist/config/generate.d.ts +61 -13
  13. package/dist/config/generate.js +108 -20
  14. package/dist/config/resolve.d.ts +5 -0
  15. package/dist/config/resolve.js +7 -0
  16. package/dist/control/correlation.js +0 -0
  17. package/dist/control/daemon.d.ts +28 -0
  18. package/dist/control/daemon.js +62 -0
  19. package/dist/control/paths.d.ts +19 -1
  20. package/dist/control/paths.js +20 -2
  21. package/dist/control/pin-guard.js +8 -2
  22. package/dist/control/recorder-main.js +17 -5
  23. package/dist/control/server.d.ts +75 -11
  24. package/dist/control/server.js +440 -77
  25. package/dist/control/transcript.d.ts +64 -3
  26. package/dist/control/transcript.js +318 -37
  27. package/dist/jsonrpc/framing.d.ts +18 -3
  28. package/dist/jsonrpc/framing.js +57 -4
  29. package/dist/proxy/intercept.d.ts +4 -0
  30. package/dist/proxy/intercept.js +4 -0
  31. package/dist/proxy/relay.d.ts +13 -0
  32. package/dist/proxy/relay.js +12 -1
  33. package/dist/proxy/session.d.ts +8 -1
  34. package/dist/proxy/session.js +67 -10
  35. package/dist/record/recorder.d.ts +6 -1
  36. package/dist/record/redact.d.ts +18 -4
  37. package/dist/record/redact.js +538 -79
  38. package/dist/record/remote-recorder.d.ts +26 -5
  39. package/dist/record/remote-recorder.js +174 -18
  40. package/dist/record/truncate.js +3 -1
  41. package/dist/replay/bash-channel.d.ts +7 -1
  42. package/dist/replay/bash-channel.js +9 -3
  43. package/dist/replay/builtins.d.ts +14 -0
  44. package/dist/replay/builtins.js +460 -95
  45. package/dist/replay/bundle.d.ts +15 -11
  46. package/dist/replay/bundle.js +167 -33
  47. package/dist/replay/controller.d.ts +92 -1
  48. package/dist/replay/controller.js +246 -47
  49. package/dist/replay/derive.js +46 -4
  50. package/dist/replay/handover.d.ts +13 -0
  51. package/dist/replay/handover.js +18 -0
  52. package/dist/replay/plan.d.ts +41 -0
  53. package/dist/replay/plan.js +171 -3
  54. package/dist/replay/pricing.d.ts +61 -11
  55. package/dist/replay/pricing.js +135 -42
  56. package/dist/replay/reads-only.d.ts +48 -0
  57. package/dist/replay/reads-only.js +161 -0
  58. package/dist/replay/safety.js +43 -5
  59. package/dist/upstream/http-client.d.ts +6 -3
  60. package/dist/upstream/http-client.js +54 -13
  61. package/dist/upstream/stdio-client.d.ts +7 -0
  62. package/dist/upstream/stdio-client.js +1 -1
  63. package/dist/util/atomic-write.d.ts +9 -1
  64. package/dist/util/atomic-write.js +9 -6
  65. package/dist/util/service-health.d.ts +7 -0
  66. package/dist/util/service-health.js +10 -3
  67. package/package.json +1 -1
  68. package/production.md +2 -2
  69. 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
- /** Read a file's text, or null when it does not exist. */
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. Returns the text written, for the sidecar's hash.
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): string;
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 written anything, instead
150
- * of stopping halfway through with some files changed and others not.
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 = parseJsonFile(settingsPath);
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
- /** Read a file's text, or null when it does not exist. */
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 existsSync(path) ? readFileSync(path, "utf8") : null;
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
- return null;
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. Returns the text written, for the sidecar's hash.
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
- writeFileAtomic(path, text);
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 written anything, instead
347
- * of stopping halfway through with some files changed and others not.
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
- return false;
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
- const auth = e.headers?.Authorization;
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
- const parsed = parseJsonFile(settingsPath);
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) is not kept verbatim — that would be a second long-lived copy of
347
- * them under ~/.baseinstrunner. Its text is kept with each value's literal
348
- * replaced by a placeholder, and {@link FileBackup.originalText} stays null;
349
- * uninstall reads the values back from the entries in the file (wrapped or
350
- * not) and restores the file byte-for-byte only when the result hashes to
351
- * `sha`, the original's. See {@link protectBackupText}. A runner older than
352
- * this field (0.2.12) reads the backup as a file install created, and never
353
- * writes it back whole.
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
- /** Placeholder `i` is the value of `mcpServers[server][field][key]`. */
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: string;
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) carry those values. Null for a
384
- * file install created; undefined when the original cannot be rebuilt exactly
385
- * (a value is gone, or the result does not hash to the original's).
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;
@@ -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
- /** The env and header values of every server in a parsed `{ mcpServers }` file, in order. */
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
- const values = config[field];
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) carry those values. Null for a
573
- * file install created; undefined when the original cannot be rebuilt exactly
574
- * (a value is gone, or the result does not hash to the original's).
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
- if (!existsSync(path))
609
- return { version: 1, servers: {} };
672
+ let text;
610
673
  try {
611
- const parsed = JSON.parse(readFileSync(path, "utf8"));
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());
@@ -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
@@ -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