@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/bin/bir-proxy.js
CHANGED
|
@@ -15,25 +15,47 @@
|
|
|
15
15
|
* --no-correlation never relax tool schemas; join on argument fingerprints
|
|
16
16
|
* --lazy create the upstream on the first host message, not at
|
|
17
17
|
* startup (see upstream/lazy-client.ts for the caveat)
|
|
18
|
-
* --restart restart a dead stdio upstream once
|
|
19
|
-
*
|
|
18
|
+
* --restart restart a dead stdio upstream once (in-flight calls
|
|
19
|
+
* are answered; the host's handshake is replayed to it)
|
|
20
|
+
* --discovery-ms <n> how long to look for a control server (default 10000)
|
|
20
21
|
*
|
|
21
22
|
* Environment:
|
|
22
23
|
* BIR_CONTROL_URL / BIR_CONTROL_TOKEN control server, bypassing discovery
|
|
23
24
|
* BIR_AUTH_URL BaseIn service, for Tier 2 recording
|
|
25
|
+
* BIR_UPSTREAM_HEADERS a remote upstream's headers, as a JSON
|
|
26
|
+
* object of name → value; `--header`
|
|
27
|
+
* wins on a clash. Read once, then
|
|
28
|
+
* removed from the environment: no
|
|
29
|
+
* process started from here sees it
|
|
30
|
+
* BIR_OFF=1 the kill switch (also `bir off`): run
|
|
31
|
+
* the server as if it were not wrapped
|
|
32
|
+
* BIR_PROXY_STAY_UP=1 keep answering after the upstream
|
|
33
|
+
* dies, instead of exiting with it
|
|
24
34
|
* BIR_VERBOSE=1 per-message detail on stderr
|
|
25
35
|
*
|
|
26
36
|
* STDOUT IS SACRED. It is the host's JSON-RPC stream; every log line, every
|
|
27
37
|
* upstream stderr line and every diagnostic goes to stderr instead. A single
|
|
28
38
|
* stray byte here corrupts the session and surfaces somewhere else entirely.
|
|
39
|
+
*
|
|
40
|
+
* STDERR IS NOT FREE EITHER. Claude Code files every stderr line of an MCP
|
|
41
|
+
* server as a "Server stderr" error in its MCP logs, so routine lines (started,
|
|
42
|
+
* spawned, bound, stopped) are `BIR_VERBOSE`-only; what is left on stderr is a
|
|
43
|
+
* decision or a failure somebody may need to read.
|
|
29
44
|
*/
|
|
30
|
-
import {
|
|
45
|
+
import { spawn } from "node:child_process";
|
|
46
|
+
import { constants as osConstants } from "node:os";
|
|
47
|
+
import { StdioUpstreamClient, resolveCommand } from "../upstream/stdio-client.js";
|
|
31
48
|
import { HttpUpstreamClient } from "../upstream/http-client.js";
|
|
32
49
|
import { LazyUpstreamClient } from "../upstream/lazy-client.js";
|
|
33
50
|
import { Relay } from "../proxy/relay.js";
|
|
34
|
-
import { RecordingInterceptor } from "../proxy/intercept.js";
|
|
51
|
+
import { RecordingInterceptor, StripCallIdInterceptor } from "../proxy/intercept.js";
|
|
35
52
|
import { ProxySession } from "../proxy/session.js";
|
|
36
|
-
import {
|
|
53
|
+
import { mergeHeaders, parseUpstreamHeaders, sendableHeaders, UPSTREAM_HEADERS_ENV } from "../upstream/headers.js";
|
|
54
|
+
import { exemptLoopbackFromProxy } from "../config/generate.js";
|
|
55
|
+
import { offSource } from "../util/off-switch.js";
|
|
56
|
+
import { logLine, logDetail, logOnce, errText } from "../util/log.js";
|
|
57
|
+
/** Hard ceiling on shutdown: the recorder's last chance, and the upstream's. */
|
|
58
|
+
const SHUTDOWN_BUDGET_MS = 3_000;
|
|
37
59
|
function usage(message) {
|
|
38
60
|
if (message)
|
|
39
61
|
process.stderr.write(`[bir] ${message}\n`);
|
|
@@ -79,8 +101,10 @@ export function parseArgs(argv) {
|
|
|
79
101
|
case "--header": {
|
|
80
102
|
const raw = argv[++i] ?? "";
|
|
81
103
|
const sep = raw.indexOf(":");
|
|
104
|
+
// Never echo `raw`: a header is usually a credential, and a malformed
|
|
105
|
+
// one (`Authorization Bearer …`, no colon) is still the whole token.
|
|
82
106
|
if (sep <= 0)
|
|
83
|
-
usage(
|
|
107
|
+
usage("bad --header (expected 'Name: value')");
|
|
84
108
|
out.headers[raw.slice(0, sep).trim()] = raw.slice(sep + 1).trim();
|
|
85
109
|
break;
|
|
86
110
|
}
|
|
@@ -113,7 +137,49 @@ export function parseArgs(argv) {
|
|
|
113
137
|
usage("give either --url or -- <command>");
|
|
114
138
|
return out;
|
|
115
139
|
}
|
|
116
|
-
|
|
140
|
+
/**
|
|
141
|
+
* `BIR_UPSTREAM_HEADERS` merged under the `--header` arguments (which win on a
|
|
142
|
+
* clash, so an older entry keeps meaning what it meant), and the variable
|
|
143
|
+
* removed from this process's environment either way: a stdio upstream — the
|
|
144
|
+
* user's own server — and anything else started from here must never inherit
|
|
145
|
+
* a remote server's credentials. A variable that is not usable is said once,
|
|
146
|
+
* by its shape; neither it nor any value is ever logged.
|
|
147
|
+
*/
|
|
148
|
+
function takeUpstreamHeaders(args) {
|
|
149
|
+
const raw = process.env[UPSTREAM_HEADERS_ENV];
|
|
150
|
+
delete process.env[UPSTREAM_HEADERS_ENV];
|
|
151
|
+
if (!args.url)
|
|
152
|
+
return args.headers;
|
|
153
|
+
const fromEnv = parseUpstreamHeaders(raw);
|
|
154
|
+
if (fromEnv.problem) {
|
|
155
|
+
logOnce("upstream.headers_invalid:env", "upstream.headers_invalid", {
|
|
156
|
+
server: args.serverName,
|
|
157
|
+
source: UPSTREAM_HEADERS_ENV,
|
|
158
|
+
why: fromEnv.problem,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const fromArgs = sendableHeaders(args.headers);
|
|
162
|
+
if (fromArgs.problem) {
|
|
163
|
+
logOnce("upstream.headers_invalid:args", "upstream.headers_invalid", {
|
|
164
|
+
server: args.serverName,
|
|
165
|
+
source: "--header",
|
|
166
|
+
why: fromArgs.problem,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return mergeHeaders(fromEnv.headers, fromArgs.headers);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The proxy-variable changes {@link exemptLoopbackFromProxy} made to this
|
|
173
|
+
* process, undone for the user's own server: its networking is not ours to
|
|
174
|
+
* change. Empty when nothing was changed.
|
|
175
|
+
*/
|
|
176
|
+
function environmentAsStarted(before, changed) {
|
|
177
|
+
if (!changed)
|
|
178
|
+
return {};
|
|
179
|
+
// On Windows the two spellings are one variable.
|
|
180
|
+
return process.platform === "win32" ? { NO_PROXY: before.NO_PROXY } : { NO_PROXY: before.NO_PROXY, no_proxy: before.no_proxy };
|
|
181
|
+
}
|
|
182
|
+
function buildUpstream(args, stdioEnv) {
|
|
117
183
|
const create = () => {
|
|
118
184
|
if (args.url) {
|
|
119
185
|
return new HttpUpstreamClient({
|
|
@@ -126,6 +192,7 @@ function buildUpstream(args) {
|
|
|
126
192
|
return new StdioUpstreamClient({
|
|
127
193
|
command: args.command,
|
|
128
194
|
args: args.commandArgs,
|
|
195
|
+
env: stdioEnv,
|
|
129
196
|
label: args.serverName,
|
|
130
197
|
restartDelayMs: args.restart ? 500 : 0,
|
|
131
198
|
});
|
|
@@ -134,60 +201,187 @@ function buildUpstream(args) {
|
|
|
134
201
|
// header comment in upstream/lazy-client.ts for why that is the honest limit.
|
|
135
202
|
return args.lazy ? new LazyUpstreamClient(create) : create();
|
|
136
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* The kill switch, for a stdio upstream: run the real server as the host would
|
|
206
|
+
* have run it — same stdin, same stdout, same stderr — and leave with its exit
|
|
207
|
+
* status. No relay, no parsing, no recording, no control server; the only thing
|
|
208
|
+
* left of the runner is one idle parent process.
|
|
209
|
+
*/
|
|
210
|
+
function runAsPipe(args) {
|
|
211
|
+
const resolved = resolveCommand(args.command, args.commandArgs, process.env);
|
|
212
|
+
let child;
|
|
213
|
+
try {
|
|
214
|
+
child = spawn(resolved.command, resolved.args, {
|
|
215
|
+
stdio: "inherit",
|
|
216
|
+
shell: false,
|
|
217
|
+
windowsHide: true,
|
|
218
|
+
windowsVerbatimArguments: resolved.windowsVerbatimArguments,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
logLine("upstream.exited", { server: args.serverName, error: errText(err) });
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
child.once("error", (err) => {
|
|
226
|
+
logLine("upstream.exited", { server: args.serverName, error: errText(err) });
|
|
227
|
+
process.exit(1);
|
|
228
|
+
});
|
|
229
|
+
child.once("exit", (code, signal) => {
|
|
230
|
+
if (signal) {
|
|
231
|
+
const n = osConstants.signals[signal] ?? 0;
|
|
232
|
+
process.exit(128 + n);
|
|
233
|
+
}
|
|
234
|
+
process.exit(code ?? 1);
|
|
235
|
+
});
|
|
236
|
+
// A signal meant for the server reaches it, and we leave when it does.
|
|
237
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
238
|
+
try {
|
|
239
|
+
process.on(sig, () => {
|
|
240
|
+
try {
|
|
241
|
+
child.kill(sig);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
/* already gone */
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
/* a signal this platform does not have */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Resolve once everything already written to stdout has been handed off, or after `ms`. */
|
|
254
|
+
function stdoutDrained(ms) {
|
|
255
|
+
return new Promise((resolve) => {
|
|
256
|
+
const timer = setTimeout(resolve, ms);
|
|
257
|
+
timer.unref?.();
|
|
258
|
+
try {
|
|
259
|
+
process.stdout.write("", () => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
resolve();
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
resolve();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
137
270
|
async function main() {
|
|
138
271
|
const args = parseArgs(process.argv.slice(2));
|
|
272
|
+
const cwd = process.cwd();
|
|
273
|
+
// First, before anything can be spawned: the variable leaves the
|
|
274
|
+
// environment even on the kill-switch path below.
|
|
275
|
+
args.headers = takeUpstreamHeaders(args);
|
|
276
|
+
// THE KILL SWITCH (`bir off`, BIR_OFF=1). Decided once, at startup: a
|
|
277
|
+
// server already running keeps the path it started with, and the next
|
|
278
|
+
// session picks up the flip.
|
|
279
|
+
const off = offSource(cwd);
|
|
280
|
+
if (off && !args.url) {
|
|
281
|
+
logDetail("proxy.off", { server: args.serverName, source: off, why: "running the server unwrapped" });
|
|
282
|
+
runAsPipe(args);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// Started with --use-env-proxy (entries carry it behind a proxy) in an
|
|
286
|
+
// environment whose NO_PROXY does not name 127.0.0.1: without this, every
|
|
287
|
+
// call to the control server — register, step reports, the replay work
|
|
288
|
+
// loop — went to the proxy, failed, and the proxy fell to Tier 2 or to
|
|
289
|
+
// nothing. Before any loopback fetch, as `bir` does. The pure pipe above
|
|
290
|
+
// runs no fetch and hands the user's server the environment untouched.
|
|
291
|
+
const noProxyBefore = { NO_PROXY: process.env.NO_PROXY, no_proxy: process.env.no_proxy };
|
|
292
|
+
const exempted = exemptLoopbackFromProxy();
|
|
139
293
|
// The upstream is built first so the session can be handed it: on a matched
|
|
140
294
|
// turn the control server runs the scenario's steps on this very connection
|
|
141
295
|
// (docs/calculatedReplay.md §16.2), which is what makes replay cost nothing.
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
296
|
+
const asStarted = environmentAsStarted(noProxyBefore, exempted);
|
|
297
|
+
const upstream = buildUpstream(args, asStarted);
|
|
298
|
+
// Off, with a remote upstream (there is no process to hand stdio to): relay
|
|
299
|
+
// only — no recording, no control server, no schema change. The call id is
|
|
300
|
+
// still stripped (StripCallIdInterceptor): `bir on` mid-session can make a
|
|
301
|
+
// control server inject one into a call routed here.
|
|
302
|
+
const session = off
|
|
303
|
+
? undefined
|
|
304
|
+
: new ProxySession({
|
|
305
|
+
serverName: args.serverName,
|
|
306
|
+
cwd,
|
|
307
|
+
standalone: args.standalone,
|
|
308
|
+
noCorrelation: args.noCorrelation,
|
|
309
|
+
discoveryWindowMs: args.discoveryMs,
|
|
310
|
+
upstream,
|
|
311
|
+
// A recorder that dies under this proxy is started again with the
|
|
312
|
+
// environment the host gave us (proxy/revive.ts) — without the
|
|
313
|
+
// loopback exemption made above, and without BIR_UPSTREAM_HEADERS,
|
|
314
|
+
// which left this process's environment at the top of main().
|
|
315
|
+
revive: { env: { ...process.env, ...asStarted } },
|
|
316
|
+
});
|
|
317
|
+
if (off)
|
|
318
|
+
logDetail("proxy.off", { server: args.serverName, source: off, why: "relaying without recording" });
|
|
151
319
|
// Negotiation runs in the background: relaying must start now, because the
|
|
152
320
|
// host may already be writing `initialize` at us.
|
|
153
|
-
session
|
|
321
|
+
session?.start();
|
|
154
322
|
upstream.ready.catch((err) => {
|
|
155
|
-
// §10: the upstream failing to start is one broken server, not a broken
|
|
156
|
-
// session. The relay stays up and answers requests with a tool-level error.
|
|
157
323
|
logLine("upstream.unavailable", { server: args.serverName, error: errText(err) });
|
|
158
324
|
});
|
|
325
|
+
// Declared before anything that can call `shutdown`.
|
|
326
|
+
let shuttingDown = false;
|
|
159
327
|
const relay = new Relay({
|
|
160
328
|
upstream,
|
|
161
329
|
hostIn: process.stdin,
|
|
162
330
|
hostOut: process.stdout,
|
|
163
|
-
interceptor:
|
|
331
|
+
interceptor: session
|
|
332
|
+
? new RecordingInterceptor({ serverName: args.serverName, session })
|
|
333
|
+
: new StripCallIdInterceptor(),
|
|
164
334
|
label: args.serverName,
|
|
165
335
|
onHostDisconnect: () => void shutdown(0),
|
|
166
336
|
});
|
|
167
337
|
relay.start();
|
|
168
|
-
|
|
338
|
+
// An upstream that is gone for good takes the proxy with it — after the
|
|
339
|
+
// relay (registered first) has answered whatever was in flight. The host
|
|
340
|
+
// then sees what it would have seen unwrapped: the server disconnected, shown
|
|
341
|
+
// as failed in /mcp with a reconnect offered, its tools gone from the model's
|
|
342
|
+
// list. Staying up instead left the tools listed and every call answering an
|
|
343
|
+
// error, turn after turn, with nothing to tell the user to reconnect.
|
|
344
|
+
upstream.onClose((info) => {
|
|
345
|
+
if (shuttingDown || process.env.BIR_PROXY_STAY_UP === "1")
|
|
346
|
+
return;
|
|
347
|
+
logLine("proxy.upstream_gone", {
|
|
348
|
+
server: args.serverName,
|
|
349
|
+
code: info.code,
|
|
350
|
+
signal: info.signal,
|
|
351
|
+
why: "exiting with it, as the server would have unwrapped",
|
|
352
|
+
});
|
|
353
|
+
// Its own exit status where it had one, as the host would have seen it.
|
|
354
|
+
void shutdown(typeof info.code === "number" ? info.code : 1);
|
|
355
|
+
});
|
|
356
|
+
logDetail("proxy.started", {
|
|
169
357
|
server: args.serverName,
|
|
170
358
|
upstream: args.url ?? args.command,
|
|
171
359
|
transport: args.url ? args.transport : "stdio",
|
|
172
360
|
pid: process.pid,
|
|
173
361
|
});
|
|
174
|
-
let shuttingDown = false;
|
|
175
362
|
async function shutdown(code) {
|
|
176
363
|
if (shuttingDown)
|
|
177
364
|
return;
|
|
178
365
|
shuttingDown = true;
|
|
179
366
|
relay.stop();
|
|
180
|
-
upstream
|
|
181
|
-
//
|
|
182
|
-
//
|
|
367
|
+
// The upstream gets its polite close (stdin EOF, a moment to exit, then a
|
|
368
|
+
// tree kill), and the recorder its last chance — side by side, and never
|
|
369
|
+
// longer than the budget: a run that loses its final steps is a worse
|
|
370
|
+
// artefact than a proxy that takes an extra moment to exit, but a proxy
|
|
371
|
+
// that lingers is worse than both.
|
|
372
|
+
const closing = Promise.allSettled([
|
|
373
|
+
session?.close(),
|
|
374
|
+
upstream.shutdown ? upstream.shutdown() : Promise.resolve(upstream.close()),
|
|
375
|
+
]);
|
|
183
376
|
await Promise.race([
|
|
184
|
-
|
|
377
|
+
closing,
|
|
185
378
|
new Promise((resolve) => {
|
|
186
|
-
const t = setTimeout(resolve,
|
|
379
|
+
const t = setTimeout(resolve, SHUTDOWN_BUDGET_MS);
|
|
187
380
|
t.unref?.();
|
|
188
381
|
}),
|
|
189
382
|
]);
|
|
190
|
-
|
|
383
|
+
logDetail("proxy.stopped", { server: args.serverName, tier: session?.tier ?? "off" });
|
|
384
|
+
await stdoutDrained(500);
|
|
191
385
|
process.exit(code);
|
|
192
386
|
}
|
|
193
387
|
process.on("SIGINT", () => void shutdown(0));
|
|
@@ -18,31 +18,71 @@
|
|
|
18
18
|
* control server exactly as a proxy does — `~/.baseinstrunner/control/<key>.json`,
|
|
19
19
|
* mode 0600, bearer token — and forwards one call.
|
|
20
20
|
*
|
|
21
|
+
* WHOSE CALL. A stdio MCP server is never told its session, and the control
|
|
22
|
+
* server runs a plan only for the session whose `PreToolUse` for
|
|
23
|
+
* `mcp__bir__run_scenario` it saw. Claude Code names the tool use in every
|
|
24
|
+
* MCP call's `params._meta["claudecode/toolUseId"]` — the same id that hook
|
|
25
|
+
* carried as `tool_use_id` — so the request forwards it as `toolUseId` and the
|
|
26
|
+
* control server matches it exactly instead of inferring the session. A host
|
|
27
|
+
* that names nothing sends an empty body, as before.
|
|
28
|
+
*
|
|
29
|
+
* IT RUNS IN EVERY SESSION, SO IT MUST NEVER BE WHY ONE GOES WRONG. Startup and
|
|
30
|
+
* `tools/list` are answered from local files alone (the hello to the recorder
|
|
31
|
+
* that follows them is sent after the answer, and never awaited). `run_scenario` never answers
|
|
32
|
+
* with an error or with the runner's internals ("fetch failed", "not_ready",
|
|
33
|
+
* "HTTP 404"): a turn with nothing to run is told so in one neutral sentence,
|
|
34
|
+
* and a replay that cannot be delivered becomes "do the task normally" — the
|
|
35
|
+
* real reason goes to the audit log, where `bir doctor` and a person look. A
|
|
36
|
+
* model called it on its own in the first session of every freshly installed
|
|
37
|
+
* project; a red error under a tool the user never asked about, five seconds
|
|
38
|
+
* late, was the result. `BIR_SCENARIO_DEBUG=1` brings the raw reasons back.
|
|
39
|
+
*
|
|
21
40
|
* THE SCENARIO TOOLS RUN `bir` ITSELF. `scenario_show`, `scenario_check` and
|
|
22
41
|
* the rest are the `bir scenario …` commands, spawned from this same package
|
|
23
42
|
* with `--json` (D1: one path, whether a person types it or the model calls
|
|
24
43
|
* it). That keeps this server credential-free — `bir` signs in the way it
|
|
25
|
-
* always does — and means the model sees exactly the answer a person would
|
|
26
|
-
*
|
|
27
|
-
* is an `isError` result that
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
44
|
+
* always does — and means the model sees exactly the answer a person would,
|
|
45
|
+
* compact (`BIR_MCP=1`) and capped in size. Logic bodies travel through
|
|
46
|
+
* temporary files, never argv, and a non-zero exit is an `isError` result that
|
|
47
|
+
* carries the output, refusal and report included.
|
|
48
|
+
*
|
|
49
|
+
* A CALL IN FLIGHT IS OWNED UNTIL IT ENDS. Each call's `bir` child, temporary
|
|
50
|
+
* directory or control-server request is tracked. When the host goes away
|
|
51
|
+
* (stdin closes, SIGINT, SIGTERM) the children are stopped and the directories
|
|
52
|
+
* removed before the process exits — `process.exit` never resumes a pending
|
|
53
|
+
* `finally`, so cleanup left to one was cleanup that did not happen, and an
|
|
54
|
+
* orphaned `bir` went on to save an edit for a session that had ended (C13). A
|
|
55
|
+
* `notifications/cancelled` (the person pressed Esc) stops that call the same
|
|
56
|
+
* way — a `run_scenario` request is aborted, and the control server stops
|
|
57
|
+
* starting steps when its request closes — and the call gets no answer, as MCP
|
|
58
|
+
* asks. A request the service already received may still complete there;
|
|
59
|
+
* nothing here can call it back, and `bir scenario edits` shows it.
|
|
60
|
+
*
|
|
61
|
+
* WHICH TOOLS ARE OFFERED. `run_scenario`, always — unless the runner is
|
|
62
|
+
* switched off (`bir off`), when nothing is: an inert runner adds nothing to
|
|
63
|
+
* the model's context, and a call from a list taken before the switch starts
|
|
64
|
+
* nothing and asks nobody. The reading tools come with `bir scenario editing on`
|
|
65
|
+
* (or `BIR_SCENARIO_TOOLS=read` in this server's environment): their
|
|
66
|
+
* definitions ride along with every request of every session, and only
|
|
67
|
+
* somebody working on a scenario ever calls them. The tools that CHANGE a
|
|
68
|
+
* scenario are offered only where a person ran `bir scenario editing on` (D2):
|
|
69
|
+
* this server runs in every session of every installed project, a fleet
|
|
70
|
+
* included, and a plan must not change because some agent there decided it
|
|
71
|
+
* should. The switch is read when the host lists the tools, and again on every
|
|
72
|
+
* call, so a call to a tool that was never offered — or was offered before the
|
|
73
|
+
* switch went off — is refused rather than run. Turning the runner off or on
|
|
74
|
+
* mid-session tells the host its list changed (`notifications/tools/list_changed`).
|
|
75
|
+
*
|
|
76
|
+
* IT SAYS IT IS HERE, AND IT BRINGS THE RECORDER BACK (scenario-liveness.ts).
|
|
77
|
+
* A configured `bir` server is not a connected one, so the recorder announces
|
|
78
|
+
* a direct plan — "call run_scenario first" — only on this server's word: a
|
|
79
|
+
* `POST /scenario/hello` (pid, version, how many tools, off or not) on
|
|
80
|
+
* `initialize`, on `tools/list`, when the off switch flips, to each new
|
|
81
|
+
* recorder, and every 5 min. And this is the one long-lived process in every
|
|
82
|
+
* session of an installed project, so when the recorder's pid is dead and its
|
|
83
|
+
* address refuses, it starts `bir-hooks revive` — detached, rate-limited —
|
|
84
|
+
* rather than leave every later hook to fail until the next session start.
|
|
85
|
+
* Neither is ever awaited on the MCP path.
|
|
46
86
|
*
|
|
47
87
|
* STDOUT IS SACRED, as in every MCP server here: it is the host's JSON-RPC
|
|
48
88
|
* stream, and every log line goes to stderr instead.
|