@basein/runner 0.2.12 → 0.3.1

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 (142) hide show
  1. package/README.md +540 -369
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2787 -302
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -7,13 +7,16 @@
7
7
  * model-visible edit (a non-goal) as well as a broken scenario corpus.
8
8
  *
9
9
  * Reversibility is not an afterthought (Phase 7.4): the original entry is stashed
10
- * verbatim in `~/.baseinstrunner/installed.json` before anything is rewritten, so
11
- * `bir uninstall` restores exactly what was there — including fields
10
+ * in `~/.baseinstrunner/installed.json` before anything is rewritten — verbatim
11
+ * but for its env and header VALUES, which uninstall reads back from the wrapped
12
+ * entry — so `bir uninstall` restores exactly what was there, including fields
12
13
  * BaseInstRunner never understood.
13
14
  */
15
+ import { createHash } from "node:crypto";
14
16
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
17
  import { createServer } from "node:net";
16
18
  import { configDir, ensureDir, installedPath, normalizePath } from "../control/paths.js";
19
+ import { writeFileAtomic } from "../util/atomic-write.js";
17
20
  import { isRemote } from "./resolve.js";
18
21
  /** npm package name, as it appears in a generated `npx -p` invocation. */
19
22
  export const PACKAGE_NAME = "@basein/runner";
