@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.
Files changed (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. 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. Three things break naive
5
- * implementations, and all three are handled here:
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. **Oversized lines.** A runaway upstream can otherwise grow the buffer
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, rather than allocating until the process
12
- * dies.
13
- * 3. **Invalid JSON.** Reported to the caller (which answers `-32700` toward a
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
- /** 32 MiB. Large enough for a base64 screenshot, small enough to be a bound. */
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
@@ -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. Three things break naive
5
- * implementations, and all three are handled here:
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. **Oversized lines.** A runaway upstream can otherwise grow the buffer
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, rather than allocating until the process
12
- * dies.
13
- * 3. **Invalid JSON.** Reported to the caller (which answers `-32700` toward a
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
- /** 32 MiB. Large enough for a base64 screenshot, small enough to be a bound. */
21
- export const DEFAULT_MAX_LINE_BYTES = 32 * 1024 * 1024;
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
- let buf = Buffer.alloc(0);
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 = Buffer.concat([buf, typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk]);
42
- for (;;) {
43
- const nl = buf.indexOf(NEWLINE);
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 += buf.length;
47
- buf = Buffer.alloc(0);
158
+ discarded += rest.length;
159
+ tail = lastBytes(tail, rest, OVERSIZE_SAMPLE_BYTES);
48
160
  }
49
- else if (buf.length > maxLineBytes) {
50
- // Enter discard mode; report once for the whole oversized line.
51
- handlers.onParseError?.({
52
- reason: `line exceeded ${maxLineBytes} bytes`,
53
- bytes: buf.length,
54
- sample: buf.subarray(0, 120).toString("utf8"),
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 line = buf.subarray(0, nl);
63
- buf = buf.subarray(nl + 1);
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 += line.length;
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
- if (line.length === 0)
72
- continue; // keep-alive blank lines are legal noise
73
- if (line.length > maxLineBytes) {
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
- const text = line.toString("utf8");
82
- let msg;
83
- try {
84
- msg = JSON.parse(text);
187
+ else {
188
+ parts.push(piece);
189
+ line = Buffer.concat(parts, pendingBytes + piece.length);
190
+ parts = [];
191
+ pendingBytes = 0;
85
192
  }
86
- catch (err) {
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; relax schemas only when Tier 1 needs it.|
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.** The step is queued after the
23
- * result is already on its way to the host.
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
- * Redact, then clip long strings, on the recording copy only.
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