Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/daemons/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,4 +328,23 @@ describe("daemon socket generations", () => {

await assert.rejects(pending.promise, /daemon disconnected/u);
});

it("survives an invalid WebSocket close code instead of crashing the process", async () => {
// Real production crash: a peer sending a close frame with a code the
// protocol forbids on the wire (1006 is reserved and must never be
// sent) makes `ws`'s Receiver emit `error` on the server socket. With
// no `error` listener, Node rethrows it as an uncaught exception and
// kills the whole Hub process. If `accept()` regresses, this test
// crashes the worker instead of merely failing an assertion.
daemon.sendInvalidClose(1006);
await daemon.waitUntilCurrentClosed();
await new Promise((resolve) => setImmediate(resolve));

assertOneFailure(stream, {
operation: "daemon.socket.error",
component: "daemons",
failureKind: "network",
canary: "invalid-close-code-1006-never-logged",
});
});
});
9 changes: 9 additions & 0 deletions src/daemons/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ export class ActiveDaemonRegistry {
previous?.agents.close();
previous?.socket.close(4001, "replaced");
socket.on("message", (data) => this.receive(active, readText(data)));
socket.on("error", (error) => {
// A peer that sends a malformed control frame (e.g. an invalid close
// status code) makes the `ws` receiver emit `error` on this socket.
// Node's EventEmitter rethrows unlistened `error` events as an
// uncaught exception, which without this listener kills the whole
// Hub process over one bad daemon connection. Report and let the
// subsequent `close` event drive the normal offline-presence cleanup.
this.report(error, "daemon.socket.error", daemon.id, "network");
});
socket.on("close", () => {
active.agents.close();
if (this.active.get(daemon.id)?.generation === active.generation) {
Expand Down
30 changes: 30 additions & 0 deletions src/daemons/test-utils/daemon-registry-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ interface PendingRequest<T> {
request: z.infer<typeof SessionRequestSchema>["message"];
}

/** Shape of the internal fields `ws` leaves untyped but always populates. */
interface WebSocketInternals {
_socket: { write(data: Buffer): void };
}

export class DaemonRegistryHarness {
private readonly presence = new DaemonPresence();
private readonly registry: ActiveDaemonRegistry;
Expand Down Expand Up @@ -271,6 +276,10 @@ export class DaemonRegistryHarness {
this.currentSocket().sendRaw(value);
}

sendInvalidClose(code: number): void {
this.currentSocket().sendInvalidClose(code);
}

waitUntilCurrentClosed(): Promise<void> {
return this.currentSocket().waitUntilClosed();
}
Expand Down Expand Up @@ -487,6 +496,27 @@ class RegistrySocket {
this.socket.send(value);
}

/**
* Writes a raw WebSocket close control frame directly onto the
* underlying TCP socket, bypassing `ws`'s own `close()` validation so a
* status code the protocol forbids on the wire (e.g. 1006, reserved for
* abnormal closure and never legally sent) reaches the server's
* `Receiver`, reproducing WS_ERR_INVALID_CLOSE_CODE.
*/
sendInvalidClose(code: number): void {
// `ws` does not type its internal `_socket`, but every `ws` WebSocket
// instance exposes the underlying net.Socket at runtime once open.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `ws` leaves `_socket` untyped
const internals = this.socket as unknown as WebSocketInternals;
const tcpSocket = internals._socket;
const mask = Buffer.alloc(4);
const payload = Buffer.alloc(2);
payload.writeUInt16BE(code, 0);
const maskedPayload = Buffer.alloc(2);
for (let i = 0; i < 2; i += 1) maskedPayload[i] = payload[i]! ^ mask[i]!;
tcpSocket.write(Buffer.concat([Buffer.from([0x88, 0x82]), mask, maskedPayload]));
}

close(): void {
this.socket.close();
}
Expand Down
38 changes: 38 additions & 0 deletions src/mcp-route-method-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { it } from "vitest";
import { Route } from "./routes/agent-executions/$executionId/mcp.js";

/**
* TanStack's `Constrain<ObjectLiteral, Fn>` handlers type resolves member
* access against the function-form branch of the union even though this
* route declares the plain object-literal form, so the generated route
* type cannot express calling an individual method handler directly.
*/
interface McpRouteMethodHandlers {
GET(): Response | Promise<Response>;
DELETE(): Response | Promise<Response>;
}

// The execution capability MCP server is stateless and POST-only. Before this
// guard, an unhandled GET (SSE stream open) or DELETE (session terminate)
// fell through to the SPA route render: 200 HTML for an unknown execution id,
// or a 500 for a real one once it hit `handleExecutionCapabilities`. A 500
// makes MCP Streamable HTTP clients treat the server as dead instead of
// retrying with the one method it actually supports.
it("rejects GET on the MCP route with 405 and an Allow: POST header", async () => {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the generated route type cannot express calling one handler directly
const handlers = Route.options.server?.handlers as unknown as McpRouteMethodHandlers;
const response = await handlers.GET();

assert.equal(response.status, 405);
assert.equal(response.headers.get("allow"), "POST");
});

it("rejects DELETE on the MCP route with 405 and an Allow: POST header", async () => {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the generated route type cannot express calling one handler directly
const handlers = Route.options.server?.handlers as unknown as McpRouteMethodHandlers;
const response = await handlers.DELETE();

assert.equal(response.status, 405);
assert.equal(response.headers.get("allow"), "POST");
});
17 changes: 17 additions & 0 deletions src/routes/agent-executions/$executionId/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ export const Route = createFileRoute("/agent-executions/$executionId/mcp")({
request,
new URL(request.url).pathname.split("/")[2] ?? "",
),
// This MCP server is stateless and POST-only: every call opens a
// fresh transport and closes it once the response finishes, so there
// is no SSE stream to resume (GET) or session to terminate (DELETE).
// Without an explicit handler these methods fell through to the SPA
// route render, which returned a misleading 200 (unknown execution
// id) or crashed to 500 (real execution id) instead of the 405 the
// MCP Streamable HTTP spec requires for an unsupported method - and a
// 500 makes MCP clients mark the server as dead instead of retrying.
GET: methodNotAllowed,
DELETE: methodNotAllowed,
},
},
});

function methodNotAllowed(): Response {
return Response.json(
{ error: "method_not_allowed" },
{ status: 405, headers: { Allow: "POST" } },
);
}
Loading