@wipcomputer/wip-ldm-os 0.4.85-alpha.22 → 0.4.85-alpha.23
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/package.json +2 -1
- package/scripts/test-crc-agentid-tenant-boundary.mjs +1 -1
- package/scripts/test-crc-e2ee-session-route.mjs +2 -2
- package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
- package/src/hosted-mcp/README.md +22 -0
- package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
- package/src/hosted-mcp/deploy.sh +1 -0
- package/src/hosted-mcp/server.mjs +75 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wipcomputer/wip-ldm-os",
|
|
3
|
-
"version": "0.4.85-alpha.
|
|
3
|
+
"version": "0.4.85-alpha.23",
|
|
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
|
|
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(
|
|
37
|
-
assertContains("removeCodexWebClient(
|
|
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");
|
package/src/hosted-mcp/README.md
CHANGED
|
@@ -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
|
+
}
|
package/src/hosted-mcp/deploy.sh
CHANGED
|
@@ -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
|
|
3261
|
-
const
|
|
3262
|
-
|
|
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(
|
|
3356
|
+
removeCodexWebClient(webKey, ws);
|
|
3286
3357
|
});
|
|
3287
3358
|
ws.on("error", (err) => {
|
|
3288
3359
|
console.error("codex-relay web ws error:", err.message);
|