@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.
- package/README.md +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2787 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
package/dist/config/generate.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
43
|
-
|
|
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 (
|
|
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
|
|
82
|
-
? { command: process.execPath, args: [...(opts.nodeFlags ?? []), opts.proxyPath
|
|
83
|
-
:
|
|
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
|
|
94
|
-
? { command: process.execPath, args: [...(opts.nodeFlags ?? []), opts.scenarioPath] }
|
|
95
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|