@basein/runner 0.2.11 → 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 -350
- 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 -10
- package/dist/bin/bir-scenario.js +704 -135
- package/dist/bin/bir.d.ts +14 -8
- package/dist/bin/bir.js +2937 -488
- package/dist/bin/investigate.js +41 -16
- package/dist/bin/scenario-edit.d.ts +43 -10
- package/dist/bin/scenario-edit.js +281 -80
- 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 +2803 -306
- 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/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- 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 +371 -20
- package/dist/replay/controller.js +1185 -111
- 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 -1244
- package/docs/calculatedReplayGuide.md +1212 -879
- 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 -271
- 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/intercept.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* | Method | Direction | Action |
|
|
5
5
|
* |--------------|----------------|-----------------------------------------------|
|
|
6
6
|
* | `initialize` | host → upstream| Relay, and rewrite as little as possible. |
|
|
7
|
-
* | `tools/list` | host → upstream| Relay;
|
|
7
|
+
* | `tools/list` | host → upstream| Relay; declare the call-id key when Tier 1 may inject it.|
|
|
8
8
|
* | `tools/call` | host → upstream| Strip the call id, time it, relay, report it. |
|
|
9
9
|
*
|
|
10
10
|
* `initialize` DESERVES ITS OWN PARAGRAPH, because getting it wrong is what makes
|
|
@@ -19,14 +19,85 @@
|
|
|
19
19
|
* TWO RULES THAT ARE EASY TO GET WRONG, and are enforced here:
|
|
20
20
|
* - **Never rewrite a `tools/call` result.** The upstream object is returned
|
|
21
21
|
* as-is, `_meta` and `structuredContent` included. A *copy* is recorded.
|
|
22
|
-
* - **Never let recording block the response.**
|
|
23
|
-
*
|
|
22
|
+
* - **Never let recording block the response.** Only bookkeeping happens on
|
|
23
|
+
* the response's own tick; the copy is redacted, clipped and queued on a
|
|
24
|
+
* later one (`setImmediate`), after the relay has written the answer. A
|
|
25
|
+
* 20 MB result used to be redacted and deep-copied twice *in front of* its
|
|
26
|
+
* own delivery.
|
|
24
27
|
*/
|
|
25
|
-
import { BIR_CALL_ID, extractCallId,
|
|
28
|
+
import { BIR_CALL_ID, extractCallId, fingerprint, qualifyToolName, } from "../control/correlation.js";
|
|
26
29
|
import { redact } from "../record/redact.js";
|
|
27
|
-
import { truncateStrings, DEFAULT_MAX_STRING_BYTES } from "../record/truncate.js";
|
|
30
|
+
import { truncateStrings, serializeCapped, DEFAULT_MAX_STRING_BYTES, DEFAULT_MAX_PAYLOAD_BYTES, } from "../record/truncate.js";
|
|
28
31
|
import { paramsObject } from "../jsonrpc/types.js";
|
|
29
|
-
import { logDetail } from "../util/log.js";
|
|
32
|
+
import { logDetail, logLine, errText } from "../util/log.js";
|
|
33
|
+
/**
|
|
34
|
+
* Declare the call-id key on one tool's input schema so a host that validates
|
|
35
|
+
* `updatedInput` against it accepts an injected id.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately minimal — the model reads these schemas on every request: no
|
|
38
|
+
* text naming the runner, and `additionalProperties` stays whatever the upstream
|
|
39
|
+
* declared (declaring the key is already enough; forcing `true` advertised a
|
|
40
|
+
* strict server as accepting anything, inviting arguments it then rejects).
|
|
41
|
+
*/
|
|
42
|
+
export function tolerateCallId(schema) {
|
|
43
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
44
|
+
return schema;
|
|
45
|
+
const base = schema;
|
|
46
|
+
const props = base.properties;
|
|
47
|
+
if (props !== undefined && (!props || typeof props !== "object" || Array.isArray(props)))
|
|
48
|
+
return schema;
|
|
49
|
+
return {
|
|
50
|
+
...base,
|
|
51
|
+
properties: {
|
|
52
|
+
...(props ?? {}),
|
|
53
|
+
[BIR_CALL_ID]: { type: "string", description: "Reserved. Leave unset." },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Apply {@link tolerateCallId} to every tool of a `tools/list` result. */
|
|
58
|
+
export function tolerateCallIdInToolsList(result) {
|
|
59
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
60
|
+
return result;
|
|
61
|
+
const r = result;
|
|
62
|
+
if (!Array.isArray(r.tools))
|
|
63
|
+
return result;
|
|
64
|
+
return {
|
|
65
|
+
...r,
|
|
66
|
+
tools: r.tools.map((tool) => {
|
|
67
|
+
if (!tool || typeof tool !== "object" || Array.isArray(tool))
|
|
68
|
+
return tool;
|
|
69
|
+
const t = tool;
|
|
70
|
+
return { ...t, inputSchema: tolerateCallId(t.inputSchema) };
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Keep a recorded payload inside the per-payload cap *before* it is queued.
|
|
76
|
+
* An oversized value becomes exactly the marker `serializeCapped` would have
|
|
77
|
+
* written for it at record time, so what is finally recorded is unchanged —
|
|
78
|
+
* but the queue, and the loopback POST to the control server (which serves
|
|
79
|
+
* every hook on the same event loop), no longer carry megabytes to get there.
|
|
80
|
+
*/
|
|
81
|
+
function capForReport(value) {
|
|
82
|
+
let json;
|
|
83
|
+
try {
|
|
84
|
+
json = JSON.stringify(value);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { truncated: true, reason: "unserializable" };
|
|
88
|
+
}
|
|
89
|
+
if (json === undefined)
|
|
90
|
+
return value;
|
|
91
|
+
if (json.length <= DEFAULT_MAX_PAYLOAD_BYTES / 3 || Buffer.byteLength(json, "utf8") <= DEFAULT_MAX_PAYLOAD_BYTES) {
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(serializeCapped(value));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { truncated: true, originalBytes: Buffer.byteLength(json, "utf8") };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
30
101
|
export class RecordingInterceptor {
|
|
31
102
|
serverName;
|
|
32
103
|
session;
|
|
@@ -46,12 +117,19 @@ export class RecordingInterceptor {
|
|
|
46
117
|
const params = paramsObject(msg.params);
|
|
47
118
|
const toolName = String(params.name ?? "");
|
|
48
119
|
const { callId, cleaned } = extractCallId(params.arguments);
|
|
49
|
-
|
|
50
|
-
|
|
120
|
+
// Nothing to record when the session records nothing (the project never
|
|
121
|
+
// opted in, or the runner is off): no call is tracked, so nothing is held.
|
|
122
|
+
if (this.session.recording) {
|
|
123
|
+
this.calls.set(id, { callId, toolName, args: cleaned, startedAt: Date.now() });
|
|
124
|
+
}
|
|
125
|
+
// `extractCallId` hands back the very same object when the key is absent.
|
|
126
|
+
if (cleaned === params.arguments)
|
|
51
127
|
return { action: "forward" };
|
|
52
128
|
// The upstream must never see a key its schema does not declare, so the
|
|
53
|
-
// correlation id is stripped here
|
|
54
|
-
|
|
129
|
+
// correlation id is stripped here — in every tier, whatever its value —
|
|
130
|
+
// rather than being relayed and ignored.
|
|
131
|
+
if (callId !== undefined)
|
|
132
|
+
logDetail("call.correlated", { server: this.serverName, tool: toolName, callId });
|
|
55
133
|
return {
|
|
56
134
|
action: "forward",
|
|
57
135
|
message: {
|
|
@@ -62,12 +140,13 @@ export class RecordingInterceptor {
|
|
|
62
140
|
}
|
|
63
141
|
onUpstreamResponse(pending, response) {
|
|
64
142
|
if (pending.method === "tools/list") {
|
|
65
|
-
// The only rewrite in the whole proxy, and only when
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
143
|
+
// The only rewrite in the whole proxy, and only when a control server may
|
|
144
|
+
// inject call ids into this server's calls (Phase 6). Decided by the
|
|
145
|
+
// session so it does not depend on whether registration happened to beat
|
|
146
|
+
// this answer.
|
|
147
|
+
if (!this.session.declaresCallId || response.error)
|
|
69
148
|
return;
|
|
70
|
-
return { ...response, result:
|
|
149
|
+
return { ...response, result: tolerateCallIdInToolsList(response.result) };
|
|
71
150
|
}
|
|
72
151
|
if (pending.method !== "tools/call")
|
|
73
152
|
return;
|
|
@@ -75,9 +154,9 @@ export class RecordingInterceptor {
|
|
|
75
154
|
this.calls.delete(pending.id);
|
|
76
155
|
if (!call)
|
|
77
156
|
return;
|
|
78
|
-
// Report
|
|
79
|
-
//
|
|
80
|
-
this.report(call, response);
|
|
157
|
+
// Report on a later tick: the relay writes the answer as soon as this
|
|
158
|
+
// returns, and the copy's redaction and clipping cost nothing it waits on.
|
|
159
|
+
setImmediate(() => this.report(call, response));
|
|
81
160
|
return; // the upstream's result, verbatim
|
|
82
161
|
}
|
|
83
162
|
onAbandoned(pending, reason) {
|
|
@@ -85,61 +164,111 @@ export class RecordingInterceptor {
|
|
|
85
164
|
if (!call)
|
|
86
165
|
return;
|
|
87
166
|
this.calls.delete(pending.id);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
167
|
+
const durationMs = Date.now() - call.startedAt;
|
|
168
|
+
setImmediate(() => {
|
|
169
|
+
try {
|
|
170
|
+
const step = {
|
|
171
|
+
callId: call.callId,
|
|
172
|
+
serverName: this.serverName,
|
|
173
|
+
toolName: call.toolName,
|
|
174
|
+
qualifiedName: qualifyToolName(this.serverName, call.toolName),
|
|
175
|
+
args: capForReport(this.scrub(call.args)),
|
|
176
|
+
argsFingerprint: fingerprint(this.serverName, call.toolName, call.args),
|
|
177
|
+
isError: true,
|
|
178
|
+
errorMessage: reason,
|
|
179
|
+
startedAt: call.startedAt,
|
|
180
|
+
durationMs,
|
|
181
|
+
};
|
|
182
|
+
this.session.reportStep(step);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
logLine("proxy.report_failed", { server: this.serverName, error: errText(err) });
|
|
186
|
+
}
|
|
98
187
|
});
|
|
99
188
|
}
|
|
100
189
|
/** Build the step report from a completed call. Redaction happens on the copy. */
|
|
101
190
|
report(call, response) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
191
|
+
try {
|
|
192
|
+
const durationMs = Date.now() - call.startedAt;
|
|
193
|
+
const result = response.result;
|
|
194
|
+
let isError = false;
|
|
195
|
+
let errorMessage;
|
|
196
|
+
if (response.error) {
|
|
197
|
+
// A protocol-level failure: the call never produced a tool result.
|
|
198
|
+
isError = true;
|
|
199
|
+
errorMessage = `${response.error.code}: ${response.error.message}`;
|
|
200
|
+
}
|
|
201
|
+
else if (result && typeof result === "object" && result.isError === true) {
|
|
202
|
+
isError = true;
|
|
203
|
+
}
|
|
204
|
+
const step = {
|
|
205
|
+
callId: call.callId,
|
|
206
|
+
serverName: this.serverName,
|
|
207
|
+
toolName: call.toolName,
|
|
208
|
+
qualifiedName: qualifyToolName(this.serverName, call.toolName),
|
|
209
|
+
args: capForReport(this.scrub(call.args)),
|
|
210
|
+
argsFingerprint: fingerprint(this.serverName, call.toolName, call.args),
|
|
211
|
+
result: capForReport(response.error ? this.scrub(response.error) : this.scrub(response.result)),
|
|
212
|
+
isError,
|
|
213
|
+
errorMessage,
|
|
214
|
+
startedAt: call.startedAt,
|
|
215
|
+
durationMs,
|
|
216
|
+
};
|
|
217
|
+
this.session.reportStep(step);
|
|
218
|
+
logDetail("call.done", {
|
|
219
|
+
server: this.serverName,
|
|
220
|
+
tool: call.toolName,
|
|
221
|
+
ms: durationMs,
|
|
222
|
+
isError,
|
|
223
|
+
});
|
|
110
224
|
}
|
|
111
|
-
|
|
112
|
-
|
|
225
|
+
catch (err) {
|
|
226
|
+
// Recording is best-effort; nothing here may become an uncaught exception.
|
|
227
|
+
logLine("proxy.report_failed", { server: this.serverName, error: errText(err) });
|
|
113
228
|
}
|
|
114
|
-
const step = {
|
|
115
|
-
callId: call.callId,
|
|
116
|
-
serverName: this.serverName,
|
|
117
|
-
toolName: call.toolName,
|
|
118
|
-
qualifiedName: qualifyToolName(this.serverName, call.toolName),
|
|
119
|
-
args: this.scrub(call.args),
|
|
120
|
-
result: response.error ? this.scrub(response.error) : this.scrub(response.result),
|
|
121
|
-
isError,
|
|
122
|
-
errorMessage,
|
|
123
|
-
startedAt: call.startedAt,
|
|
124
|
-
durationMs,
|
|
125
|
-
};
|
|
126
|
-
this.session.reportStep(step);
|
|
127
|
-
logDetail("call.done", {
|
|
128
|
-
server: this.serverName,
|
|
129
|
-
tool: call.toolName,
|
|
130
|
-
ms: durationMs,
|
|
131
|
-
isError,
|
|
132
|
-
});
|
|
133
229
|
}
|
|
134
230
|
/**
|
|
135
|
-
*
|
|
231
|
+
* Clip, redact, clip — on the recording copy only.
|
|
232
|
+
*
|
|
233
|
+
* Clipping first keeps redaction's regexes off megabytes that are about to be
|
|
234
|
+
* thrown away. It clips to *twice* the cap first, so a secret straddling the
|
|
235
|
+
* final cut is still whole — and still recognisable — when redaction runs;
|
|
236
|
+
* clipping straight to the cap could leave a token prefix no pattern matches.
|
|
136
237
|
*
|
|
137
238
|
* Clipping happens here rather than only at serialization time because a
|
|
138
239
|
* browser MCP's base64 screenshot would otherwise sit in the step queue in
|
|
139
240
|
* full, possibly for the length of the run.
|
|
140
241
|
*/
|
|
141
242
|
scrub(value) {
|
|
142
|
-
return truncateStrings(redact(value), DEFAULT_MAX_STRING_BYTES);
|
|
243
|
+
return truncateStrings(redact(truncateStrings(value, DEFAULT_MAX_STRING_BYTES * 2)), DEFAULT_MAX_STRING_BYTES);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The interceptor of a proxy that records nothing — started while the runner
|
|
248
|
+
* was off (`bir off`, `BIR_OFF=1`) with a remote upstream, where there is no
|
|
249
|
+
* process to hand stdio to and the relay must stay in the path.
|
|
250
|
+
*
|
|
251
|
+
* It does one thing: strip the call id. The runner can be switched back on
|
|
252
|
+
* mid-session, and a control server may then inject an id into a call routed
|
|
253
|
+
* here (a replay-pinned step, `bypassPermissions`, an explicit opt-in). A
|
|
254
|
+
* strict upstream would reject the unknown key, and the user would see a
|
|
255
|
+
* tool error the runner caused. No tracking, no reporting, no schema change.
|
|
256
|
+
*/
|
|
257
|
+
export class StripCallIdInterceptor {
|
|
258
|
+
onHostMessage(msg) {
|
|
259
|
+
if (msg.method !== "tools/call")
|
|
260
|
+
return { action: "forward" };
|
|
261
|
+
const params = paramsObject(msg.params);
|
|
262
|
+
const { cleaned } = extractCallId(params.arguments);
|
|
263
|
+
if (cleaned === params.arguments)
|
|
264
|
+
return { action: "forward" };
|
|
265
|
+
return {
|
|
266
|
+
action: "forward",
|
|
267
|
+
message: {
|
|
268
|
+
...msg,
|
|
269
|
+
params: { ...params, arguments: cleaned },
|
|
270
|
+
},
|
|
271
|
+
};
|
|
143
272
|
}
|
|
144
273
|
}
|
|
145
274
|
/** Re-exported so callers wiring a proxy need one import for the correlation key. */
|
package/dist/proxy/relay.d.ts
CHANGED
|
@@ -92,6 +92,22 @@ export declare class Relay {
|
|
|
92
92
|
* where a JSON-RPC error just breaks the client's tool plumbing (§10).
|
|
93
93
|
*/
|
|
94
94
|
private deadUpstreamAnswer;
|
|
95
|
+
/** An `isError` tool result for `tools/call`, a JSON-RPC error for anything else. */
|
|
96
|
+
private failureAnswer;
|
|
97
|
+
/**
|
|
98
|
+
* The upstream answered, but the answer could not be delivered (a line over
|
|
99
|
+
* the framing cap). Answer the request it was for, so the host's call ends
|
|
100
|
+
* with an error the model can react to instead of spinning until Esc.
|
|
101
|
+
*
|
|
102
|
+
* The id comes from the line's ends and may be missing. Without it, the
|
|
103
|
+
* request is identifiable only when the line was recognisably an answer
|
|
104
|
+
* (`info.response`) and exactly one host call — a `tools/call`, the request
|
|
105
|
+
* whose answers are the big ones — is in flight. Anything looser is a guess:
|
|
106
|
+
* an oversized notification used to be "matched" to the one call in flight,
|
|
107
|
+
* which got a false error and then its real answer too. With several in
|
|
108
|
+
* flight nothing is answered, and each keeps waiting for its own reply.
|
|
109
|
+
*/
|
|
110
|
+
private onUndeliverable;
|
|
95
111
|
private abandonAll;
|
|
96
112
|
}
|
|
97
113
|
//# sourceMappingURL=relay.d.ts.map
|
package/dist/proxy/relay.js
CHANGED
|
@@ -66,6 +66,9 @@ export class Relay {
|
|
|
66
66
|
writeFrame(hostOut, msg, (err) => logLine("relay.write_failed", { label, error: errText(err) }));
|
|
67
67
|
});
|
|
68
68
|
upstream.onClose(() => this.abandonAll("upstream closed"));
|
|
69
|
+
// `--restart`: nothing in flight will be answered by the new process.
|
|
70
|
+
upstream.onRestart?.(() => this.abandonAll("upstream restarted"));
|
|
71
|
+
upstream.onUndeliverable?.((info) => this.onUndeliverable(info));
|
|
69
72
|
this.detach = readFrames(hostIn, {
|
|
70
73
|
onMessage: (msg) => this.onHostMessage(msg),
|
|
71
74
|
onParseError: (detail) => {
|
|
@@ -110,14 +113,32 @@ export class Relay {
|
|
|
110
113
|
const p = this.pending.get(requestId);
|
|
111
114
|
if (p) {
|
|
112
115
|
this.pending.delete(requestId);
|
|
113
|
-
|
|
116
|
+
try {
|
|
117
|
+
interceptor?.onAbandoned?.(p, "cancelled by host");
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
logLine("relay.intercept_failed", { label, error: errText(err) });
|
|
121
|
+
}
|
|
114
122
|
}
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
if (isRequest(outgoing)) {
|
|
118
126
|
if (upstream.dead) {
|
|
119
|
-
// §10: the host must get an answer, or it waits forever.
|
|
120
|
-
|
|
127
|
+
// §10: the host must get an answer, or it waits forever. The
|
|
128
|
+
// interceptor has already opened a call for it; close that too, or it
|
|
129
|
+
// is held (arguments and all) for the life of the process.
|
|
130
|
+
try {
|
|
131
|
+
interceptor?.onAbandoned?.({
|
|
132
|
+
id: outgoing.id,
|
|
133
|
+
method: outgoing.method,
|
|
134
|
+
params: outgoing.params,
|
|
135
|
+
startedAt: Date.now(),
|
|
136
|
+
}, "upstream unavailable");
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
logLine("relay.intercept_failed", { label, error: errText(err) });
|
|
140
|
+
}
|
|
141
|
+
writeFrame(hostOut, this.deadUpstreamAnswer(outgoing.id, outgoing.method), (err) => logLine("relay.write_failed", { label, error: errText(err) }));
|
|
121
142
|
return;
|
|
122
143
|
}
|
|
123
144
|
this.pending.set(outgoing.id, {
|
|
@@ -141,7 +162,12 @@ export class Relay {
|
|
|
141
162
|
* where a JSON-RPC error just breaks the client's tool plumbing (§10).
|
|
142
163
|
*/
|
|
143
164
|
deadUpstreamAnswer(id, method) {
|
|
144
|
-
|
|
165
|
+
// Phrased as the host would phrase it unwrapped — about the server, not
|
|
166
|
+
// about a proxy the user never needs to know is there.
|
|
167
|
+
return this.failureAnswer(id, method, `MCP server "${this.opts.label ?? "unknown"}" is not available`);
|
|
168
|
+
}
|
|
169
|
+
/** An `isError` tool result for `tools/call`, a JSON-RPC error for anything else. */
|
|
170
|
+
failureAnswer(id, method, message) {
|
|
145
171
|
if (method === "tools/call") {
|
|
146
172
|
return {
|
|
147
173
|
jsonrpc: "2.0",
|
|
@@ -151,6 +177,44 @@ export class Relay {
|
|
|
151
177
|
}
|
|
152
178
|
return errorResponse(id, JsonRpcErrorCode.UpstreamUnavailable, message);
|
|
153
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* The upstream answered, but the answer could not be delivered (a line over
|
|
182
|
+
* the framing cap). Answer the request it was for, so the host's call ends
|
|
183
|
+
* with an error the model can react to instead of spinning until Esc.
|
|
184
|
+
*
|
|
185
|
+
* The id comes from the line's ends and may be missing. Without it, the
|
|
186
|
+
* request is identifiable only when the line was recognisably an answer
|
|
187
|
+
* (`info.response`) and exactly one host call — a `tools/call`, the request
|
|
188
|
+
* whose answers are the big ones — is in flight. Anything looser is a guess:
|
|
189
|
+
* an oversized notification used to be "matched" to the one call in flight,
|
|
190
|
+
* which got a false error and then its real answer too. With several in
|
|
191
|
+
* flight nothing is answered, and each keeps waiting for its own reply.
|
|
192
|
+
*/
|
|
193
|
+
onUndeliverable(info) {
|
|
194
|
+
const { hostOut, interceptor, label } = this.opts;
|
|
195
|
+
let target;
|
|
196
|
+
if (info.id !== undefined) {
|
|
197
|
+
target = this.pending.get(info.id);
|
|
198
|
+
}
|
|
199
|
+
else if (info.response && this.pending.size === 1) {
|
|
200
|
+
const only = [...this.pending.values()][0];
|
|
201
|
+
if (only.method === "tools/call")
|
|
202
|
+
target = only;
|
|
203
|
+
}
|
|
204
|
+
if (!target) {
|
|
205
|
+
logLine("relay.undeliverable_unmatched", { label, bytes: info.bytes, id: info.id, inFlight: this.pending.size });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.pending.delete(target.id);
|
|
209
|
+
logLine("relay.undeliverable", { label, method: target.method, id: target.id, bytes: info.bytes });
|
|
210
|
+
try {
|
|
211
|
+
interceptor?.onAbandoned?.(target, info.reason);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
logLine("relay.intercept_failed", { label, error: errText(err) });
|
|
215
|
+
}
|
|
216
|
+
writeFrame(hostOut, this.failureAnswer(target.id, target.method, capitalise(info.reason)), (err) => logLine("relay.write_failed", { label, error: errText(err) }));
|
|
217
|
+
}
|
|
154
218
|
abandonAll(reason) {
|
|
155
219
|
if (this.pending.size === 0)
|
|
156
220
|
return;
|
|
@@ -158,9 +222,17 @@ export class Relay {
|
|
|
158
222
|
logLine("relay.abandoning", { label, requests: this.pending.size, reason });
|
|
159
223
|
for (const [id, p] of [...this.pending]) {
|
|
160
224
|
this.pending.delete(id);
|
|
161
|
-
|
|
225
|
+
try {
|
|
226
|
+
interceptor?.onAbandoned?.(p, reason);
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
logLine("relay.intercept_failed", { label, error: errText(err) });
|
|
230
|
+
}
|
|
162
231
|
writeFrame(hostOut, this.deadUpstreamAnswer(p.id, p.method), (err) => logLine("relay.write_failed", { label, error: errText(err) }));
|
|
163
232
|
}
|
|
164
233
|
}
|
|
165
234
|
}
|
|
235
|
+
function capitalise(text) {
|
|
236
|
+
return text ? text[0].toUpperCase() + text.slice(1) : text;
|
|
237
|
+
}
|
|
166
238
|
//# sourceMappingURL=relay.js.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* revive — a bound proxy brings back the recorder that died under it.
|
|
3
|
+
*
|
|
4
|
+
* Every Claude Code hook but SessionStart is an HTTP post to the project's
|
|
5
|
+
* recorder. When that process dies mid-session (killed by an antivirus or the
|
|
6
|
+
* OOM killer, or a restart from another window that did not come back), every
|
|
7
|
+
* later prompt and tool call shows the user a hook error, and only a new
|
|
8
|
+
* session, `/clear` or a compaction — the next SessionStart — started it again.
|
|
9
|
+
* A bound proxy is the one long-lived process of the session that notices, so
|
|
10
|
+
* it asks for a new one: it launches `bir-hooks revive --cwd <project>`,
|
|
11
|
+
* detached, which starts the recorder under the same start lock the
|
|
12
|
+
* SessionStart hook uses, so any number of proxies (and bir-scenario) asking
|
|
13
|
+
* at once start one recorder.
|
|
14
|
+
*
|
|
15
|
+
* ONLY WHEN IT IS DEAD, NOT SLOW. All of these, checked in this order, cheapest
|
|
16
|
+
* first:
|
|
17
|
+
*
|
|
18
|
+
* - the project's discovery file is still there and names a pid that is no
|
|
19
|
+
* longer running. A recorder that stopped cleanly — `bir down`, a restart —
|
|
20
|
+
* removed its file, so a recorder the user stopped on purpose is never
|
|
21
|
+
* brought back from here; a pid that is alive (busy, or recycled by an
|
|
22
|
+
* unrelated process) is never second-guessed either;
|
|
23
|
+
* - the recorder's address refuses a TCP connection. Anything that accepts
|
|
24
|
+
* one — a recorder coming up, whatever else took the port — or does not
|
|
25
|
+
* answer within a couple of seconds is left alone;
|
|
26
|
+
* - `bir install` set this directory up (the sidecar has its project record).
|
|
27
|
+
*
|
|
28
|
+
* NEVER IN ANYONE'S WAY. {@link RecorderReviver.nudge} is synchronous and
|
|
29
|
+
* constant-time: it only arms a timer. The checks run on that timer, after a
|
|
30
|
+
* random 0-5 s (so the proxies of one session do not all launch in the same
|
|
31
|
+
* instant), and the launch is a detached, unref'd process with no stdio: the
|
|
32
|
+
* proxy neither waits for it nor learns how it went. At most one launch per
|
|
33
|
+
* {@link REVIVE_MIN_INTERVAL_MS} per process, backing off to
|
|
34
|
+
* {@link REVIVE_MAX_INTERVAL_MS} while launches keep not taking (a recorder
|
|
35
|
+
* that dies on start). Each launch is one audit line.
|
|
36
|
+
*
|
|
37
|
+
* Not for an explicit control channel (`BIR_CONTROL_URL` + `BIR_CONTROL_TOKEN`,
|
|
38
|
+
* the Agent SDK's): that control server is its owner's to run.
|
|
39
|
+
*/
|
|
40
|
+
/** At most one launch per process in this long. */
|
|
41
|
+
export declare const REVIVE_MIN_INTERVAL_MS = 30000;
|
|
42
|
+
/** ...doubling per launch that did not take, up to this. */
|
|
43
|
+
export declare const REVIVE_MAX_INTERVAL_MS: number;
|
|
44
|
+
/** A launch is delayed by a random 0 to this, so sibling proxies spread out. */
|
|
45
|
+
export declare const REVIVE_MAX_JITTER_MS = 5000;
|
|
46
|
+
/** The recorder a discovery file still names although its process is gone. */
|
|
47
|
+
export interface DeadRecorder {
|
|
48
|
+
pid: number;
|
|
49
|
+
url: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The project's discovery file as it is on disk, when the pid it names is no
|
|
53
|
+
* longer running. `readDiscovery` hides exactly these files — correctly, for
|
|
54
|
+
* binding — and they are the evidence here: a recorder that stopped cleanly
|
|
55
|
+
* removed its own.
|
|
56
|
+
*/
|
|
57
|
+
export declare function deadRecorder(cwd: string): Promise<DeadRecorder | undefined>;
|
|
58
|
+
/** Whether the install sidecar has a project record for `cwd`. Unreadable → no. */
|
|
59
|
+
export declare function projectRecordedHere(cwd: string): Promise<boolean>;
|
|
60
|
+
/**
|
|
61
|
+
* True only when a loopback `url`'s port actively refuses a TCP connection.
|
|
62
|
+
* Accepted, timed out, any other error, or not a loopback address: false.
|
|
63
|
+
*/
|
|
64
|
+
export declare function refusesConnections(url: string, timeoutMs?: number): Promise<boolean>;
|
|
65
|
+
/**
|
|
66
|
+
* Launch `bir-hooks revive --cwd <cwd>` detached, and forget it. Never throws.
|
|
67
|
+
*
|
|
68
|
+
* On Windows the revive always starts the recorder through PowerShell's
|
|
69
|
+
* `Start-Process`, whatever `BIR_WINDOWS_LAUNCH` says: a process started from
|
|
70
|
+
* here inherits this proxy's handles — its stdout is the host's JSON-RPC pipe
|
|
71
|
+
* — and a recorder launched directly from it would hold that pipe for days
|
|
72
|
+
* (see HOW IT IS LAUNCHED in control/daemon.ts). The revive itself lives for
|
|
73
|
+
* seconds.
|
|
74
|
+
*/
|
|
75
|
+
export declare function launchRevive(cwd: string, opts?: {
|
|
76
|
+
script?: string;
|
|
77
|
+
env?: NodeJS.ProcessEnv;
|
|
78
|
+
}): Promise<boolean>;
|
|
79
|
+
export interface ReviverOptions {
|
|
80
|
+
/** The project directory: the proxy's cwd. */
|
|
81
|
+
cwd: string;
|
|
82
|
+
/** The wrapped server, for the audit line. */
|
|
83
|
+
server: string;
|
|
84
|
+
/**
|
|
85
|
+
* The environment the revive (and so the recorder it starts) is given:
|
|
86
|
+
* the one this process was started with. Default `process.env`.
|
|
87
|
+
*/
|
|
88
|
+
env?: NodeJS.ProcessEnv;
|
|
89
|
+
/** Launch the revive. Default {@link launchRevive}; tests inject one. */
|
|
90
|
+
launch?: (cwd: string) => Promise<boolean> | boolean;
|
|
91
|
+
minIntervalMs?: number;
|
|
92
|
+
maxIntervalMs?: number;
|
|
93
|
+
maxJitterMs?: number;
|
|
94
|
+
checkIntervalMs?: number;
|
|
95
|
+
}
|
|
96
|
+
/** One per bound proxy: decides whether, and when, to launch a revive. */
|
|
97
|
+
export declare class RecorderReviver {
|
|
98
|
+
private readonly opts;
|
|
99
|
+
private readonly minIntervalMs;
|
|
100
|
+
private readonly maxIntervalMs;
|
|
101
|
+
private readonly maxJitterMs;
|
|
102
|
+
private readonly checkIntervalMs;
|
|
103
|
+
private interval;
|
|
104
|
+
private timer?;
|
|
105
|
+
private pending;
|
|
106
|
+
private stopped;
|
|
107
|
+
private nextLaunchAt;
|
|
108
|
+
private nextCheckAt;
|
|
109
|
+
private lastLaunchAt;
|
|
110
|
+
/** How many revives this process launched. */
|
|
111
|
+
launches: number;
|
|
112
|
+
constructor(opts: ReviverOptions);
|
|
113
|
+
/**
|
|
114
|
+
* The recorder did not answer. Arms the check, unless one is pending or
|
|
115
|
+
* the rate limit says not yet. Synchronous, constant-time, never throws.
|
|
116
|
+
*/
|
|
117
|
+
nudge(why: string): void;
|
|
118
|
+
/** No more launches: the proxy is closing. */
|
|
119
|
+
stop(): void;
|
|
120
|
+
private attempt;
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=revive.d.ts.map
|