@basein/runner 0.2.12 → 0.3.0
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 +2786 -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/proxy/session.js
CHANGED
|
@@ -9,30 +9,56 @@
|
|
|
9
9
|
* the run; this proxy only reports steps. Built-ins and MCP land in one
|
|
10
10
|
* ordered stream, with the prompt and the final answer.
|
|
11
11
|
*
|
|
12
|
-
* **Tier 2 — Standalone.** No control server inside the discovery window
|
|
13
|
-
* proxy owns a run of its own and
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `metadata.tier` so nothing downstream
|
|
12
|
+
* **Tier 2 — Standalone.** No control server inside the discovery window, in
|
|
13
|
+
* a project that asked to be recorded. The proxy owns a run of its own and
|
|
14
|
+
* records MCP calls only — no prompt, no final answer, no built-in steps.
|
|
15
|
+
* That is not a degraded bug; it is the honest ceiling of what an MCP proxy
|
|
16
|
+
* can observe, and the run says so in `metadata.tier` so nothing downstream
|
|
17
|
+
* mistakes it for a complete trace.
|
|
18
|
+
*
|
|
19
|
+
* **Passthrough.** No control server, in a project that never opted in. A
|
|
20
|
+
* user-scope server is wrapped once, by `bir install` in *one* project, but
|
|
21
|
+
* Claude Code starts it in *every* project — and Tier 2 there meant uploading
|
|
22
|
+
* that project's tool arguments and results to the service without anyone
|
|
23
|
+
* having asked. So a hooks-wired user-scope entry records only where its
|
|
24
|
+
* directory was set up, or a recorder is running for it; anywhere else it
|
|
25
|
+
* relays and records nothing. Every other entry — another client's config
|
|
26
|
+
* file, a project-scope entry, a hand-pasted `bir wrap` — keeps Tier 2 (see
|
|
27
|
+
* `optedIn`).
|
|
17
28
|
*
|
|
18
29
|
* THE DISCOVERY WINDOW EXISTS BECAUSE OF A RACE: the host may spawn MCP servers
|
|
19
30
|
* before `SessionStart` fires, and the ordering between the two is not
|
|
20
31
|
* guaranteed. So steps are **buffered in memory** while the search runs, and
|
|
21
32
|
* flushed into whichever owner wins. Nothing is lost to the race, and nothing
|
|
22
33
|
* blocks: relaying never waits on this.
|
|
34
|
+
*
|
|
35
|
+
* AND IT IS NOT A ONE-WAY DOOR. A recorder that starts late, or is restarted
|
|
36
|
+
* mid-session (an upgrade), is found again: while not bound, the proxy looks
|
|
37
|
+
* for it every {@link REBIND_INTERVAL_MS} and binds when it answers. Losing it
|
|
38
|
+
* costs one downgrade and at most one standalone run per proxy, however many
|
|
39
|
+
* reports were queued when it went. A recorder that *died* under a bound proxy
|
|
40
|
+
* — its pid gone, its port refusing — is started again from here, in the
|
|
41
|
+
* background (proxy/revive.ts): until it is back, every hook of the session
|
|
42
|
+
* fails in front of the user.
|
|
23
43
|
*/
|
|
24
44
|
import { randomUUID } from "node:crypto";
|
|
25
45
|
import { hostname } from "node:os";
|
|
46
|
+
import { readFile } from "node:fs/promises";
|
|
47
|
+
import { resolve as resolvePath } from "node:path";
|
|
26
48
|
import { ControlClient } from "../control/client.js";
|
|
27
|
-
import {
|
|
49
|
+
import { readDiscovery } from "../control/discovery.js";
|
|
28
50
|
import { qualifyToolName } from "../control/correlation.js";
|
|
29
|
-
import {
|
|
51
|
+
import { projectRecord } from "../config/generate.js";
|
|
52
|
+
import { installedPath, normalizePath } from "../control/paths.js";
|
|
53
|
+
import { authenticate, loadCredentials, normalizeAuthUrl, resolveAuthUrl } from "../auth/client.js";
|
|
30
54
|
import { NullRecorder } from "../record/recorder.js";
|
|
31
55
|
import { RemoteRecorder } from "../record/remote-recorder.js";
|
|
32
56
|
import { StepQueue } from "../record/queue.js";
|
|
33
57
|
import { StepIndexAllocator } from "../control/ordering.js";
|
|
34
58
|
import { serializeCapped } from "../record/truncate.js";
|
|
35
59
|
import { redact } from "../record/redact.js";
|
|
60
|
+
import { isRunnerOff } from "../util/off-switch.js";
|
|
61
|
+
import { RecorderReviver } from "./revive.js";
|
|
36
62
|
import { logLine, logDetail, errText } from "../util/log.js";
|
|
37
63
|
import { packageVersion } from "../util/version.js";
|
|
38
64
|
/** How long a proxy waits for a control server before falling to Tier 2. */
|
|
@@ -44,6 +70,151 @@ import { packageVersion } from "../util/version.js";
|
|
|
44
70
|
* dropped, and a host session is never waiting on this.
|
|
45
71
|
*/
|
|
46
72
|
export const DISCOVERY_WINDOW_MS = 10_000;
|
|
73
|
+
/** While not bound, how often to look for a control server that came (back) up. */
|
|
74
|
+
export const REBIND_INTERVAL_MS = 15_000;
|
|
75
|
+
/**
|
|
76
|
+
* Step reports waiting to be sent. Tier 1 sends go to loopback and take
|
|
77
|
+
* milliseconds; Tier 2 sends wait on the service (one step at a time, see
|
|
78
|
+
* `dispatch`). Either way a backlog this deep means the far end is not
|
|
79
|
+
* keeping up, and the oldest is dropped — and the run flagged lossy — rather
|
|
80
|
+
* than growing inside the user's MCP server process tree.
|
|
81
|
+
*/
|
|
82
|
+
export const STEP_BACKLOG = 250;
|
|
83
|
+
/** Steps held while the tier is still being negotiated. */
|
|
84
|
+
const DISCOVERY_BUFFER = 1000;
|
|
85
|
+
/**
|
|
86
|
+
* The replay work loop's ceiling between polls of a control server that is
|
|
87
|
+
* reachable but not holding them — replay is off there, or it is an older one
|
|
88
|
+
* that answers at once. One loopback request every 30 s is nothing, and it is
|
|
89
|
+
* what lets a proxy notice that replay was turned back on (a restart applies
|
|
90
|
+
* that) without being told.
|
|
91
|
+
*/
|
|
92
|
+
const MAX_POLL_BACKOFF_MS = 30_000;
|
|
93
|
+
/**
|
|
94
|
+
* The ceiling while the control server cannot be reached at all — most often
|
|
95
|
+
* because it is restarting. Lower than the idle ceiling on purpose: a
|
|
96
|
+
* restarted recorder routes nothing to this proxy until it polls, so every
|
|
97
|
+
* second of backoff past its return is a replay step that falls back to a
|
|
98
|
+
* recorded output. A refused loopback connect every 5 s costs nothing.
|
|
99
|
+
*/
|
|
100
|
+
const MAX_UNREACHABLE_BACKOFF_MS = 5_000;
|
|
101
|
+
/**
|
|
102
|
+
* Spread over which proxies that all hit the end of the discovery window at
|
|
103
|
+
* once (one per wrapped server) start their Tier 2 recorder, so they do not
|
|
104
|
+
* all refresh the same rotating token in the same instant.
|
|
105
|
+
*/
|
|
106
|
+
const STANDALONE_JITTER_MS = 2_000;
|
|
107
|
+
/**
|
|
108
|
+
* All of {@link ProxySession.close}: negotiation, the step queue and the run's
|
|
109
|
+
* final flush. Under bir-proxy's hard 3 s shutdown budget, with room for the
|
|
110
|
+
* process to write its last bytes and exit.
|
|
111
|
+
*/
|
|
112
|
+
const CLOSE_BUDGET_MS = 2_500;
|
|
113
|
+
/** The final flush is always given at least this, even when the queue used the budget up. */
|
|
114
|
+
const MIN_FINAL_FLUSH_MS = 250;
|
|
115
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
116
|
+
const t = setTimeout(resolve, ms);
|
|
117
|
+
t.unref?.();
|
|
118
|
+
});
|
|
119
|
+
/** Settles when `promise` does or after `ms`, whichever is first; never rejects. */
|
|
120
|
+
function settledWithin(promise, ms) {
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
const timer = setTimeout(resolve, ms);
|
|
123
|
+
timer.unref?.();
|
|
124
|
+
promise.then(() => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
resolve();
|
|
127
|
+
}, () => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
resolve();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/** Loopback: never behind a TLS front that redirects, and not readable on the network. */
|
|
134
|
+
function isLoopbackHost(hostname) {
|
|
135
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
136
|
+
return host === "localhost" || host.endsWith(".localhost") || host === "::1" || /^127\./.test(host);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The service address a Tier 2 recorder should use for `configured`, the way
|
|
140
|
+
* the recorder (control/recorder-keeper.ts) and `bir login` bind sessions:
|
|
141
|
+
*
|
|
142
|
+
* - A saved session issued by the https form of a configured `http://host`
|
|
143
|
+
* is the same service, already upgraded — use its address. No request.
|
|
144
|
+
* - Any other plain-http address off this machine is checked once for a
|
|
145
|
+
* same-host https redirect ({@link normalizeAuthUrl}, bounded): following
|
|
146
|
+
* one would strip the bearer token from every request. Not when signed
|
|
147
|
+
* out: there is no token to send.
|
|
148
|
+
* - Everything else is used as configured.
|
|
149
|
+
*/
|
|
150
|
+
export async function normalizedServiceUrl(configured, fetchImpl = fetch) {
|
|
151
|
+
const base = configured.replace(/\/+$/, "");
|
|
152
|
+
let url;
|
|
153
|
+
try {
|
|
154
|
+
url = new URL(base);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return base;
|
|
158
|
+
}
|
|
159
|
+
if (url.protocol !== "http:")
|
|
160
|
+
return base;
|
|
161
|
+
const cached = loadCredentials();
|
|
162
|
+
const issuer = cached?.authUrl?.replace(/\/+$/, "");
|
|
163
|
+
if (issuer) {
|
|
164
|
+
try {
|
|
165
|
+
const to = new URL(issuer);
|
|
166
|
+
if (to.protocol === "https:" && to.host === url.host)
|
|
167
|
+
return issuer;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
/* not a URL: not this service */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (isLoopbackHost(url.hostname))
|
|
174
|
+
return base;
|
|
175
|
+
// Signed out: there is no token to protect, and authenticate() is about to
|
|
176
|
+
// say "no session" whatever the address — not worth a request per proxy.
|
|
177
|
+
if (!cached && process.env.BIR_AUTH_DISABLE !== "1")
|
|
178
|
+
return base;
|
|
179
|
+
return normalizeAuthUrl(base, fetchImpl);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Read the install sidecar's facts for `cwd` and `serverName`.
|
|
183
|
+
*
|
|
184
|
+
* Read asynchronously, and only when a decision needs it: the sidecar carries
|
|
185
|
+
* byte-exact backups of the files install edited — `~/.claude.json` among
|
|
186
|
+
* them, which can run to megabytes — and a proxy must not spend its start-up
|
|
187
|
+
* parsing that in front of the host's `initialize`. Unreadable → no facts,
|
|
188
|
+
* which every caller treats as "not installed, not written by an install".
|
|
189
|
+
*/
|
|
190
|
+
export async function readInstallFacts(cwd, serverName) {
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(await readFile(installedPath(), "utf8"));
|
|
193
|
+
const sidecar = { version: 1, ...parsed, servers: parsed.servers ?? {} };
|
|
194
|
+
const key = normalizePath(resolvePath(cwd));
|
|
195
|
+
let installedHere = projectRecord(sidecar, cwd) !== undefined;
|
|
196
|
+
const scopes = [];
|
|
197
|
+
for (const [recordKey, r] of Object.entries(sidecar.servers)) {
|
|
198
|
+
if (!r || typeof r !== "object")
|
|
199
|
+
continue;
|
|
200
|
+
if (!installedHere && typeof r.cwd === "string" && normalizePath(resolvePath(r.cwd)) === key) {
|
|
201
|
+
installedHere = true;
|
|
202
|
+
}
|
|
203
|
+
// Records are keyed `<cwd>::<serverName>` (generate.ts `sidecarKey`).
|
|
204
|
+
if (serverName !== undefined && recordKey.endsWith(`::${serverName}`)) {
|
|
205
|
+
scopes.push(typeof r.scope === "string" ? r.scope : "unknown");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { installedHere, scopes };
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return { installedHere: false, scopes: [] };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Whether `bir install` set this directory up for recording. */
|
|
215
|
+
export async function projectInstalledHere(cwd) {
|
|
216
|
+
return (await readInstallFacts(cwd)).installedHere;
|
|
217
|
+
}
|
|
47
218
|
export class ProxySession {
|
|
48
219
|
serverName;
|
|
49
220
|
/** This proxy process, as the control server tells proxies apart (replay/executor.ts). */
|
|
@@ -51,11 +222,16 @@ export class ProxySession {
|
|
|
51
222
|
opts;
|
|
52
223
|
tierValue = "pending";
|
|
53
224
|
control;
|
|
54
|
-
/**
|
|
225
|
+
/** Where `control` lives, for the log and to reuse a client across re-binds. */
|
|
226
|
+
controlKey;
|
|
227
|
+
/** The last control server this proxy was bound to; the work loop polls it. */
|
|
228
|
+
pollClient;
|
|
229
|
+
/** Tier 2 only. One per proxy, however often the tier changes. */
|
|
55
230
|
recorder;
|
|
56
231
|
runId;
|
|
57
232
|
ordering = new StepIndexAllocator();
|
|
58
233
|
queue = new StepQueue({
|
|
234
|
+
capacity: STEP_BACKLOG,
|
|
59
235
|
onDrop: (dropped) => {
|
|
60
236
|
if (!this.lossyValue)
|
|
61
237
|
logLine("run.lossy", { why: "step queue overflowed", dropped });
|
|
@@ -67,13 +243,51 @@ export class ProxySession {
|
|
|
67
243
|
buffer = [];
|
|
68
244
|
lossyValue = false;
|
|
69
245
|
negotiation;
|
|
246
|
+
downgrading;
|
|
70
247
|
startedAt = Date.now();
|
|
71
248
|
/** The replay work loop, when one was started. */
|
|
72
249
|
workLoop;
|
|
250
|
+
/** What the control server last said about calculated replay. */
|
|
251
|
+
replayOn = false;
|
|
252
|
+
pollHoldMs = 25_000;
|
|
253
|
+
rebindTimer;
|
|
254
|
+
/** A discovery file for this cwd has been seen: a recorder was started here. */
|
|
255
|
+
sawDiscovery = false;
|
|
256
|
+
/**
|
|
257
|
+
* May a control server inject call ids here? Decided from the project at
|
|
258
|
+
* start-up (see {@link declaresCallId}), not from how negotiation is going.
|
|
259
|
+
*/
|
|
260
|
+
expectsBinding;
|
|
261
|
+
/** `readInstallFacts(cwd, serverName)`, started once and shared by everything that asks. */
|
|
262
|
+
installFactsPromise;
|
|
73
263
|
closing = false;
|
|
264
|
+
/**
|
|
265
|
+
* The work loop's current pause between polls. An instance field, not a
|
|
266
|
+
* local, so that {@link bind} can reset it: a proxy that finds its recorder
|
|
267
|
+
* again must poll it now, not after a backoff it grew while it was gone.
|
|
268
|
+
*/
|
|
269
|
+
pollBackoff = 0;
|
|
270
|
+
/** Ends the work loop's current pause early (see {@link pause}). */
|
|
271
|
+
wakeLoop;
|
|
272
|
+
/** A re-registration in flight, so a burst of triggers makes one request. */
|
|
273
|
+
reregistering;
|
|
274
|
+
/**
|
|
275
|
+
* Brings back a recorder that died under this proxy. Made at the first
|
|
276
|
+
* bind: only a proxy that has had a recorder asks for one back.
|
|
277
|
+
*/
|
|
278
|
+
reviver;
|
|
74
279
|
constructor(opts) {
|
|
75
280
|
this.opts = opts;
|
|
76
281
|
this.serverName = opts.serverName;
|
|
282
|
+
this.expectsBinding = !opts.standalone && !opts.noCorrelation && this.likelyBound();
|
|
283
|
+
}
|
|
284
|
+
/** The install sidecar's facts for this directory and server. Read once, off the start-up path. */
|
|
285
|
+
installFacts() {
|
|
286
|
+
this.installFactsPromise ??= readInstallFacts(this.opts.cwd, this.serverName);
|
|
287
|
+
return this.installFactsPromise;
|
|
288
|
+
}
|
|
289
|
+
async isInstalled() {
|
|
290
|
+
return (await this.installFacts()).installedHere;
|
|
77
291
|
}
|
|
78
292
|
get tier() {
|
|
79
293
|
return this.tierValue;
|
|
@@ -83,22 +297,94 @@ export class ProxySession {
|
|
|
83
297
|
}
|
|
84
298
|
/**
|
|
85
299
|
* Whether the schema-relaxation half of correlation should be applied to
|
|
86
|
-
* `tools/list` (Phase 6). Only ever true in Tier 1 with correlation enabled
|
|
87
|
-
* relaxation is visible to the model on every call, so it is never paid for
|
|
88
|
-
* unless it is actually buying a merge.
|
|
300
|
+
* `tools/list` (Phase 6). Only ever true in Tier 1 with correlation enabled.
|
|
89
301
|
*/
|
|
90
302
|
get correlationActive() {
|
|
91
303
|
return this.tierValue === "bound" && !this.opts.noCorrelation;
|
|
92
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Whether `tools/list` should declare the call-id key. True where a control
|
|
307
|
+
* server may inject one: bound, or still negotiating in a project that is set
|
|
308
|
+
* up for it — decided from the project, not from whether registration
|
|
309
|
+
* happened to finish before the host listed tools, so a warm and a cold
|
|
310
|
+
* start show the model the same schemas.
|
|
311
|
+
*/
|
|
312
|
+
get declaresCallId() {
|
|
313
|
+
if (this.opts.noCorrelation || this.opts.standalone)
|
|
314
|
+
return false;
|
|
315
|
+
if (isRunnerOff(this.opts.cwd))
|
|
316
|
+
return false;
|
|
317
|
+
return this.tierValue === "bound" || (this.tierValue === "pending" && this.expectsBinding);
|
|
318
|
+
}
|
|
319
|
+
/** False when nothing this proxy sees will be recorded (passthrough, or the runner is off). */
|
|
320
|
+
get recording() {
|
|
321
|
+
return this.tierValue !== "passthrough" && !isRunnerOff(this.opts.cwd);
|
|
322
|
+
}
|
|
93
323
|
/** Begin tier negotiation. Returns immediately; relaying never waits on it. */
|
|
94
324
|
start() {
|
|
95
325
|
if (this.negotiation)
|
|
96
326
|
return;
|
|
327
|
+
// A hooks-wired entry with no recorder up yet: whether this directory is
|
|
328
|
+
// installed decides the schemas the model is shown, so learn it early —
|
|
329
|
+
// but after relaying has started, never in front of it.
|
|
330
|
+
if (!this.expectsBinding && !this.opts.standalone && !this.opts.noCorrelation && process.env.BIR_CONTROL_URL) {
|
|
331
|
+
void this.isInstalled().then((installed) => {
|
|
332
|
+
if (installed)
|
|
333
|
+
this.expectsBinding = true;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
97
336
|
this.negotiation = this.negotiate().catch((err) => {
|
|
98
337
|
logLine("proxy.negotiation_failed", { server: this.serverName, error: errText(err) });
|
|
99
|
-
this.tierValue
|
|
338
|
+
if (this.tierValue === "pending")
|
|
339
|
+
this.becomePassthrough("negotiation failed");
|
|
100
340
|
});
|
|
101
341
|
}
|
|
342
|
+
/** Cheap first guess, at construction, of whether this proxy will end up bound. */
|
|
343
|
+
likelyBound() {
|
|
344
|
+
if (process.env.BIR_CONTROL_URL && process.env.BIR_CONTROL_TOKEN)
|
|
345
|
+
return true;
|
|
346
|
+
return readDiscovery(this.opts.cwd) !== undefined;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* May this proxy record on its own (Tier 2) when no control server answers?
|
|
350
|
+
*
|
|
351
|
+
* The one entry that must NOT is the one that leaks: a Claude Code
|
|
352
|
+
* **user-scope** server. `bir install` wraps it once, in one project, but
|
|
353
|
+
* Claude Code starts it in every project — and Tier 2 there meant uploading
|
|
354
|
+
* that project's tool arguments and results without anyone having asked.
|
|
355
|
+
* Everything else records as it always did:
|
|
356
|
+
*
|
|
357
|
+
* - An explicit SDK channel (`BIR_CONTROL_TOKEN`) or `--standalone`: yes.
|
|
358
|
+
* - An entry without `BIR_CONTROL_URL` (`install --no-hooks`, a `bir wrap`
|
|
359
|
+
* made before any install): Tier 2 is what it asked for.
|
|
360
|
+
* - This directory was installed, or a recorder has run for it: yes.
|
|
361
|
+
* - Otherwise it depends on where the entry came from, which the install
|
|
362
|
+
* sidecar knows by server name. Another client's file (`install
|
|
363
|
+
* --config`, whose host starts servers from any directory — Claude
|
|
364
|
+
* Desktop uses `/`), a project- or local-scope entry (which Claude Code
|
|
365
|
+
* only starts in its own project), or no install record at all (a `bir
|
|
366
|
+
* wrap` entry, which bakes `BIR_CONTROL_URL` too once any install has
|
|
367
|
+
* run): yes. Only when every record for this name is a user-scope one is
|
|
368
|
+
* this a global entry running somewhere nobody set up — and then no.
|
|
369
|
+
*
|
|
370
|
+
* Keying this on `BIR_CONTROL_URL` alone was wrong: `install --config` and
|
|
371
|
+
* `bir wrap` bake it as well, and those entries silently stopped recording
|
|
372
|
+
* whenever their host's directory differed from the install's.
|
|
373
|
+
*/
|
|
374
|
+
async optedIn() {
|
|
375
|
+
if (this.opts.standalone || process.env.BIR_CONTROL_TOKEN)
|
|
376
|
+
return true;
|
|
377
|
+
if (!process.env.BIR_CONTROL_URL)
|
|
378
|
+
return true;
|
|
379
|
+
if (this.sawDiscovery)
|
|
380
|
+
return true;
|
|
381
|
+
const facts = await this.installFacts();
|
|
382
|
+
if (facts.installedHere)
|
|
383
|
+
return true;
|
|
384
|
+
if (facts.scopes.length === 0)
|
|
385
|
+
return true;
|
|
386
|
+
return facts.scopes.some((scope) => scope !== "user");
|
|
387
|
+
}
|
|
102
388
|
async negotiate() {
|
|
103
389
|
if (this.opts.standalone) {
|
|
104
390
|
await this.becomeStandalone("--standalone was requested");
|
|
@@ -106,50 +392,182 @@ export class ProxySession {
|
|
|
106
392
|
}
|
|
107
393
|
const window = this.opts.discoveryWindowMs ?? DISCOVERY_WINDOW_MS;
|
|
108
394
|
const deadline = Date.now() + window;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
395
|
+
// A control server that is still coming up — the SessionStart hook is
|
|
396
|
+
// starting it while the host spawns us — answers nothing for a moment.
|
|
397
|
+
// Keep trying inside the window rather than settle on the first refusal.
|
|
398
|
+
for (;;) {
|
|
399
|
+
if (this.closing)
|
|
400
|
+
return;
|
|
401
|
+
if (await this.tryBind())
|
|
402
|
+
return;
|
|
403
|
+
if (Date.now() >= deadline)
|
|
404
|
+
break;
|
|
405
|
+
await sleep(Math.min(250, Math.max(0, deadline - Date.now())));
|
|
406
|
+
}
|
|
407
|
+
if (this.closing)
|
|
112
408
|
return;
|
|
409
|
+
await this.settleUnbound(`no control server answered within ${window}ms`);
|
|
410
|
+
this.scheduleRebind();
|
|
411
|
+
}
|
|
412
|
+
/** Where this cwd's control server is, if anywhere. */
|
|
413
|
+
findControl() {
|
|
414
|
+
const envUrl = process.env.BIR_CONTROL_URL;
|
|
415
|
+
const envToken = process.env.BIR_CONTROL_TOKEN;
|
|
416
|
+
// Explicit wiring (the Agent SDK channel) wins.
|
|
417
|
+
if (envUrl && envToken)
|
|
418
|
+
return { url: envUrl.replace(/\/+$/, ""), token: envToken };
|
|
419
|
+
// Otherwise this directory's own recorder, wherever it listens. The URL an
|
|
420
|
+
// install baked into the entry is not consulted: for a user-scope server it
|
|
421
|
+
// names *another* project's recorder, whose token is not ours.
|
|
422
|
+
const found = readDiscovery(this.opts.cwd);
|
|
423
|
+
if (found) {
|
|
424
|
+
this.sawDiscovery = true;
|
|
425
|
+
return { url: found.url, token: found.token };
|
|
113
426
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
/** Register with this cwd's control server, if one answers. True when bound. */
|
|
430
|
+
async tryBind() {
|
|
431
|
+
const target = this.findControl();
|
|
432
|
+
if (!target)
|
|
433
|
+
return false;
|
|
434
|
+
const key = `${target.url} ${target.token}`;
|
|
435
|
+
const client = this.control && this.controlKey === key
|
|
436
|
+
? this.control
|
|
437
|
+
: new ControlClient(target.url, target.token, undefined, {
|
|
438
|
+
proxyId: this.proxyId,
|
|
439
|
+
startedAt: this.startedAt,
|
|
440
|
+
pid: process.pid,
|
|
441
|
+
});
|
|
442
|
+
const registered = await client.register({
|
|
120
443
|
serverName: this.serverName,
|
|
121
444
|
pid: process.pid,
|
|
122
445
|
cwd: this.opts.cwd,
|
|
123
446
|
version: packageVersion(),
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
447
|
+
});
|
|
448
|
+
if (!registered || this.closing)
|
|
449
|
+
return false;
|
|
450
|
+
this.bind(client, key, target.url, registered);
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
bind(client, key, url, registered) {
|
|
454
|
+
const was = this.tierValue;
|
|
455
|
+
this.control = client;
|
|
456
|
+
this.controlKey = key;
|
|
457
|
+
this.pollClient = client;
|
|
458
|
+
this.tierValue = "bound";
|
|
459
|
+
if (!this.reviver && this.opts.revive !== false && !this.opts.standalone) {
|
|
460
|
+
this.reviver = new RecorderReviver({
|
|
461
|
+
...(this.opts.revive || {}),
|
|
462
|
+
cwd: this.opts.cwd,
|
|
463
|
+
server: this.serverName,
|
|
134
464
|
});
|
|
135
|
-
registered = await client.register(info);
|
|
136
465
|
}
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
466
|
+
if (this.rebindTimer) {
|
|
467
|
+
clearTimeout(this.rebindTimer);
|
|
468
|
+
this.rebindTimer = undefined;
|
|
140
469
|
}
|
|
141
|
-
|
|
142
|
-
this.tierValue = "bound";
|
|
143
|
-
logLine("tier.bound", {
|
|
470
|
+
const fields = {
|
|
144
471
|
server: this.serverName,
|
|
145
|
-
control:
|
|
472
|
+
control: url,
|
|
146
473
|
sess: registered.sessionId,
|
|
147
474
|
correlation: this.opts.noCorrelation ? "fingerprint" : "injected",
|
|
148
475
|
replay: registered.replay ? "on" : undefined,
|
|
149
|
-
}
|
|
476
|
+
};
|
|
477
|
+
// The normal start is routine, and so is telling a restarted recorder we
|
|
478
|
+
// are still here; coming back after losing it is worth a line.
|
|
479
|
+
if (was === "pending")
|
|
480
|
+
logDetail("tier.bound", fields);
|
|
481
|
+
else if (was === "bound")
|
|
482
|
+
logDetail("tier.reregistered", fields);
|
|
483
|
+
else
|
|
484
|
+
logLine("tier.rebound", { ...fields, was });
|
|
150
485
|
this.flushBuffer();
|
|
151
|
-
|
|
152
|
-
|
|
486
|
+
this.replayOn = registered.replay === true;
|
|
487
|
+
this.pollHoldMs = registered.pollHoldMs ?? 25_000;
|
|
488
|
+
// A bound proxy always has a work loop — paced to one poll every
|
|
489
|
+
// {@link MAX_POLL_BACKOFF_MS} while replay is off here — because replay is
|
|
490
|
+
// switched on by restarting the recorder, and a restarted recorder on the
|
|
491
|
+
// same address never makes a bound proxy register again. A loop that only
|
|
492
|
+
// started (or only kept going) while replay was on never noticed.
|
|
493
|
+
//
|
|
494
|
+
// Whatever pause the loop grew against the server it had before ends now:
|
|
495
|
+
// this one is answering, and routes nothing to us until we poll it.
|
|
496
|
+
this.pollBackoff = this.replayOn ? 0 : this.maxIdleBackoff();
|
|
497
|
+
this.wakeLoop?.();
|
|
498
|
+
this.startWorkLoop();
|
|
499
|
+
}
|
|
500
|
+
maxIdleBackoff() {
|
|
501
|
+
return this.opts.maxPollBackoffMs ?? MAX_POLL_BACKOFF_MS;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Tell the control server we exist again — it answered after a spell of not
|
|
505
|
+
* answering, so it is probably a restarted one that has never heard of this
|
|
506
|
+
* proxy (its `/health` would not list it, and it would not know this server
|
|
507
|
+
* has a proxy that can strip a call id). One request at a time; best-effort.
|
|
508
|
+
*/
|
|
509
|
+
reregister() {
|
|
510
|
+
if (this.reregistering || this.closing || this.tierValue !== "bound")
|
|
511
|
+
return;
|
|
512
|
+
this.reregistering = this.tryBind()
|
|
513
|
+
.catch(() => false)
|
|
514
|
+
.finally(() => {
|
|
515
|
+
this.reregistering = undefined;
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/** Sleep `ms`, or less if {@link wakeLoop} is called first. True when woken early. */
|
|
519
|
+
pause(ms) {
|
|
520
|
+
return new Promise((resolve) => {
|
|
521
|
+
const done = (early) => {
|
|
522
|
+
clearTimeout(timer);
|
|
523
|
+
if (this.wakeLoop === wake)
|
|
524
|
+
this.wakeLoop = undefined;
|
|
525
|
+
resolve(early);
|
|
526
|
+
};
|
|
527
|
+
const wake = () => done(true);
|
|
528
|
+
const timer = setTimeout(() => done(false), ms);
|
|
529
|
+
timer.unref?.();
|
|
530
|
+
this.wakeLoop = wake;
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
/** No control server: record on our own if this project opted in, else stay out of the way. */
|
|
534
|
+
async settleUnbound(why) {
|
|
535
|
+
if (!(await this.optedIn())) {
|
|
536
|
+
this.becomePassthrough("this directory is not set up for recording, and no recorder is running for it");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// Only the real recorder authenticates; an injected one (tests) has nothing to spread.
|
|
540
|
+
if (!this.opts.recorderFactory)
|
|
541
|
+
await sleep(Math.floor(Math.random() * STANDALONE_JITTER_MS));
|
|
542
|
+
if (this.closing)
|
|
543
|
+
return;
|
|
544
|
+
await this.becomeStandalone(why);
|
|
545
|
+
}
|
|
546
|
+
/** While not bound, look for a control server again now and then. */
|
|
547
|
+
scheduleRebind() {
|
|
548
|
+
if (this.rebindTimer || this.closing || this.opts.standalone)
|
|
549
|
+
return;
|
|
550
|
+
const interval = this.opts.rebindIntervalMs ?? REBIND_INTERVAL_MS;
|
|
551
|
+
this.rebindTimer = setTimeout(() => {
|
|
552
|
+
this.rebindTimer = undefined;
|
|
553
|
+
void (async () => {
|
|
554
|
+
if (this.closing || this.tierValue === "bound")
|
|
555
|
+
return;
|
|
556
|
+
try {
|
|
557
|
+
if (!isRunnerOff(this.opts.cwd) && (await this.tryBind()))
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
logDetail("proxy.rebind_failed", { server: this.serverName, error: errText(err) });
|
|
562
|
+
}
|
|
563
|
+
// Still no recorder, where there used to be one: if it died, ask for
|
|
564
|
+
// a new one (off the relay path; see proxy/revive.ts).
|
|
565
|
+
if (!this.closing)
|
|
566
|
+
this.reviver?.nudge("no recorder answered since it was lost");
|
|
567
|
+
this.scheduleRebind();
|
|
568
|
+
})();
|
|
569
|
+
}, interval);
|
|
570
|
+
this.rebindTimer.unref?.();
|
|
153
571
|
}
|
|
154
572
|
/**
|
|
155
573
|
* Take calculated-scenario steps from the control server and run them on the
|
|
@@ -159,37 +577,101 @@ export class ProxySession {
|
|
|
159
577
|
* allocates an id in the reserved `bir:` space and the transport consumes its
|
|
160
578
|
* own response in `accept()` before the relay's handlers ever see it — so this
|
|
161
579
|
* costs **zero tokens**, the model is not involved, and `RecordingInterceptor`
|
|
162
|
-
* cannot double-record it.
|
|
163
|
-
*
|
|
164
|
-
*
|
|
580
|
+
* cannot double-record it.
|
|
581
|
+
*
|
|
582
|
+
* PACING. A long-poll that is held is free; one that is not must not become
|
|
583
|
+
* four requests a second for the rest of the session. So:
|
|
584
|
+
*
|
|
585
|
+
* - held (the server parked it until work or its deadline): poll again now;
|
|
586
|
+
* - unreachable (usually a recorder restarting): back off 250 ms → 5 s
|
|
587
|
+
* ({@link MAX_UNREACHABLE_BACKOFF_MS}), and when it answers again,
|
|
588
|
+
* register again so the new process knows about us;
|
|
589
|
+
* - answered at once, or `{ replay: false }`: back off to one poll every
|
|
590
|
+
* {@link MAX_POLL_BACKOFF_MS}. Never stop: replay is turned back on by a
|
|
591
|
+
* restart that leaves this proxy bound, and a loop that had stopped for
|
|
592
|
+
* good left every later scenario step falling back to recorded outputs
|
|
593
|
+
* until the host itself was restarted.
|
|
594
|
+
*
|
|
595
|
+
* {@link bind} resets the pace and ends a pause early, so a proxy that finds
|
|
596
|
+
* its recorder again polls it at once rather than after a stale backoff.
|
|
165
597
|
*/
|
|
166
|
-
startWorkLoop(
|
|
598
|
+
startWorkLoop() {
|
|
167
599
|
const upstream = this.opts.upstream;
|
|
168
600
|
if (!upstream || this.workLoop)
|
|
169
601
|
return;
|
|
170
602
|
this.workLoop = (async () => {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
603
|
+
logDetail("replay.worker", { server: this.serverName, why: "polling for scenario steps" });
|
|
604
|
+
let wasUnreachable = false;
|
|
605
|
+
while (!this.closing && !upstream.dead) {
|
|
606
|
+
// Woken early by bind(): re-read the pace it set rather than poll blindly.
|
|
607
|
+
if (this.pollBackoff > 0 && (await this.pause(this.pollBackoff)))
|
|
608
|
+
continue;
|
|
609
|
+
if (this.closing || upstream.dead)
|
|
610
|
+
break;
|
|
611
|
+
const client = this.pollClient;
|
|
612
|
+
if (!client)
|
|
613
|
+
break;
|
|
614
|
+
const holdMs = this.pollHoldMs;
|
|
615
|
+
const asked = Date.now();
|
|
616
|
+
let outcome;
|
|
174
617
|
try {
|
|
175
|
-
|
|
618
|
+
outcome = await client.pollWork(this.serverName, holdMs);
|
|
176
619
|
}
|
|
177
620
|
catch {
|
|
178
|
-
|
|
621
|
+
outcome = { reachable: false };
|
|
179
622
|
}
|
|
180
623
|
if (this.closing)
|
|
181
624
|
break;
|
|
625
|
+
const work = outcome.work;
|
|
626
|
+
// Re-bound while this poll was out: bind() already set the pace for
|
|
627
|
+
// the new server, and this answer says nothing about it. Work handed
|
|
628
|
+
// out by the old one is still run, below — it is waiting for it.
|
|
629
|
+
const current = client === this.pollClient;
|
|
182
630
|
if (!work) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
631
|
+
if (!current)
|
|
632
|
+
continue;
|
|
633
|
+
if (!outcome.reachable) {
|
|
634
|
+
wasUnreachable = true;
|
|
635
|
+
this.reviver?.nudge("the recorder stopped answering");
|
|
636
|
+
this.pollBackoff = Math.min(MAX_UNREACHABLE_BACKOFF_MS, this.pollBackoff ? this.pollBackoff * 2 : 250);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (wasUnreachable) {
|
|
640
|
+
wasUnreachable = false;
|
|
641
|
+
this.reregister();
|
|
642
|
+
}
|
|
643
|
+
if (outcome.replayOff) {
|
|
644
|
+
if (this.replayOn) {
|
|
645
|
+
logDetail("replay.worker_idle", { server: this.serverName, why: "replay is off; polling slowly" });
|
|
646
|
+
}
|
|
647
|
+
this.replayOn = false;
|
|
648
|
+
this.pollBackoff = this.maxIdleBackoff();
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const held = Date.now() - asked >= Math.min(holdMs / 2, 5_000);
|
|
652
|
+
if (held) {
|
|
653
|
+
this.replayOn = true;
|
|
654
|
+
this.pollBackoff = 0;
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
this.pollBackoff = Math.min(this.maxIdleBackoff(), this.pollBackoff ? this.pollBackoff * 2 : 250);
|
|
658
|
+
}
|
|
189
659
|
continue;
|
|
190
660
|
}
|
|
661
|
+
if (current) {
|
|
662
|
+
if (wasUnreachable) {
|
|
663
|
+
wasUnreachable = false;
|
|
664
|
+
this.reregister();
|
|
665
|
+
}
|
|
666
|
+
this.replayOn = true;
|
|
667
|
+
this.pollBackoff = 0;
|
|
668
|
+
}
|
|
669
|
+
// A little under the control server's own budget for the step, so that
|
|
670
|
+
// on a timeout the cancellation reaches the upstream *before* the server
|
|
671
|
+
// gives up on it and, possibly, substitutes a recorded output.
|
|
672
|
+
const budget = Math.max(1, work.timeoutMs - Math.min(1_000, Math.floor(work.timeoutMs / 10)));
|
|
191
673
|
try {
|
|
192
|
-
const result = await upstream.request("tools/call", { name: work.toolName, arguments: work.arguments }, AbortSignal.timeout(
|
|
674
|
+
const result = await upstream.request("tools/call", { name: work.toolName, arguments: work.arguments }, AbortSignal.timeout(budget));
|
|
193
675
|
await client.result(work.workId, result);
|
|
194
676
|
}
|
|
195
677
|
catch (err) {
|
|
@@ -200,7 +682,11 @@ export class ProxySession {
|
|
|
200
682
|
await client.result(work.workId, undefined, errText(err));
|
|
201
683
|
}
|
|
202
684
|
}
|
|
203
|
-
})()
|
|
685
|
+
})()
|
|
686
|
+
.catch((err) => logLine("replay.worker_failed", { server: this.serverName, error: errText(err) }))
|
|
687
|
+
.finally(() => {
|
|
688
|
+
this.workLoop = undefined;
|
|
689
|
+
});
|
|
204
690
|
}
|
|
205
691
|
/**
|
|
206
692
|
* Take ownership of a run ourselves.
|
|
@@ -211,45 +697,88 @@ export class ProxySession {
|
|
|
211
697
|
* buffered nor recordable, and every `tools/call` in that window is lost.
|
|
212
698
|
* `dispatch` re-buffers defensively too — belt and braces on a race whose
|
|
213
699
|
* failure mode is silent data loss.
|
|
700
|
+
*
|
|
701
|
+
* One run per proxy: a proxy that went standalone, re-bound, and lost its
|
|
702
|
+
* control server again carries on in the run it already owns.
|
|
214
703
|
*/
|
|
215
704
|
async becomeStandalone(why) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
this.recorder
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
705
|
+
// The first Tier 2 start, its run and (in close) its finish are routine —
|
|
706
|
+
// every proxy for a host without hooks does this on every start — so they
|
|
707
|
+
// are BIR_VERBOSE detail: Claude Code files each stderr line of an MCP
|
|
708
|
+
// server as a "Server stderr" error. Losing a recorder mid-session
|
|
709
|
+
// (tier.downgrade), an overflow (run.lossy) and failures stay on stderr.
|
|
710
|
+
if (!this.recorder || !this.runId) {
|
|
711
|
+
logDetail("tier.standalone", {
|
|
712
|
+
server: this.serverName,
|
|
713
|
+
why,
|
|
714
|
+
records: "MCP calls only — no prompt, no final answer, no built-in steps",
|
|
715
|
+
});
|
|
716
|
+
this.recorder = await this.buildRecorder();
|
|
717
|
+
this.runId = this.recorder.startRun("", {
|
|
718
|
+
recorder: "baseinstrunner",
|
|
719
|
+
tier: "standalone",
|
|
720
|
+
host: { app: process.env.BIR_HOST_APP ?? "unknown", version: process.env.BIR_HOST_VERSION },
|
|
721
|
+
wrappedServers: [this.serverName],
|
|
722
|
+
lossy: this.lossyValue,
|
|
723
|
+
machine: hostname(),
|
|
724
|
+
cwd: this.opts.cwd,
|
|
725
|
+
});
|
|
726
|
+
logDetail("run.start", { run: this.runId, tier: "standalone", server: this.serverName });
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
logDetail("tier.standalone", { server: this.serverName, why, run: this.runId, resumed: true });
|
|
730
|
+
}
|
|
231
731
|
this.tierValue = "standalone";
|
|
232
|
-
logLine("run.start", { run: this.runId, tier: "standalone", server: this.serverName });
|
|
233
732
|
this.flushBuffer();
|
|
234
733
|
}
|
|
734
|
+
/**
|
|
735
|
+
* Record nothing; relay only. Held steps are released, not kept.
|
|
736
|
+
*
|
|
737
|
+
* Detail, not a stderr line: this is the designed outcome for a user-scope
|
|
738
|
+
* server in a project nobody set up, and a line there would appear as a
|
|
739
|
+
* "Server stderr" error in a project that never asked for the runner at all.
|
|
740
|
+
* `bir doctor`, run in that directory, is where "not set up here" is told.
|
|
741
|
+
*/
|
|
742
|
+
becomePassthrough(why) {
|
|
743
|
+
this.tierValue = "passthrough";
|
|
744
|
+
this.buffer.length = 0;
|
|
745
|
+
logDetail("tier.passthrough", { server: this.serverName, why, records: "nothing" });
|
|
746
|
+
}
|
|
235
747
|
async buildRecorder() {
|
|
236
748
|
if (this.opts.recorderFactory) {
|
|
237
749
|
return (await this.opts.recorderFactory()) ?? new NullRecorder();
|
|
238
750
|
}
|
|
239
751
|
// BIR_AUTH_URL, or the address 'bir setup' stored — the same lookup the
|
|
240
752
|
// control server and the CLI make, so no two processes disagree about it.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
753
|
+
// "Not set up" and "signed out" are states `bir doctor` reports, not
|
|
754
|
+
// failures of this session: detail here, so a signed-out machine does not
|
|
755
|
+
// add a stderr "error" to every wrapped server on every start.
|
|
756
|
+
const configured = resolveAuthUrl();
|
|
757
|
+
if (!configured) {
|
|
758
|
+
logDetail("recorder.disabled", { why: "no service address — run 'bir setup', or set BIR_AUTH_URL" });
|
|
244
759
|
return new NullRecorder();
|
|
245
760
|
}
|
|
761
|
+
// The address as the rest of the runner binds sessions to it: a plain-http
|
|
762
|
+
// service that redirects to https is used at its https address (a
|
|
763
|
+
// cross-origin redirect strips the bearer token), and a session saved
|
|
764
|
+
// under that address is this one's. Asking with the configured http form
|
|
765
|
+
// instead read as "issued by another service" (auth.url_mismatch), and
|
|
766
|
+
// the proxy recorded nothing for its whole life.
|
|
767
|
+
const baseUrl = await normalizedServiceUrl(configured);
|
|
768
|
+
if (this.closing)
|
|
769
|
+
return new NullRecorder();
|
|
246
770
|
// A proxy runs inside the host's process tree with its stdio bound to the
|
|
247
771
|
// JSON-RPC stream: there is nowhere to prompt, and blocking on one would
|
|
248
772
|
// hang the host's server startup. `authenticate` never interacts — signing
|
|
249
773
|
// in lives in `bir login` alone — so that is guaranteed here, not requested.
|
|
250
|
-
|
|
774
|
+
//
|
|
775
|
+
// `keepStaleOnNoAnswer`: a service that is down or slow at the moment this
|
|
776
|
+
// proxy goes standalone must not disable recording for the proxy's whole
|
|
777
|
+
// life when the refresh token is fine. The recorder renews the session in
|
|
778
|
+
// the background once the service answers (a 401 there refreshes).
|
|
779
|
+
const session = await authenticate({ authUrl: baseUrl, keepStaleOnNoAnswer: true });
|
|
251
780
|
if (!session) {
|
|
252
|
-
|
|
781
|
+
logDetail("recorder.disabled", { why: "no BaseIn session — run `bir login`" });
|
|
253
782
|
return new NullRecorder();
|
|
254
783
|
}
|
|
255
784
|
return new RemoteRecorder({ baseUrl, session });
|
|
@@ -259,6 +788,8 @@ export class ProxySession {
|
|
|
259
788
|
* *after* the result is already on its way to the host, and it never awaits.
|
|
260
789
|
*/
|
|
261
790
|
reportStep(step) {
|
|
791
|
+
if (!this.recording)
|
|
792
|
+
return;
|
|
262
793
|
if (this.tierValue === "pending") {
|
|
263
794
|
this.bufferStep(step);
|
|
264
795
|
return;
|
|
@@ -267,7 +798,7 @@ export class ProxySession {
|
|
|
267
798
|
}
|
|
268
799
|
/** Hold a step until an owner exists. Bounded, and honest when it overflows. */
|
|
269
800
|
bufferStep(step) {
|
|
270
|
-
if (this.buffer.length >=
|
|
801
|
+
if (this.buffer.length >= DISCOVERY_BUFFER) {
|
|
271
802
|
this.buffer.shift();
|
|
272
803
|
if (!this.lossyValue) {
|
|
273
804
|
logLine("run.lossy", { why: "discovery buffer overflowed", server: this.serverName });
|
|
@@ -285,23 +816,37 @@ export class ProxySession {
|
|
|
285
816
|
}
|
|
286
817
|
}
|
|
287
818
|
dispatch(step) {
|
|
819
|
+
if (this.tierValue === "passthrough")
|
|
820
|
+
return;
|
|
288
821
|
if (this.tierValue === "bound" && this.control) {
|
|
289
822
|
const control = this.control;
|
|
290
823
|
this.queue.push(async () => {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
// the bound run, the rest in ours.
|
|
296
|
-
logLine("tier.downgrade", {
|
|
297
|
-
server: this.serverName,
|
|
298
|
-
why: "control server stopped answering",
|
|
299
|
-
});
|
|
300
|
-
this.control = undefined;
|
|
301
|
-
this.lossyValue = true;
|
|
302
|
-
await this.becomeStandalone("control server stopped answering");
|
|
824
|
+
// Queued under a control server this proxy has since left (or found
|
|
825
|
+
// again under another address): route it by the tier as it is *now*,
|
|
826
|
+
// rather than fail against the old one and downgrade a second time.
|
|
827
|
+
if (this.control !== control) {
|
|
303
828
|
this.dispatch(step);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
let ok = await control.report(step);
|
|
832
|
+
if (!ok && !this.closing) {
|
|
833
|
+
// One refused report is not a dead recorder — a restart for an
|
|
834
|
+
// upgrade refuses connections for a second or two.
|
|
835
|
+
await sleep(1_000);
|
|
836
|
+
if (this.control !== control) {
|
|
837
|
+
this.dispatch(step);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
ok = await control.report(step);
|
|
841
|
+
// Refused, then taken: most likely a restarted recorder, which has
|
|
842
|
+
// never heard of this proxy. Introduce ourselves again.
|
|
843
|
+
if (ok)
|
|
844
|
+
this.reregister();
|
|
304
845
|
}
|
|
846
|
+
if (ok)
|
|
847
|
+
return;
|
|
848
|
+
await this.downgrade(control);
|
|
849
|
+
this.dispatch(step);
|
|
305
850
|
});
|
|
306
851
|
return;
|
|
307
852
|
}
|
|
@@ -320,24 +865,88 @@ export class ProxySession {
|
|
|
320
865
|
const toolError = step.isError
|
|
321
866
|
? (step.errorMessage ?? serializeCapped(redact(step.result)))
|
|
322
867
|
: undefined;
|
|
323
|
-
this.queue.push(() => {
|
|
868
|
+
this.queue.push(async () => {
|
|
324
869
|
recorder.recordToolSelected(runId, pair.selected, { toolName, toolInput });
|
|
325
870
|
recorder.recordToolResponse(runId, pair.response, { toolName, toolOutput, toolError });
|
|
871
|
+
// Wait for the service to take them before the next step goes. The
|
|
872
|
+
// recorder's own per-run chain is unbounded; waiting here is what makes
|
|
873
|
+
// the step queue's bound apply to what is actually waiting on the
|
|
874
|
+
// network, so a slow or dead service costs a bounded backlog (oldest
|
|
875
|
+
// dropped, run flagged lossy) instead of a growing one.
|
|
876
|
+
//
|
|
877
|
+
// Not while closing: then every step is handed over at once, and the
|
|
878
|
+
// one bounded flush in close() waits for them all.
|
|
879
|
+
if (!this.closing)
|
|
880
|
+
await recorder.flush(runId);
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* The control server stopped answering. Exactly once per loss, however many
|
|
885
|
+
* reports were queued against it: later failures wait for this one and are
|
|
886
|
+
* re-routed, instead of each building a recorder and opening a run.
|
|
887
|
+
*/
|
|
888
|
+
downgrade(control) {
|
|
889
|
+
if (this.control !== control)
|
|
890
|
+
return this.downgrading ?? Promise.resolve();
|
|
891
|
+
this.downgrading ??= (async () => {
|
|
892
|
+
// §10: the control server died mid-session. What follows goes to a run of
|
|
893
|
+
// our own (where this project records at all) until it is found again.
|
|
894
|
+
logLine("tier.downgrade", {
|
|
895
|
+
server: this.serverName,
|
|
896
|
+
why: "control server stopped answering",
|
|
897
|
+
});
|
|
898
|
+
this.control = undefined;
|
|
899
|
+
this.controlKey = undefined;
|
|
900
|
+
this.lossyValue = true;
|
|
901
|
+
this.reviver?.nudge("the recorder stopped taking step reports");
|
|
902
|
+
await this.settleUnbound("control server stopped answering");
|
|
903
|
+
this.scheduleRebind();
|
|
904
|
+
})().finally(() => {
|
|
905
|
+
this.downgrading = undefined;
|
|
326
906
|
});
|
|
907
|
+
return this.downgrading;
|
|
327
908
|
}
|
|
328
|
-
/**
|
|
909
|
+
/**
|
|
910
|
+
* Close the Tier 2 run, if we own one, and drain the queue — within
|
|
911
|
+
* {@link CLOSE_BUDGET_MS} all told. bir-proxy gives shutdown a hard 3 s; a
|
|
912
|
+
* close that spent it waiting on a slow service never reached `finishRun`.
|
|
913
|
+
* Whatever has not been sent by then carries on in the background for as
|
|
914
|
+
* long as the process lives.
|
|
915
|
+
*/
|
|
329
916
|
async close() {
|
|
917
|
+
const deadline = Date.now() + CLOSE_BUDGET_MS;
|
|
918
|
+
const left = () => Math.max(0, deadline - Date.now());
|
|
330
919
|
// Stops the work loop at its next turn. It is not awaited: it may be parked
|
|
331
920
|
// in a 25 s long-poll, and shutdown does not wait on a poll that will be
|
|
332
921
|
// answered by the control server closing anyway.
|
|
333
922
|
this.closing = true;
|
|
334
|
-
|
|
923
|
+
this.reviver?.stop();
|
|
924
|
+
this.wakeLoop?.();
|
|
925
|
+
if (this.rebindTimer) {
|
|
926
|
+
clearTimeout(this.rebindTimer);
|
|
927
|
+
this.rebindTimer = undefined;
|
|
928
|
+
}
|
|
929
|
+
// The interceptor reports on a later tick (setImmediate), after the relay
|
|
930
|
+
// has written the answer — and at shutdown the relay has just answered or
|
|
931
|
+
// abandoned whatever was in flight, with those reports still scheduled.
|
|
932
|
+
// Let them land first, or the run is finished (or the process gone)
|
|
933
|
+
// before its last steps are queued.
|
|
934
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
935
|
+
// Negotiation stops at its next turn once `closing` is set, but a turn can
|
|
936
|
+
// be a sign-in refresh against a slow service: bounded, not awaited whole.
|
|
937
|
+
if (this.negotiation)
|
|
938
|
+
await settledWithin(this.negotiation, left());
|
|
335
939
|
this.flushBuffer();
|
|
336
|
-
|
|
940
|
+
// Steps waiting behind a slow service: each is handed to the recorder at
|
|
941
|
+
// once now (dispatch skips its per-step wait while closing), so this is
|
|
942
|
+
// one in-progress wait at most.
|
|
943
|
+
await settledWithin(this.queue.flush(), left());
|
|
337
944
|
if (this.recorder && this.runId) {
|
|
338
945
|
this.recorder.finishRun(this.runId, undefined, { durationMs: Date.now() - this.startedAt });
|
|
339
|
-
await this.recorder
|
|
340
|
-
|
|
946
|
+
await this.recorder
|
|
947
|
+
.flush(this.runId, { timeoutMs: Math.max(MIN_FINAL_FLUSH_MS, left()) })
|
|
948
|
+
.catch(() => undefined);
|
|
949
|
+
logDetail("run.finish", {
|
|
341
950
|
run: this.runId,
|
|
342
951
|
tier: "standalone",
|
|
343
952
|
steps: this.ordering.next,
|