@basein/runner 0.2.12 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2786 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
|
@@ -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
|