@basein/runner 0.2.12 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +540 -369
  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 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  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 +2786 -302
  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/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -0,0 +1,143 @@
1
+ /**
2
+ * http-transport — the one HTTP primitive the remote upstream (http-client.ts)
3
+ * sends through. Built on `node:http` / `node:https`, not on `fetch`.
4
+ *
5
+ * WHY NOT FETCH. Node's fetch runs on a global undici dispatcher whose defaults
6
+ * are a 300 s headers timeout and a 300 s body-idle timeout, and nothing short
7
+ * of a dependency (or a Node-internal symbol) changes them per request. Both cut
8
+ * off legitimate MCP traffic:
9
+ *
10
+ * - a remote tool that works for more than five minutes before answering (a
11
+ * report, a build, a crawl) was reported at 306 s as `upstream unreachable:
12
+ * fetch failed`, however long the user had told Claude Code to wait;
13
+ * - an SSE stream with nothing to say for five minutes (the legacy transport's
14
+ * event stream, the standing GET, a slow tool's reply stream) was
15
+ * terminated, which on the legacy transport killed the server for the rest
16
+ * of the session.
17
+ *
18
+ * `node:http` has no implicit timeouts at all, so the bounds here are exactly
19
+ * the ones we choose:
20
+ *
21
+ * - **connect**: DNS + TCP + TLS (+ a proxy tunnel) must finish within
22
+ * `connectTimeoutMs`, so a blackholed or captive host fails in seconds
23
+ * rather than after the OS connect timeout (21 s on Windows, 75 s on macOS);
24
+ * - **after connect, none**: how long an answer may take is the host's call
25
+ * (Claude Code's own tool timeouts, and its `notifications/cancelled`), never
26
+ * ours. Dead peers are still noticed: sockets get TCP keep-alive probes.
27
+ * - **idle in the pool**: a keep-alive socket nobody is using is closed after
28
+ * 4 s, or 1 s before the server's `Keep-Alive: timeout=N`, as undici did —
29
+ * so we never reuse one the server is in the middle of closing.
30
+ *
31
+ * It also makes the failure reason concrete. fetch reports every network error
32
+ * as `fetch failed` and hides the cause; here the message is the socket's own
33
+ * (`connect ECONNREFUSED …`, `getaddrinfo ENOTFOUND …`, `self-signed
34
+ * certificate in certificate chain`), which is what a user or `bir doctor`
35
+ * needs to act on.
36
+ *
37
+ * WHAT FETCH DID THAT IS KEPT: redirects are followed (307/308 for any method,
38
+ * 301/302/303 for GET — Starlette answers `/mcp` with a 307 to `/mcp/`); a
39
+ * gzip/deflate/br body is decoded; `User-Agent: node` is sent, so a WAF rule
40
+ * written against the old client still matches. Compression is not *asked*
41
+ * for (no `Accept-Encoding`): an SSE reply behind a compressing middleware can
42
+ * sit in its buffer, and MCP messages are small.
43
+ *
44
+ * WHAT IS STRICTER THAN FETCH: the user's headers stay with the origin they
45
+ * were configured for. fetch drops only `Authorization`, `Cookie` and
46
+ * `Proxy-Authorization` when a redirect leaves the origin, but a remote
47
+ * server's credential is as often `X-Api-Key`, `Private-Token` or a tenancy
48
+ * header — so a redirect to another origin carries only the MCP protocol's own
49
+ * headers ({@link CROSS_ORIGIN_KEPT}), and every header that came from
50
+ * `BIR_UPSTREAM_HEADERS` or `--header` is dropped. And a redirect from https to
51
+ * plain http is refused outright (a loopback target excepted: it never leaves
52
+ * the machine): following it would send the request — the tool call, its
53
+ * arguments, the session id — in clear text.
54
+ *
55
+ * PROXIES (HTTPS_PROXY / HTTP_PROXY / NO_PROXY). Claude Code honours them, so a
56
+ * server it could reach through a corporate proxy must stay reachable once
57
+ * wrapped. The agents are built with `proxyEnv: process.env`, which Node's own
58
+ * http stack implements from Node 24.5 (and the 22.x line from 22.21); older
59
+ * Nodes ignore the option and connect directly — `upstream.proxy_env_ignored`
60
+ * in the audit log says so when that is the likely cause of a failure.
61
+ * Loopback targets (`localhost`, 127.0.0.0/8, ::1) always connect directly:
62
+ * Node's proxyEnv would tunnel them to the proxy unless NO_PROXY lists them,
63
+ * and a proxy can never reach the user's own machine — a local server that
64
+ * worked before must not break because a proxy variable is set. A proxy
65
+ * variable Node refuses as malformed is ignored (`upstream.proxy_env_invalid`),
66
+ * as fetch ignored them all: a bad variable must not break a server.
67
+ * `BIR_UPSTREAM_PROXY_ENV=0` turns it off (the old behaviour: always connect directly).
68
+ */
69
+ import * as http from "node:http";
70
+ import * as https from "node:https";
71
+ import type { IncomingHttpHeaders } from "node:http";
72
+ import { type Readable } from "node:stream";
73
+ /** Keep-alive agents per upstream, so `close()` can drop its sockets. */
74
+ export interface Agents {
75
+ /** May route through HTTP(S)_PROXY (see the header). */
76
+ http: http.Agent;
77
+ https: https.Agent;
78
+ /** Never proxied: for loopback targets. The same objects when proxying is off. */
79
+ directHttp: http.Agent;
80
+ directHttps: https.Agent;
81
+ }
82
+ /** `localhost`, `*.localhost`, 127.0.0.0/8, ::1 and 0.0.0.0 — this machine. */
83
+ export declare function isLoopbackHost(hostname: string): boolean;
84
+ /** Does this environment ask for the proxy variables to be honoured? */
85
+ export declare function proxyEnvEnabled(env?: NodeJS.ProcessEnv): boolean;
86
+ /** Node versions whose `http.Agent` implements `proxyEnv`. */
87
+ export declare function nodeSupportsProxyEnv(version?: string): boolean;
88
+ /** The proxy URL a request would go through, when one is configured. */
89
+ export declare function configuredProxy(env?: NodeJS.ProcessEnv): string | undefined;
90
+ /**
91
+ * How long a pooled keep-alive socket may sit idle before we close it: undici's
92
+ * default, i.e. what the old fetch path did.
93
+ */
94
+ export declare const DEFAULT_FREE_SOCKET_TIMEOUT_MS = 4000;
95
+ export interface AgentOptions {
96
+ /** Honour HTTP(S)_PROXY / NO_PROXY. Default: {@link proxyEnvEnabled}. */
97
+ proxyEnv?: boolean;
98
+ /** Idle time after which a pooled socket is closed. Default {@link DEFAULT_FREE_SOCKET_TIMEOUT_MS}. */
99
+ freeSocketTimeoutMs?: number;
100
+ }
101
+ export declare function createAgents(opts?: AgentOptions): Agents;
102
+ /** `http://user:secret@proxy` → `http://***@proxy`, anywhere in a message. */
103
+ export declare function redactCredentials(text: string): string;
104
+ export declare function destroyAgents(agents: Agents): void;
105
+ export interface HttpRequestOptions {
106
+ method: "GET" | "POST" | "DELETE";
107
+ headers: Record<string, string>;
108
+ body?: string;
109
+ signal?: AbortSignal;
110
+ /** Bound on DNS + TCP + TLS (+ proxy tunnel). Nothing bounds the answer. */
111
+ connectTimeoutMs: number;
112
+ agents: Agents;
113
+ }
114
+ export interface HttpResponse {
115
+ status: number;
116
+ statusText: string;
117
+ ok: boolean;
118
+ headers: IncomingHttpHeaders;
119
+ /** The URL that answered, after redirects. */
120
+ url: string;
121
+ /** The body, already decompressed. Consume it, or `destroy()` it. */
122
+ body: Readable;
123
+ header(name: string): string | undefined;
124
+ }
125
+ /** Raised by {@link readBodyText} when a body outgrows its cap. */
126
+ export declare class BodyTooLargeError extends Error {
127
+ constructor(limit: number);
128
+ }
129
+ /**
130
+ * Where a redirect from `current` to `next` may go, and with which headers.
131
+ * Throws when it may not be followed at all: a scheme other than http(s), or
132
+ * a step down from https to plain http off this machine. Exported for tests.
133
+ */
134
+ export declare function redirectHeaders(current: URL, next: URL, headers: Record<string, string>): Record<string, string>;
135
+ /**
136
+ * Send one request and resolve with the response head once it arrives. Rejects
137
+ * on a network error, a connect timeout, an abort, or a redirect that may not
138
+ * be followed ({@link redirectHeaders}). Follows the others.
139
+ */
140
+ export declare function httpRequest(url: string, opts: HttpRequestOptions): Promise<HttpResponse>;
141
+ /** Read a whole body as UTF-8, refusing past `maxBytes` (the body is destroyed). */
142
+ export declare function readBodyText(body: Readable, maxBytes: number): Promise<string>;
143
+ //# sourceMappingURL=http-transport.d.ts.map
@@ -0,0 +1,391 @@
1
+ /**
2
+ * http-transport — the one HTTP primitive the remote upstream (http-client.ts)
3
+ * sends through. Built on `node:http` / `node:https`, not on `fetch`.
4
+ *
5
+ * WHY NOT FETCH. Node's fetch runs on a global undici dispatcher whose defaults
6
+ * are a 300 s headers timeout and a 300 s body-idle timeout, and nothing short
7
+ * of a dependency (or a Node-internal symbol) changes them per request. Both cut
8
+ * off legitimate MCP traffic:
9
+ *
10
+ * - a remote tool that works for more than five minutes before answering (a
11
+ * report, a build, a crawl) was reported at 306 s as `upstream unreachable:
12
+ * fetch failed`, however long the user had told Claude Code to wait;
13
+ * - an SSE stream with nothing to say for five minutes (the legacy transport's
14
+ * event stream, the standing GET, a slow tool's reply stream) was
15
+ * terminated, which on the legacy transport killed the server for the rest
16
+ * of the session.
17
+ *
18
+ * `node:http` has no implicit timeouts at all, so the bounds here are exactly
19
+ * the ones we choose:
20
+ *
21
+ * - **connect**: DNS + TCP + TLS (+ a proxy tunnel) must finish within
22
+ * `connectTimeoutMs`, so a blackholed or captive host fails in seconds
23
+ * rather than after the OS connect timeout (21 s on Windows, 75 s on macOS);
24
+ * - **after connect, none**: how long an answer may take is the host's call
25
+ * (Claude Code's own tool timeouts, and its `notifications/cancelled`), never
26
+ * ours. Dead peers are still noticed: sockets get TCP keep-alive probes.
27
+ * - **idle in the pool**: a keep-alive socket nobody is using is closed after
28
+ * 4 s, or 1 s before the server's `Keep-Alive: timeout=N`, as undici did —
29
+ * so we never reuse one the server is in the middle of closing.
30
+ *
31
+ * It also makes the failure reason concrete. fetch reports every network error
32
+ * as `fetch failed` and hides the cause; here the message is the socket's own
33
+ * (`connect ECONNREFUSED …`, `getaddrinfo ENOTFOUND …`, `self-signed
34
+ * certificate in certificate chain`), which is what a user or `bir doctor`
35
+ * needs to act on.
36
+ *
37
+ * WHAT FETCH DID THAT IS KEPT: redirects are followed (307/308 for any method,
38
+ * 301/302/303 for GET — Starlette answers `/mcp` with a 307 to `/mcp/`); a
39
+ * gzip/deflate/br body is decoded; `User-Agent: node` is sent, so a WAF rule
40
+ * written against the old client still matches. Compression is not *asked*
41
+ * for (no `Accept-Encoding`): an SSE reply behind a compressing middleware can
42
+ * sit in its buffer, and MCP messages are small.
43
+ *
44
+ * WHAT IS STRICTER THAN FETCH: the user's headers stay with the origin they
45
+ * were configured for. fetch drops only `Authorization`, `Cookie` and
46
+ * `Proxy-Authorization` when a redirect leaves the origin, but a remote
47
+ * server's credential is as often `X-Api-Key`, `Private-Token` or a tenancy
48
+ * header — so a redirect to another origin carries only the MCP protocol's own
49
+ * headers ({@link CROSS_ORIGIN_KEPT}), and every header that came from
50
+ * `BIR_UPSTREAM_HEADERS` or `--header` is dropped. And a redirect from https to
51
+ * plain http is refused outright (a loopback target excepted: it never leaves
52
+ * the machine): following it would send the request — the tool call, its
53
+ * arguments, the session id — in clear text.
54
+ *
55
+ * PROXIES (HTTPS_PROXY / HTTP_PROXY / NO_PROXY). Claude Code honours them, so a
56
+ * server it could reach through a corporate proxy must stay reachable once
57
+ * wrapped. The agents are built with `proxyEnv: process.env`, which Node's own
58
+ * http stack implements from Node 24.5 (and the 22.x line from 22.21); older
59
+ * Nodes ignore the option and connect directly — `upstream.proxy_env_ignored`
60
+ * in the audit log says so when that is the likely cause of a failure.
61
+ * Loopback targets (`localhost`, 127.0.0.0/8, ::1) always connect directly:
62
+ * Node's proxyEnv would tunnel them to the proxy unless NO_PROXY lists them,
63
+ * and a proxy can never reach the user's own machine — a local server that
64
+ * worked before must not break because a proxy variable is set. A proxy
65
+ * variable Node refuses as malformed is ignored (`upstream.proxy_env_invalid`),
66
+ * as fetch ignored them all: a bad variable must not break a server.
67
+ * `BIR_UPSTREAM_PROXY_ENV=0` turns it off (the old behaviour: always connect directly).
68
+ */
69
+ import * as http from "node:http";
70
+ import * as https from "node:https";
71
+ import { pipeline } from "node:stream";
72
+ import { constants as zlib, createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
73
+ import { errText, logOnce } from "../util/log.js";
74
+ /** `localhost`, `*.localhost`, 127.0.0.0/8, ::1 and 0.0.0.0 — this machine. */
75
+ export function isLoopbackHost(hostname) {
76
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
77
+ return (host === "localhost" ||
78
+ host.endsWith(".localhost") ||
79
+ host === "::1" ||
80
+ host === "0.0.0.0" ||
81
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host));
82
+ }
83
+ /** Does this environment ask for the proxy variables to be honoured? */
84
+ export function proxyEnvEnabled(env = process.env) {
85
+ const flag = env.BIR_UPSTREAM_PROXY_ENV?.trim().toLowerCase();
86
+ return !(flag === "0" || flag === "false" || flag === "off" || flag === "no");
87
+ }
88
+ /** Node versions whose `http.Agent` implements `proxyEnv`. */
89
+ export function nodeSupportsProxyEnv(version = process.versions.node) {
90
+ const [major, minor] = version.split(".").map((n) => Number(n));
91
+ if (major > 24)
92
+ return true;
93
+ if (major === 24)
94
+ return minor >= 5;
95
+ if (major === 22)
96
+ return minor >= 21;
97
+ return false;
98
+ }
99
+ /** The proxy URL a request would go through, when one is configured. */
100
+ export function configuredProxy(env = process.env) {
101
+ return env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy || undefined;
102
+ }
103
+ /**
104
+ * How long a pooled keep-alive socket may sit idle before we close it: undici's
105
+ * default, i.e. what the old fetch path did.
106
+ */
107
+ export const DEFAULT_FREE_SOCKET_TIMEOUT_MS = 4_000;
108
+ export function createAgents(opts = {}) {
109
+ const direct = {
110
+ keepAlive: true,
111
+ // Not a timeout on requests: the delay before the first TCP keep-alive probe
112
+ // on an idle socket, which is how a vanished peer (laptop sleep, a dropped
113
+ // NAT entry) turns into an error instead of a stream that never ends.
114
+ keepAliveMsecs: 15_000,
115
+ // Not a timeout on requests either. On an http.Agent `timeout` only ever
116
+ // destroys a socket that is idle *in the pool* (an in-use socket's timeout
117
+ // just emits an event nobody listens to), and it is what lets the agent
118
+ // honour a server's `Keep-Alive: timeout=N` (it closes at the lower of this
119
+ // and N - 1 s). Without it an idle socket is kept until the server or its
120
+ // load balancer closes it — and a POST that reuses a socket in the instant
121
+ // the server is closing it dies with ECONNRESET: a tool call that fails
122
+ // because of the runner (POSTs are not retried, see sendOnce). undici, which
123
+ // the old fetch path used, closes idle sockets after 4 s for the same reason.
124
+ timeout: opts.freeSocketTimeoutMs ?? DEFAULT_FREE_SOCKET_TIMEOUT_MS,
125
+ };
126
+ const directHttp = new http.Agent(direct);
127
+ const directHttps = new https.Agent(direct);
128
+ if (!(opts.proxyEnv ?? proxyEnvEnabled())) {
129
+ return { http: directHttp, https: directHttps, directHttp, directHttps };
130
+ }
131
+ // `proxyEnv` is newer than the @types/node this package builds against, and
132
+ // unknown to Node < 22.21, which ignores it — hence the untyped options bag.
133
+ const proxied = { ...direct, proxyEnv: process.env };
134
+ try {
135
+ return {
136
+ http: new http.Agent(proxied),
137
+ https: new https.Agent(proxied),
138
+ directHttp,
139
+ directHttps,
140
+ };
141
+ }
142
+ catch (err) {
143
+ // A malformed proxy variable (`http://`, `http://:3128`, a port out of
144
+ // range, an unencoded `%` in a password) makes Node's Agent throw — which,
145
+ // uncaught, took bir-proxy down at start and showed the wrapped server as
146
+ // failed in every session, even a loopback one that never uses a proxy.
147
+ // fetch used to ignore these variables, so ignoring a broken one is the old
148
+ // behaviour; the audit log says so (`bir doctor` reads it). The message can
149
+ // carry the proxy URL, credentials included, hence the redaction.
150
+ logOnce("upstream.proxy_env_invalid", "upstream.proxy_env_invalid", {
151
+ reason: redactCredentials(errText(err)),
152
+ effect: "the proxy variables are ignored; upstreams are reached directly",
153
+ });
154
+ return { http: directHttp, https: directHttps, directHttp, directHttps };
155
+ }
156
+ }
157
+ /** `http://user:secret@proxy` → `http://***@proxy`, anywhere in a message. */
158
+ export function redactCredentials(text) {
159
+ return text.replace(/\/\/[^/@\s]*@/g, "//***@");
160
+ }
161
+ export function destroyAgents(agents) {
162
+ for (const agent of new Set([agents.http, agents.https, agents.directHttp, agents.directHttps])) {
163
+ agent.destroy();
164
+ }
165
+ }
166
+ /** Raised by {@link readBodyText} when a body outgrows its cap. */
167
+ export class BodyTooLargeError extends Error {
168
+ constructor(limit) {
169
+ super(`response body exceeded ${limit} bytes`);
170
+ this.name = "BodyTooLargeError";
171
+ }
172
+ }
173
+ const MAX_REDIRECTS = 10;
174
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
175
+ /**
176
+ * The only headers a redirect carries to another origin: the ones the MCP
177
+ * protocol itself puts on a request, which say nothing about the user. The
178
+ * session id is among them because the server that answered with the
179
+ * redirect is the one that issued it. Everything else — every header from
180
+ * `BIR_UPSTREAM_HEADERS` or `--header`, whatever its name — is dropped.
181
+ */
182
+ const CROSS_ORIGIN_KEPT = new Set([
183
+ "accept",
184
+ "content-type",
185
+ "last-event-id",
186
+ "mcp-protocol-version",
187
+ "mcp-session-id",
188
+ ]);
189
+ /**
190
+ * Where a redirect from `current` to `next` may go, and with which headers.
191
+ * Throws when it may not be followed at all: a scheme other than http(s), or
192
+ * a step down from https to plain http off this machine. Exported for tests.
193
+ */
194
+ export function redirectHeaders(current, next, headers) {
195
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
196
+ throw new Error(`redirected to an unsupported URL scheme (${next.protocol})`);
197
+ }
198
+ if (current.protocol === "https:" && next.protocol === "http:" && !isLoopbackHost(next.hostname)) {
199
+ throw new Error(`refused a redirect from https to plain http (${next.host})`);
200
+ }
201
+ if (next.origin === current.origin)
202
+ return headers;
203
+ return Object.fromEntries(Object.entries(headers).filter(([key]) => CROSS_ORIGIN_KEPT.has(key.toLowerCase())));
204
+ }
205
+ /**
206
+ * Send one request and resolve with the response head once it arrives. Rejects
207
+ * on a network error, a connect timeout, an abort, or a redirect that may not
208
+ * be followed ({@link redirectHeaders}). Follows the others.
209
+ */
210
+ export async function httpRequest(url, opts) {
211
+ let current = new URL(url);
212
+ let headers = { ...opts.headers };
213
+ for (let hop = 0;; hop += 1) {
214
+ const res = await sendOnce(current, headers, opts, false);
215
+ const status = res.statusCode ?? 0;
216
+ const location = res.headers.location;
217
+ const followable = status === 307 || status === 308 || (opts.method === "GET" && REDIRECTS.has(status));
218
+ if (location && followable && hop < MAX_REDIRECTS) {
219
+ res.resume();
220
+ let next;
221
+ try {
222
+ next = new URL(location, current);
223
+ }
224
+ catch {
225
+ throw new Error("redirected to an invalid URL");
226
+ }
227
+ // Once dropped, a header stays dropped: a later hop back to the first
228
+ // origin does not bring it back (as fetch does it).
229
+ headers = redirectHeaders(current, next, headers);
230
+ current = next;
231
+ continue;
232
+ }
233
+ return wrap(res, current);
234
+ }
235
+ }
236
+ function sendOnce(url, headers, opts, isRetry) {
237
+ return new Promise((resolve, reject) => {
238
+ const signal = opts.signal;
239
+ if (signal?.aborted) {
240
+ reject(abortError());
241
+ return;
242
+ }
243
+ const isHttps = url.protocol === "https:";
244
+ if (!isHttps && url.protocol !== "http:") {
245
+ reject(new Error(`unsupported URL scheme (${url.protocol})`));
246
+ return;
247
+ }
248
+ const payload = opts.body === undefined ? undefined : Buffer.from(opts.body, "utf8");
249
+ const allHeaders = { "user-agent": "node", ...headers };
250
+ if (payload)
251
+ allHeaders["content-length"] = String(payload.length);
252
+ let req;
253
+ try {
254
+ req = (isHttps ? https : http).request(url, {
255
+ method: opts.method,
256
+ headers: allHeaders,
257
+ agent: agentFor(url, opts.agents),
258
+ });
259
+ }
260
+ catch (err) {
261
+ // An invalid header value (a newline in a pasted token) throws here.
262
+ reject(err);
263
+ return;
264
+ }
265
+ let settled = false;
266
+ let response;
267
+ // Aborted by hand rather than with http.request's own `signal`: that
268
+ // destroys the socket even when the exchange has just completed, and a
269
+ // keep-alive socket on its way back to the pool has no error listener in
270
+ // that instant — an uncaught error that would take the proxy down. Here an
271
+ // abort only ever tears down an exchange that is still going.
272
+ const onAbort = () => {
273
+ if (!response)
274
+ req.destroy(abortError());
275
+ else if (!response.complete)
276
+ response.destroy();
277
+ };
278
+ const release = () => signal?.removeEventListener("abort", onAbort);
279
+ signal?.addEventListener("abort", onAbort, { once: true });
280
+ const timer = setTimeout(() => {
281
+ const err = new Error(`connect timed out after ${opts.connectTimeoutMs} ms (${url.host})`);
282
+ err.code = "ETIMEDOUT";
283
+ req.destroy(err);
284
+ }, opts.connectTimeoutMs);
285
+ timer.unref?.();
286
+ const connected = () => clearTimeout(timer);
287
+ req.on("socket", (socket) => {
288
+ socket.setKeepAlive?.(true, 15_000);
289
+ // A pooled socket is already connected; a fresh one says when it is.
290
+ if (!socket.connecting)
291
+ connected();
292
+ else
293
+ socket.once(isHttps ? "secureConnect" : "connect", connected);
294
+ });
295
+ // The request is on the wire, so the connection exists — covers transports
296
+ // (a proxy tunnel) whose socket does not emit the events above.
297
+ req.on("finish", connected);
298
+ req.on("response", (res) => {
299
+ connected();
300
+ settled = true;
301
+ response = res;
302
+ res.once("close", release);
303
+ resolve(res);
304
+ });
305
+ req.on("error", (err) => {
306
+ clearTimeout(timer);
307
+ if (settled)
308
+ return;
309
+ settled = true;
310
+ release();
311
+ // The keep-alive race: the server closed an idle pooled socket just as we
312
+ // reused it. One retry on a fresh socket — the pattern Node's own
313
+ // documentation gives for `reusedSocket` — but only for GET and DELETE:
314
+ // a POST may be a tool call the server did receive before the reset, and
315
+ // running it twice is worse than one "socket hang up" (undici, which the
316
+ // old fetch path used, does not retry POSTs either). What keeps POSTs out
317
+ // of this race is the agents' idle timeout (createAgents).
318
+ if (!isRetry &&
319
+ opts.method !== "POST" &&
320
+ req.reusedSocket &&
321
+ err.code === "ECONNRESET" &&
322
+ !signal?.aborted) {
323
+ sendOnce(url, headers, opts, true).then(resolve, reject);
324
+ return;
325
+ }
326
+ reject(signal?.aborted ? abortError() : err);
327
+ });
328
+ req.end(payload);
329
+ });
330
+ }
331
+ function agentFor(url, agents) {
332
+ const direct = isLoopbackHost(url.hostname);
333
+ if (url.protocol === "https:")
334
+ return direct ? agents.directHttps : agents.https;
335
+ return direct ? agents.directHttp : agents.http;
336
+ }
337
+ function abortError() {
338
+ const err = new Error("request aborted");
339
+ err.name = "AbortError";
340
+ return err;
341
+ }
342
+ function wrap(res, url) {
343
+ const status = res.statusCode ?? 0;
344
+ const encoding = String(res.headers["content-encoding"] ?? "").trim().toLowerCase();
345
+ let body = res;
346
+ const decoder = encoding === "gzip" || encoding === "x-gzip"
347
+ ? createGunzip({ flush: zlib.Z_SYNC_FLUSH, finishFlush: zlib.Z_SYNC_FLUSH })
348
+ : encoding === "deflate"
349
+ ? createInflate({ flush: zlib.Z_SYNC_FLUSH, finishFlush: zlib.Z_SYNC_FLUSH })
350
+ : encoding === "br"
351
+ ? createBrotliDecompress({
352
+ flush: zlib.BROTLI_OPERATION_FLUSH,
353
+ finishFlush: zlib.BROTLI_OPERATION_FLUSH,
354
+ })
355
+ : undefined;
356
+ if (decoder) {
357
+ // pipeline() carries an error on either side to the other, so a reset
358
+ // mid-body still surfaces to whoever reads the decoded stream.
359
+ pipeline(res, decoder, () => undefined);
360
+ body = decoder;
361
+ }
362
+ return {
363
+ status,
364
+ statusText: res.statusMessage ?? "",
365
+ ok: status >= 200 && status < 300,
366
+ headers: res.headers,
367
+ url: url.toString(),
368
+ body,
369
+ header(name) {
370
+ const value = res.headers[name.toLowerCase()];
371
+ return Array.isArray(value) ? value.join(", ") : value;
372
+ },
373
+ };
374
+ }
375
+ /** Read a whole body as UTF-8, refusing past `maxBytes` (the body is destroyed). */
376
+ export async function readBodyText(body, maxBytes) {
377
+ const chunks = [];
378
+ let size = 0;
379
+ for await (const chunk of body) {
380
+ const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
381
+ size += buf.length;
382
+ if (size > maxBytes) {
383
+ body.destroy();
384
+ throw new BodyTooLargeError(maxBytes);
385
+ }
386
+ chunks.push(buf);
387
+ }
388
+ const text = Buffer.concat(chunks, size).toString("utf8");
389
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
390
+ }
391
+ //# sourceMappingURL=http-transport.js.map
@@ -26,6 +26,14 @@ export declare class LazyUpstreamClient extends BaseUpstreamClient {
26
26
  constructor(create: () => UpstreamClient);
27
27
  private ensure;
28
28
  send(msg: JsonRpcMessage): void;
29
+ /**
30
+ * Proxy-originated calls go to the inner client, which owns their `bir:` ids:
31
+ * its `accept()` consumes the answer before this wrapper would ever see it,
32
+ * so a request tracked *here* would wait forever (the replay work loop did,
33
+ * for its full step timeout, on every `--lazy` server).
34
+ */
35
+ request(method: string, params?: unknown, signal?: AbortSignal): Promise<unknown>;
29
36
  close(): void;
37
+ shutdown(graceMs?: number): Promise<void>;
30
38
  }
