@intentic/sandbox-contract 1.235.0 → 1.236.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/dist/chores/chores.d.ts.map +1 -1
- package/dist/chores/chores.js +2 -1
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/index.d.ts +1 -0
- package/dist/chores/index.d.ts.map +1 -1
- package/dist/chores/index.js +1 -0
- package/dist/chores/index.js.map +1 -1
- package/dist/chores/probes.d.ts.map +1 -1
- package/dist/chores/probes.js +4 -3
- package/dist/chores/probes.js.map +1 -1
- package/dist/chores/workspace-scope.d.ts +4 -0
- package/dist/chores/workspace-scope.d.ts.map +1 -0
- package/dist/chores/workspace-scope.js +4 -0
- package/dist/chores/workspace-scope.js.map +1 -0
- package/dist/contracts/extensions.contract.d.ts +1 -2
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.js +5 -5
- package/dist/contracts/extensions.contract.js.map +1 -1
- package/dist/contracts/workspace.contract.d.ts +1 -0
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.js +2 -2
- package/dist/contracts/workspace.contract.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/ingress-protocol.d.ts +16 -0
- package/dist/ingress-protocol.d.ts.map +1 -0
- package/dist/ingress-protocol.js +263 -0
- package/dist/ingress-protocol.js.map +1 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +5 -5
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/extension-updates.d.ts +2 -3
- package/dist/schemas/extension-updates.d.ts.map +1 -1
- package/dist/schemas/extension-updates.js +4 -3
- package/dist/schemas/extension-updates.js.map +1 -1
- package/dist/schemas/workspace-tree.d.ts +1 -0
- package/dist/schemas/workspace-tree.d.ts.map +1 -1
- package/dist/schemas/workspace-tree.js +8 -1
- package/dist/schemas/workspace-tree.js.map +1 -1
- package/package.json +4 -4
- package/src/chores/chores.ts +2 -1
- package/src/chores/index.ts +1 -0
- package/src/chores/probes.test.ts +12 -0
- package/src/chores/probes.ts +5 -3
- package/src/chores/workspace-scope.ts +12 -0
- package/src/contracts/extensions.contract.ts +7 -6
- package/src/contracts/workspace.contract.ts +3 -2
- package/src/events.ts +2 -2
- package/src/ingress-protocol.test.ts +509 -0
- package/src/ingress-protocol.ts +574 -0
- package/src/schemas/agents.ts +20 -21
- package/src/schemas/extension-updates.ts +9 -7
- package/src/schemas/workspace-tree.ts +13 -4
- package/src/tunnel-ids.ts +4 -4
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { Agent, request as h1Request, type IncomingMessage, type OutgoingHttpHeaders, type ServerResponse } from "node:http";
|
|
2
|
+
import {
|
|
3
|
+
type ClientHttp2Session,
|
|
4
|
+
connect as h2Connect,
|
|
5
|
+
constants,
|
|
6
|
+
createServer as createH2Server,
|
|
7
|
+
type IncomingHttpHeaders,
|
|
8
|
+
type ServerHttp2Stream,
|
|
9
|
+
} from "node:http2";
|
|
10
|
+
import { type AddressInfo, createServer as createNetServer, connect as netConnect, type Socket } from "node:net";
|
|
11
|
+
import type { Duplex } from "node:stream";
|
|
12
|
+
|
|
13
|
+
/* THE DATA PLANE OF THE INGRESS TUNNEL: both halves of it, over any node Duplex, in node core alone.
|
|
14
|
+
*
|
|
15
|
+
* ./ingress-contract.ts pins WHAT the parties agree on (the grant, the host routing, the door, the env names)
|
|
16
|
+
* and states that the session over the tunnel is a cleartext HTTP/2 one. This file is that session, and it
|
|
17
|
+
* holds both ends on purpose: they are one wire format, and a wire format with two owners is a wire format
|
|
18
|
+
* that drifts. The ingress calls `openIngressSession` (h2 CLIENT over the WebSocket's byte stream), the daemon
|
|
19
|
+
* calls `serveIngressSession` (h2 SERVER over the same stream, forwarding to its own loopback listener).
|
|
20
|
+
*
|
|
21
|
+
* WHY H2 AND NOT FRAMES OF OUR OWN. The tunnel carries every request for a sandbox: many at once, some of them
|
|
22
|
+
* long-lived (an SSE stream per browser window, an agent attach per live turn), some of them multi-megabyte in
|
|
23
|
+
* either direction. A hand-rolled protocol over the WebSocket would need stream ids, interleaving, per-stream
|
|
24
|
+
* flow control and half-close, which is HTTP/2's entire specification, implemented worse and tested less. Node
|
|
25
|
+
* ships it: `http2.connect({ createConnection })` and `server.emit("connection", duplex)` will run a session
|
|
26
|
+
* over anything that is a Duplex, so the WebSocket is reduced to what it is good at (one authenticated,
|
|
27
|
+
* proxy-traversing, framed byte pipe) and the multiplexing is nghttp2's.
|
|
28
|
+
*
|
|
29
|
+
* It also fixes backpressure for free, and that is not a small thing here. The daemon's OTHER byte tunnel
|
|
30
|
+
* (_sandbox/sandbox/src/platform/sync-ssh.ts) has to poll `ws.bufferedAmount` against a high/low watermark and
|
|
31
|
+
* pause the TCP socket by hand, because `ws.send()` accepts everything and reports nothing: there is no
|
|
32
|
+
* backpressure signal on a raw WebSocket to propagate. Every hop HERE is a node stream with its own
|
|
33
|
+
* ({ h1 socket ⇄ h2 stream ⇄ h2 session ⇄ ws duplex }), so `pipe` propagates the whole chain and a slow browser
|
|
34
|
+
* ends up slowing the daemon's own socket, which is the correct outcome and none of our code.
|
|
35
|
+
*
|
|
36
|
+
* WHY PER-REQUEST ROUTING IS THE POINT (the property to preserve if this file is ever rewritten): the edge
|
|
37
|
+
* terminates TLS under ONE wildcard certificate, and h2 browsers coalesce connections across every host a
|
|
38
|
+
* certificate covers, so a single edge connection interleaves requests for several sandboxes. The unit of
|
|
39
|
+
* routing is therefore the REQUEST, and each one arrives here already carrying the host it was made to. A
|
|
40
|
+
* design that routed a connection by its first Host would deliver one user's requests into another user's
|
|
41
|
+
* container.
|
|
42
|
+
*
|
|
43
|
+
* BYTE SAFETY. Nothing in this file hands a pooled Buffer to an asynchronous writer: every hop is a stream
|
|
44
|
+
* write whose completion callback gates the next read (`createWebSocketStream` calls `ws.send(chunk, callback)`
|
|
45
|
+
* with the stream's own write callback, so the chunk is owned until the frame is out). That is the property
|
|
46
|
+
* sync-ssh.ts has to copy each chunk to obtain — see `frameOf` there — and the reason it does not have to be
|
|
47
|
+
* done here rather than an accident.
|
|
48
|
+
*
|
|
49
|
+
* ── WRITE SERIALIZATION: WHY THE CALLER'S DUPLEX IS BRIDGED ONTO A REAL SOCKET ───────────────────────────
|
|
50
|
+
*
|
|
51
|
+
* Both factories take a Duplex and neither gives it to node's http2. Each one first opens a loopback socket
|
|
52
|
+
* pair, splices the caller's Duplex onto one end, and runs the h2 session over the other, which is a genuine
|
|
53
|
+
* `net.Socket`. That hop is not free and it is not optional.
|
|
54
|
+
*
|
|
55
|
+
* NODE CANNOT RUN HTTP/2 OVER A PLAIN DUPLEX WITHOUT CRASHING THE PROCESS. `http2.connect({ createConnection })`
|
|
56
|
+
* and `server.emit("connection", stream)` accept anything stream-shaped, but a session needs a NATIVE handle to
|
|
57
|
+
* consume, so anything that is not a `net.Socket` is wrapped in an internal `JSStreamSocket` first (verified:
|
|
58
|
+
* a Duplex yields `session.socket.constructor.name === "JSStreamSocket"`, a real socket yields `Socket`). That
|
|
59
|
+
* wrapper permits exactly ONE write in flight: `doWrite` opens with `assert(this[kCurrentWriteRequest] === null)`
|
|
60
|
+
* and clears that slot from a `setImmediate`, so a SECOND write dispatched in the same turn does not queue, it
|
|
61
|
+
* throws ERR_INTERNAL_ASSERTION out of an internal callback — an uncaught exception, with no `try` of ours
|
|
62
|
+
* anywhere on the stack, killing the process.
|
|
63
|
+
*
|
|
64
|
+
* Two writes in one turn is not an exotic interleaving here, it is Tuesday: nghttp2 emits a control frame the
|
|
65
|
+
* moment it has one, so a RST_STREAM (a browser tab closing mid-SSE) or a GOAWAY (a tunnel being displaced)
|
|
66
|
+
* lands on top of a data write that has not completed. Measured, it did two things at once — the assertion
|
|
67
|
+
* killed the process AND the RST_STREAM it was trying to write never went out, so the cancellation never
|
|
68
|
+
* reached the container either. On the ingress, which holds every sandbox's tunnel in one process, one
|
|
69
|
+
* sandbox's ordinary traffic pattern would take every other sandbox offline.
|
|
70
|
+
*
|
|
71
|
+
* A wrapper around the caller's Duplex cannot fix it: the slot is cleared on a `setImmediate` inside node, so
|
|
72
|
+
* no completion discipline available to us makes a same-turn second write legal. A real socket has a real
|
|
73
|
+
* handle, libuv queues concurrent writes, and the whole failure class is gone. The cost is one loopback hop per
|
|
74
|
+
* TUNNEL (not per request, not per byte of setup) on a stream that has already crossed the internet.
|
|
75
|
+
*
|
|
76
|
+
* The tests below pin it from the outside: `a burst of mid-stream cancellations` drives exactly that
|
|
77
|
+
* interleaving through the public API. Do not "simplify" the bridge away because the Duplex looks like it would
|
|
78
|
+
* work — it works right up until someone closes a tab. */
|
|
79
|
+
|
|
80
|
+
// ── Header hygiene ──────────────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/* WHAT MAY NOT CROSS, in either direction, and this is enforcement rather than tidiness: node's http2 THROWS
|
|
83
|
+
* on connection-specific headers (ERR_HTTP2_INVALID_CONNECTION_HEADERS) because RFC 9113 §8.2.2 forbids them,
|
|
84
|
+
* and node's h1 server would happily accept a forwarded `transfer-encoding` and then frame the body twice.
|
|
85
|
+
*
|
|
86
|
+
* `host` is in the set because :authority carries it: it is re-derived on the far side rather than duplicated,
|
|
87
|
+
* so the two can never disagree about which sandbox a request is for. Pseudo-headers are dropped by the same
|
|
88
|
+
* function (they are h2's own and never belong on an h1 message), which is why every mapping below is one call
|
|
89
|
+
* plus the fields that mapping adds. */
|
|
90
|
+
const HOP_BY_HOP = new Set([
|
|
91
|
+
"connection",
|
|
92
|
+
"host",
|
|
93
|
+
"http2-settings",
|
|
94
|
+
"keep-alive",
|
|
95
|
+
"proxy-authenticate",
|
|
96
|
+
"proxy-authorization",
|
|
97
|
+
"proxy-connection",
|
|
98
|
+
"te",
|
|
99
|
+
"trailer",
|
|
100
|
+
"transfer-encoding",
|
|
101
|
+
"upgrade",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
// Everything that survives a hop: the end-to-end headers, pseudo-headers and hop-by-hop ones removed. Values
|
|
105
|
+
// stay as they arrived, including the arrays node uses for a repeated field (`set-cookie`), which both node's
|
|
106
|
+
// h1 and h2 writers re-emit as repeated fields.
|
|
107
|
+
const endToEnd = (headers: IncomingHttpHeaders): OutgoingHttpHeaders => {
|
|
108
|
+
const kept: OutgoingHttpHeaders = {};
|
|
109
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
110
|
+
if (value !== undefined && !name.startsWith(":") && !HOP_BY_HOP.has(name)) {
|
|
111
|
+
kept[name] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return kept;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ── The upgrade envelope ────────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/* A WEBSOCKET UPGRADE RIDES A CONNECT STREAM, AND ITS ORIGINAL HEAD RIDES UNDER A PREFIX.
|
|
120
|
+
*
|
|
121
|
+
* The h2 form of an upgrade would be extended CONNECT (RFC 8441, `:protocol`), which needs the
|
|
122
|
+
* enableConnectProtocol setting negotiated on both ends; and a plain CONNECT stream may not carry `:path` or
|
|
123
|
+
* `:scheme` at all — node enforces that with ERR_HTTP2_CONNECT_PATH. The headers that MATTER for an upgrade are
|
|
124
|
+
* exactly the ones h2 rejects, too: `Connection: Upgrade` and `Upgrade: websocket` are connection-specific by
|
|
125
|
+
* definition, and `Sec-WebSocket-Key` is meaningless without them.
|
|
126
|
+
*
|
|
127
|
+
* So the rule is one rule instead of two: on a CONNECT stream NOTHING is a real header. The whole h1 request
|
|
128
|
+
* head travels under `x-ingress-*` — method, path, and every original field under `x-ingress-h-` — and the far
|
|
129
|
+
* side rebuilds it verbatim before performing a genuine h1 upgrade against its loopback port. `:authority` is
|
|
130
|
+
* ALSO set, though the daemon reads the host back out of the envelope: it keeps a packet capture readable and
|
|
131
|
+
* keeps the "every stream names its sandbox" property literally true of every stream.
|
|
132
|
+
*
|
|
133
|
+
* Prefixing all of them rather than only the forbidden three is what makes this reviewable: there is no list to
|
|
134
|
+
* remember of which fields are envelope and which are cargo, and no chance of a header nobody thought about
|
|
135
|
+
* (`te`, a future h2 addition) taking the wrong path and killing the stream instead of the request. */
|
|
136
|
+
const UPGRADE_METHOD_HEADER = "x-ingress-method";
|
|
137
|
+
const UPGRADE_PATH_HEADER = "x-ingress-path";
|
|
138
|
+
const UPGRADE_HEADER_PREFIX = "x-ingress-h-";
|
|
139
|
+
|
|
140
|
+
const upgradeEnvelope = (request: IncomingMessage, authority: string): OutgoingHttpHeaders => {
|
|
141
|
+
const envelope: OutgoingHttpHeaders = {
|
|
142
|
+
[constants.HTTP2_HEADER_METHOD]: constants.HTTP2_METHOD_CONNECT,
|
|
143
|
+
[constants.HTTP2_HEADER_AUTHORITY]: authority,
|
|
144
|
+
[UPGRADE_METHOD_HEADER]: request.method ?? "GET",
|
|
145
|
+
[UPGRADE_PATH_HEADER]: request.url ?? "/",
|
|
146
|
+
};
|
|
147
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
148
|
+
if (value !== undefined) {
|
|
149
|
+
envelope[`${UPGRADE_HEADER_PREFIX}${name}`] = value;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return envelope;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// The h1 head an envelope was made from. Anything not carrying the prefix is not part of the original request
|
|
156
|
+
// (h2 pseudo-headers, and whatever a future version of this file adds beside them), so it is dropped.
|
|
157
|
+
const upgradeHead = (headers: IncomingHttpHeaders): { method: string; path: string; headers: OutgoingHttpHeaders } => {
|
|
158
|
+
const rebuilt: OutgoingHttpHeaders = {};
|
|
159
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
160
|
+
if (value !== undefined && name.startsWith(UPGRADE_HEADER_PREFIX)) {
|
|
161
|
+
rebuilt[name.slice(UPGRADE_HEADER_PREFIX.length)] = value;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
method: single(headers[UPGRADE_METHOD_HEADER]) ?? "GET",
|
|
166
|
+
path: single(headers[UPGRADE_PATH_HEADER]) ?? "/",
|
|
167
|
+
headers: rebuilt,
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// One value of a header that should never have been repeated. A repeated `x-ingress-path` is not a request
|
|
172
|
+
// anyone can serve, so the first is taken rather than guessed at or concatenated.
|
|
173
|
+
const single = (value: string | string[] | undefined): string | undefined => (Array.isArray(value) ? value[0] : value);
|
|
174
|
+
|
|
175
|
+
/* THE UPGRADED RESPONSE HEAD GOES BACK AS BYTES, not as h2 headers, and that is the choice that makes this
|
|
176
|
+
* path short. Past a successful CONNECT the stream is a raw pipe, so the FIRST thing on it is the local
|
|
177
|
+
* server's own response head, serialized from `rawHeaders` — original spelling, original order, original
|
|
178
|
+
* values. The ingress therefore writes the daemon's literal `101 Switching Protocols` (and its
|
|
179
|
+
* `Sec-WebSocket-Accept`, which is a digest of the key the browser sent and must not be recomputed by anyone)
|
|
180
|
+
* onto the browser's socket without knowing what a WebSocket is.
|
|
181
|
+
*
|
|
182
|
+
* It also means a local server that DECLINES the upgrade — a 404 for a path it does not serve — reaches the
|
|
183
|
+
* browser as that 404 rather than as a synthesized error or a hang, since its head takes the same route.
|
|
184
|
+
*
|
|
185
|
+
* latin1, because that is what node's h1 parser decoded these bytes as: header values are opaque octets, and
|
|
186
|
+
* re-encoding them as utf-8 would mangle any field that is not ASCII (a filename in a Content-Disposition) into
|
|
187
|
+
* different bytes than the ones that arrived. */
|
|
188
|
+
const serializeHead = (response: IncomingMessage, drop: ReadonlySet<string>, add: readonly string[]): Buffer => {
|
|
189
|
+
const lines = [`HTTP/${response.httpVersion} ${String(response.statusCode)} ${response.statusMessage ?? ""}`];
|
|
190
|
+
for (let index = 0; index + 1 < response.rawHeaders.length; index += 2) {
|
|
191
|
+
const name = response.rawHeaders[index] as string;
|
|
192
|
+
if (!drop.has(name.toLowerCase())) {
|
|
193
|
+
lines.push(`${name}: ${response.rawHeaders[index + 1] as string}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return Buffer.from(`${[...lines, ...add].join("\r\n")}\r\n\r\n`, "latin1");
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/* A non-101 answer on a CONNECT stream needs its FRAMING rewritten even though its head is passed through:
|
|
200
|
+
* node's h1 client has already de-chunked the body it is about to hand us, so forwarding the
|
|
201
|
+
* `transfer-encoding: chunked` that described it would have the browser parse chunk headers that are no longer
|
|
202
|
+
* there. Dropping it and saying `connection: close` leaves the body delimited by the close that this path
|
|
203
|
+
* performs anyway, which is the one framing that is true of what we are about to send. */
|
|
204
|
+
const DECLINED_UPGRADE_DROP = new Set(["connection", "keep-alive", "transfer-encoding"]);
|
|
205
|
+
const DECLINED_UPGRADE_ADD = ["connection: close"];
|
|
206
|
+
const NOTHING_DROPPED: ReadonlySet<string> = new Set();
|
|
207
|
+
|
|
208
|
+
// ── Session tuning ──────────────────────────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/* Node's default session memory is 10MB, and it is a budget SHARED by every stream on the connection. Here the
|
|
211
|
+
* connection is the sandbox's only route in, so exhausting it does not slow one request down, it kills the
|
|
212
|
+
* whole session and every request on it at once — and the traffic that exhausts it is ordinary for this
|
|
213
|
+
* workspace (a transcript replay, a file download, an upload). Same number and the same reasoning as the
|
|
214
|
+
* daemon's own h2 listener (platform/loopback-listener.ts). Megabytes. */
|
|
215
|
+
const MAX_SESSION_MEMORY_MB = 128;
|
|
216
|
+
|
|
217
|
+
/* A BIGGER RECEIVE WINDOW, because unlike every other h2 session in this repo, this one crosses the internet.
|
|
218
|
+
* h2's default per-stream window is 65535 bytes: the sender stops after 64KB until a WINDOW_UPDATE comes back,
|
|
219
|
+
* so one stream's throughput is capped at window/RTT — about 640KB/s over a 100ms link, whatever the pipe is
|
|
220
|
+
* worth. At 1MB the same link carries ~10MB/s per stream, and the memory it can pin stays two orders below the
|
|
221
|
+
* session budget above. */
|
|
222
|
+
const INITIAL_WINDOW_SIZE = 1024 * 1024;
|
|
223
|
+
|
|
224
|
+
/* How many streams the ingress will have in flight to one sandbox before it queues them locally. Node's
|
|
225
|
+
* default is 100 until the peer's SETTINGS arrive, and this session's streams are not all short: one `/events`
|
|
226
|
+
* per open browser window plus one attach per conversation with a live turn are held for as long as the
|
|
227
|
+
* workspace is open. Queueing an ordinary read behind those is exactly the "the sandbox froze, and its log
|
|
228
|
+
* looks healthy" failure that loopback-http2.integration.test.ts exists to pin, one layer out. */
|
|
229
|
+
const PEER_MAX_CONCURRENT_STREAMS = 256;
|
|
230
|
+
|
|
231
|
+
// The only address the daemon half will ever forward to. Fixed, like the SSH tunnel's (platform/sync-ssh.ts):
|
|
232
|
+
// the port comes from the daemon's own boot, the host is not the caller's to choose, so no stream arriving over
|
|
233
|
+
// this tunnel can be pointed at anything but this container's own listener.
|
|
234
|
+
const LOOPBACK = "127.0.0.1";
|
|
235
|
+
|
|
236
|
+
// ── The splice ──────────────────────────────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
/* Two streams, one connection. `pipe` carries end-of-stream in each direction on its own (a browser's FIN
|
|
239
|
+
* becomes the h2 END_STREAM that becomes the local socket's FIN, which is what lets a WebSocket close
|
|
240
|
+
* handshake complete rather than hang), so the only thing left to state is that a FAILURE on either side is
|
|
241
|
+
* not a half-open connection: it takes both ends down. `destroy` is idempotent, so the two handlers settle
|
|
242
|
+
* instead of bouncing a close back and forth. */
|
|
243
|
+
const splice = (left: Duplex, right: Duplex): void => {
|
|
244
|
+
left.pipe(right);
|
|
245
|
+
right.pipe(left);
|
|
246
|
+
const fail = (): void => {
|
|
247
|
+
left.destroy();
|
|
248
|
+
right.destroy();
|
|
249
|
+
};
|
|
250
|
+
left.on("error", fail);
|
|
251
|
+
right.on("error", fail);
|
|
252
|
+
/* A peer that VANISHES (an h2 reset, a socket the network dropped) has to take the other half with it, or
|
|
253
|
+
* the far end sits on a connection nobody is at the other end of. `writableFinished` is what separates that
|
|
254
|
+
* from the graceful case, where the FIN has already been forwarded by the pipe above and destroying now
|
|
255
|
+
* would truncate bytes that are still on their way out. */
|
|
256
|
+
left.on("close", () => {
|
|
257
|
+
if (!right.writableFinished) {
|
|
258
|
+
right.destroy();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
right.on("close", () => {
|
|
262
|
+
if (!left.writableFinished) {
|
|
263
|
+
left.destroy();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// ── The loopback bridge ─────────────────────────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/* One connected pair of real sockets, for the reason set out at the top of this file. Both ends are this
|
|
271
|
+
* process's own: a listener on 127.0.0.1 with an ephemeral port, one connection to it, and the listener shut
|
|
272
|
+
* the moment that connection is accepted.
|
|
273
|
+
*
|
|
274
|
+
* THE ACCEPTED SOCKET IS CHECKED AGAINST OUR OWN, because for the microseconds the listener is up it is a port
|
|
275
|
+
* on this machine that anything local may connect to, and a sandbox's tunnel is not something to hand to
|
|
276
|
+
* whoever got there first. The first arrival either IS our connection — same remote port as our client's local
|
|
277
|
+
* port, which the kernel guarantees is unique among live loopback connections — or the bridge fails outright
|
|
278
|
+
* and the tunnel redials. There is deliberately no "wait for the right one": a stranger on this port means the
|
|
279
|
+
* assumption behind the whole arrangement is wrong, and retrying with a fresh port is the only safe move. */
|
|
280
|
+
const loopbackBridge = async (): Promise<{ session: Socket; tunnel: Socket }> => {
|
|
281
|
+
const listener = createNetServer();
|
|
282
|
+
try {
|
|
283
|
+
await new Promise<void>((resolve, reject) => {
|
|
284
|
+
listener.once("error", reject);
|
|
285
|
+
listener.listen(0, LOOPBACK, resolve);
|
|
286
|
+
});
|
|
287
|
+
const accepted = new Promise<Socket>((resolve, reject) => {
|
|
288
|
+
listener.once("connection", resolve);
|
|
289
|
+
listener.once("error", reject);
|
|
290
|
+
});
|
|
291
|
+
const tunnel = netConnect((listener.address() as AddressInfo).port, LOOPBACK);
|
|
292
|
+
await new Promise<void>((resolve, reject) => {
|
|
293
|
+
tunnel.once("connect", resolve);
|
|
294
|
+
tunnel.once("error", reject);
|
|
295
|
+
});
|
|
296
|
+
const session = await accepted;
|
|
297
|
+
if (session.remotePort !== tunnel.localPort) {
|
|
298
|
+
session.destroy();
|
|
299
|
+
tunnel.destroy();
|
|
300
|
+
throw new Error("the loopback bridge accepted a connection that was not its own");
|
|
301
|
+
}
|
|
302
|
+
// Nagle would batch this hop's writes into 40ms windows for no gain: both ends are in this process, and
|
|
303
|
+
// the frames crossing here are already sized by h2.
|
|
304
|
+
session.setNoDelay(true);
|
|
305
|
+
tunnel.setNoDelay(true);
|
|
306
|
+
return { session, tunnel };
|
|
307
|
+
} finally {
|
|
308
|
+
listener.close();
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// ── The ingress half: an h2 client over the tunnel ──────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
export interface IngressSession {
|
|
315
|
+
/* Forward one edge request down the tunnel and answer `response` with what comes back.
|
|
316
|
+
*
|
|
317
|
+
* Resolves when the response has been fully written. REJECTS when the exchange failed, and the caller reads
|
|
318
|
+
* `response.headersSent` to know what that means: false, and nothing has been said to the browser yet, so
|
|
319
|
+
* the ingress writes its own 502 (the body naming the sandbox label, which is the ingress's vocabulary and
|
|
320
|
+
* not this file's); true, and the response was truncated mid-body, where a reset socket is the only honest
|
|
321
|
+
* signal HTTP has. */
|
|
322
|
+
readonly forwardRequest: (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
|
323
|
+
/* The same for an upgrade, taking the hijacked socket and whatever bytes arrived past the request head.
|
|
324
|
+
* Nothing is written to `socket` until the far end has accepted the stream, so a rejection leaves it
|
|
325
|
+
* untouched and the caller free to answer on it (an h1 error head) rather than merely resetting it. */
|
|
326
|
+
readonly forwardUpgrade: (request: IncomingMessage, socket: Duplex, head: Buffer) => Promise<void>;
|
|
327
|
+
// Shutdown and displacement. Ends the session and drops the bridge under it, which ends the caller's duplex
|
|
328
|
+
// and so closes the WebSocket.
|
|
329
|
+
readonly close: () => void;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* Open the ingress side of a tunnel. Asynchronous because the bridge is: a tunnel exists once its transport
|
|
333
|
+
* does, which is also the moment the caller should register it.
|
|
334
|
+
*
|
|
335
|
+
* The URL is a placeholder and says so: a session with an authority of its own would invite exactly the mistake
|
|
336
|
+
* the contract warns about, since every request that rides it carries the authority it must be routed by and
|
|
337
|
+
* this session has no host of its own. */
|
|
338
|
+
export const openIngressSession = async (duplex: Duplex): Promise<IngressSession> => {
|
|
339
|
+
const bridge = await loopbackBridge();
|
|
340
|
+
splice(bridge.tunnel, duplex);
|
|
341
|
+
const session: ClientHttp2Session = h2Connect("http://tunnel.invalid", {
|
|
342
|
+
createConnection: () => bridge.session,
|
|
343
|
+
maxSessionMemory: MAX_SESSION_MEMORY_MB,
|
|
344
|
+
peerMaxConcurrentStreams: PEER_MAX_CONCURRENT_STREAMS,
|
|
345
|
+
settings: { initialWindowSize: INITIAL_WINDOW_SIZE },
|
|
346
|
+
});
|
|
347
|
+
/* CONTAINMENT, and it is the whole reason this listener exists rather than a tidier `throw`. The ingress
|
|
348
|
+
* holds every sandbox's tunnel in ONE process: a peer that vanishes mid-request, or one that speaks
|
|
349
|
+
* nonsense, is a fact about that tunnel and must end at that tunnel's teardown. Without a handler here node
|
|
350
|
+
* raises the session's error as an uncaught exception and takes every other sandbox down with it.
|
|
351
|
+
*
|
|
352
|
+
* Destroying the caller's duplex is what the registry is watching: the WebSocket closes, the id is
|
|
353
|
+
* unregistered, and the container's own reconnect loop redials. */
|
|
354
|
+
session.on("error", () => duplex.destroy());
|
|
355
|
+
session.on("close", () => duplex.destroy());
|
|
356
|
+
|
|
357
|
+
// The authority to route by, read off the request that is being forwarded. The ingress has already refused
|
|
358
|
+
// anything without a sandbox-owned Host (hostOwnerId), so a request with no Host cannot reach here; if one
|
|
359
|
+
// does, that is a bug in the caller and it says so rather than opening a stream to an empty authority.
|
|
360
|
+
const authorityOf = (request: IncomingMessage): string => {
|
|
361
|
+
const host = request.headers.host;
|
|
362
|
+
if (host === undefined || host === "") {
|
|
363
|
+
throw new Error("an ingress request is routed by its Host header, and this one has none");
|
|
364
|
+
}
|
|
365
|
+
return host;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const forwardRequest = (request: IncomingMessage, response: ServerResponse): Promise<void> =>
|
|
369
|
+
new Promise<void>((resolve, reject) => {
|
|
370
|
+
const authority = authorityOf(request);
|
|
371
|
+
/* `endStream: false` for every method, so there is ONE code path: the request is piped, and a GET
|
|
372
|
+
* (whose IncomingMessage is already ended) closes the stream with an empty frame the instant the
|
|
373
|
+
* pipe runs. The alternative is a branch on which methods may carry a body, which is a list that
|
|
374
|
+
* has been wrong in every proxy that has ever kept one. */
|
|
375
|
+
const stream = session.request(
|
|
376
|
+
{
|
|
377
|
+
[constants.HTTP2_HEADER_METHOD]: request.method ?? "GET",
|
|
378
|
+
[constants.HTTP2_HEADER_PATH]: request.url ?? "/",
|
|
379
|
+
[constants.HTTP2_HEADER_AUTHORITY]: authority,
|
|
380
|
+
// The edge is HTTPS-only (fly.toml forces it), so this is what the browser used. Nothing
|
|
381
|
+
// routes on it; `x-forwarded-proto` is what a framework behind us will read, and it rides
|
|
382
|
+
// through as an ordinary header.
|
|
383
|
+
[constants.HTTP2_HEADER_SCHEME]: "https",
|
|
384
|
+
...endToEnd(request.headers),
|
|
385
|
+
},
|
|
386
|
+
{ endStream: false },
|
|
387
|
+
);
|
|
388
|
+
request.pipe(stream);
|
|
389
|
+
|
|
390
|
+
/* THE BROWSER GIVING UP HAS TO REACH THE DAEMON. Without this, a closed tab leaves the h2 stream
|
|
391
|
+
* open, the daemon's own h1 request to its listener open behind it, and — for the long-lived
|
|
392
|
+
* streams this tunnel mostly carries — an SSE generator producing frames for nobody, for as long as
|
|
393
|
+
* the container lives. RST_STREAM(CANCEL) is the signal that unwinds all three. */
|
|
394
|
+
response.on("close", () => {
|
|
395
|
+
if (!response.writableFinished) {
|
|
396
|
+
stream.close(constants.NGHTTP2_CANCEL);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
stream.on("response", (headers) => {
|
|
401
|
+
if (response.destroyed) {
|
|
402
|
+
stream.close(constants.NGHTTP2_CANCEL);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
response.writeHead(Number(headers[constants.HTTP2_HEADER_STATUS] ?? 502), endToEnd(headers));
|
|
406
|
+
stream.pipe(response);
|
|
407
|
+
});
|
|
408
|
+
stream.on("error", reject);
|
|
409
|
+
// `close` after a clean exchange is the h2 stream ending; the response is only DONE once node has
|
|
410
|
+
// flushed it to the browser, which is what the caller is waiting to hear.
|
|
411
|
+
response.on("finish", resolve);
|
|
412
|
+
stream.on("close", () => {
|
|
413
|
+
if (!response.writableEnded) {
|
|
414
|
+
reject(new Error("the tunnel closed the stream before the response was complete"));
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const forwardUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> =>
|
|
420
|
+
new Promise<void>((resolve, reject) => {
|
|
421
|
+
const stream = session.request(upgradeEnvelope(request, authorityOf(request)), { endStream: false });
|
|
422
|
+
stream.on("error", reject);
|
|
423
|
+
stream.once("response", (headers) => {
|
|
424
|
+
const status = Number(headers[constants.HTTP2_HEADER_STATUS]);
|
|
425
|
+
if (status !== constants.HTTP_STATUS_OK) {
|
|
426
|
+
// The far end refused to open the tunnel at all (its listener is down). Nothing has been
|
|
427
|
+
// written to the browser, so the caller still owns the socket and can say so properly.
|
|
428
|
+
stream.close(constants.NGHTTP2_CANCEL);
|
|
429
|
+
reject(new Error(`the tunnel refused an upgrade with :status ${String(status)}`));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
// Bytes the client sent past its request head, before anything of the far end's answer: they
|
|
433
|
+
// are the first thing the local server must read, and dropping them is a WebSocket handshake
|
|
434
|
+
// that completes and then hangs on a frame nobody has.
|
|
435
|
+
if (head.length > 0) {
|
|
436
|
+
stream.write(head);
|
|
437
|
+
}
|
|
438
|
+
splice(stream, socket);
|
|
439
|
+
resolve();
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
forwardRequest,
|
|
445
|
+
forwardUpgrade,
|
|
446
|
+
// Graceful first (GOAWAY, then node ends its own socket), then the far half of the bridge, which the
|
|
447
|
+
// session knows nothing about. Both sockets are this session's alone, so nothing else is affected.
|
|
448
|
+
close: () => {
|
|
449
|
+
session.close(() => bridge.tunnel.destroy());
|
|
450
|
+
bridge.tunnel.end();
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// ── The daemon half: an h2 server over the tunnel, onto the loopback listener ───────────────────────────
|
|
456
|
+
|
|
457
|
+
export interface ServeIngressSessionOptions {
|
|
458
|
+
// The daemon's own listener. Every stream on this session lands there as a plain HTTP/1.1 request or a
|
|
459
|
+
// genuine HTTP/1.1 upgrade — the Hono app already dispatches previews, ports and the outbox by Host.
|
|
460
|
+
readonly targetPort: number;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export interface IngressSessionServer {
|
|
464
|
+
/* Stop serving. The session says GOAWAY over its own half of the bridge; this ends the other half, which
|
|
465
|
+
* ends the caller's duplex. The duplex is never destroyed out from under a session that is still writing
|
|
466
|
+
* its shutdown frame — that ordering is what the WRITE SERIALIZATION note at the top of this file is
|
|
467
|
+
* about. The WebSocket belongs to whoever dialled it and is closed there. */
|
|
468
|
+
readonly close: () => void;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/* Serve the daemon side of a tunnel. One h2 server per session: it binds nothing and listens on nothing, it
|
|
472
|
+
* exists to be handed a connection, which is the only way node exposes a server-side session at all.
|
|
473
|
+
*
|
|
474
|
+
* The h1 hop to the loopback listener runs over a keep-alive agent, so a workspace's steady traffic reuses
|
|
475
|
+
* sockets instead of paying a connect per request; the upgrade path deliberately does not (below). */
|
|
476
|
+
export const serveIngressSession = async (duplex: Duplex, options: ServeIngressSessionOptions): Promise<IngressSessionServer> => {
|
|
477
|
+
const bridge = await loopbackBridge();
|
|
478
|
+
splice(bridge.tunnel, duplex);
|
|
479
|
+
const agent = new Agent({ keepAlive: true, maxSockets: PEER_MAX_CONCURRENT_STREAMS });
|
|
480
|
+
const server = createH2Server({
|
|
481
|
+
maxSessionMemory: MAX_SESSION_MEMORY_MB,
|
|
482
|
+
settings: { initialWindowSize: INITIAL_WINDOW_SIZE },
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
/* Same reasoning as the client's, one container in: a session that failed is news for the reconnect loop,
|
|
486
|
+
* never an uncaught exception that takes the daemon — and with it the workspace, the agent turns and the
|
|
487
|
+
* terminals — down over a dropped tunnel. Destroying the duplex closes the WebSocket, which is what that
|
|
488
|
+
* loop waits on. `clientError` is the h1 door on an h2 server; nothing should ever arrive there, and if
|
|
489
|
+
* something does it is still not worth a crash. */
|
|
490
|
+
server.on("sessionError", () => duplex.destroy());
|
|
491
|
+
server.on("clientError", () => duplex.destroy());
|
|
492
|
+
server.on("error", () => duplex.destroy());
|
|
493
|
+
|
|
494
|
+
const forwardToLoopback = (stream: ServerHttp2Stream, headers: IncomingHttpHeaders): void => {
|
|
495
|
+
const local = h1Request({
|
|
496
|
+
host: LOOPBACK,
|
|
497
|
+
port: options.targetPort,
|
|
498
|
+
method: single(headers[constants.HTTP2_HEADER_METHOD]) ?? "GET",
|
|
499
|
+
path: single(headers[constants.HTTP2_HEADER_PATH]) ?? "/",
|
|
500
|
+
// The Host the request was MADE to, put back where an h1 server reads it. This is the whole of how
|
|
501
|
+
// a preview, a forwarded port and the daemon itself are told apart inside the container.
|
|
502
|
+
headers: { host: single(headers[constants.HTTP2_HEADER_AUTHORITY]) ?? "", ...endToEnd(headers) },
|
|
503
|
+
agent,
|
|
504
|
+
});
|
|
505
|
+
stream.pipe(local);
|
|
506
|
+
local.on("response", (response) => {
|
|
507
|
+
stream.respond({ [constants.HTTP2_HEADER_STATUS]: response.statusCode ?? 502, ...endToEnd(response.headers) });
|
|
508
|
+
response.pipe(stream);
|
|
509
|
+
});
|
|
510
|
+
/* The listener is not answering. Closed with an error code rather than answered with a 502 of our own:
|
|
511
|
+
* the ingress is the party that owns what an unreachable sandbox looks like to a browser (it has the
|
|
512
|
+
* host label to name in the body), and one author for that message beats two that will drift. */
|
|
513
|
+
local.on("error", () => stream.close(constants.NGHTTP2_INTERNAL_ERROR));
|
|
514
|
+
stream.on("error", () => local.destroy());
|
|
515
|
+
/* The edge cancelled (RST_STREAM): stop generating a response nobody will read. Without this, a closed
|
|
516
|
+
* browser tab leaves an SSE route in this container producing frames until the daemon restarts.
|
|
517
|
+
*
|
|
518
|
+
* `aborted` is the event that means this and the only one that does. On an incoming RST node fires
|
|
519
|
+
* `aborted`, then `finish`, then `close` — so a guard on `writableEnded` or `writableFinished` inside a
|
|
520
|
+
* `close` handler reads as "ended normally" for a stream that was reset, which is how this was wrong
|
|
521
|
+
* first: nothing propagated, and the target only noticed when the whole session went away. */
|
|
522
|
+
stream.on("aborted", () => local.destroy());
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const spliceUpgrade = (stream: ServerHttp2Stream, headers: IncomingHttpHeaders): void => {
|
|
526
|
+
const head = upgradeHead(headers);
|
|
527
|
+
/* `agent: false`, deliberately, where the request path above pools: an upgraded connection stops being
|
|
528
|
+
* HTTP the moment it is accepted, so it can never go back in a keep-alive pool. Node would take it out
|
|
529
|
+
* of one for us; asking for a dedicated socket says why. */
|
|
530
|
+
const local = h1Request({ host: LOOPBACK, port: options.targetPort, method: head.method, path: head.path, headers: head.headers, agent: false });
|
|
531
|
+
local.on("upgrade", (response, socket, first) => {
|
|
532
|
+
stream.respond({ [constants.HTTP2_HEADER_STATUS]: constants.HTTP_STATUS_OK });
|
|
533
|
+
// Verbatim, including Sec-WebSocket-Accept: see serializeHead. Then the bytes the local server had
|
|
534
|
+
// already sent past its own head, then the pipe.
|
|
535
|
+
stream.write(serializeHead(response, NOTHING_DROPPED, []));
|
|
536
|
+
if (first.length > 0) {
|
|
537
|
+
stream.write(first);
|
|
538
|
+
}
|
|
539
|
+
splice(stream, socket);
|
|
540
|
+
});
|
|
541
|
+
// The local server answered instead of upgrading. Its answer is the browser's answer; it just needs
|
|
542
|
+
// framing that matches a body node has already de-chunked.
|
|
543
|
+
local.on("response", (response) => {
|
|
544
|
+
stream.respond({ [constants.HTTP2_HEADER_STATUS]: constants.HTTP_STATUS_OK });
|
|
545
|
+
stream.write(serializeHead(response, DECLINED_UPGRADE_DROP, DECLINED_UPGRADE_ADD));
|
|
546
|
+
response.pipe(stream);
|
|
547
|
+
});
|
|
548
|
+
local.on("error", () => stream.close(constants.NGHTTP2_CONNECT_ERROR));
|
|
549
|
+
// An upgrade request carries no body: the head IS the request, and the client is waiting for the
|
|
550
|
+
// handshake before it says anything else.
|
|
551
|
+
local.end();
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
server.on("stream", (stream, headers) => {
|
|
555
|
+
if (headers[constants.HTTP2_HEADER_METHOD] === constants.HTTP2_METHOD_CONNECT) {
|
|
556
|
+
spliceUpgrade(stream, headers);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
forwardToLoopback(stream, headers);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// This is the whole of how a server-side session is created without a listener of its own: the server never
|
|
563
|
+
// binds, it is handed a connection — the same property platform/loopback-listener.ts relies on to feed a
|
|
564
|
+
// rewound socket to a server that is already running.
|
|
565
|
+
server.emit("connection", bridge.session);
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
close: () => {
|
|
569
|
+
agent.destroy();
|
|
570
|
+
server.close();
|
|
571
|
+
bridge.tunnel.end();
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
};
|