@intentic/sandbox-contract 1.235.0 → 1.237.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 (68) 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/agent.contract.d.ts +2 -0
  16. package/dist/contracts/agent.contract.d.ts.map +1 -1
  17. package/dist/contracts/agents.contract.d.ts +6 -0
  18. package/dist/contracts/agents.contract.d.ts.map +1 -1
  19. package/dist/contracts/extensions.contract.d.ts +1 -2
  20. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  21. package/dist/contracts/extensions.contract.js +5 -5
  22. package/dist/contracts/extensions.contract.js.map +1 -1
  23. package/dist/contracts/runner.contract.d.ts +2 -0
  24. package/dist/contracts/runner.contract.d.ts.map +1 -1
  25. package/dist/contracts/workspace.contract.d.ts +1 -0
  26. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  27. package/dist/contracts/workspace.contract.js +2 -2
  28. package/dist/contracts/workspace.contract.js.map +1 -1
  29. package/dist/events.d.ts +8 -0
  30. package/dist/events.d.ts.map +1 -1
  31. package/dist/events.js +12 -2
  32. package/dist/events.js.map +1 -1
  33. package/dist/index.d.ts +10 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/ingress-contract.d.ts.map +1 -1
  36. package/dist/ingress-contract.js.map +1 -1
  37. package/dist/ingress-protocol.d.ts +16 -0
  38. package/dist/ingress-protocol.d.ts.map +1 -0
  39. package/dist/ingress-protocol.js +263 -0
  40. package/dist/ingress-protocol.js.map +1 -0
  41. package/dist/schemas/agents.d.ts.map +1 -1
  42. package/dist/schemas/agents.js +5 -5
  43. package/dist/schemas/agents.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 +4 -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/workspace.contract.ts +3 -2
  60. package/src/events.ts +35 -4
  61. package/src/hostnames.ts +2 -2
  62. package/src/ingress-contract.ts +4 -2
  63. package/src/ingress-protocol.test.ts +509 -0
  64. package/src/ingress-protocol.ts +574 -0
  65. package/src/schemas/agents.ts +20 -21
  66. package/src/schemas/extension-updates.ts +9 -7
  67. package/src/schemas/workspace-tree.ts +13 -4
  68. package/src/tunnel-ids.ts +4 -4
