From c3a057fc7e813a40e471ac33da6147113b81de3b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 15 Aug 2026 16:51:57 -0700 Subject: [PATCH 1/3] fix(bootstrap): abort hung Cloudflare terminal handshake The Cloudflare Crabbox terminal upgrade fetch had no AbortSignal. Post-connect diagnostics already time out at 15s, but a handshake that never returns HTTP 101 could hang the isolate. AWS already uses handshakeTimeout 15_000. Clear the timer after a successful upgrade so an accepted socket is not torn down. Signed-off-by: Sebastien Tardif --- tests/worker-bootstrap.test.ts | 35 ++++++++++++++++++++++++++++++++++ worker/bootstrap.ts | 26 ++++++++++++++++++------- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/tests/worker-bootstrap.test.ts b/tests/worker-bootstrap.test.ts index 4292384..a41b289 100644 --- a/tests/worker-bootstrap.test.ts +++ b/tests/worker-bootstrap.test.ts @@ -508,3 +508,38 @@ test("live inference proof is re-keyed by managed policy", async () => { assert.ok(changed.includes(`'v5:${testReleaseMarker}:p${second}:openai/gpt-5.5'`)); await run("/bin/bash", ["-n", "-c", changed]); }); + +test("Cloudflare terminal upgrade fetch carries a handshake abort signal", async (t) => { + const seen: Array = []; + const original = globalThis.fetch; + t.after(() => { + globalThis.fetch = original; + }); + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + seen.push(init?.signal); + return new Response(null, { status: 504 }); + }) as typeof fetch; + + const claw = createClawRecord({ + name: "Terminal child", + owner: { subject: "github:terminal", label: "@terminal", source: "github" }, + }); + const bootstrap = new CrabboxWorkspaceBootstrap({ + brokerToken: "broker-test-token", + publicUrl: "https://crabhelm.example.test", + releaseId: "a".repeat(64), + archiveId: "c".repeat(64), + nodeId: "e".repeat(64), + signingSecret: testSigningKey, + }); + + await assert.rejects( + () => bootstrap.runtimeDiagnostics(claw, { + status: "ready", + attachUrl: "wss://crabbox.example.test/attach", + }), + /terminal upgrade failed/u, + ); + assert.equal(seen.length, 1); + assert.ok(seen[0] instanceof AbortSignal); +}); diff --git a/worker/bootstrap.ts b/worker/bootstrap.ts index 02c7ef5..c232129 100644 --- a/worker/bootstrap.ts +++ b/worker/bootstrap.ts @@ -562,6 +562,8 @@ async function captureTerminalSection( }); } +const handshakeTimeoutMs = 15_000; + async function cloudflareTerminalDialer( attachUrl: string, brokerToken: string, @@ -569,13 +571,23 @@ async function cloudflareTerminalDialer( const url = new URL(attachUrl); if (url.protocol !== "wss:") throw new Error("Crabbox terminal URL must use WSS"); url.protocol = "https:"; - const response = await fetch(url, { - headers: { authorization: `Bearer ${brokerToken}`, upgrade: "websocket" }, - }) as Response & { webSocket?: WorkerWebSocket }; - const socket = response.webSocket; - if (response.status !== 101 || !socket) throw new Error(`Crabbox terminal upgrade failed (HTTP ${response.status})`); - socket.accept(); - return socket; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), handshakeTimeoutMs); + try { + const response = await fetch(url, { + headers: { authorization: `Bearer ${brokerToken}`, upgrade: "websocket" }, + signal: controller.signal, + }) as Response & { webSocket?: WorkerWebSocket }; + const socket = response.webSocket; + if (response.status !== 101 || !socket) throw new Error(`Crabbox terminal upgrade failed (HTTP ${response.status})`); + socket.accept(); + return socket; + } catch (error) { + if (controller.signal.aborted) throw new Error("Crabbox terminal handshake timed out"); + throw error; + } finally { + clearTimeout(timer); + } } function terminalInferenceFailure( From fcec701dc4cf9197f900c622fe874bdd48704837 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 15 Aug 2026 19:21:50 -0700 Subject: [PATCH 2/3] test(bootstrap): exercise handshake timeout --- tests/worker-bootstrap.test.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/worker-bootstrap.test.ts b/tests/worker-bootstrap.test.ts index a41b289..63db34b 100644 --- a/tests/worker-bootstrap.test.ts +++ b/tests/worker-bootstrap.test.ts @@ -509,15 +509,18 @@ test("live inference proof is re-keyed by managed policy", async () => { await run("/bin/bash", ["-n", "-c", changed]); }); -test("Cloudflare terminal upgrade fetch carries a handshake abort signal", async (t) => { +test("Cloudflare terminal upgrade aborts a hung handshake after 15 seconds", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); const seen: Array = []; const original = globalThis.fetch; t.after(() => { globalThis.fetch = original; }); - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { seen.push(init?.signal); - return new Response(null, { status: 504 }); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); }) as typeof fetch; const claw = createClawRecord({ @@ -533,13 +536,14 @@ test("Cloudflare terminal upgrade fetch carries a handshake abort signal", async signingSecret: testSigningKey, }); - await assert.rejects( - () => bootstrap.runtimeDiagnostics(claw, { - status: "ready", - attachUrl: "wss://crabbox.example.test/attach", - }), - /terminal upgrade failed/u, - ); + const diagnostics = bootstrap.runtimeDiagnostics(claw, { + status: "ready", + attachUrl: "wss://crabbox.example.test/attach", + }); + t.mock.timers.tick(15_000); + + await assert.rejects(diagnostics, /terminal handshake timed out/u); assert.equal(seen.length, 1); assert.ok(seen[0] instanceof AbortSignal); + assert.equal(seen[0].aborted, true); }); From afd56d49ccd326a9aef34691c5c32f5fb1a51ba5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 15 Aug 2026 19:38:44 -0700 Subject: [PATCH 3/3] docs: note terminal handshake timeout --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 057914b..b525875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Abort hung Cloudflare terminal WebSocket upgrades after 15 seconds, matching the AWS handshake budget. Thanks @SebTardif. - Pin the reviewed Crabbox appliance to OpenClaw, Slack, and `diagnostics-otel` `2026.7.1` with upstream managed-ClawRouter and SQLite plugin-metadata migration backports plus provider-compatible route probes. - Harden AWS FakeCo admission with explicit verified-email handling for Cognito UserInfo, a locked Slack-off first-canary path without placeholder secrets or ingress, and ALB cookie-shard logout to an unauthenticated landing page. - Add a protected-main FakeCo Crabhelm control-plane image publisher with a dedicated OIDC identity, landed-source fencing, native Linux/AMD64 BuildKit SBOM/provenance output, exact immutable ECR binding, canonical digest/platform proof, fail-closed vulnerability threshold, and non-secret handoff artifact.