Skip to content

Commit 3668325

Browse files
fix(codex-supervisor): bound inbound websocket frame size at 16 MiB
1 parent 738b2be commit 3668325

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Codex Supervisor tests cover the WebSocketCodexJsonRpcConnection maxPayload
2+
// guard — a hostile or misbehaving Codex app-server could otherwise stream an
3+
// unbounded JSON-RPC frame into memory (OOM vector for the supervisor worker).
4+
//
5+
// Real WebSocketServer + real WebSocket client (loopback `ws://localhost`) —
6+
// not mocked. Verifies that the `ws` library's `maxPayload` enforcement is
7+
// wired through the production constructor (`new WebSocket(...)` inside
8+
// `connectCodexAppServerEndpoint`), not just the SDK helper. Helper-only
9+
// unit tests on the underlying `ws` library would not catch a regression
10+
// where the production constructor dropped the option or pointed it at the
11+
// wrong WebSocket instance.
12+
import { once } from "node:events";
13+
import type { AddressInfo } from "node:net";
14+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
15+
import { WebSocket, WebSocketServer } from "ws";
16+
import { connectCodexAppServerEndpoint } from "./json-rpc-client.js";
17+
import type { CodexSupervisorEndpoint } from "./types.js";
18+
19+
// Same constant the source declares. Asserted in the test so a silent change
20+
// to the cap value triggers a review reminder here, not a silent drift.
21+
const MAX_CODEX_SUPERVISOR_WS_INBOUND_BYTES = 16 * 1024 * 1024;
22+
23+
describe("connectCodexAppServerEndpoint websocket maxPayload guard", () => {
24+
let server: WebSocketServer;
25+
let serverPort: number;
26+
let connection: { close: () => Promise<void> } | null = null;
27+
28+
beforeEach(async () => {
29+
// Server-side has a generous cap so it can SEND the hostile 32 MiB frame
30+
// for the test; only the CLIENT is the surface we are bounding.
31+
server = new WebSocketServer({ port: 0, maxPayload: 64 * 1024 * 1024 });
32+
await once(server, "listening");
33+
serverPort = (server.address() as AddressInfo).port;
34+
});
35+
36+
afterEach(async () => {
37+
if (connection) {
38+
await connection.close().catch(() => undefined);
39+
connection = null;
40+
}
41+
if (server) {
42+
await new Promise<void>((resolve) => {
43+
server.close(() => resolve());
44+
});
45+
}
46+
});
47+
48+
it("rejects an inbound frame that exceeds the 16 MiB cap", async () => {
49+
const endpoint: CodexSupervisorEndpoint = {
50+
id: "loopback-test-endpoint",
51+
transport: "websocket",
52+
url: `ws://localhost:${serverPort}`,
53+
};
54+
55+
// Kick off connect + initialize; this hangs waiting for the server to
56+
// respond to the initialize request. `.catch` returns the rejected
57+
// value as-is (typed unknown) so the surrounding test can probe it.
58+
const connectPromise = connectCodexAppServerEndpoint(endpoint)
59+
.then((c) => {
60+
connection = c;
61+
return null as Error | null;
62+
})
63+
.catch((e: unknown) => e as Error);
64+
65+
// Wait for the server to accept the client connection.
66+
const [serverSide] = (await once(server, "connection")) as [WebSocket];
67+
68+
// Hostile frame: 2× the cap (32 MiB). Server sends WITHOUT responding to
69+
// initialize, so the client's pending initialize request will reject.
70+
const oversized = Buffer.alloc(2 * MAX_CODEX_SUPERVISOR_WS_INBOUND_BYTES, 0x78);
71+
serverSide.send(oversized);
72+
73+
const captured = await connectPromise;
74+
expect(captured).toBeInstanceOf(Error);
75+
// The `ws` library emits `RangeError: Max payload size exceeded` (or
76+
// similar wording depending on version) when an inbound frame exceeds
77+
// the configured `maxPayload`. Match the contract loosely so the test
78+
// does not break on minor `ws` wording changes.
79+
expect((captured as Error).message.toLowerCase()).toMatch(
80+
/max.*payload|payload.*size|too large|exceeds/i,
81+
);
82+
});
83+
84+
it("accepts a frame well under the 16 MiB cap (regression: the guard does not block normal traffic)", async () => {
85+
const endpoint: CodexSupervisorEndpoint = {
86+
id: "loopback-test-endpoint",
87+
transport: "websocket",
88+
url: `ws://localhost:${serverPort}`,
89+
};
90+
91+
const connectPromise = connectCodexAppServerEndpoint(endpoint)
92+
.then((c) => {
93+
connection = c;
94+
return null as Error | null;
95+
})
96+
.catch((e: unknown) => e as Error);
97+
98+
const [serverSide] = (await once(server, "connection")) as [WebSocket];
99+
100+
// Wait for the client to send the initialize request and read its id
101+
// so the response matches — the production client uses `randomUUID()`,
102+
// so a hard-coded `id: 1` here would be silently dropped by the
103+
// pending-request lookup in `handleMessage`.
104+
const [requestRaw] = (await once(serverSide, "message")) as [Buffer];
105+
const request = JSON.parse(requestRaw.toString()) as { id: string | number };
106+
107+
// Reply to the initialize request with a small, valid JSON-RPC success
108+
// frame. The frame is ~120 bytes — well under the 16 MiB cap.
109+
const initializeResponse = JSON.stringify({
110+
jsonrpc: "2.0",
111+
id: request.id,
112+
result: {
113+
protocolVersion: "2025-03-26",
114+
capabilities: {},
115+
serverInfo: { name: "loopback-test", version: "0.0.0" },
116+
},
117+
});
118+
serverSide.send(initializeResponse);
119+
120+
const captured = await connectPromise;
121+
expect(captured).toBeNull();
122+
expect(connection).not.toBeNull();
123+
});
124+
});

extensions/codex-supervisor/src/json-rpc-client.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import * as path from "node:path";
1010
import WebSocket from "ws";
1111
import type { CodexJsonRpcConnection, CodexSupervisorEndpoint } from "./types.js";
1212

13+
// Bound inbound app-server JSON-RPC frames so a hostile or misbehaving Codex
14+
// app-server cannot OOM the supervisor worker by streaming an unbounded frame.
15+
// 16 MiB matches the headroom for tool responses / file reads while capping
16+
// worst-case memory exposure well below the `ws` library's 100 MiB default.
17+
const MAX_CODEX_SUPERVISOR_WS_INBOUND_BYTES = 16 * 1024 * 1024;
18+
1319
type PendingRequest = {
1420
reject: (error: Error) => void;
1521
resolve: (value: unknown) => void;
@@ -289,8 +295,9 @@ class WebSocketCodexJsonRpcConnection extends BaseCodexJsonRpcConnection {
289295
? new WebSocket("ws://localhost/", {
290296
headers,
291297
createConnection: () => connectCodexSupervisorUnixSocket(endpoint.url),
298+
maxPayload: MAX_CODEX_SUPERVISOR_WS_INBOUND_BYTES,
292299
})
293-
: new WebSocket(endpoint.url, { headers });
300+
: new WebSocket(endpoint.url, { headers, maxPayload: MAX_CODEX_SUPERVISOR_WS_INBOUND_BYTES });
294301
this.openPromise = new Promise((resolve, reject) => {
295302
this.ws.once("open", resolve);
296303
this.ws.once("error", reject);

0 commit comments

Comments
 (0)