@wipcomputer/wip-ldm-os 0.4.85-alpha.22 → 0.4.85-alpha.24

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.
package/README.md CHANGED
@@ -22,7 +22,8 @@ Open your AI and paste this:
22
22
  ```
23
23
  Read https://wip.computer/install/wip-ldm-os.txt
24
24
 
25
- Use the install document and live local checks as the source of truth. Do not search memory or prior notes for this install.
25
+ Use the install document and live local checks as the source of truth.
26
+ Do not search memory or prior notes for this install. Do not pre-load context from `MEMORY.md`, `crystal_search`, local skill dev guides, or other local memory before fetching the install document.
26
27
 
27
28
  Check if LDM OS is installed (`which ldm && ldm --version`). Branch.
28
29
 
package/SKILL.md CHANGED
@@ -29,7 +29,7 @@ metadata:
29
29
 
30
30
  ## Source of truth
31
31
 
32
- Memory policy for install flows: your first action is to fetch `https://wip.computer/install/wip-ldm-os.txt`. Do not consult `MEMORY.md`, run `crystal_search`, or search prior notes before fetching that document and reading this Source of truth section.
32
+ Memory policy for install flows: do not consult `MEMORY.md`, do not run `crystal_search`, and do not search prior notes when this skill is invoked, including in any parallel or batched exploration step. The only context sources for this install flow are `https://wip.computer/install/wip-ldm-os.txt` and the live local commands that document prescribes. Read that document and run those commands. Do not pre-load other context.
33
33
 
34
34
  Use this install document and live local checks as the source of truth. Do not search memory or prior notes for this install.
35
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.85-alpha.22",
3
+ "version": "0.4.85-alpha.24",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -37,6 +37,7 @@
37
37
  "test:crc-pair-relink-audit-and-rotation": "node scripts/test-crc-pair-relink-audit-and-rotation.mjs",
38
38
  "test:crc-e2ee-key-persistence": "node scripts/test-crc-e2ee-key-persistence.mjs",
39
39
  "test:crc-e2ee-session-route": "node scripts/test-crc-e2ee-session-route.mjs",
40
+ "test:crc-websocket-abuse-limits": "node scripts/test-crc-websocket-abuse-limits.mjs",
40
41
  "fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
41
42
  "fmt:check": "npx prettier --check 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'"
42
43
  },