31
39
  //# sourceMappingURL=lazy-client.d.ts.map
@@ -15,7 +15,7 @@
15
15
  * The flag is opt-in for exactly that reason, and this comment is the measurement
16
16
  * the open question asks for: the saving is a property of the host, not of us.
17
17
  */
18
- import { BaseUpstreamClient } from "./client.js";
18
+ import { BaseUpstreamClient, } from "./client.js";
19
19
  export class LazyUpstreamClient extends BaseUpstreamClient {
20
20
  ready;
21
21
  inner;
@@ -29,6 +29,9 @@ export class LazyUpstreamClient extends BaseUpstreamClient {
29
29
  this.resolveReady = resolve;
30
30
  this.rejectReady = reject;
31
31
  });
32
+ // Nobody may ever speak to a lazy upstream; an unobserved rejection of
33
+ // `ready` must not become an unhandled rejection.
34
+ this.ready.catch(() => undefined);
32
35
  }
33
36
  ensure() {
34
37
  if (this.inner)
@@ -37,6 +40,8 @@ export class LazyUpstreamClient extends BaseUpstreamClient {
37
40
  this.inner = inner;
38
41
  inner.onMessage((msg) => this.accept(msg));
39
42
  inner.onClose((info) => this.closed(info));
43
+ inner.onUndeliverable?.((info) => this.undeliverable(info));
44
+ inner.onRestart?.((info) => this.restarting(info));
40
45
  inner.ready.then(() => this.resolveReady(), (err) => this.rejectReady(err instanceof Error ? err : new Error(String(err))));
41
46
  return inner;
42
47
  }
@@ -45,9 +50,32 @@ export class LazyUpstreamClient extends BaseUpstreamClient {
45
50
  return;
46
51
  this.ensure().send(msg);
47
52
  }
53
+ /**
54
+ * Proxy-originated calls go to the inner client, which owns their `bir:` ids:
55
+ * its `accept()` consumes the answer before this wrapper would ever see it,
56
+ * so a request tracked *here* would wait forever (the replay work loop did,
57
+ * for its full step timeout, on every `--lazy` server).
58
+ */
59
+ request(method, params, signal) {
60
+ if (this.dead)
61
+ return Promise.reject(new Error("upstream is not available"));
62
+ // Refused here, before `ensure()`: a call nobody is waiting for must not
63
+ // be what starts the user's server.
64
+ if (signal?.aborted)
65
+ return Promise.reject(new Error(`${method} aborted`));
66
+ return this.ensure().request(method, params, signal);
67
+ }
48
68
  close() {
49
69
  this.inner?.close();
50
70
  this.closed({});
51
71
  }
72
+ async shutdown(graceMs) {
73
+ const inner = this.inner;
74
+ if (inner?.shutdown)
75
+ await inner.shutdown(graceMs);
76
+ else
77
+ inner?.close();
78
+ this.closed({});
79
+ }
52
80
  }
53
81
  //# sourceMappingURL=lazy-client.js.map