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. diff --git a/tests/worker-bootstrap.test.ts b/tests/worker-bootstrap.test.ts index 4292384..63db34b 100644 --- a/tests/worker-bootstrap.test.ts +++ b/tests/worker-bootstrap.test.ts @@ -508,3 +508,42 @@ 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 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 = ((_input: RequestInfo | URL, init?: RequestInit) => { + seen.push(init?.signal); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + }) 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, + }); + + 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); +}); 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(