@@ -0,0 +1,509 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer, type IncomingMessage, request as h1Request, type Server, type ServerResponse } from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import { type Duplex, duplexPair } from "node:stream";
5
+ import { afterAll, beforeAll, expect, test } from "vitest";
6
+ import { openIngressSession, serveIngressSession } from "./ingress-protocol.js";
7
+
8
+ /* THE WHOLE CHAIN, IN PROCESS, exactly as the two consumers wire it:
9
+ *
10
+ * node http client ─h1→ FRONT server (the ingress) ─h2 stream→ duplex pair ─h2 session→ DAEMON half ─h1→ TARGET
11
+ *
12
+ * Driven by node's own http client rather than by calling the exported functions with hand-built objects,
13
+ * because every property worth pinning here is a property of the BYTES: that a 4MB body survives, that a
14
+ * response arrives in pieces instead of being buffered whole, that a WebSocket's `Sec-WebSocket-Accept` is the
15
+ * daemon's own and not a recomputation, that a half-close reaches the far end. A fake IncomingMessage proves
16
+ * none of those, and each of them is a way this file can be wrong while type-checking perfectly.
17
+ *
18
+ * The front server IS the shape the ingress uses (request → forwardRequest, upgrade → forwardUpgrade, a 502
19
+ * when either rejects with nothing yet said to the browser), so a regression in the promise contract fails
20
+ * here rather than in the ingress package alone. */
21
+
22
+ const listen = async (server: Server): Promise<number> => {
23
+ await new Promise<void>((resolve) => void server.listen(0, "127.0.0.1", resolve));
24
+ return (server.address() as AddressInfo).port;
25
+ };
26
+
27
+ /* Two halves of a test that have to happen in a fixed ORDER without either measuring time: the gate is opened
28
+ * by the far end observing something, and the near end waits for that rather than for a duration. A gate that
29
+ * is never opened fails as the suite's own hang bound, which is the correct report — "the bytes never arrived"
30
+ * is the failure, and no duration in this file would be measuring anything else. */
31
+ const gate = <T = void>(): { readonly open: (value: T) => void; readonly opened: Promise<T> } => {
32
+ let open = (_value: T): void => {};
33
+ const opened = new Promise<T>((resolve) => {
34
+ open = resolve;
35
+ });
36
+ return { open, opened };
37
+ };
38
+
39
+ const HOST = "sandbox-0123456789ab.sbx.test";
40
+
41
+ // What the target saw, as the target's own answer, so the assertions are about a real server's view of the
42
+ // forwarded request rather than about the proxy's bookkeeping.
43
+ interface Seen {
44
+ readonly method: string;
45
+ readonly url: string;
46
+ readonly host: string;
47
+ readonly connection: string;
48
+ readonly headerNames: readonly string[];
49
+ }
50
+
51
+ const bodyOf = async (message: IncomingMessage): Promise<Buffer> => {
52
+ const chunks: Buffer[] = [];
53
+ for await (const chunk of message) {
54
+ chunks.push(chunk as Buffer);
55
+ }
56
+ return Buffer.concat(chunks);
57
+ };
58
+
59
+ const digest = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex");
60
+
61
+ const firstChunk = gate();
62
+ const secondSent = gate();
63
+ const uploadStarted = gate();
64
+
65
+ // 64KB a write, so a `/flood` response keeps node's write queue non-empty and a reset lands on top of a write
66
+ // that has not completed.
67
+ const FLOOD_CHUNK = Buffer.alloc(64 * 1024, 7);
68
+
69
+ type Route = (request: IncomingMessage, response: ServerResponse) => void | Promise<void>;
70
+
71
+ // One entry per property under test, rather than a chain of ifs: the routes are independent, and a reader
72
+ // looking for "what does a cancelled request do" should find one function, not the fifth branch of one.
73
+ const routes: Record<string, Route> = {
74
+ "/seen": (request, response) => {
75
+ const seen: Seen = {
76
+ method: request.method ?? "",
77
+ url: request.url ?? "",
78
+ host: request.headers.host ?? "",
79
+ connection: request.headers.connection ?? "",
80
+ headerNames: Object.keys(request.headers).sort(),
81
+ };
82
+ response.writeHead(201, { "content-type": "application/json", "x-target": "yes" });
83
+ response.end(JSON.stringify(seen));
84
+ },
85
+ "/echo": async (request, response) => {
86
+ const bytes = await bodyOf(request);
87
+ response.writeHead(200, { "content-type": "application/octet-stream", "x-sha256": digest(bytes) });
88
+ response.end(bytes);
89
+ },
90
+ // Two writes with the SECOND one held until the client has read the first: a proxy that buffers the whole
91
+ // response before forwarding it deadlocks here instead of passing.
92
+ "/drip": async (_request, response) => {
93
+ response.writeHead(200, { "content-type": "text/event-stream" });
94
+ response.write("one");
95
+ await firstChunk.opened;
96
+ response.write("two");
97
+ response.end();
98
+ secondSent.open();
99
+ },
100
+ // The mirror image: the request body's first chunk must reach here before the client sends the rest.
101
+ "/slurp": async (request, response) => {
102
+ request.once("data", () => uploadStarted.open());
103
+ const bytes = await bodyOf(request);
104
+ response.writeHead(200, { "content-type": "text/plain" });
105
+ response.end(bytes.toString("utf8"));
106
+ },
107
+ // Never answered: the test asserts that the BROWSER giving up reaches this far, as a close on a response
108
+ // this server is still holding — "aborted", never the "ended" of an exchange that completed.
109
+ "/hangup": (_request, response) => {
110
+ response.writeHead(200, { "content-type": "text/event-stream" });
111
+ response.write("open");
112
+ response.on("close", () => cancelled.open(response.writableEnded ? "ended" : "aborted"));
113
+ },
114
+ // A response big enough that writes are still pending when the client resets the stream: the interleaving
115
+ // that the loopback bridge exists to survive.
116
+ "/flood": (_request, response) => {
117
+ response.writeHead(200, { "content-type": "application/octet-stream" });
118
+ const pump = (): void => {
119
+ while (response.write(FLOOD_CHUNK)) {
120
+ if (response.writableEnded) {
121
+ return;
122
+ }
123
+ }
124
+ };
125
+ response.on("drain", pump);
126
+ pump();
127
+ },
128
+ };
129
+
130
+ const target = createServer((request: IncomingMessage, response: ServerResponse) => {
131
+ const route = routes[(request.url ?? "").split("?")[0] ?? ""];
132
+ if (route === undefined) {
133
+ response.writeHead(404, { "content-type": "text/plain" });
134
+ response.end("no such route");
135
+ return;
136
+ }
137
+ void route(request, response);
138
+ });
139
+
140
+ const cancelled = gate<string>();
141
+
142
+ /* A real HTTP/1.1 upgrade, hand-written because this package depends on no WebSocket library and does not need
143
+ * one: what the protocol has to carry is the 101 head and the bytes after it. `/refuse` answers instead of
144
+ * upgrading, which is the other branch of the daemon's CONNECT handling. */
145
+ target.on("upgrade", (request: IncomingMessage, socket: Duplex, head: Buffer) => {
146
+ if ((request.url ?? "") === "/refuse") {
147
+ socket.end("HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 7\r\n\r\nno dice");
148
+ return;
149
+ }
150
+ socket.write(
151
+ [
152
+ "HTTP/1.1 101 Switching Protocols",
153
+ "Upgrade: websocket",
154
+ "Connection: Upgrade",
155
+ // Stands in for Sec-WebSocket-Accept: a value only this server can produce, so reading it back
156
+ // proves the head travelled rather than being reconstructed by the proxy.
157
+ `Sec-WebSocket-Accept: ${digest(Buffer.from(String(request.headers["sec-websocket-key"])))}`,
158
+ `X-Seen-Host: ${String(request.headers.host)}`,
159
+ `X-Seen-Path: ${String(request.url)}`,
160
+ "",
161
+ "",
162
+ ].join("\r\n"),
163
+ );
164
+ if (head.length > 0) {
165
+ socket.write(head);
166
+ }
167
+ socket.on("data", (chunk: Buffer) => void socket.write(Buffer.concat([Buffer.from("echo:"), chunk])));
168
+ // A FIN from the far end of the whole chain must arrive as a FIN here, or a WebSocket close handshake never
169
+ // completes.
170
+ socket.on("end", () => void socket.end("bye"));
171
+ });
172
+
173
+ // The ingress's shape: one session, every request routed through it per request.
174
+ const front = async (
175
+ targetPort: number,
176
+ ): Promise<{ readonly server: Server; readonly poison: (bytes: Buffer) => void; readonly close: () => void }> => {
177
+ const [edgeSide, daemonSide] = duplexPair();
178
+ const daemon = await serveIngressSession(daemonSide, { targetPort });
179
+ const session = await openIngressSession(edgeSide);
180
+ const server = createServer((request, response) => {
181
+ void session.forwardRequest(request, response).catch(() => {
182
+ if (!response.headersSent) {
183
+ response.writeHead(502, { "content-type": "application/json" });
184
+ response.end(JSON.stringify({ error: "sandbox unreachable" }));
185
+ return;
186
+ }
187
+ response.destroy();
188
+ });
189
+ });
190
+ server.on("upgrade", (request, socket, head) => {
191
+ void session.forwardUpgrade(request, socket, head).catch(() => {
192
+ socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
193
+ });
194
+ });
195
+ return {
196
+ server,
197
+ // Garbage straight onto the wire the edge reads, interleaved with whatever the daemon half is sending:
198
+ // what a wedged peer or a half-open socket that came back wrong looks like from the ingress's side.
199
+ poison: (bytes: Buffer) => void daemonSide.write(bytes),
200
+ close: () => {
201
+ session.close();
202
+ daemon.close();
203
+ server.close();
204
+ },
205
+ };
206
+ };
207
+
208
+ let edge: Awaited<ReturnType<typeof front>>;
209
+ let edgePort = 0;
210
+
211
+ /* Every ERR_INTERNAL_ASSERTION this module can produce arrives as an uncaught exception from inside node, with
212
+ * none of our frames on the stack — so it is caught HERE or not at all, and a test that merely "passed" while
213
+ * the process was dying is exactly the report that hid this the first time. */
214
+ const uncaught: string[] = [];
215
+
216
+ beforeAll(async () => {
217
+ process.on("uncaughtException", (error: NodeJS.ErrnoException) => void uncaught.push(error.code ?? error.message));
218
+ const targetPort = await listen(target);
219
+ edge = await front(targetPort);
220
+ edgePort = await listen(edge.server);
221
+ });
222
+
223
+ afterAll(() => {
224
+ edge.close();
225
+ target.close();
226
+ });
227
+
228
+ const call = (
229
+ path: string,
230
+ options: { readonly method?: string; readonly body?: Buffer; readonly headers?: Record<string, string> } = {},
231
+ ): Promise<{ status: number; headers: NodeJS.Dict<string | string[]>; body: Buffer }> =>
232
+ new Promise((resolve, reject) => {
233
+ const request = h1Request(
234
+ { host: "127.0.0.1", port: edgePort, path, method: options.method ?? "GET", headers: { host: HOST, ...options.headers } },
235
+ (response) => {
236
+ void bodyOf(response).then((body) =>
237
+ resolve({ status: response.statusCode ?? 0, headers: response.headers, body }),
238
+ );
239
+ },
240
+ );
241
+ request.on("error", reject);
242
+ request.end(options.body);
243
+ });
244
+
245
+ test("a request round-trips with its authority, path and method, and no hop-by-hop header crosses", async () => {
246
+ const answer = await call("/seen?q=1", {
247
+ method: "PUT",
248
+ // The three the browser must not be able to push through a hop, alongside two that must survive it.
249
+ headers: { "x-custom": "kept", "x-forwarded-proto": "https", connection: "close", upgrade: "h2c", "keep-alive": "timeout=99" },
250
+ body: Buffer.from("hi"),
251
+ });
252
+
253
+ expect(answer.status).toBe(201);
254
+ expect(answer.headers["x-target"]).toBe("yes");
255
+ const seen = JSON.parse(answer.body.toString("utf8")) as Seen;
256
+ // The Host the browser used is what the daemon's own listener sees — how a preview, a forwarded port and
257
+ // the daemon itself are told apart inside the container.
258
+ expect(seen).toMatchObject({ method: "PUT", url: "/seen?q=1", host: HOST });
259
+ expect(seen.headerNames).toContain("x-custom");
260
+ expect(seen.headerNames).toContain("x-forwarded-proto");
261
+ /* Hop-by-hop headers describe ONE hop and are re-derived on each, never forwarded. `connection` is the
262
+ * assertion that says so by value: the browser sent `close`, and what reaches the target is the `keep-alive`
263
+ * of the daemon's own loopback hop. Asserting merely that the target sees no `connection` would be asserting
264
+ * something false — node writes one for its own hop — and would pass just as well if the browser's value had
265
+ * been forwarded and then overwritten. */
266
+ expect(seen.connection).toBe("keep-alive");
267
+ expect(seen.headerNames).not.toContain("upgrade");
268
+ expect(seen.headerNames).not.toContain("keep-alive");
269
+ expect(seen.headerNames).not.toContain("transfer-encoding");
270
+ });
271
+
272
+ test("a multi-megabyte body survives in both directions, byte for byte", async () => {
273
+ const payload = randomBytes(4 * 1024 * 1024);
274
+ const answer = await call("/echo", { method: "POST", body: payload });
275
+
276
+ expect(answer.status).toBe(200);
277
+ // The target's own digest of what it received, and ours of what came back: one assertion per direction,
278
+ // and neither can pass on a truncated or re-ordered stream.
279
+ expect(answer.headers["x-sha256"]).toBe(digest(payload));
280
+ expect(digest(answer.body)).toBe(digest(payload));
281
+ });
282
+
283
+ test("a response is streamed, not buffered: the client reads chunk one before the target writes chunk two", async () => {
284
+ const chunks: string[] = [];
285
+ const done = new Promise<void>((resolve, reject) => {
286
+ const request = h1Request({ host: "127.0.0.1", port: edgePort, path: "/drip", headers: { host: HOST } }, (response) => {
287
+ response.on("data", (chunk: Buffer) => {
288
+ chunks.push(chunk.toString("utf8"));
289
+ firstChunk.open();
290
+ });
291
+ response.on("end", resolve);
292
+ response.on("error", reject);
293
+ });
294
+ request.on("error", reject);
295
+ request.end();
296
+ });
297
+ await done;
298
+ await secondSent.opened;
299
+
300
+ expect(chunks.join("")).toBe("onetwo");
301
+ // Two writes, two reads: coalesced into one would mean the proxy held the first until the body was
302
+ // complete, which is the failure this asserts against.
303
+ expect(chunks.length).toBeGreaterThan(1);
304
+ });
305
+
306
+ test("a request body is streamed: the target reads the first chunk before the client sends the rest", async () => {
307
+ const answered = new Promise<string>((resolve, reject) => {
308
+ const request = h1Request(
309
+ { host: "127.0.0.1", port: edgePort, path: "/slurp", method: "POST", headers: { host: HOST } },
310
+ (response) => void bodyOf(response).then((body) => resolve(body.toString("utf8"))),
311
+ );
312
+ request.on("error", reject);
313
+ request.write("one");
314
+ void uploadStarted.opened.then(() => request.end("two"));
315
+ });
316
+
317
+ expect(await answered).toBe("onetwo");
318
+ });
319
+
320
+ test("an upgrade splices raw bytes, carries the far end's own handshake head, and passes a half-close through", async () => {
321
+ const key = "dGhlIHNhbXBsZSBub25jZQ==";
322
+ const upgraded = await new Promise<{ status: number; headers: NodeJS.Dict<string | string[]>; socket: Duplex }>((resolve, reject) => {
323
+ const request = h1Request({
324
+ host: "127.0.0.1",
325
+ port: edgePort,
326
+ path: "/socket",
327
+ headers: { host: HOST, connection: "Upgrade", upgrade: "websocket", "sec-websocket-key": key },
328
+ });
329
+ request.on("upgrade", (response, socket, head) => {
330
+ expect(head.length).toBe(0);
331
+ resolve({ status: response.statusCode ?? 0, headers: response.headers, socket });
332
+ });
333
+ request.on("response", (response) => reject(new Error(`the upgrade was answered with ${String(response.statusCode)}`)));
334
+ request.on("error", reject);
335
+ request.end();
336
+ });
337
+
338
+ expect(upgraded.status).toBe(101);
339
+ // Computed by the target from the key the browser sent, and therefore proof that the original request
340
+ // headers reached it through the CONNECT envelope AND that its answer came back verbatim.
341
+ expect(upgraded.headers["sec-websocket-accept"]).toBe(digest(Buffer.from(key)));
342
+ expect(upgraded.headers["x-seen-host"]).toBe(HOST);
343
+ expect(upgraded.headers["x-seen-path"]).toBe("/socket");
344
+
345
+ const spliced = new Promise<string>((resolve) => {
346
+ let read = "";
347
+ upgraded.socket.on("data", (chunk: Buffer) => {
348
+ read += chunk.toString("utf8");
349
+ });
350
+ upgraded.socket.on("end", () => resolve(read));
351
+ });
352
+ upgraded.socket.write("abc");
353
+ // Half-close: the far end must see the FIN, answer on the still-open direction, and then end.
354
+ upgraded.socket.end();
355
+
356
+ expect(await spliced).toBe("echo:abcbye");
357
+ });
358
+
359
+ test("a local server that declines to upgrade answers the browser itself", async () => {
360
+ const declined = await new Promise<{ status: number; body: Buffer }>((resolve, reject) => {
361
+ const request = h1Request({
362
+ host: "127.0.0.1",
363
+ port: edgePort,
364
+ path: "/refuse",
365
+ headers: { host: HOST, connection: "Upgrade", upgrade: "websocket", "sec-websocket-key": "x" },
366
+ });
367
+ request.on("upgrade", () => reject(new Error("the target refused, so nothing should have been spliced")));
368
+ request.on("response", (response) => void bodyOf(response).then((body) => resolve({ status: response.statusCode ?? 0, body })));
369
+ request.on("error", reject);
370
+ request.end();
371
+ });
372
+
373
+ expect(declined.status).toBe(404);
374
+ expect(declined.body.toString("utf8")).toBe("no dice");
375
+ });
376
+
377
+ test("a browser that gives up cancels the stream all the way to the target", async () => {
378
+ const request = h1Request({ host: "127.0.0.1", port: edgePort, path: "/hangup", headers: { host: HOST } });
379
+ await new Promise<void>((resolve, reject) => {
380
+ request.on("response", (response) => {
381
+ response.once("data", () => resolve());
382
+ response.on("error", () => resolve());
383
+ });
384
+ request.on("error", reject);
385
+ request.end();
386
+ });
387
+ request.destroy();
388
+
389
+ // Without the RST_STREAM this asserts, the target keeps generating a response for a browser that is gone,
390
+ // for as long as the container lives.
391
+ await expect(cancelled.opened).resolves.toBe("aborted");
392
+ });
393
+
394
+ test("a tunnel whose target is not listening fails the exchange rather than answering for it", async () => {
395
+ // A port nothing serves: the daemon half cannot reach a listener, so the exchange must fail in a way the
396
+ // ingress can turn into its own 502 — the body naming the host label is the ingress's to write, not this
397
+ // module's.
398
+ const dead = createServer();
399
+ const deadPort = await listen(dead);
400
+ await new Promise<void>((resolve) => void dead.close(() => resolve()));
401
+
402
+ const [edgeSide, daemonSide] = duplexPair();
403
+ const daemon = await serveIngressSession(daemonSide, { targetPort: deadPort });
404
+ const session = await openIngressSession(edgeSide);
405
+ // What the ingress needs to be true of the rejection, reported through the answer rather than asserted
406
+ // inside a catch nothing awaits: it is free to write a status, so nothing was said to the browser first.
407
+ const said = gate<boolean>();
408
+ const server = createServer((request, response) => {
409
+ void session.forwardRequest(request, response).catch(() => {
410
+ said.open(response.headersSent);
411
+ response.writeHead(502, { "content-type": "application/json" });
412
+ response.end(JSON.stringify({ error: "sandbox unreachable" }));
413
+ });
414
+ });
415
+ const port = await listen(server);
416
+
417
+ const answer = await new Promise<number>((resolve, reject) => {
418
+ const request = h1Request({ host: "127.0.0.1", port, path: "/anything", headers: { host: HOST } }, (response) =>
419
+ resolve(response.statusCode ?? 0),
420
+ );
421
+ request.on("error", reject);
422
+ request.end();
423
+ });
424
+
425
+ expect(answer).toBe(502);
426
+ await expect(said.opened).resolves.toBe(false);
427
+ session.close();
428
+ daemon.close();
429
+ server.close();
430
+ });
431
+
432
+ /* THE REGRESSION THIS MODULE'S TRANSPORT EXISTS FOR. Reset a batch of streams that are mid-write and the h2
433
+ * session emits control frames on top of writes that have not completed — which, run directly over a Duplex,
434
+ * is node's one-write-per-turn JSStreamSocket invariant and an ERR_INTERNAL_ASSERTION out of an internal
435
+ * callback. Measured twice over: the process died, AND the RST_STREAM never went out, so the cancellation
436
+ * never reached the container.
437
+ *
438
+ * Driven entirely through the public API, so it keeps pinning the behaviour however the transport is built. If
439
+ * someone removes the loopback bridge because "http2 takes a Duplex", this is the test that goes red. */
440
+ test("a shutdown landing on top of pending writes neither crashes nor wedges the session", async () => {
441
+ const own = await front((target.address() as AddressInfo).port);
442
+ const ownPort = await listen(own.server);
443
+
444
+ /* Eight responses actively writing, each confirmed to be delivering bytes before the shutdown, so node's
445
+ * write queue is genuinely non-empty when the GOAWAY is produced. All eight stay live on purpose: an
446
+ * earlier version of this test reset half of them first and passed against the broken transport, because
447
+ * the resets drained the very pressure the shutdown has to land on top of. (The reset path has its own
448
+ * test above; what is being pinned here is a control frame written over pending data.) */
449
+ const flooding = Array.from({ length: 8 }, () =>
450
+ new Promise<void>((resolve) => {
451
+ const request = h1Request({ host: "127.0.0.1", port: ownPort, path: "/flood", headers: { host: HOST } }, (response) => {
452
+ response.once("data", () => resolve());
453
+ });
454
+ request.on("error", () => resolve());
455
+ request.end();
456
+ }),
457
+ );
458
+ await Promise.all(flooding);
459
+
460
+ own.close();
461
+ await new Promise((resolve) => setTimeout(resolve, 400));
462
+ own.server.close();
463
+
464
+ /* Two ways to fail, and the suite's own budget is the second one. Run straight over a Duplex this hangs:
465
+ * the shutdown frame cannot be written, so `close()` never completes and the tunnel wedges holding every
466
+ * stream on it — which is why a hang bound, rather than a duration, is the right report here. */
467
+ expect(uncaught).toStrictEqual([]);
468
+ // And the neighbours are untouched: the shared fixture's session still serves.
469
+ const after = await call("/seen");
470
+ expect(after.status).toBe(201);
471
+ });
472
+
473
+ /* CONTAINMENT: one tunnel's session dying must be one tunnel's problem. The ingress holds every sandbox's
474
+ * session in a single process, so a peer that speaks nonsense — a wedged container, a half-open socket that
475
+ * came back as garbage, anything that makes nghttp2 give up — is the failure most likely to be shared, and it
476
+ * must not be. */
477
+ test("a poisoned session dies alone and leaves another tunnel serving", async () => {
478
+ const targetPort = (target.address() as AddressInfo).port;
479
+ const poisoned = await front(targetPort);
480
+ const poisonedPort = await listen(poisoned.server);
481
+ const healthy = await front(targetPort);
482
+ const healthyPort = await listen(healthy.server);
483
+
484
+ const through = (port: number): Promise<number> =>
485
+ new Promise((resolve, reject) => {
486
+ const request = h1Request({ host: "127.0.0.1", port, path: "/seen", headers: { host: HOST } }, (response) => {
487
+ void bodyOf(response).then(() => resolve(response.statusCode ?? 0));
488
+ });
489
+ request.on("error", reject);
490
+ request.end();
491
+ });
492
+
493
+ expect(await through(poisonedPort)).toBe(201);
494
+ expect(await through(healthyPort)).toBe(201);
495
+
496
+ // Not an h2 frame by any reading: the session must fail rather than try to interpret it.
497
+ poisoned.poison(Buffer.from("this is not a PRI * HTTP/2.0 preface, nor anything else nghttp2 accepts"));
498
+ await new Promise((resolve) => setTimeout(resolve, 250));
499
+
500
+ // The dead session refuses new streams, which the front turns into its 502 — the tunnel is gone, and that
501
+ // is a routing fact rather than a crash.
502
+ expect(await through(poisonedPort)).toBe(502);
503
+ expect(uncaught).toStrictEqual([]);
504
+ // The whole point: the other tunnel never noticed.
505
+ expect(await through(healthyPort)).toBe(201);
506
+
507
+ poisoned.close();
508
+ healthy.close();
509
+ });