@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
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* framing — line-delimited JSON-RPC over a byte stream (Phase 1.1).
|
|
3
3
|
*
|
|
4
|
-
* MCP's stdio transport is one JSON value per line.
|
|
5
|
-
* implementations, and all
|
|
4
|
+
* MCP's stdio transport is one JSON value per line. Four things break naive
|
|
5
|
+
* implementations, and all four are handled here:
|
|
6
6
|
*
|
|
7
7
|
* 1. **Partial reads.** A chunk boundary lands mid-message. Bytes are buffered
|
|
8
8
|
* until a newline arrives.
|
|
9
|
-
* 2. **
|
|
9
|
+
* 2. **Large lines.** A 20 MB screenshot arrives as ~300 pipe reads. The
|
|
10
|
+
* reader keeps the pieces in a list and searches only the *new* chunk for
|
|
11
|
+
* the newline, then joins the pieces once per complete line — linear in
|
|
12
|
+
* the line's size. (It used to re-concatenate and re-scan the whole
|
|
13
|
+
* pending buffer on every chunk: 0.8 s of pure copying at 20 MB, 1.7 s at
|
|
14
|
+
* 30 MB, all of it added to the user's tool call.)
|
|
15
|
+
* 3. **Oversized lines.** A runaway upstream can otherwise grow the buffer
|
|
10
16
|
* without bound. Past `maxLineBytes` the reader drops bytes until the next
|
|
11
|
-
* newline and reports one error
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* newline and reports one error *when the line ends*, with its first and
|
|
18
|
+
* last bytes, so a caller can still find the request id in it and answer
|
|
19
|
+
* the request instead of leaving it hanging (see stdio-client.ts).
|
|
20
|
+
* 4. **Invalid JSON.** Reported to the caller (which answers `-32700` toward a
|
|
14
21
|
* host, or logs and drops toward an upstream). It never throws.
|
|
15
22
|
*
|
|
16
23
|
* Buffering is done on `Buffer`, not on a decoded string: a multi-byte UTF-8
|
|
@@ -19,16 +26,33 @@
|
|
|
19
26
|
*/
|
|
20
27
|
import type { Readable, Writable } from "node:stream";
|
|
21
28
|
import type { JsonRpcMessage } from "./types.js";
|
|
22
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* 128 MiB. Large enough for any realistic tool result (a full-page screenshot
|
|
31
|
+
* at high DPI is tens of MB once base64-encoded), small enough to be a bound:
|
|
32
|
+
* one line costs roughly three to four times its size while it is joined,
|
|
33
|
+
* decoded and parsed, and V8 caps a single string at ~512 MB.
|
|
34
|
+
*/
|
|
23
35
|
export declare const DEFAULT_MAX_LINE_BYTES: number;
|
|
36
|
+
/** How many bytes of each end of an oversized line are kept for the report. */
|
|
37
|
+
export declare const OVERSIZE_SAMPLE_BYTES = 256;
|
|
38
|
+
export interface ParseErrorDetail {
|
|
39
|
+
reason: string;
|
|
40
|
+
bytes: number;
|
|
41
|
+
/** The first bytes of the offending line, decoded (lossy at the cut). */
|
|
42
|
+
sample: string;
|
|
43
|
+
/** Set only for a line over `maxLineBytes`. */
|
|
44
|
+
oversized?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The last bytes of an oversized line, decoded. The TypeScript MCP SDK
|
|
47
|
+
* serialises `id` *last* (`{ result, jsonrpc, id }`), so this is where a
|
|
48
|
+
* response's id usually is; Python/pydantic puts it first, in `sample`.
|
|
49
|
+
*/
|
|
50
|
+
tail?: string;
|
|
51
|
+
}
|
|
24
52
|
export interface FrameHandlers {
|
|
25
53
|
onMessage: (msg: JsonRpcMessage) => void;
|
|
26
54
|
/** A line that was not valid JSON, or that exceeded `maxLineBytes`. */
|
|
27
|
-
onParseError?: (detail:
|
|
28
|
-
reason: string;
|
|
29
|
-
bytes: number;
|
|
30
|
-
sample: string;
|
|
31
|
-
}) => void;
|
|
55
|
+
onParseError?: (detail: ParseErrorDetail) => void;
|
|
32
56
|
/** The input stream ended or errored. Called at most once. */
|
|
33
57
|
onEnd?: (err?: Error) => void;
|
|
34
58
|
}
|
|
@@ -46,4 +70,44 @@ export declare function readFrames(input: Readable, handlers: FrameHandlers, opt
|
|
|
46
70
|
* session down with it, which rule 4 of §1 forbids.
|
|
47
71
|
*/
|
|
48
72
|
export declare function writeFrame(output: Writable, msg: unknown, onError?: (err: Error) => void): void;
|
|
73
|
+
/**
|
|
74
|
+
* What an oversized line was, as far as its two ends tell:
|
|
75
|
+
*
|
|
76
|
+
* - `request` — a request or notification from the upstream (a huge log
|
|
77
|
+
* line, a sampling request carrying an image). Nobody on the host side is
|
|
78
|
+
* waiting for it by id; `id`, when there is one, is in the *upstream's*
|
|
79
|
+
* id space and must never be matched against a host's request.
|
|
80
|
+
* - `response` — recognisably an answer, `id` when either end carries it.
|
|
81
|
+
* - `unknown` — neither end says; `id` is what the tail's usual position
|
|
82
|
+
* held, if anything, and is only good for an exact match.
|
|
83
|
+
*/
|
|
84
|
+
export type OversizedLineKind = {
|
|
85
|
+
kind: "request";
|
|
86
|
+
id?: string | number;
|
|
87
|
+
} | {
|
|
88
|
+
kind: "response";
|
|
89
|
+
id?: string | number;
|
|
90
|
+
} | {
|
|
91
|
+
kind: "unknown";
|
|
92
|
+
id?: string | number;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Classify an oversized line from its first and last bytes.
|
|
96
|
+
*
|
|
97
|
+
* Only a top-level `"id"` is wanted, and a nested one (inside `result`) could
|
|
98
|
+
* sit near either end, so each end is tried only in the position a serialiser
|
|
99
|
+
* puts the top-level key: last, right before the final brace (`…,"id":7}`,
|
|
100
|
+
* the TypeScript SDK), or first (`{"jsonrpc":"2.0","id":7,…`, Python/pydantic).
|
|
101
|
+
* A nested `"id"` is followed by more than one closing bracket, so it does not
|
|
102
|
+
* match the first form, and cannot be at the start, so it does not match the
|
|
103
|
+
* second.
|
|
104
|
+
*/
|
|
105
|
+
export declare function classifyOversizedLine(sample: string, tail?: string): OversizedLineKind;
|
|
106
|
+
/**
|
|
107
|
+
* Best-effort recovery of the `id` of an oversized JSON-RPC **response** from
|
|
108
|
+
* its first and last bytes. `undefined` when neither end carries a
|
|
109
|
+
* recognisable one, or when the line looks like a request (whose id lives in
|
|
110
|
+
* the upstream's own id space and must never be mistaken for a host's).
|
|
111
|
+
*/
|
|
112
|
+
export declare function recoverResponseId(sample: string, tail?: string): string | number | undefined;
|
|
49
113
|
//# sourceMappingURL=framing.d.ts.map
|
package/dist/jsonrpc/framing.js
CHANGED
|
@@ -1,107 +1,196 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* framing — line-delimited JSON-RPC over a byte stream (Phase 1.1).
|
|
3
3
|
*
|
|
4
|
-
* MCP's stdio transport is one JSON value per line.
|
|
5
|
-
* implementations, and all
|
|
4
|
+
* MCP's stdio transport is one JSON value per line. Four things break naive
|
|
5
|
+
* implementations, and all four are handled here:
|
|
6
6
|
*
|
|
7
7
|
* 1. **Partial reads.** A chunk boundary lands mid-message. Bytes are buffered
|
|
8
8
|
* until a newline arrives.
|
|
9
|
-
* 2. **
|
|
9
|
+
* 2. **Large lines.** A 20 MB screenshot arrives as ~300 pipe reads. The
|
|
10
|
+
* reader keeps the pieces in a list and searches only the *new* chunk for
|
|
11
|
+
* the newline, then joins the pieces once per complete line — linear in
|
|
12
|
+
* the line's size. (It used to re-concatenate and re-scan the whole
|
|
13
|
+
* pending buffer on every chunk: 0.8 s of pure copying at 20 MB, 1.7 s at
|
|
14
|
+
* 30 MB, all of it added to the user's tool call.)
|
|
15
|
+
* 3. **Oversized lines.** A runaway upstream can otherwise grow the buffer
|
|
10
16
|
* without bound. Past `maxLineBytes` the reader drops bytes until the next
|
|
11
|
-
* newline and reports one error
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* newline and reports one error *when the line ends*, with its first and
|
|
18
|
+
* last bytes, so a caller can still find the request id in it and answer
|
|
19
|
+
* the request instead of leaving it hanging (see stdio-client.ts).
|
|
20
|
+
* 4. **Invalid JSON.** Reported to the caller (which answers `-32700` toward a
|
|
14
21
|
* host, or logs and drops toward an upstream). It never throws.
|
|
15
22
|
*
|
|
16
23
|
* Buffering is done on `Buffer`, not on a decoded string: a multi-byte UTF-8
|
|
17
24
|
* character split across two chunks decodes to replacement characters if you
|
|
18
25
|
* concatenate strings, which silently corrupts payloads containing non-ASCII.
|
|
19
26
|
*/
|
|
20
|
-
/**
|
|
21
|
-
|
|
27
|
+
/**
|
|
28
|
+
* 128 MiB. Large enough for any realistic tool result (a full-page screenshot
|
|
29
|
+
* at high DPI is tens of MB once base64-encoded), small enough to be a bound:
|
|
30
|
+
* one line costs roughly three to four times its size while it is joined,
|
|
31
|
+
* decoded and parsed, and V8 caps a single string at ~512 MB.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_MAX_LINE_BYTES = 128 * 1024 * 1024;
|
|
34
|
+
/** How many bytes of each end of an oversized line are kept for the report. */
|
|
35
|
+
export const OVERSIZE_SAMPLE_BYTES = 256;
|
|
22
36
|
const NEWLINE = 0x0a;
|
|
37
|
+
const EMPTY = Buffer.alloc(0);
|
|
38
|
+
/** The last `n` bytes of `a` followed by `b`, as a fresh buffer. */
|
|
39
|
+
function lastBytes(a, b, n) {
|
|
40
|
+
if (b.length >= n)
|
|
41
|
+
return Buffer.from(b.subarray(b.length - n));
|
|
42
|
+
const joined = Buffer.concat([a, b]);
|
|
43
|
+
return joined.length > n ? Buffer.from(joined.subarray(joined.length - n)) : joined;
|
|
44
|
+
}
|
|
45
|
+
/** The first `n` bytes across `parts`, as a fresh buffer. */
|
|
46
|
+
function firstBytes(parts, n) {
|
|
47
|
+
const out = [];
|
|
48
|
+
let have = 0;
|
|
49
|
+
for (const p of parts) {
|
|
50
|
+
if (have >= n)
|
|
51
|
+
break;
|
|
52
|
+
const take = p.subarray(0, n - have);
|
|
53
|
+
out.push(take);
|
|
54
|
+
have += take.length;
|
|
55
|
+
}
|
|
56
|
+
return Buffer.concat(out, have);
|
|
57
|
+
}
|
|
58
|
+
/** The last `n` bytes across `parts`, as a fresh buffer. */
|
|
59
|
+
function lastBytesOf(parts, n) {
|
|
60
|
+
const out = [];
|
|
61
|
+
let have = 0;
|
|
62
|
+
for (let i = parts.length - 1; i >= 0 && have < n; i -= 1) {
|
|
63
|
+
const p = parts[i];
|
|
64
|
+
const take = p.subarray(Math.max(0, p.length - (n - have)));
|
|
65
|
+
out.unshift(take);
|
|
66
|
+
have += take.length;
|
|
67
|
+
}
|
|
68
|
+
return Buffer.concat(out, have);
|
|
69
|
+
}
|
|
23
70
|
/**
|
|
24
71
|
* Read newline-delimited JSON messages off `input`.
|
|
25
72
|
* Returns a detach function that stops the reader (it does not close `input`).
|
|
26
73
|
*/
|
|
27
74
|
export function readFrames(input, handlers, opts = {}) {
|
|
28
75
|
const maxLineBytes = opts.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
|
|
29
|
-
|
|
76
|
+
/** The current line's bytes so far, in arrival order. Joined once, at its newline. */
|
|
77
|
+
let parts = [];
|
|
78
|
+
let pendingBytes = 0;
|
|
30
79
|
/** Set after an oversized line: drop bytes until the next newline resynchronises us. */
|
|
31
80
|
let discarding = false;
|
|
32
81
|
let discarded = 0;
|
|
82
|
+
let head = EMPTY;
|
|
83
|
+
let tail = EMPTY;
|
|
33
84
|
let ended = false;
|
|
85
|
+
const reportOversized = () => {
|
|
86
|
+
handlers.onParseError?.({
|
|
87
|
+
reason: `line exceeded ${maxLineBytes} bytes`,
|
|
88
|
+
bytes: discarded,
|
|
89
|
+
sample: head.toString("utf8"),
|
|
90
|
+
oversized: true,
|
|
91
|
+
tail: tail.toString("utf8"),
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
const enterDiscard = () => {
|
|
95
|
+
head = firstBytes(parts, OVERSIZE_SAMPLE_BYTES);
|
|
96
|
+
tail = lastBytesOf(parts, OVERSIZE_SAMPLE_BYTES);
|
|
97
|
+
discarding = true;
|
|
98
|
+
discarded = pendingBytes;
|
|
99
|
+
parts = [];
|
|
100
|
+
pendingBytes = 0;
|
|
101
|
+
};
|
|
34
102
|
const finish = (err) => {
|
|
35
103
|
if (ended)
|
|
36
104
|
return;
|
|
37
105
|
ended = true;
|
|
106
|
+
if (discarding) {
|
|
107
|
+
// The stream ended inside an oversized line: still say so, once.
|
|
108
|
+
discarding = false;
|
|
109
|
+
reportOversized();
|
|
110
|
+
}
|
|
38
111
|
handlers.onEnd?.(err);
|
|
39
112
|
};
|
|
113
|
+
const handleLine = (line) => {
|
|
114
|
+
if (line.length === 0)
|
|
115
|
+
return; // keep-alive blank lines are legal noise
|
|
116
|
+
if (line.length > maxLineBytes) {
|
|
117
|
+
head = Buffer.from(line.subarray(0, OVERSIZE_SAMPLE_BYTES));
|
|
118
|
+
tail = Buffer.from(line.subarray(Math.max(0, line.length - OVERSIZE_SAMPLE_BYTES)));
|
|
119
|
+
discarded = line.length;
|
|
120
|
+
reportOversized();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const text = line.toString("utf8");
|
|
124
|
+
let msg;
|
|
125
|
+
try {
|
|
126
|
+
msg = JSON.parse(text);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
handlers.onParseError?.({
|
|
130
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
131
|
+
bytes: line.length,
|
|
132
|
+
sample: text.slice(0, 120),
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
|
|
137
|
+
// A bare scalar or a JSON-RPC batch. Neither appears in MCP; both would
|
|
138
|
+
// desynchronise a 1:1 relay, so they are reported rather than forwarded.
|
|
139
|
+
handlers.onParseError?.({
|
|
140
|
+
reason: "message was not a JSON object",
|
|
141
|
+
bytes: line.length,
|
|
142
|
+
sample: text.slice(0, 120),
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
handlers.onMessage(msg);
|
|
147
|
+
};
|
|
40
148
|
const onData = (chunk) => {
|
|
41
|
-
buf =
|
|
42
|
-
|
|
43
|
-
|
|
149
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
150
|
+
let start = 0;
|
|
151
|
+
while (start < buf.length) {
|
|
152
|
+
// Only the new bytes are searched: everything already in `parts` is known
|
|
153
|
+
// to contain no newline.
|
|
154
|
+
const nl = buf.indexOf(NEWLINE, start);
|
|
44
155
|
if (nl === -1) {
|
|
156
|
+
const rest = buf.subarray(start);
|
|
45
157
|
if (discarding) {
|
|
46
|
-
discarded +=
|
|
47
|
-
|
|
158
|
+
discarded += rest.length;
|
|
159
|
+
tail = lastBytes(tail, rest, OVERSIZE_SAMPLE_BYTES);
|
|
48
160
|
}
|
|
49
|
-
else
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
});
|
|
56
|
-
discarding = true;
|
|
57
|
-
discarded = buf.length;
|
|
58
|
-
buf = Buffer.alloc(0);
|
|
161
|
+
else {
|
|
162
|
+
parts.push(rest);
|
|
163
|
+
pendingBytes += rest.length;
|
|
164
|
+
// Enter discard mode; the one report goes out when the line ends.
|
|
165
|
+
if (pendingBytes > maxLineBytes)
|
|
166
|
+
enterDiscard();
|
|
59
167
|
}
|
|
60
168
|
return;
|
|
61
169
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
170
|
+
const piece = buf.subarray(start, nl);
|
|
171
|
+
start = nl + 1;
|
|
64
172
|
if (discarding) {
|
|
65
173
|
// The tail of an oversized line. Resynchronised — resume normal reading.
|
|
66
|
-
discarded +=
|
|
174
|
+
discarded += piece.length;
|
|
175
|
+
tail = lastBytes(tail, piece, OVERSIZE_SAMPLE_BYTES);
|
|
67
176
|
discarding = false;
|
|
177
|
+
reportOversized();
|
|
68
178
|
discarded = 0;
|
|
179
|
+
head = EMPTY;
|
|
180
|
+
tail = EMPTY;
|
|
69
181
|
continue;
|
|
70
182
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
handlers.onParseError?.({
|
|
75
|
-
reason: `line exceeded ${maxLineBytes} bytes`,
|
|
76
|
-
bytes: line.length,
|
|
77
|
-
sample: line.subarray(0, 120).toString("utf8"),
|
|
78
|
-
});
|
|
79
|
-
continue;
|
|
183
|
+
let line;
|
|
184
|
+
if (pendingBytes === 0) {
|
|
185
|
+
line = piece;
|
|
80
186
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
187
|
+
else {
|
|
188
|
+
parts.push(piece);
|
|
189
|
+
line = Buffer.concat(parts, pendingBytes + piece.length);
|
|
190
|
+
parts = [];
|
|
191
|
+
pendingBytes = 0;
|
|
85
192
|
}
|
|
86
|
-
|
|
87
|
-
handlers.onParseError?.({
|
|
88
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
89
|
-
bytes: line.length,
|
|
90
|
-
sample: text.slice(0, 120),
|
|
91
|
-
});
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
|
|
95
|
-
// A bare scalar or a JSON-RPC batch. Neither appears in MCP; both would
|
|
96
|
-
// desynchronise a 1:1 relay, so they are reported rather than forwarded.
|
|
97
|
-
handlers.onParseError?.({
|
|
98
|
-
reason: "message was not a JSON object",
|
|
99
|
-
bytes: line.length,
|
|
100
|
-
sample: text.slice(0, 120),
|
|
101
|
-
});
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
handlers.onMessage(msg);
|
|
193
|
+
handleLine(line);
|
|
105
194
|
}
|
|
106
195
|
};
|
|
107
196
|
const onError = (err) => finish(err);
|
|
@@ -140,4 +229,72 @@ export function writeFrame(output, msg, onError) {
|
|
|
140
229
|
onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
141
230
|
}
|
|
142
231
|
}
|
|
232
|
+
const ID_VALUE = String.raw `("(?:[^"\\]|\\.)*"|-?\d+)`;
|
|
233
|
+
/** An optional leading `"jsonrpc":"2.0",` — where Python/pydantic, Go and Rust put it. */
|
|
234
|
+
const OPT_JSONRPC = String.raw `(?:"jsonrpc"\s*:\s*"2\.0"\s*,\s*)?`;
|
|
235
|
+
/**
|
|
236
|
+
* `{"method":…` (TypeScript SDK) or `{"jsonrpc":"2.0","method":…` (Python) —
|
|
237
|
+
* a request or notification, not an answer.
|
|
238
|
+
*/
|
|
239
|
+
const LEADS_WITH_METHOD = new RegExp(String.raw `^\s*\{\s*${OPT_JSONRPC}"method"\s*:`);
|
|
240
|
+
/** `{"result":…` / `{"error":…` (TypeScript SDK), with or without `jsonrpc` first. */
|
|
241
|
+
const LEADS_WITH_OUTCOME = new RegExp(String.raw `^\s*\{\s*${OPT_JSONRPC}"(?:result|error)"\s*:`);
|
|
242
|
+
/**
|
|
243
|
+
* `{"jsonrpc":"2.0","id":7,<next key>` / `{"id":7,<next key>` (Python and most
|
|
244
|
+
* other serialisers). The key after the id says which kind of message it is.
|
|
245
|
+
*/
|
|
246
|
+
const LEADING_ID = new RegExp(String.raw `^\s*\{\s*${OPT_JSONRPC}"id"\s*:\s*${ID_VALUE}\s*(?:,\s*"(method|result|error)"\s*:)?`);
|
|
247
|
+
/** `…,"id":7}` at the very end (TypeScript SDK key order). */
|
|
248
|
+
const TRAILING_ID = new RegExp(String.raw `"id"\s*:\s*${ID_VALUE}\s*\}\s*$`);
|
|
249
|
+
/**
|
|
250
|
+
* Classify an oversized line from its first and last bytes.
|
|
251
|
+
*
|
|
252
|
+
* Only a top-level `"id"` is wanted, and a nested one (inside `result`) could
|
|
253
|
+
* sit near either end, so each end is tried only in the position a serialiser
|
|
254
|
+
* puts the top-level key: last, right before the final brace (`…,"id":7}`,
|
|
255
|
+
* the TypeScript SDK), or first (`{"jsonrpc":"2.0","id":7,…`, Python/pydantic).
|
|
256
|
+
* A nested `"id"` is followed by more than one closing bracket, so it does not
|
|
257
|
+
* match the first form, and cannot be at the start, so it does not match the
|
|
258
|
+
* second.
|
|
259
|
+
*/
|
|
260
|
+
export function classifyOversizedLine(sample, tail) {
|
|
261
|
+
const parse = (raw) => {
|
|
262
|
+
try {
|
|
263
|
+
const v = JSON.parse(raw);
|
|
264
|
+
return typeof v === "string" || typeof v === "number" ? v : undefined;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const trailing = () => {
|
|
271
|
+
const m = tail ? TRAILING_ID.exec(tail) : null;
|
|
272
|
+
return m ? parse(m[1]) : undefined;
|
|
273
|
+
};
|
|
274
|
+
// TypeScript SDK requests end in their id; Python ones lead with it.
|
|
275
|
+
if (LEADS_WITH_METHOD.test(sample))
|
|
276
|
+
return { kind: "request", id: trailing() };
|
|
277
|
+
const lead = LEADING_ID.exec(sample);
|
|
278
|
+
if (lead) {
|
|
279
|
+
const id = parse(lead[1]);
|
|
280
|
+
if (lead[2] === "method")
|
|
281
|
+
return { kind: "request", id };
|
|
282
|
+
if (lead[2] === "result" || lead[2] === "error")
|
|
283
|
+
return { kind: "response", id };
|
|
284
|
+
return { kind: "unknown", id };
|
|
285
|
+
}
|
|
286
|
+
if (LEADS_WITH_OUTCOME.test(sample))
|
|
287
|
+
return { kind: "response", id: trailing() };
|
|
288
|
+
return { kind: "unknown", id: trailing() };
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Best-effort recovery of the `id` of an oversized JSON-RPC **response** from
|
|
292
|
+
* its first and last bytes. `undefined` when neither end carries a
|
|
293
|
+
* recognisable one, or when the line looks like a request (whose id lives in
|
|
294
|
+
* the upstream's own id space and must never be mistaken for a host's).
|
|
295
|
+
*/
|
|
296
|
+
export function recoverResponseId(sample, tail) {
|
|
297
|
+
const shape = classifyOversizedLine(sample, tail);
|
|
298
|
+
return shape.kind === "request" ? undefined : shape.id;
|
|
299
|
+
}
|
|
143
300
|
//# sourceMappingURL=framing.js.map
|
|
@@ -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,10 +19,13 @@
|
|
|
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 } from "../control/correlation.js";
|
|
28
|
+
import { BIR_CALL_ID, type ProxyStepReport } from "../control/correlation.js";
|
|
26
29
|
import { type JsonRpcMessage, type JsonRpcResponse } from "../jsonrpc/types.js";
|
|
27
30
|
import type { Interceptor, PendingRequest } from "./relay.js";
|
|
28
31
|
import type { ProxySession } from "./session.js";
|
|
@@ -30,6 +33,28 @@ export interface RecordingInterceptorOptions {
|
|
|
30
33
|
serverName: string;
|
|
31
34
|
session: ProxySession;
|
|
32
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* A step report as this proxy sends it. `argsFingerprint` is the fingerprint
|
|
38
|
+
* join key computed on the *raw* arguments: the `args` a report carries are
|
|
39
|
+
* redacted and clipped, so a fingerprint the control server computed from them
|
|
40
|
+
* would miss its hook-side twin whenever redaction or clipping changed
|
|
41
|
+
* anything. Extra, so an older control server just ignores it.
|
|
42
|
+
*/
|
|
43
|
+
export type ProxyStepWireReport = ProxyStepReport & {
|
|
44
|
+
argsFingerprint?: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Declare the call-id key on one tool's input schema so a host that validates
|
|
48
|
+
* `updatedInput` against it accepts an injected id.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately minimal — the model reads these schemas on every request: no
|
|
51
|
+
* text naming the runner, and `additionalProperties` stays whatever the upstream
|
|
52
|
+
* declared (declaring the key is already enough; forcing `true` advertised a
|
|
53
|
+
* strict server as accepting anything, inviting arguments it then rejects).
|
|
54
|
+
*/
|
|
55
|
+
export declare function tolerateCallId(schema: unknown): unknown;
|
|
56
|
+
/** Apply {@link tolerateCallId} to every tool of a `tools/list` result. */
|
|
57
|
+
export declare function tolerateCallIdInToolsList(result: unknown): unknown;
|
|
33
58
|
export declare class RecordingInterceptor implements Interceptor {
|
|
34
59
|
private readonly serverName;
|
|
35
60
|
private readonly session;
|
|
@@ -42,7 +67,12 @@ export declare class RecordingInterceptor implements Interceptor {
|
|
|
42
67
|
/** Build the step report from a completed call. Redaction happens on the copy. */
|
|
43
68
|
private report;
|
|
44
69
|
/**
|
|
45
|
-
*
|
|
70
|
+
* Clip, redact, clip — on the recording copy only.
|
|
71
|
+
*
|
|
72
|
+
* Clipping first keeps redaction's regexes off megabytes that are about to be
|
|
73
|
+
* thrown away. It clips to *twice* the cap first, so a secret straddling the
|
|
74
|
+
* final cut is still whole — and still recognisable — when redaction runs;
|
|
75
|
+
* clipping straight to the cap could leave a token prefix no pattern matches.
|
|
46
76
|
*
|
|
47
77
|
* Clipping happens here rather than only at serialization time because a
|
|
48
78
|
* browser MCP's base64 screenshot would otherwise sit in the step queue in
|
|
@@ -50,6 +80,20 @@ export declare class RecordingInterceptor implements Interceptor {
|
|
|
50
80
|
*/
|
|
51
81
|
private scrub;
|
|
52
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* The interceptor of a proxy that records nothing — started while the runner
|
|
85
|
+
* was off (`bir off`, `BIR_OFF=1`) with a remote upstream, where there is no
|
|
86
|
+
* process to hand stdio to and the relay must stay in the path.
|
|
87
|
+
*
|
|
88
|
+
* It does one thing: strip the call id. The runner can be switched back on
|
|
89
|
+
* mid-session, and a control server may then inject an id into a call routed
|
|
90
|
+
* here (a replay-pinned step, `bypassPermissions`, an explicit opt-in). A
|
|
91
|
+
* strict upstream would reject the unknown key, and the user would see a
|
|
92
|
+
* tool error the runner caused. No tracking, no reporting, no schema change.
|
|
93
|
+
*/
|
|
94
|
+
export declare class StripCallIdInterceptor implements Interceptor {
|
|
95
|
+
onHostMessage(msg: JsonRpcMessage): ReturnType<NonNullable<Interceptor["onHostMessage"]>>;
|
|
96
|
+
}
|
|
53
97
|
/** Re-exported so callers wiring a proxy need one import for the correlation key. */
|
|
54
98
|
export { BIR_CALL_ID };
|
|
55
99
|
//# sourceMappingURL=intercept.d.ts.map
|