@intentic/sandbox-contract 1.234.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.
Files changed (70) hide show
  1. package/dist/chores/chores.d.ts.map +1 -1
  2. package/dist/chores/chores.js +2 -1
  3. package/dist/chores/chores.js.map +1 -1
  4. package/dist/chores/index.d.ts +1 -0
  5. package/dist/chores/index.d.ts.map +1 -1
  6. package/dist/chores/index.js +1 -0
  7. package/dist/chores/index.js.map +1 -1
  8. package/dist/chores/probes.d.ts.map +1 -1
  9. package/dist/chores/probes.js +4 -3
  10. package/dist/chores/probes.js.map +1 -1
  11. package/dist/chores/workspace-scope.d.ts +4 -0
  12. package/dist/chores/workspace-scope.d.ts.map +1 -0
  13. package/dist/chores/workspace-scope.js +4 -0
  14. package/dist/chores/workspace-scope.js.map +1 -0
  15. package/dist/contracts/extensions.contract.d.ts +1 -2
  16. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  17. package/dist/contracts/extensions.contract.js +5 -5
  18. package/dist/contracts/extensions.contract.js.map +1 -1
  19. package/dist/contracts/system.contract.d.ts +12 -0
  20. package/dist/contracts/system.contract.d.ts.map +1 -1
  21. package/dist/contracts/system.contract.js +10 -1
  22. package/dist/contracts/system.contract.js.map +1 -1
  23. package/dist/contracts/workspace.contract.d.ts +1 -0
  24. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  25. package/dist/contracts/workspace.contract.js +2 -2
  26. package/dist/contracts/workspace.contract.js.map +1 -1
  27. package/dist/index.d.ts +14 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/ingress-contract.d.ts +23 -0
  30. package/dist/ingress-contract.d.ts.map +1 -0
  31. package/dist/ingress-contract.js +43 -0
  32. package/dist/ingress-contract.js.map +1 -0
  33. package/dist/ingress-protocol.d.ts +16 -0
  34. package/dist/ingress-protocol.d.ts.map +1 -0
  35. package/dist/ingress-protocol.js +263 -0
  36. package/dist/ingress-protocol.js.map +1 -0
  37. package/dist/schemas/agents.d.ts.map +1 -1
  38. package/dist/schemas/agents.js +5 -5
  39. package/dist/schemas/agents.js.map +1 -1
  40. package/dist/schemas/computers.d.ts +41 -0
  41. package/dist/schemas/computers.d.ts.map +1 -1
  42. package/dist/schemas/computers.js +16 -0
  43. package/dist/schemas/computers.js.map +1 -1
  44. package/dist/schemas/extension-updates.d.ts +2 -3
  45. package/dist/schemas/extension-updates.d.ts.map +1 -1
  46. package/dist/schemas/extension-updates.js +4 -3
  47. package/dist/schemas/extension-updates.js.map +1 -1
  48. package/dist/schemas/workspace-tree.d.ts +1 -0
  49. package/dist/schemas/workspace-tree.d.ts.map +1 -1
  50. package/dist/schemas/workspace-tree.js +8 -1
  51. package/dist/schemas/workspace-tree.js.map +1 -1
  52. package/package.json +26 -4
  53. package/src/chores/chores.ts +2 -1
  54. package/src/chores/index.ts +1 -0
  55. package/src/chores/probes.test.ts +12 -0
  56. package/src/chores/probes.ts +5 -3
  57. package/src/chores/workspace-scope.ts +12 -0
  58. package/src/contracts/extensions.contract.ts +7 -6
  59. package/src/contracts/system.contract.ts +22 -1
  60. package/src/contracts/workspace.contract.ts +3 -2
  61. package/src/events.ts +2 -2
  62. package/src/ingress-contract.test.ts +65 -0
  63. package/src/ingress-contract.ts +155 -0
  64. package/src/ingress-protocol.test.ts +509 -0
  65. package/src/ingress-protocol.ts +574 -0
  66. package/src/schemas/agents.ts +20 -21
  67. package/src/schemas/computers.ts +60 -0
  68. package/src/schemas/extension-updates.ts +9 -7
  69. package/src/schemas/workspace-tree.ts +13 -4
  70. 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
+ };