@@ -32,15 +35,177 @@ export function packageSpec(version) {
32
35
  * model, and a collision would route that call somewhere else entirely.
33
36
  */
34
37
  export const SCENARIO_SERVER_KEY = "bir";
38
+ /** The node flags `bir` knows how to carry into everything it sets up. */
39
+ const CARRIED_FLAGS = ["--use-system-ca", "--use-env-proxy"];
40
+ /** True when an HTTP(S) proxy is configured in `env` (either spelling). */
41
+ export function proxyConfigured(env = process.env) {
42
+ return Boolean(env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy);
43
+ }
44
+ /**
45
+ * True when NO_PROXY (either spelling) sends 127.0.0.1 straight to the socket.
46
+ *
47
+ * `--use-env-proxy` sends EVERY request through HTTP(S)_PROXY — the runner's own
48
+ * loopback traffic too: the hooks' recorder, a proxy registering, `bir setup`
49
+ * waiting for the recorder — unless NO_PROXY exempts the address. Measured on
50
+ * Node 24: `127.0.0.1`, `*` and a suffix such as `.0.0.1` do; `localhost`,
51
+ * `127.0.0.0/8` and `127.*` do not. Node and undici disagree on which spelling
52
+ * wins when both are set, so every one that is set must exempt it.
53
+ */
54
+ export function loopbackBypassesProxy(env = process.env) {
55
+ const values = [env.NO_PROXY, env.no_proxy].filter((v) => typeof v === "string" && v.trim() !== "");
56
+ if (values.length === 0)
57
+ return false;
58
+ return values.every((value) => value
59
+ .split(/[\s,]+/)
60
+ .map((e) => e.trim().toLowerCase())
61
+ .some((e) => e === "*" || e === "127.0.0.1" || (e.startsWith(".") && "127.0.0.1".endsWith(e))));
62
+ }
63
+ /** The addresses to add to NO_PROXY so the runner's loopback traffic skips the proxy. */
64
+ export const LOOPBACK_NO_PROXY = "127.0.0.1,localhost,::1";
65
+ /**
66
+ * What to tell a person whose proxy the runner will NOT use, or undefined. A
67
+ * proxy with a NO_PROXY that does not name 127.0.0.1 would swallow the runner's
68
+ * own loopback calls, so `--use-env-proxy` is left out rather than break every
69
+ * hook; the service is then reached directly, if at all.
70
+ */
71
+ export function envProxyHint(env = process.env) {
72
+ if (!proxyConfigured(env) || loopbackBypassesProxy(asStarted(env)) || !nodeKnowsFlag("--use-env-proxy"))
73
+ return undefined;
74
+ return (`HTTPS_PROXY/HTTP_PROXY is set but NO_PROXY does not name 127.0.0.1, so the runner does not use the proxy ` +
75
+ `(it would swallow the runner's own loopback calls). Set NO_PROXY=${LOOPBACK_NO_PROXY} (plus your own entries) ` +
76
+ "and run `bir setup` again so the runner reaches the service through it");
77
+ }
78
+ /**
79
+ * Make THIS process's loopback fetches skip the proxy when it runs with
80
+ * `--use-env-proxy` and NO_PROXY does not already exempt them: a launcher
81
+ * written behind a proxy keeps the flag after the shell's NO_PROXY changes.
82
+ * undici re-reads NO_PROXY per request, so fetch honours the change; so does
83
+ * every process started from here (a recorder `bir up` launches). Returns true
84
+ * when it changed anything. The flags carried into the entries are decided
85
+ * from the environment as it was ({@link nodeFlagsToCarry}).
86
+ */
87
+ export function exemptLoopbackFromProxy(env = process.env, execArgv = process.execArgv) {
88
+ if (!execArgv.includes("--use-env-proxy") || !proxyConfigured(env) || loopbackBypassesProxy(env))
89
+ return false;
90
+ if (env === process.env)
91
+ originalNoProxy ??= { NO_PROXY: env.NO_PROXY, no_proxy: env.no_proxy };
92
+ const add = (value) => (value && value.trim() ? `${value},${LOOPBACK_NO_PROXY}` : LOOPBACK_NO_PROXY);
93
+ const lower = env.no_proxy;
94
+ env.NO_PROXY = add(env.NO_PROXY);
95
+ // On Windows the two spellings are one variable; elsewhere both are fixed.
96
+ if (lower !== undefined && env.no_proxy === lower)
97
+ env.no_proxy = add(lower);
98
+ return true;
99
+ }
100
+ /** NO_PROXY as it was before {@link exemptLoopbackFromProxy} changed it. */
101
+ let originalNoProxy;
102
+ /**
103
+ * `env` as this process was started with it: the entries and hooks run in the
104
+ * user's environment, not in one this process has since exempted its own
105
+ * loopback in.
106
+ */
107
+ function asStarted(env) {
108
+ return env === process.env && originalNoProxy ? { ...env, ...originalNoProxy } : env;
109
+ }
110
+ /** True when this Node knows `flag` (a cheap, spawn-free probe). */
111
+ function nodeKnowsFlag(flag) {
112
+ try {
113
+ return process.allowedNodeEnvironmentFlags.has(flag);
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
35
119
  /**
36
120
  * The node flags this process was started with that everything it sets up
37
121
  * should share. The install scripts run `bir setup` with `--use-system-ca`
38
122
  * when the Node supports it; a hook, a proxy or a recorder started without it
39
123
  * would then fail the first TLS-inspected upload while the install itself
40
124
  * succeeded.
125
+ *
126
+ * `--use-env-proxy` is carried the same way, and added when HTTPS_PROXY or
127
+ * HTTP_PROXY is set and this Node knows the flag: Node's `fetch` ignores those
128
+ * variables otherwise, so behind a proxy-only network every service call waited
129
+ * out a connect timeout. A FLAG rather than `NODE_USE_ENV_PROXY=1` in the
130
+ * entries' env on purpose: `bir-proxy` hands its environment to the user's own
131
+ * MCP server, and that server's networking is not ours to change.
132
+ *
133
+ * ONLY WHEN NO_PROXY EXEMPTS 127.0.0.1 ({@link loopbackBypassesProxy}). With the
134
+ * flag, Node sends the runner's loopback calls to the proxy too, which refuses
135
+ * or swallows them: the SessionStart hook could not see its own recorder, the
136
+ * proxies could not register, and `bir setup` timed out — on a network where
137
+ * all of that worked without the flag. So without that exemption the flag is
138
+ * neither added nor carried, and {@link envProxyHint} says what to set.
139
+ */
140
+ export function nodeFlagsToCarry(execArgv = process.execArgv, env = process.env) {
141
+ const envProxyOk = loopbackBypassesProxy(asStarted(env));
142
+ const flags = execArgv.filter((a) => CARRIED_FLAGS.includes(a) && (a !== "--use-env-proxy" || envProxyOk));
143
+ if (!flags.includes("--use-env-proxy") && envProxyOk && proxyConfigured(env) && nodeKnowsFlag("--use-env-proxy")) {
144
+ flags.push("--use-env-proxy");
145
+ }
146
+ return [...new Set(flags)];
147
+ }
148
+ /** Fields a wrapped entry regenerates, so they are never copied from the original. */
149
+ const REGENERATED_FIELDS = new Set(["type", "url", "headers", "headersHelper", "oauth", "command", "args", "env"]);
150
+ /**
151
+ * The env variable a wrapped REMOTE entry carries its upstream's static
152
+ * headers in: a JSON object, `{"Header-Name": "value", …}`. bir-proxy reads it
153
+ * (merged with any `--header K:V` arguments, which an entry an older runner
154
+ * wrote still has), and never hands it to a stdio upstream. In the env rather
155
+ * than on the command line because argv is visible to every local user
156
+ * (`ps`, /proc), and a header is usually a credential.
157
+ */
158
+ export const UPSTREAM_HEADERS_ENV = "BIR_UPSTREAM_HEADERS";
159
+ /**
160
+ * The headers in a wrapped entry's {@link UPSTREAM_HEADERS_ENV}, or none. Only
161
+ * string values count; anything unreadable is no headers rather than an error.
162
+ */
163
+ export function upstreamHeadersFromEnv(env) {
164
+ const raw = env?.[UPSTREAM_HEADERS_ENV];
165
+ if (!raw)
166
+ return {};
167
+ try {
168
+ const parsed = JSON.parse(raw);
169
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
170
+ return {};
171
+ return Object.fromEntries(Object.entries(parsed).filter((e) => typeof e[1] === "string"));
172
+ }
173
+ catch {
174
+ return {};
175
+ }
176
+ }
177
+ /**
178
+ * `npx … <bin>`, pinned and offline-first, for an MCP server entry.
179
+ *
180
+ * Plain `npx` on every platform, Windows included. Claude Code spawns MCP
181
+ * servers through cross-spawn, which resolves `npx` to `npx.cmd` and escapes
182
+ * each argument for cmd.exe the way cmd.exe reads it. Wrapping the entry in
183
+ * `cmd /c` instead hands cmd.exe a line it parses itself — and everything after
184
+ * `--` is the user's own server invocation: `?a=1&b=2` in a URL ran `b=2` as a
185
+ * command, `|` and `>` piped or redirected the proxy's stdout, `^1.2.0` lost its
186
+ * caret and `%VAR%` was expanded. `platform` is accepted and ignored.
187
+ */
188
+ export function npxInvocation(bin, version, _platform = process.platform) {
189
+ return { command: "npx", args: [...(version ? ["--prefer-offline"] : []), "-y", "-p", packageSpec(version), bin] };
190
+ }
191
+ /**
192
+ * `npx … <bin>` for a COMMAND HOOK (the SessionStart `bir-hooks ensure`).
193
+ *
194
+ * Claude Code spawns a hook's exec form directly, without cross-spawn's
195
+ * `.cmd` resolution, so on native Windows it keeps `cmd /c` — safe there
196
+ * because every argument is ours: a pinned package spec, a bin name and
197
+ * fixed flags, never a user's text.
41
198
  */
42
- export function nodeFlagsToCarry(execArgv = process.execArgv) {
43
- return execArgv.filter((a) => a === "--use-system-ca");
199
+ export function npxHookInvocation(bin, version, platform = process.platform) {
200
+ const plain = npxInvocation(bin, version);
201
+ return platform === "win32" ? { command: "cmd", args: ["/c", "npx", ...plain.args] } : plain;
202
+ }
203
+ /** True when an entry is spawned through npx (with or without `cmd /c`). */
204
+ export function isNpxEntry(config) {
205
+ if (isRemote(config))
206
+ return false;
207
+ const argv = config.args ?? [];
208
+ return config.command === "npx" || (/^cmd(\.exe)?$/i.test(config.command) && argv[0] === "/c" && argv[1] === "npx");
44
209
  }
45
210
  /** True when this entry already points at `bir-proxy` — makes install idempotent. */
46
211
  export function isWrapped(config) {
@@ -60,12 +225,6 @@ export function wrapEntry(config, opts) {
60
225
  proxyArgs.push("--no-correlation");
61
226
  if (isRemote(config)) {
62
227
  proxyArgs.push("--url", config.url, "--transport", config.type);
63
- for (const [key, value] of Object.entries(config.headers ?? {})) {
64
- // §9: the proxy holds the credential for a remote upstream, because the
65
- // host's own OAuth now authenticates *us*. It is passed at spawn and never
66
- // written to BaseInstRunner's own state.
67
- proxyArgs.push("--header", `${key}:${value}`);
68
- }
69
228
  }
70
229
  else {
71
230
  proxyArgs.push("--", config.command, ...(config.args ?? []));
@@ -73,26 +232,245 @@ export function wrapEntry(config, opts) {
73
232
  const env = {};
74
233
  if (opts.controlUrl)
75
234
  env.BIR_CONTROL_URL = opts.controlUrl;
76
- if (!isRemote(config)) {
235
+ if (isRemote(config)) {
236
+ // §9: the proxy holds the credential for a remote upstream, because the
237
+ // host's own OAuth now authenticates *us*. It travels in the entry's env,
238
+ // not on the command line, where any local user's `ps` could read it.
239
+ // Values go in verbatim: Claude Code expands a `${VAR}` in an env value
240
+ // when it spawns the proxy. The install sidecar keeps the header NAMES
241
+ // only (see redactSecrets).
242
+ const headers = config.headers ?? {};
243
+ if (Object.keys(headers).length > 0)
244
+ env[UPSTREAM_HEADERS_ENV] = JSON.stringify(headers);
245
+ }
246
+ else {
77
247
  for (const [key, value] of Object.entries(config.env ?? {}))
78
248
  env[key] = value;
79
249
  }
80
250
  const invocation = opts.invocation ?? "npx";
81
- const entry = invocation !== "npx" && opts.proxyPath
82
- ? { command: process.execPath, args: [...(opts.nodeFlags ?? []), opts.proxyPath, ...proxyArgs] }
83
- : { command: "npx", args: ["-y", "-p", packageSpec(opts.version), "bir-proxy", ...proxyArgs] };
251
+ const spawnAs = invocation !== "npx" && opts.proxyPath
252
+ ? { command: opts.node ?? process.execPath, args: [...(opts.nodeFlags ?? []), opts.proxyPath] }
253
+ : npxInvocation("bir-proxy", opts.version);
254
+ // Everything else the client understands about this server stays as it was:
255
+ // a `"disabled": true` server must not come back to life through the proxy,
256
+ // and a per-server auto-approve list must not quietly disappear.
257
+ const extra = {};
258
+ for (const [key, value] of Object.entries(config)) {
259
+ if (!REGENERATED_FIELDS.has(key))
260
+ extra[key] = value;
261
+ }
262
+ const entry = { ...extra, command: spawnAs.command, args: [...spawnAs.args, ...proxyArgs] };
84
263
  if (Object.keys(env).length > 0)
85
264
  entry.env = env;
86
265
  return entry;
87
266
  }
267
+ /**
268
+ * Rebuild the original entry from a wrapped one — for an entry wrapped by an
269
+ * older runner or on another machine (nothing stashed here), and for remote
270
+ * header values, which the sidecar does not keep. `undefined` when `wrapped`
271
+ * is not an entry `wrapEntry` could have produced.
272
+ */
273
+ export function unwrapEntry(wrapped) {
274
+ if (isRemote(wrapped) || !isWrapped(wrapped))
275
+ return undefined;
276
+ const argv = wrapped.args ?? [];
277
+ const at = argv.findIndex((a) => /(^|[\\/])bir-proxy(\.js)?$/.test(a));
278
+ if (at < 0)
279
+ return undefined;
280
+ const rest = argv.slice(at + 1);
281
+ let url;
282
+ let transport;
283
+ // A current entry carries its headers in its env; an older one as
284
+ // `--header K:V` arguments, read below.
285
+ const headers = upstreamHeadersFromEnv(wrapped.env);
286
+ let upstream;
287
+ for (let i = 0; i < rest.length; i += 1) {
288
+ const a = rest[i];
289
+ if (a === "--") {
290
+ upstream = rest.slice(i + 1);
291
+ break;
292
+ }
293
+ if (a === "--url")
294
+ url = rest[++i];
295
+ else if (a === "--transport")
296
+ transport = rest[++i];
297
+ else if (a === "--header") {
298
+ const raw = rest[++i] ?? "";
299
+ const sep = raw.indexOf(":");
300
+ if (sep > 0)
301
+ headers[raw.slice(0, sep)] = raw.slice(sep + 1);
302
+ }
303
+ else if (a === "--server-name")
304
+ i += 1;
305
+ }
306
+ const extra = {};
307
+ for (const [key, value] of Object.entries(wrapped)) {
308
+ if (!REGENERATED_FIELDS.has(key))
309
+ extra[key] = value;
310
+ }
311
+ if (url) {
312
+ const remote = { ...extra, type: transport === "sse" ? "sse" : "http", url };
313
+ if (Object.keys(headers).length > 0)
314
+ remote.headers = headers;
315
+ return remote;
316
+ }
317
+ if (!upstream || upstream.length === 0)
318
+ return undefined;
319
+ const stdio = { ...extra, command: upstream[0] };
320
+ if (upstream.length > 1)
321
+ stdio.args = upstream.slice(1);
322
+ const env = { ...(wrapped.env ?? {}) };
323
+ delete env.BIR_CONTROL_URL;
324
+ if (Object.keys(env).length > 0)
325
+ stdio.env = env;
326
+ return stdio;
327
+ }
328
+ /**
329
+ * The options `wrapEntry` was given for `wrapped`, read back from the entry:
330
+ * the interpreter, its flags and the proxy script (or the npx spec), the
331
+ * control URL and `--no-correlation`. What lets a local copy be re-generated
332
+ * over a changed source without knowing how `bir install` was run.
333
+ */
334
+ export function wrapOptionsOf(wrapped) {
335
+ if (isRemote(wrapped) || !isWrapped(wrapped))
336
+ return undefined;
337
+ const argv = wrapped.args ?? [];
338
+ const at = argv.findIndex((a) => /(^|[\\/])bir-proxy(\.js)?$/.test(a));
339
+ if (at < 0)
340
+ return undefined;
341
+ const end = argv.indexOf("--", at + 1);
342
+ const proxyArgs = argv.slice(at + 1, end < 0 ? undefined : end);
343
+ const controlUrl = wrapped.env?.BIR_CONTROL_URL;
344
+ const common = { controlUrl, noCorrelation: proxyArgs.includes("--no-correlation") || undefined };
345
+ if (isNpxEntry(wrapped)) {
346
+ const spec = argv.find((a) => a.startsWith(`${PACKAGE_NAME}@`));
347
+ // No platform: an entry an unreleased build wrote as `cmd /c npx …` is
348
+ // re-generated in the plain shape, which is the point (npxInvocation).
349
+ return { ...common, invocation: "npx", version: spec ? spec.slice(PACKAGE_NAME.length + 1) : undefined };
350
+ }
351
+ return { ...common, invocation: "global", node: wrapped.command, nodeFlags: argv.slice(0, at), proxyPath: argv[at] };
352
+ }
353
+ /**
354
+ * An entry in the one shape two spellings of the same server share: keys
355
+ * sorted, `type: "stdio"` (optional in Claude Code's files) dropped, and empty
356
+ * `args` / `env` dropped (a wrapped entry cannot tell them from absent ones).
357
+ */
358
+ function canonicalServer(config) {
359
+ const sort = (v) => Array.isArray(v)
360
+ ? v.map(sort)
361
+ : v && typeof v === "object"
362
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort(v[k])]))
363
+ : v;
364
+ if (!config || typeof config !== "object" || Array.isArray(config))
365
+ return sort(config);
366
+ const copy = { ...config };
367
+ if (copy.type === "stdio")
368
+ delete copy.type;
369
+ if (Array.isArray(copy.args) && copy.args.length === 0)
370
+ delete copy.args;
371
+ if (copy.env && typeof copy.env === "object" && Object.keys(copy.env).length === 0)
372
+ delete copy.env;
373
+ return sort(copy);
374
+ }
375
+ /** Two entries that say the same thing, whatever order or optional spelling they were written in. */
376
+ export function sameServerConfig(a, b) {
377
+ return a !== undefined && b !== undefined && JSON.stringify(canonicalServer(a)) === JSON.stringify(canonicalServer(b));
378
+ }
379
+ /**
380
+ * A fingerprint of an entry (see {@link canonicalServer}), kept in an
381
+ * override's record so a later change to the entry it copied is noticed —
382
+ * without keeping the entry, whose env and headers are credentials.
383
+ */
384
+ export function configSha(config) {
385
+ return createHash("sha256").update(JSON.stringify(canonicalServer(config))).digest("hex");
386
+ }
387
+ /** Header names that carry a credential. */
388
+ const CREDENTIAL_HEADER = /authorization|api[-_]?key|token|secret|cookie|^x-.*key$/i;
389
+ /**
390
+ * Whether a remote entry can be wrapped without breaking it.
391
+ *
392
+ * The proxy can only send what the entry spells out. A server Claude Code signs
393
+ * in itself — OAuth through `/mcp`, a `headersHelper` script, `${VAR}` headers
394
+ * Claude Code expands — would reach the upstream with no credentials, answer
395
+ * 401 on every call, and lose the "Authenticate" option in `/mcp` because the
396
+ * entry is now a stdio one. Those are left unwrapped: their calls are still
397
+ * recorded by the PreToolUse/PostToolUse hooks. Only an entry carrying a
398
+ * literal credential header is wrapped by default; `--server <name>` wraps
399
+ * any other one anyway.
400
+ */
401
+ export function remoteWrapBlocker(config) {
402
+ if (config.oauth !== undefined)
403
+ return "it is signed in through Claude Code (an oauth block)";
404
+ if (config.headersHelper !== undefined)
405
+ return "its headers come from a headersHelper only Claude Code runs";
406
+ const headers = Object.entries(config.headers ?? {});
407
+ if (headers.length === 0)
408
+ return "it has no static credentials, so Claude Code signs it in (OAuth)";
409
+ if (headers.some(([, v]) => String(v).includes("${")))
410
+ return "its headers use ${VAR} expansion only Claude Code performs";
411
+ if (!headers.some(([k]) => CREDENTIAL_HEADER.test(k)))
412
+ return "it carries no credential header, so Claude Code signs it in (OAuth)";
413
+ return undefined;
414
+ }
415
+ /**
416
+ * `config` as the sidecar keeps it: a remote entry's header VALUES and a stdio
417
+ * entry's env VALUES are dropped — they are credentials more often than not
418
+ * (API keys, database URLs, bearer tokens), and a second long-lived copy of
419
+ * them in installed.json is exposure for nothing. The names stay; uninstall
420
+ * reads the values back from the wrapped entry, which carries them verbatim
421
+ * (and carries a key the user rotated since, which a stash would not).
422
+ */
423
+ export function redactSecrets(config) {
424
+ const blank = (values) => Object.fromEntries(Object.keys(values).map((key) => [key, ""]));
425
+ if (isRemote(config)) {
426
+ if (!config.headers || Object.keys(config.headers).length === 0)
427
+ return { config, redacted: false };
428
+ return { config: { ...config, headers: blank(config.headers) }, redacted: true };
429
+ }
430
+ if (!config.env || Object.keys(config.env).length === 0)
431
+ return { config, redacted: false };
432
+ return { config: { ...config, env: blank(config.env) }, redacted: true };
433
+ }
434
+ /**
435
+ * Undo {@link redactSecrets}: the stash, with every redacted value read back
436
+ * from `wrapped`. Undefined when a value cannot be found there (the wrapped
437
+ * entry was changed or removed) — restoring an entry without its credentials
438
+ * would break it, so the caller leaves the entry alone instead.
439
+ */
440
+ export function restoreSecrets(stash, wrapped) {
441
+ const back = wrapped ? unwrapEntry(wrapped) : undefined;
442
+ if (!back)
443
+ return undefined;
444
+ if (isRemote(stash)) {
445
+ if (!isRemote(back))
446
+ return undefined;
447
+ const headers = {};
448
+ for (const key of Object.keys(stash.headers ?? {})) {
449
+ if (back.headers?.[key] === undefined)
450
+ return undefined;
451
+ headers[key] = back.headers[key];
452
+ }
453
+ return { ...stash, headers };
454
+ }
455
+ if (isRemote(back))
456
+ return undefined;
457
+ const env = {};
458
+ for (const key of Object.keys(stash.env ?? {})) {
459
+ if (back.env?.[key] === undefined)
460
+ return undefined;
461
+ env[key] = back.env[key];
462
+ }
463
+ return { ...stash, env };
464
+ }
88
465
  /**
89
466
  * The entry for the scenario server itself. Not a proxy: it wraps nothing and
90
467
  * spawns nothing, it just forwards one call to the control server.
91
468
  */
92
469
  export function scenarioEntry(opts) {
93
- const entry = opts.invocation && opts.invocation !== "npx" && opts.scenarioPath
94
- ? { command: process.execPath, args: [...(opts.nodeFlags ?? []), opts.scenarioPath] }
95
- : { command: "npx", args: ["-y", "-p", packageSpec(opts.version), "bir-scenario"] };
470
+ const spawnAs = opts.invocation && opts.invocation !== "npx" && opts.scenarioPath
471
+ ? { command: opts.node ?? process.execPath, args: [...(opts.nodeFlags ?? []), opts.scenarioPath] }
472
+ : npxInvocation("bir-scenario", opts.version);
473
+ const entry = { command: spawnAs.command, args: spawnAs.args };
96
474
  if (opts.controlUrl)
97
475
  entry.env = { BIR_CONTROL_URL: opts.controlUrl };
98
476
  return entry;
@@ -103,21 +481,231 @@ export function isScenarioServer(config) {
103
481
  return false;
104
482
  return /\bbir-scenario\b/.test([config.command, ...(config.args ?? [])].join(" "));
105
483
  }
484
+ /** The placeholder literal for secret `i` (with its JSON quotes). */
485
+ function secretPlaceholder(i) {
486
+ return `"@@bir-secret-${i}@@"`;
487
+ }
488
+ /** The env and header values of every server in a parsed `{ mcpServers }` file, in order. */
489
+ function secretValues(parsed) {
490
+ const servers = parsed?.mcpServers;
491
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
492
+ return [];
493
+ const found = [];
494
+ for (const [server, config] of Object.entries(servers)) {
495
+ if (!config || typeof config !== "object")
496
+ 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
+ }
506
+ }
507
+ return found;
508
+ }
509
+ /**
510
+ * The backup of a project file's pre-install text, as installed.json keeps it
511
+ * (SEC-8): verbatim when it holds no env or header value; otherwise with every
512
+ * such value's literal replaced by a placeholder ({@link FileBackup.redacted}).
513
+ * When that cannot be done exactly — a value spelled with escapes, a text that
514
+ * does not parse, a placeholder already in the text — no text is kept at all
515
+ * and the file is repaired entry by entry on uninstall (`stale`), which loses
516
+ * the byte-for-byte restore and nothing else.
517
+ */
518
+ export function protectBackupText(text) {
519
+ let parsed;
520
+ try {
521
+ parsed = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
522
+ }
523
+ catch {
524
+ return { originalText: text, writtenSha: "" };
525
+ }
526
+ const values = secretValues(parsed);
527
+ if (values.length === 0)
528
+ return { originalText: text, writtenSha: "" };
529
+ const unprotected = { originalText: null, writtenSha: "", stale: true };
530
+ if (text.includes("@@bir-secret-"))
531
+ return unprotected;
532
+ const secrets = [];
533
+ const byValue = new Map();
534
+ for (const v of values) {
535
+ if (byValue.has(v.value))
536
+ continue;
537
+ byValue.set(v.value, secrets.length);
538
+ secrets.push({ server: v.server, field: v.field, key: v.key });
539
+ }
540
+ // Longest first, so a short value cannot eat into a longer one's literal.
541
+ let redactedText = text;
542
+ for (const [value, i] of [...byValue].sort((a, b) => b[0].length - a[0].length)) {
543
+ const literal = JSON.stringify(value);
544
+ if (!redactedText.includes(literal))
545
+ return unprotected;
546
+ redactedText = redactedText.split(literal).join(secretPlaceholder(i));
547
+ }
548
+ const redacted = { text: redactedText, sha: sha256Hex(text), secrets };
549
+ // Proven now, not hoped for at uninstall: the values put back give the original, exactly.
550
+ const values_ = secrets.map((s) => values.find((v) => v.server === s.server && v.field === s.field && v.key === s.key)?.value);
551
+ if (fillSecrets(redacted, values_) !== text)
552
+ return unprotected;
553
+ return { originalText: null, writtenSha: "", redacted };
554
+ }
555
+ function sha256Hex(text) {
556
+ return createHash("sha256").update(text).digest("hex");
557
+ }
558
+ /** The redacted text with each placeholder's value put back; undefined when a value is missing. */
559
+ function fillSecrets(redacted, values) {
560
+ let text = redacted.text;
561
+ for (let i = 0; i < redacted.secrets.length; i += 1) {
562
+ const value = values[i];
563
+ if (value === undefined)
564
+ return undefined;
565
+ text = text.split(secretPlaceholder(i)).join(JSON.stringify(value));
566
+ }
567
+ return text;
568
+ }
569
+ /**
570
+ * The text a backup restores: its verbatim original, or the redacted one with
571
+ * 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).
575
+ */
576
+ export function backupOriginalText(backup, currentText) {
577
+ if (backup.originalText !== null)
578
+ return backup.originalText;
579
+ const redacted = backup.redacted;
580
+ if (!redacted)
581
+ return null;
582
+ let parsed;
583
+ try {
584
+ parsed = currentText === null ? undefined : JSON.parse(currentText.charCodeAt(0) === 0xfeff ? currentText.slice(1) : currentText);
585
+ }
586
+ catch {
587
+ return undefined;
588
+ }
589
+ const servers = (parsed?.mcpServers ?? {});
590
+ const values = redacted.secrets.map(({ server, field, key }) => {
591
+ const entry = servers[server];
592
+ if (!entry || typeof entry !== "object")
593
+ return undefined;
594
+ const plain = isWrapped(entry) ? unwrapEntry(entry) : entry;
595
+ const bag = plain?.[field];
596
+ const value = bag?.[key];
597
+ return typeof value === "string" ? value : undefined;
598
+ });
599
+ const text = fillSecrets(redacted, values);
600
+ return text !== undefined && sha256Hex(text) === redacted.sha ? text : undefined;
601
+ }
602
+ /** True when a backup says the file did not exist before install (nothing to restore). */
603
+ export function backupIsCreation(backup) {
604
+ return backup.originalText === null && !backup.redacted;
605
+ }
106
606
  export function readSidecar() {
107
607
  const path = installedPath();
108
608
  if (!existsSync(path))
109
609
  return { version: 1, servers: {} };
110
610
  try {
111
611
  const parsed = JSON.parse(readFileSync(path, "utf8"));
112
- return { version: 1, servers: parsed.servers ?? {}, ...parsed };
612
+ const sidecar = { version: 1, servers: parsed.servers ?? {}, ...parsed };
613
+ sidecar.servers ??= {};
614
+ normaliseRecordKeys(sidecar.servers);
615
+ return sidecar;
113
616
  }
114
617
  catch {
115
618
  return { version: 1, servers: {} };
116
619
  }
117
620
  }
621
+ /**
622
+ * The prefix of the key a record is kept under when its stash has its
623
+ * secrets blanked ({@link InstalledRecord.secretsRedacted}).
624
+ *
625
+ * A runner older than redaction (0.2.12) finds its records by the exact key
626
+ * `<cwd>::<name>`, or by that prefix, and writes `original` back VERBATIM: on
627
+ * a rollback (`BIR_VERSION=0.2.12`, a second `bir` earlier on PATH, `npx
628
+ * @basein/runner@0.2.12`) its `bir uninstall` would put a server back with its
629
+ * API key blanked, the key's only copy — the wrapped entry — overwritten. Under
630
+ * this prefix it finds no record, says nothing to undo for that server, and
631
+ * leaves the wrapped entry, credentials and all, alone. Records it can restore
632
+ * correctly (no env, no headers, or an `original: null`) keep the old key.
633
+ */
634
+ export const REDACTED_KEY_PREFIX = "v2|";
635
+ /** The key a record for `serverName` in `cwd` belongs under, redacted or not. */
636
+ export function recordKey(cwd, serverName, redacted = false) {
637
+ return `${redacted ? REDACTED_KEY_PREFIX : ""}${sidecarKey(cwd, serverName)}`;
638
+ }
639
+ /**
640
+ * Put every record under the key its stash calls for ({@link REDACTED_KEY_PREFIX}):
641
+ * a redacted record written under the plain key (by an earlier build, or by
642
+ * code that wrote the plain key) moves out of an older runner's sight, and
643
+ * two records for one server — one per key shape, left by installs of both
644
+ * versions — become the newer one. Returns true when anything moved.
645
+ */
646
+ export function normaliseRecordKeys(servers) {
647
+ let changed = false;
648
+ for (const key of Object.keys(servers)) {
649
+ const record = servers[key];
650
+ if (!record || typeof record !== "object")
651
+ continue;
652
+ const redacted = key.startsWith(REDACTED_KEY_PREFIX);
653
+ const bare = redacted ? key.slice(REDACTED_KEY_PREFIX.length) : key;
654
+ const wanted = record.secretsRedacted === true ? `${REDACTED_KEY_PREFIX}${bare}` : bare;
655
+ if (wanted === key)
656
+ continue;
657
+ const other = servers[wanted];
658
+ if (!other || (record.installedAt ?? 0) >= (other.installedAt ?? 0))
659
+ servers[wanted] = record;
660
+ delete servers[key];
661
+ changed = true;
662
+ }
663
+ // One server under both shapes (a 0.2.12 install after a new one): the newer wins.
664
+ for (const key of Object.keys(servers)) {
665
+ if (!key.startsWith(REDACTED_KEY_PREFIX))
666
+ continue;
667
+ const bare = key.slice(REDACTED_KEY_PREFIX.length);
668
+ const plain = servers[bare];
669
+ if (!plain)
670
+ continue;
671
+ if ((plain.installedAt ?? 0) > (servers[key].installedAt ?? 0))
672
+ delete servers[key];
673
+ else
674
+ delete servers[bare];
675
+ changed = true;
676
+ }
677
+ return changed;
678
+ }
679
+ /**
680
+ * Write the sidecar so no reader ever sees half of it (a recorder that read a
681
+ * torn file used to start with a random token and refuse every hook).
682
+ *
683
+ * `readSidecar` answers an empty sidecar for a file it cannot parse, so the
684
+ * next write would silently replace every project's originals, tokens and
685
+ * ports with nothing. Before that happens the unreadable file is copied aside
686
+ * as `installed.json.corrupt-<time>`, where a person (or support) can get the
687
+ * originals back.
688
+ */
118
689
  export function writeSidecar(sidecar) {
690
+ // Whoever built the record, it lands under the key its stash calls for.
691
+ normaliseRecordKeys((sidecar.servers ??= {}));
119
692
  ensureDir(configDir());
120
- writeFileSync(installedPath(), JSON.stringify(sidecar, null, 2), { mode: 0o600 });
693
+ const path = installedPath();
694
+ try {
695
+ if (existsSync(path)) {
696
+ const current = readFileSync(path, "utf8");
697
+ try {
698
+ JSON.parse(current);
699
+ }
700
+ catch {
701
+ writeFileSync(`${path}.corrupt-${Date.now()}`, current, { mode: 0o600 });
702
+ }
703
+ }
704
+ }
705
+ catch {
706
+ /* unreadable: nothing to keep */
707
+ }
708
+ writeFileAtomic(path, JSON.stringify(sidecar, null, 2), { mode: 0o600 });
121
709
  }
122
710
  export function sidecarKey(cwd, serverName) {
123
711
  return `${cwd}::${serverName}`;
@@ -186,6 +774,27 @@ export async function allocateFreeProjectPort(sidecar, cwd) {
186
774
  }
187
775
  return allocateProjectPort(sidecar, cwd);
188
776
  }
777
+ /**
778
+ * A new port for a project whose recorded one this machine will no longer let
779
+ * it bind (a Hyper-V/WSL/Docker exclusion that moved after a reboot, or another
780
+ * program). The lowest free one no other project holds, never `avoid`;
781
+ * `undefined` when none of the 200 is free. The caller rewrites the hooks and
782
+ * every wrapped entry's control URL — `bir setup` does, by re-running install.
783
+ */
784
+ export async function allocateReplacementPort(sidecar, cwd, avoid) {
785
+ const key = normalizePath(cwd);
786
+ const taken = new Set(Object.entries(sidecar.projects ?? {})
787
+ .filter(([k]) => k !== key)
788
+ .map(([, r]) => r.port));
789
+ taken.add(avoid);
790
+ for (let port = FIRST_PROJECT_PORT; port < FIRST_PROJECT_PORT + 200; port += 1) {
791
+ if (taken.has(port))
792
+ continue;
793
+ if (await probeFreePort(port))
794
+ return port;
795
+ }
796
+ return undefined;
797
+ }
189
798
  /** Merge `patch` into the record for `cwd`, creating it when absent. */
190
799
  export function setProjectRecord(sidecar, cwd, patch) {
191
800
  sidecar.projects ??= {};
@@ -214,4 +823,42 @@ export function setEditing(sidecar, cwd, on) {
214
823
  return undefined;
215
824
  return setProjectRecord(sidecar, cwd, { port: record.port, editing: on });
216
825
  }
826
+ /**
827
+ * Keep this project's stored replay switch ({@link ReplayPolicy.enabled}) in
828
+ * line with what the person asked for, so the recorder never steers — or runs
829
+ * steps unattended — in a project that did not opt in. It used to default to
830
+ * on everywhere: `bir setup --no-replay` printed "replay not installed" while
831
+ * the recorder replayed matched prompts anyway, and a direct plan named a
832
+ * delivery tool the session did not have.
833
+ *
834
+ * refused → off, unless it is off already.
835
+ * unasked → off, but only while the switch was never set and no scenario
836
+ * server is here (a project that has one keeps it — a plain
837
+ * `bir install --server x` must not take replay away — and
838
+ * `bir replay on|off` is the person's to keep).
839
+ * wanted → on again, but only when an install turned it off; a
840
+ * `bir replay off` stays off.
841
+ *
842
+ * Returns what changed, or undefined. The caller writes the sidecar.
843
+ */
844
+ export function settleReplaySwitch(sidecar, cwd, intent, scenarioServerHere) {
845
+ const record = projectRecord(sidecar, cwd);
846
+ if (!record)
847
+ return undefined;
848
+ const policy = { ...(record.replay ?? {}) };
849
+ if (intent === "wanted") {
850
+ if (policy.enabled !== false || policy.disabledBy !== "install")
851
+ return undefined;
852
+ delete policy.enabled;
853
+ delete policy.disabledBy;
854
+ setProjectRecord(sidecar, cwd, { port: record.port, replay: Object.keys(policy).length > 0 ? policy : undefined });
855
+ return "on";
856
+ }
857
+ if (policy.enabled === false)
858
+ return undefined;
859
+ if (intent === "unasked" && (policy.enabled !== undefined || scenarioServerHere))
860
+ return undefined;
861
+ setProjectRecord(sidecar, cwd, { port: record.port, replay: { ...policy, enabled: false, disabledBy: "install" } });
862
+ return "off";
863
+ }
217
864
  //# sourceMappingURL=generate.js.map