@@ -33,7 +33,7 @@ assertContains("codexDaemonPubkeyRegistry.get(identity.agentId)", "daemon pubkey
33
33
  assertContains("agentId: identity.agentId,", "relay tickets bind tenant id");
34
34
  assertContains("handle: identity.handle,", "relay tickets preserve display handle");
35
35
  assertContains("codexDaemons.set(identity.agentId, ws);", "daemon ws keyed by tenant id");
36
- assertContains("const key = codexRelayKey(identity.agentId, threadId);", "web ws keyed by tenant id");
36
+ assertContains("const webKey = codexRelayKey(identity.agentId, threadId);", "web ws keyed by tenant id");
37
37
  assertContains("const daemonWs = codexDaemons.get(identity.agentId);", "web sends to tenant daemon");
38
38
  assertNotContains("const agentId = stored.username || (\"passkey-\"", "registration must not use chosen handle as tenant");
39
39
  assertNotContains("const existingKey = Object.entries(API_KEYS).find(([k, v]) => v === agentId);", "oauth must not reuse chosen handle as tenant");
@@ -33,8 +33,8 @@ assertContains("if (isCodexE2eeEnvelope(envelope) && envelope.session) {", "web
33
33
  assertContains("envelope.route_thread_id = threadId;", "relay injects ticket-bound thread into e2ee hello");
34
34
  assertContains("text = JSON.stringify(envelope);", "relay forwards the route-bound e2ee hello");
35
35
  assertContains("registerCodexE2eeSessionRoute(identity.agentId, envelope.session, threadId, ws);", "web e2ee session is registered");
36
- assertContains("const clientCount = addCodexWebClient(key, ws);", "new web connections are added without replacing existing clients");
37
- assertContains("removeCodexWebClient(key, ws);", "close cleanup removes only the closing socket");
36
+ assertContains("const clientCount = addCodexWebClient(webKey, ws);", "new web connections are added without replacing existing clients");
37
+ assertContains("removeCodexWebClient(webKey, ws);", "close cleanup removes only the closing socket");
38
38
  assertContains("removeCodexE2eeRoutesForWeb(identity.agentId, threadId, ws);", "close cleanup");
39
39
  assertContains("if (route.webKey === webKey && (!ws || route.ws === ws)) {", "cleanup only removes routes owned by the closing socket");
40
40
  assertBefore(
@@ -0,0 +1,128 @@
1
+ import { readFileSync } from "node:fs";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ CODEX_WS_CLOSE_CODES,
5
+ codexWsFrameByteLength,
6
+ createCodexWsAbuseLimitConfig,
7
+ createCodexWsConnectionGuard,
8
+ formatCodexWsLimitLog,
9
+ isCodexWsAgentDisabled,
10
+ } from "../src/hosted-mcp/codex-relay-ws-abuse-limits.mjs";
11
+
12
+ const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
13
+ const deploy = readFileSync("src/hosted-mcp/deploy.sh", "utf8");
14
+
15
+ function assertContains(source, needle, label) {
16
+ if (!source.includes(needle)) {
17
+ throw new Error(`${label} missing expected text: ${needle}`);
18
+ }
19
+ }
20
+
21
+ function assertBefore(source, first, second, label) {
22
+ const firstIndex = source.indexOf(first);
23
+ const secondIndex = firstIndex === -1 ? -1 : source.indexOf(second, firstIndex + first.length);
24
+ if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
25
+ throw new Error(`${label} expected "${first}" before "${second}"`);
26
+ }
27
+ }
28
+
29
+ const config = createCodexWsAbuseLimitConfig({
30
+ LDM_CODEX_WS_MAX_FRAME_BYTES: "10",
31
+ LDM_CODEX_WS_RATE_WINDOW_MS: "100",
32
+ LDM_CODEX_WS_MAX_MESSAGES_PER_WINDOW: "2",
33
+ LDM_CODEX_WS_MAX_BYTES_PER_WINDOW: "15",
34
+ LDM_CODEX_WS_MAX_BROWSER_SOCKETS_PER_THREAD: "3",
35
+ LDM_CODEX_WS_IDLE_TTL_MS: "50",
36
+ LDM_CODEX_WS_MAX_MALFORMED_FRAMES: "1",
37
+ LDM_CODEX_WS_MAX_PENDING_BYTES: "20",
38
+ LDM_CODEX_WS_KILL_SWITCH_AGENTS: "acct:blocked, acct:other",
39
+ });
40
+
41
+ assert.equal(config.maxFrameBytes, 10);
42
+ assert.equal(config.maxBrowserSocketsPerThread, 3);
43
+ assert.equal(isCodexWsAgentDisabled(config, "acct:blocked"), true);
44
+ assert.equal(isCodexWsAgentDisabled(config, "acct:allowed"), false);
45
+
46
+ let nowMs = 1_000;
47
+ const guard = createCodexWsConnectionGuard({
48
+ config,
49
+ agentId: "acct:allowed",
50
+ now: () => nowMs,
51
+ });
52
+
53
+ assert.equal(guard.observeFrame(11).code, CODEX_WS_CLOSE_CODES.oversizedFrame);
54
+ assert.equal(guard.observeFrame(5).ok, true);
55
+ assert.equal(guard.observeFrame(5).ok, true);
56
+ assert.equal(guard.observeFrame(5).reason, "message rate limit");
57
+
58
+ nowMs += 101;
59
+ const byteGuard = createCodexWsConnectionGuard({ config, agentId: "acct:allowed", now: () => nowMs });
60
+ assert.equal(byteGuard.observeFrame(8).ok, true);
61
+ assert.equal(byteGuard.observeFrame(8).reason, "byte rate limit");
62
+
63
+ const malformedGuard = createCodexWsConnectionGuard({ config, agentId: "acct:allowed", now: () => nowMs });
64
+ assert.equal(malformedGuard.observeMalformed().ok, true);
65
+ assert.equal(malformedGuard.observeMalformed().code, CODEX_WS_CLOSE_CODES.malformedFrames);
66
+
67
+ const pendingGuard = createCodexWsConnectionGuard({ config, agentId: "acct:allowed", now: () => nowMs });
68
+ assert.equal(pendingGuard.observePendingBytes(21).code, CODEX_WS_CLOSE_CODES.pendingBytes);
69
+
70
+ const idleGuard = createCodexWsConnectionGuard({ config, agentId: "acct:allowed", now: () => nowMs });
71
+ assert.equal(idleGuard.observeFrame(1).ok, true);
72
+ assert.equal(idleGuard.observeIdle(nowMs + 51).code, CODEX_WS_CLOSE_CODES.idleTimeout);
73
+
74
+ const killedGuard = createCodexWsConnectionGuard({ config, agentId: "acct:blocked", now: () => nowMs });
75
+ assert.equal(killedGuard.observeFrame(1).code, CODEX_WS_CLOSE_CODES.operatorDisabled);
76
+ assert.equal(codexWsFrameByteLength(Buffer.from("hello")), 5);
77
+ assert.match(
78
+ formatCodexWsLimitLog({
79
+ agentId: "acct:blocked",
80
+ threadId: "thread-a",
81
+ connectionId: "conn-a",
82
+ reason: "message rate limit",
83
+ }),
84
+ /reason=message rate limit agent=acct:blocked thread=thread-a conn=conn-a/,
85
+ );
86
+
87
+ assertContains(server, "import {", "server imports abuse module");
88
+ assertContains(server, "createCodexWsAbuseLimitConfig", "server configures websocket limits");
89
+ assertContains(server, "isCodexWsAgentDisabled(CODEX_WS_ABUSE_LIMITS, identity.agentId)", "server checks operator kill switch");
90
+ assertContains(server, "openBrowserSockets >= CODEX_WS_ABUSE_LIMITS.maxBrowserSocketsPerThread", "server limits browser sockets per thread");
91
+ assertContains(server, "createCodexWsConnectionGuard({", "server creates per-socket guard");
92
+ assertContains(server, "guard.observeFrame(codexWsFrameByteLength(data))", "server observes browser frame size and rate");
93
+ assertContains(server, "guard.observeMalformed()", "server observes malformed browser frames");
94
+ assertContains(server, "guard.observePendingBytes(daemonWs.bufferedAmount || 0)", "server observes pending daemon bytes");
95
+ assertContains(server, "guard.observeIdle()", "server observes idle connections");
96
+ assertContains(server, "closeCodexWsForLimit(ws, guardContext, decision)", "server closes idle sockets by limit");
97
+ assertContains(server, "closeCodexWsForLimit(ws, guardContext, frameDecision)", "server closes frame abuse");
98
+ assertContains(server, "closeCodexWsForLimit(ws, guardContext, malformedDecision)", "server closes malformed abuse");
99
+ assertContains(server, "closeCodexWsForLimit(ws, guardContext, pendingDecision)", "server closes pending byte abuse");
100
+ assertContains(server, "codex-relay-ws-abuse-limits.mjs", "deploy inventory includes abuse module");
101
+ assertContains(deploy, "add_file \"codex-relay-ws-abuse-limits.mjs\"", "deploy copies abuse module");
102
+
103
+ assertBefore(
104
+ server,
105
+ "openBrowserSockets >= CODEX_WS_ABUSE_LIMITS.maxBrowserSocketsPerThread",
106
+ "codexRelayWss.handleUpgrade(req, socket, head, (ws) => {",
107
+ "socket cap should run before websocket upgrade is accepted",
108
+ );
109
+ assertBefore(
110
+ server,
111
+ "const frameDecision = guard.observeFrame(codexWsFrameByteLength(data));",
112
+ "let text = data.toString();",
113
+ "frame limit should run before parsing or forwarding browser data",
114
+ );
115
+ assertBefore(
116
+ server,
117
+ "if (!envelope || typeof envelope !== \"object\" || Array.isArray(envelope)) {",
118
+ "const daemonWs = codexDaemons.get(identity.agentId);",
119
+ "malformed browser frames should not be forwarded",
120
+ );
121
+ assertBefore(
122
+ server,
123
+ "const pendingDecision = guard.observePendingBytes(daemonWs.bufferedAmount || 0);",
124
+ "daemonWs.send(text);",
125
+ "pending byte check should run before forwarding to daemon",
126
+ );
127
+
128
+ console.log("crc websocket abuse limit checks passed");
@@ -19,7 +19,8 @@ const failures = [];
19
19
  for (const file of ["README.md", "shared/templates/install-prompt.md"]) {
20
20
  const text = contents[file];
21
21
  for (const phrase of [
22
- "Use the install document and live local checks as the source of truth. Do not search memory or prior notes for this install.",
22
+ "Use the install document and live local checks as the source of truth.",
23
+ "Do not search memory or prior notes for this install. Do not pre-load context from `MEMORY.md`, `crystal_search`, local skill dev guides, or other local memory before fetching the install document.",
23
24
  "If installed: run `ldm status`",
24
25
  "If yes to dry run, run `ldm install --dry-run`.",
25
26
  "`npm install -g @wipcomputer/wip-ldm-os@latest && ldm install && ldm doctor`",
@@ -37,8 +38,9 @@ for (const file of ["README.md", "shared/templates/install-prompt.md"]) {
37
38
 
38
39
  const skill = contents["SKILL.md"];
39
40
  for (const phrase of [
40
- "Memory policy for install flows: your first action is to fetch `https://wip.computer/install/wip-ldm-os.txt`.",
41
- "Do not consult `MEMORY.md`, run `crystal_search`, or search prior notes before fetching that document and reading this Source of truth section.",
41
+ "Memory policy for install flows: do not consult `MEMORY.md`, do not run `crystal_search`, and do not search prior notes when this skill is invoked, including in any parallel or batched exploration step.",
42
+ "The only context sources for this install flow are `https://wip.computer/install/wip-ldm-os.txt` and the live local commands that document prescribes.",
43
+ "Read that document and run those commands. Do not pre-load other context.",
42
44
  "Do not run GitHub release commands during the install-state flow.",
43
45
  "Do not run `gh release list` or `gh release view` unless the user explicitly asks for release notes.",
44
46
  "Use the output of `ldm status`, installed package metadata, and npm metadata.",
@@ -53,6 +55,11 @@ if (/gh release (list|view) --repo/.test(skill)) {
53
55
  failures.push("SKILL.md still includes concrete gh release commands in the install flow");
54
56
  }
55
57
 
58
+ const temporalMemoryPolicyPhrase = "your first action is " + "to fetch";
59
+ if (skill.includes(temporalMemoryPolicyPhrase)) {
60
+ failures.push("SKILL.md still uses temporal first-action memory-policy phrasing");
61
+ }
62
+
56
63
  if (failures.length > 0) {
57
64
  console.error("install prompt policy checks failed:");
58
65
  for (const failure of failures) console.error(` - ${failure}`);
@@ -6,7 +6,8 @@ Open your AI and paste this:
6
6
 
7
7
  Read https://wip.computer/install/wip-ldm-os.txt
8
8
 
9
- Use the install document and live local checks as the source of truth. Do not search memory or prior notes for this install.
9
+ Use the install document and live local checks as the source of truth.
10
+ Do not search memory or prior notes for this install. Do not pre-load context from `MEMORY.md`, `crystal_search`, local skill dev guides, or other local memory before fetching the install document.
10
11
 
11
12
  Check if LDM OS is installed (`which ldm && ldm --version`). Branch.
12
13
 
@@ -12,4 +12,26 @@ It includes:
12
12
 
13
13
  WIP runs the production hosted relay so user setup is easy and works across networks. The source is public so users can inspect the relay path and build their own infrastructure.
14
14
 
15
+ ## Codex Remote Control WebSocket Abuse Limits
16
+
17
+ The browser relay path enforces app-layer limits after a Remote Control ticket attaches:
18
+
19
+ - max browser frame bytes;
20
+ - max messages per rate window;
21
+ - max browser bytes per rate window;
22
+ - max malformed browser frames;
23
+ - max pending bytes on the daemon socket before forwarding;
24
+ - max browser sockets per `(tenant id, thread id)`;
25
+ - idle connection TTL;
26
+ - env-driven operator kill switch for all tenants or selected tenant ids.
27
+
28
+ Violation logs are metadata-only: reason, tenant id, thread id, and a generated connection id. The relay does not inspect decrypted Remote Control payloads.
29
+
30
+ Operational notes:
31
+
32
+ - rate windows are tumbling windows, not sliding windows, so short bursts can straddle a window boundary;
33
+ - kill switch environment changes take effect after the hosted relay process reloads;
34
+ - idle close runs on a timer, so the close can happen up to one polling interval after the configured TTL;
35
+ - daemon-to-browser Codex output is intentionally not throughput-limited in this browser-abuse slice.
36
+
15
37
  For the self-hosting shape, read [docs/self-host.md](docs/self-host.md).
@@ -0,0 +1,140 @@
1
+ const DEFAULTS = {
2
+ maxFrameBytes: 256 * 1024,
3
+ rateWindowMs: 10_000,
4
+ maxMessagesPerWindow: 120,
5
+ maxBytesPerWindow: 1024 * 1024,
6
+ maxBrowserSocketsPerThread: 8,
7
+ idleTtlMs: 30 * 60 * 1000,
8
+ maxMalformedFrames: 3,
9
+ maxPendingBytes: 1024 * 1024,
10
+ };
11
+
12
+ export const CODEX_WS_CLOSE_CODES = Object.freeze({
13
+ oversizedFrame: 4400,
14
+ rateLimited: 4401,
15
+ tooManySockets: 4402,
16
+ idleTimeout: 4403,
17
+ malformedFrames: 4404,
18
+ operatorDisabled: 4405,
19
+ pendingBytes: 4406,
20
+ });
21
+
22
+ function positiveInt(value, fallback) {
23
+ const parsed = Number.parseInt(String(value ?? ""), 10);
24
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
25
+ }
26
+
27
+ function parseAgentSet(value) {
28
+ if (!value) return new Set();
29
+ return new Set(
30
+ String(value)
31
+ .split(",")
32
+ .map((part) => part.trim())
33
+ .filter(Boolean),
34
+ );
35
+ }
36
+
37
+ export function createCodexWsAbuseLimitConfig(env = process.env) {
38
+ return {
39
+ maxFrameBytes: positiveInt(env.LDM_CODEX_WS_MAX_FRAME_BYTES, DEFAULTS.maxFrameBytes),
40
+ rateWindowMs: positiveInt(env.LDM_CODEX_WS_RATE_WINDOW_MS, DEFAULTS.rateWindowMs),
41
+ maxMessagesPerWindow: positiveInt(env.LDM_CODEX_WS_MAX_MESSAGES_PER_WINDOW, DEFAULTS.maxMessagesPerWindow),
42
+ maxBytesPerWindow: positiveInt(env.LDM_CODEX_WS_MAX_BYTES_PER_WINDOW, DEFAULTS.maxBytesPerWindow),
43
+ maxBrowserSocketsPerThread: positiveInt(
44
+ env.LDM_CODEX_WS_MAX_BROWSER_SOCKETS_PER_THREAD,
45
+ DEFAULTS.maxBrowserSocketsPerThread,
46
+ ),
47
+ idleTtlMs: positiveInt(env.LDM_CODEX_WS_IDLE_TTL_MS, DEFAULTS.idleTtlMs),
48
+ maxMalformedFrames: positiveInt(env.LDM_CODEX_WS_MAX_MALFORMED_FRAMES, DEFAULTS.maxMalformedFrames),
49
+ maxPendingBytes: positiveInt(env.LDM_CODEX_WS_MAX_PENDING_BYTES, DEFAULTS.maxPendingBytes),
50
+ killSwitchAll: env.LDM_CODEX_WS_KILL_SWITCH_ALL === "1",
51
+ killSwitchAgents: parseAgentSet(env.LDM_CODEX_WS_KILL_SWITCH_AGENTS),
52
+ };
53
+ }
54
+
55
+ export function isCodexWsAgentDisabled(config, agentId) {
56
+ return !!(
57
+ config?.killSwitchAll
58
+ || (typeof agentId === "string" && config?.killSwitchAgents?.has(agentId))
59
+ );
60
+ }
61
+
62
+ function rejected(code, reason) {
63
+ return { ok: false, code, reason };
64
+ }
65
+
66
+ export function createCodexWsConnectionGuard({ config, agentId, now = Date.now }) {
67
+ let windowStartMs = now();
68
+ let messagesInWindow = 0;
69
+ let bytesInWindow = 0;
70
+ let malformedFrames = 0;
71
+ let lastActivityMs = windowStartMs;
72
+
73
+ function resetWindow(nowMs) {
74
+ windowStartMs = nowMs;
75
+ messagesInWindow = 0;
76
+ bytesInWindow = 0;
77
+ }
78
+
79
+ return {
80
+ observeFrame(byteLength, nowMs = now()) {
81
+ if (isCodexWsAgentDisabled(config, agentId)) {
82
+ return rejected(CODEX_WS_CLOSE_CODES.operatorDisabled, "operator disabled");
83
+ }
84
+ if (byteLength > config.maxFrameBytes) {
85
+ return rejected(CODEX_WS_CLOSE_CODES.oversizedFrame, "frame too large");
86
+ }
87
+ if (nowMs - windowStartMs > config.rateWindowMs) {
88
+ resetWindow(nowMs);
89
+ }
90
+ messagesInWindow += 1;
91
+ bytesInWindow += byteLength;
92
+ lastActivityMs = nowMs;
93
+ if (messagesInWindow > config.maxMessagesPerWindow) {
94
+ return rejected(CODEX_WS_CLOSE_CODES.rateLimited, "message rate limit");
95
+ }
96
+ if (bytesInWindow > config.maxBytesPerWindow) {
97
+ return rejected(CODEX_WS_CLOSE_CODES.rateLimited, "byte rate limit");
98
+ }
99
+ return { ok: true };
100
+ },
101
+ observeMalformed(nowMs = now()) {
102
+ lastActivityMs = nowMs;
103
+ malformedFrames += 1;
104
+ if (malformedFrames > config.maxMalformedFrames) {
105
+ return rejected(CODEX_WS_CLOSE_CODES.malformedFrames, "malformed frame limit");
106
+ }
107
+ return { ok: true };
108
+ },
109
+ observePendingBytes(bufferedAmount) {
110
+ if (bufferedAmount > config.maxPendingBytes) {
111
+ return rejected(CODEX_WS_CLOSE_CODES.pendingBytes, "pending byte limit");
112
+ }
113
+ return { ok: true };
114
+ },
115
+ observeIdle(nowMs = now()) {
116
+ if (nowMs - lastActivityMs > config.idleTtlMs) {
117
+ return rejected(CODEX_WS_CLOSE_CODES.idleTimeout, "idle timeout");
118
+ }
119
+ return { ok: true };
120
+ },
121
+ };
122
+ }
123
+
124
+ export function codexWsFrameByteLength(data) {
125
+ if (typeof data === "string") return Buffer.byteLength(data);
126
+ if (Buffer.isBuffer(data)) return data.length;
127
+ if (data instanceof ArrayBuffer) return data.byteLength;
128
+ if (ArrayBuffer.isView(data)) return data.byteLength;
129
+ return Buffer.byteLength(String(data ?? ""));
130
+ }
131
+
132
+ export function formatCodexWsLimitLog({ agentId, threadId, connectionId, reason }) {
133
+ return (
134
+ "codex-relay: websocket limit"
135
+ + " reason=" + String(reason || "unknown").slice(0, 64)
136
+ + " agent=" + String(agentId || "<none>").slice(0, 96)
137
+ + " thread=" + String(threadId || "<none>").slice(0, 96)
138
+ + " conn=" + String(connectionId || "<none>").slice(0, 64)
139
+ );
140
+ }
@@ -117,6 +117,7 @@ if [ "$SKIP_APP" -ne 1 ]; then
117
117
  add_file "inbox.mjs" "${APP_REMOTE_DIR}/inbox.mjs"
118
118
  add_file "tools.mjs" "${APP_REMOTE_DIR}/tools.mjs"
119
119
  add_file "codex-relay-e2ee-registry.mjs" "${APP_REMOTE_DIR}/codex-relay-e2ee-registry.mjs"
120
+ add_file "codex-relay-ws-abuse-limits.mjs" "${APP_REMOTE_DIR}/codex-relay-ws-abuse-limits.mjs"
120
121
  add_file "package.json" "${APP_REMOTE_DIR}/package.json"
121
122
  # Phone app static files (codex-remote-control, login).
122
123
  if [ -d "${SCRIPT_DIR}/app" ]; then
@@ -29,6 +29,13 @@ import {
29
29
  createCodexDaemonPubkeyRegistry,
30
30
  evaluateCodexDaemonReconnectPubkey,
31
31
  } from "./codex-relay-e2ee-registry.mjs";
32
+ import {
33
+ codexWsFrameByteLength,
34
+ createCodexWsAbuseLimitConfig,
35
+ createCodexWsConnectionGuard,
36
+ formatCodexWsLimitLog,
37
+ isCodexWsAgentDisabled,
38
+ } from "./codex-relay-ws-abuse-limits.mjs";
32
39
 
33
40
  // ── Settings ─────────────────────────────────────────────────────────
34
41
 
@@ -48,6 +55,7 @@ const WS_ORIGIN_ALLOWLIST = (process.env.LDM_HOSTED_MCP_WS_ORIGIN_ALLOWLIST || "
48
55
  .split(",")
49
56
  .map(s => s.trim())
50
57
  .filter(Boolean);
58
+ const CODEX_WS_ABUSE_LIMITS = createCodexWsAbuseLimitConfig(process.env);
51
59
 
52
60
  function isWsOriginAllowed(origin) {
53
61
  if (!origin) return false;
@@ -2727,6 +2735,15 @@ function invalidateCodexBrowserSessionsForAgent(agentId, reason) {
2727
2735
  return closed;
2728
2736
  }
2729
2737
 
2738
+ function logCodexWsLimit({ agentId, threadId, connectionId, reason }) {
2739
+ console.warn(formatCodexWsLimitLog({ agentId, threadId, connectionId, reason }));
2740
+ }
2741
+
2742
+ function closeCodexWsForLimit(ws, { agentId, threadId, connectionId }, decision) {
2743
+ logCodexWsLimit({ agentId, threadId, connectionId, reason: decision.reason });
2744
+ try { ws.close(decision.code, decision.reason); } catch {}
2745
+ }
2746
+
2730
2747
  function generateCodexPairingCode() {
2731
2748
  for (let attempt = 0; attempt < 100; attempt += 1) {
2732
2749
  let code = "";
@@ -3256,14 +3273,62 @@ httpServer.on("upgrade", (req, socket, head) => {
3256
3273
  socket.destroy();
3257
3274
  return;
3258
3275
  }
3276
+ const webKey = codexRelayKey(identity.agentId, threadId);
3277
+ if (isCodexWsAgentDisabled(CODEX_WS_ABUSE_LIMITS, identity.agentId)) {
3278
+ console.warn(formatCodexWsLimitLog({
3279
+ agentId: identity.agentId,
3280
+ threadId,
3281
+ connectionId: "upgrade",
3282
+ reason: "operator disabled",
3283
+ }));
3284
+ socket.write("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
3285
+ socket.destroy();
3286
+ return;
3287
+ }
3288
+ const openBrowserSockets = openCodexWebClientsForKey(webKey).length;
3289
+ if (openBrowserSockets >= CODEX_WS_ABUSE_LIMITS.maxBrowserSocketsPerThread) {
3290
+ console.warn(formatCodexWsLimitLog({
3291
+ agentId: identity.agentId,
3292
+ threadId,
3293
+ connectionId: "upgrade",
3294
+ reason: "too many browser sockets",
3295
+ }));
3296
+ socket.write("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n");
3297
+ socket.destroy();
3298
+ return;
3299
+ }
3259
3300
  codexRelayWss.handleUpgrade(req, socket, head, (ws) => {
3260
- const key = codexRelayKey(identity.agentId, threadId);
3261
- const clientCount = addCodexWebClient(key, ws);
3262
- console.log("codex-relay: web online " + key + " clients=" + clientCount);
3301
+ const connectionId = randomUUID();
3302
+ const guard = createCodexWsConnectionGuard({
3303
+ config: CODEX_WS_ABUSE_LIMITS,
3304
+ agentId: identity.agentId,
3305
+ });
3306
+ const guardContext = { agentId: identity.agentId, threadId, connectionId };
3307
+ const idleIntervalMs = Math.max(1000, Math.min(60_000, Math.floor(CODEX_WS_ABUSE_LIMITS.idleTtlMs / 2)));
3308
+ const idleTimer = setInterval(() => {
3309
+ const decision = guard.observeIdle();
3310
+ if (!decision.ok && ws.readyState === ws.OPEN) {
3311
+ closeCodexWsForLimit(ws, guardContext, decision);
3312
+ }
3313
+ }, idleIntervalMs);
3314
+ const clientCount = addCodexWebClient(webKey, ws);
3315
+ console.log("codex-relay: web online " + webKey + " clients=" + clientCount + " conn=" + connectionId);
3263
3316
  ws.on("message", (data) => {
3317
+ const frameDecision = guard.observeFrame(codexWsFrameByteLength(data));
3318
+ if (!frameDecision.ok) {
3319
+ closeCodexWsForLimit(ws, guardContext, frameDecision);
3320
+ return;
3321
+ }
3264
3322
  let text = data.toString();
3265
3323
  let envelope = null;
3266
3324
  try { envelope = JSON.parse(text); } catch {}
3325
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
3326
+ const malformedDecision = guard.observeMalformed();
3327
+ if (!malformedDecision.ok) {
3328
+ closeCodexWsForLimit(ws, guardContext, malformedDecision);
3329
+ }
3330
+ return;
3331
+ }
3267
3332
  if (isCodexE2eeEnvelope(envelope) && envelope.session) {
3268
3333
  // The browser cannot be allowed to choose this value. The relay
3269
3334
  // owns the route because it consumed the ticket for this URL
@@ -3275,14 +3340,20 @@ httpServer.on("upgrade", (req, socket, head) => {
3275
3340
  }
3276
3341
  const daemonWs = codexDaemons.get(identity.agentId);
3277
3342
  if (daemonWs && daemonWs.readyState === daemonWs.OPEN) {
3343
+ const pendingDecision = guard.observePendingBytes(daemonWs.bufferedAmount || 0);
3344
+ if (!pendingDecision.ok) {
3345
+ closeCodexWsForLimit(ws, guardContext, pendingDecision);
3346
+ return;
3347
+ }
3278
3348
  daemonWs.send(text);
3279
3349
  } else {
3280
3350
  try { ws.send(JSON.stringify({ type: "error", message: "daemon offline" })); } catch {}
3281
3351
  }
3282
3352
  });
3283
3353
  ws.on("close", () => {
3354
+ clearInterval(idleTimer);
3284
3355
  removeCodexE2eeRoutesForWeb(identity.agentId, threadId, ws);
3285
- removeCodexWebClient(key, ws);
3356
+ removeCodexWebClient(webKey, ws);
3286
3357
  });
3287
3358
  ws.on("error", (err) => {
3288
3359
  console.error("codex-relay web ws error:", err.message);