@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 0.4.85-alpha.32
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 +22 -2
- package/SKILL.md +136 -14
- package/bin/ldm.js +525 -75
- package/docs/universal-installer/SPEC.md +16 -3
- package/docs/universal-installer/TECHNICAL.md +4 -4
- package/lib/deploy.mjs +104 -20
- package/lib/detect.mjs +35 -4
- package/lib/registry-migrations.mjs +296 -0
- package/package.json +24 -3
- package/scripts/test-boot-hook-registration.mjs +136 -0
- package/scripts/test-boot-payload-trim.mjs +200 -0
- package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
- package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
- package/scripts/test-crc-e2ee-session-route.mjs +129 -0
- package/scripts/test-crc-pair-login-flow.mjs +115 -0
- package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
- package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
- package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
- package/scripts/test-doctor-hook-dedupe.mjs +188 -0
- package/scripts/test-install-prompt-policy.mjs +84 -0
- package/scripts/test-installer-skill-directory.mjs +55 -0
- package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
- package/scripts/test-installer-target-self-update.mjs +131 -0
- package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
- package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
- package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
- package/scripts/test-ldm-status-concurrency.mjs +118 -0
- package/scripts/test-ldm-status-timeout.mjs +96 -0
- package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
- package/scripts/test-readme-install-prompt.mjs +66 -0
- package/shared/boot/boot-config.json +18 -8
- package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
- package/shared/rules/security.md +4 -0
- package/shared/templates/install-prompt.md +20 -2
- package/src/boot/README.md +24 -1
- package/src/boot/boot-config.json +18 -8
- package/src/boot/boot-hook.mjs +118 -28
- package/src/boot/installer.mjs +33 -10
- package/src/hosted-mcp/.env.example +4 -0
- package/src/hosted-mcp/README.md +37 -0
- package/src/hosted-mcp/app/footer.js +74 -0
- package/src/hosted-mcp/app/kaleidoscope-login.html +1290 -0
- package/src/hosted-mcp/app/pair.html +165 -57
- package/src/hosted-mcp/app/sprites.png +0 -0
- package/src/hosted-mcp/app/wip-logo.png +0 -0
- package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
- package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
- package/src/hosted-mcp/demo/footer.js +2 -2
- package/src/hosted-mcp/demo/index.html +169 -51
- package/src/hosted-mcp/demo/login.html +390 -28
- package/src/hosted-mcp/demo/privacy.html +4 -214
- package/src/hosted-mcp/demo/tos.html +4 -189
- package/src/hosted-mcp/deploy.sh +308 -56
- package/src/hosted-mcp/docs/self-host.md +268 -0
- package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
- package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
- package/src/hosted-mcp/legal/legal-footer.js +75 -0
- package/src/hosted-mcp/legal/legal.css +166 -0
- package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
- package/src/hosted-mcp/legal/privacy/index.html +253 -0
- package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
- package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
- package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
- package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
- package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
- package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
- package/src/hosted-mcp/server.mjs +1681 -166
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
|
|
4
|
+
|
|
5
|
+
function assertContains(needle, label) {
|
|
6
|
+
if (!server.includes(needle)) {
|
|
7
|
+
throw new Error(`${label} missing expected text: ${needle}`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function assertBefore(first, second, label) {
|
|
12
|
+
const firstIndex = server.indexOf(first);
|
|
13
|
+
const secondIndex = firstIndex === -1 ? -1 : server.indexOf(second, firstIndex + first.length);
|
|
14
|
+
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
|
|
15
|
+
throw new Error(`${label} expected "${first}" before "${second}"`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
assertContains("const codexWebClients = new Map(); // `${agentId}:${threadId}` -> Set<ws>", "thread-keyed web client sets");
|
|
20
|
+
assertContains("const codexE2eeSessionRoutes = new Map(); // `${agentId}:${e2eeSession}` -> { threadId, webKey, ws }", "e2ee session route map");
|
|
21
|
+
assertContains("function registerCodexE2eeSessionRoute(agentId, e2eeSession, threadId, ws)", "route registration helper");
|
|
22
|
+
assertContains("codexE2eeSessionRoutes.set(codexRelayKey(agentId, e2eeSession), { threadId, webKey, ws });", "route map stores e2ee session to thread");
|
|
23
|
+
assertContains("function addCodexWebClient(webKey, ws)", "web client set add helper");
|
|
24
|
+
assertContains("function removeCodexWebClient(webKey, ws)", "web client set remove helper");
|
|
25
|
+
assertContains("function openCodexWebClientsForKey(webKey)", "web client set read helper");
|
|
26
|
+
assertContains("function resolveCodexWebClientsForDaemonFrame(agentId, routeId)", "daemon route resolver");
|
|
27
|
+
assertContains("const routed = codexE2eeSessionRoutes.get(codexRelayKey(agentId, routeId));", "daemon route lookup uses e2ee session map");
|
|
28
|
+
assertContains("if (routed && routed.ws && routed.ws.readyState === routed.ws.OPEN) return [routed.ws];", "daemon route resolves to active owner socket");
|
|
29
|
+
assertContains("return openCodexWebClientsForKey(codexRelayKey(agentId, routeId));", "daemon route keeps direct thread fallback");
|
|
30
|
+
assertContains("const targets = resolveCodexWebClientsForDaemonFrame(identity.agentId, sessionId);", "daemon frames use route resolver");
|
|
31
|
+
assertContains("for (const target of targets) {", "daemon frames send to every resolved target");
|
|
32
|
+
assertContains("if (isCodexE2eeEnvelope(envelope) && envelope.session) {", "web e2ee messages are detected");
|
|
33
|
+
assertContains("envelope.route_thread_id = threadId;", "relay injects ticket-bound thread into e2ee hello");
|
|
34
|
+
assertContains("text = JSON.stringify(envelope);", "relay forwards the route-bound e2ee hello");
|
|
35
|
+
assertContains("registerCodexE2eeSessionRoute(identity.agentId, envelope.session, threadId, ws);", "web e2ee session is registered");
|
|
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
|
+
assertContains("removeCodexE2eeRoutesForWeb(identity.agentId, threadId, ws);", "close cleanup");
|
|
39
|
+
assertContains("if (route.webKey === webKey && (!ws || route.ws === ws)) {", "cleanup only removes routes owned by the closing socket");
|
|
40
|
+
assertBefore(
|
|
41
|
+
"envelope.route_thread_id = threadId;",
|
|
42
|
+
"daemonWs.send(text);",
|
|
43
|
+
"web route thread is injected before forwarding to daemon",
|
|
44
|
+
);
|
|
45
|
+
assertBefore(
|
|
46
|
+
"registerCodexE2eeSessionRoute(identity.agentId, envelope.session, threadId, ws);",
|
|
47
|
+
"daemonWs.send(text);",
|
|
48
|
+
"web session route registered before forwarding to daemon",
|
|
49
|
+
);
|
|
50
|
+
if (server.includes("const previous = codexWebClients.get(key);")) {
|
|
51
|
+
throw new Error("web client replacement lookup is still present");
|
|
52
|
+
}
|
|
53
|
+
if (server.includes("codexWebClients.set(key, ws);")) {
|
|
54
|
+
throw new Error("web client singleton set is still present");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const OPEN = 1;
|
|
58
|
+
const agentId = "parker-smoke-test";
|
|
59
|
+
const threadId = "thread-123";
|
|
60
|
+
const e2eeSession = "e2ee-random-session-456";
|
|
61
|
+
if (e2eeSession === threadId) {
|
|
62
|
+
throw new Error("test setup must use a random E2EE session distinct from threadId");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const modelWebClients = new Map();
|
|
66
|
+
const modelRoutes = new Map();
|
|
67
|
+
const webSocketA = { readyState: OPEN, OPEN };
|
|
68
|
+
const webSocketB = { readyState: OPEN, OPEN };
|
|
69
|
+
const webSocketC = { readyState: OPEN, OPEN };
|
|
70
|
+
const webKey = `${agentId}:${threadId}`;
|
|
71
|
+
|
|
72
|
+
function modelAddWebClient(ws) {
|
|
73
|
+
let clients = modelWebClients.get(webKey);
|
|
74
|
+
if (!clients) {
|
|
75
|
+
clients = new Set();
|
|
76
|
+
modelWebClients.set(webKey, clients);
|
|
77
|
+
}
|
|
78
|
+
clients.add(ws);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function modelRegister(session, ws) {
|
|
82
|
+
modelRoutes.set(`${agentId}:${session}`, { webKey, ws });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function modelResolve(routeId) {
|
|
86
|
+
const route = modelRoutes.get(`${agentId}:${routeId}`);
|
|
87
|
+
if (route && route.ws.readyState === route.ws.OPEN) return [route.ws];
|
|
88
|
+
const clients = modelWebClients.get(`${agentId}:${routeId}`) || new Set();
|
|
89
|
+
return [...clients].filter((webWs) => webWs.readyState === webWs.OPEN);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function modelRemoveOwnedRoutes(ws) {
|
|
93
|
+
for (const [routeKey, route] of modelRoutes) {
|
|
94
|
+
if (route.webKey === webKey && route.ws === ws) modelRoutes.delete(routeKey);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function modelRemoveWebClient(ws) {
|
|
99
|
+
const clients = modelWebClients.get(webKey);
|
|
100
|
+
if (!clients) return;
|
|
101
|
+
clients.delete(ws);
|
|
102
|
+
if (clients.size === 0) modelWebClients.delete(webKey);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
modelAddWebClient(webSocketA);
|
|
106
|
+
modelAddWebClient(webSocketB);
|
|
107
|
+
modelRegister(e2eeSession, webSocketA);
|
|
108
|
+
if (modelResolve(e2eeSession)[0] !== webSocketA) {
|
|
109
|
+
throw new Error("random E2EE session did not route to the owning thread web socket");
|
|
110
|
+
}
|
|
111
|
+
const threadTargets = modelResolve(threadId);
|
|
112
|
+
if (threadTargets.length !== 2 || !threadTargets.includes(webSocketA) || !threadTargets.includes(webSocketB)) {
|
|
113
|
+
throw new Error("direct thread fallback did not broadcast to every thread web socket");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
modelRemoveOwnedRoutes(webSocketA);
|
|
117
|
+
modelRemoveWebClient(webSocketA);
|
|
118
|
+
modelAddWebClient(webSocketC);
|
|
119
|
+
modelRegister(e2eeSession, webSocketC);
|
|
120
|
+
modelRemoveOwnedRoutes(webSocketA);
|
|
121
|
+
if (modelResolve(e2eeSession)[0] !== webSocketC) {
|
|
122
|
+
throw new Error("old socket cleanup removed or stole the replacement E2EE route");
|
|
123
|
+
}
|
|
124
|
+
const remainingThreadTargets = modelResolve(threadId);
|
|
125
|
+
if (remainingThreadTargets.length !== 2 || !remainingThreadTargets.includes(webSocketB) || !remainingThreadTargets.includes(webSocketC)) {
|
|
126
|
+
throw new Error("closing one web socket removed another browser client");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log("crc e2ee session route checks passed");
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
|
|
4
|
+
const loginFiles = [
|
|
5
|
+
"src/hosted-mcp/app/kaleidoscope-login.html",
|
|
6
|
+
"src/hosted-mcp/demo/login.html",
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
function assertContains(source, needle, label) {
|
|
10
|
+
if (!source.includes(needle)) {
|
|
11
|
+
throw new Error(`${label} missing expected text: ${needle}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assertNotContains(source, needle, label) {
|
|
16
|
+
if (source.includes(needle)) {
|
|
17
|
+
throw new Error(`${label} must not contain: ${needle}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertBefore(source, first, second, label) {
|
|
22
|
+
const firstIndex = source.indexOf(first);
|
|
23
|
+
const secondIndex = source.indexOf(second);
|
|
24
|
+
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
|
|
25
|
+
throw new Error(`${label} expected "${first}" before "${second}"`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extractServerNextSanitizer(source) {
|
|
30
|
+
const consts = [
|
|
31
|
+
source.match(/const PAIR_NEXT_REGEX = .+;/)?.[0],
|
|
32
|
+
source.match(/const REMOTE_CONTROL_NEXT_REGEX = .+;/)?.[0],
|
|
33
|
+
source.match(/const DEMO_NEXT_REGEX = .+;/)?.[0],
|
|
34
|
+
];
|
|
35
|
+
if (consts.some((line) => !line)) {
|
|
36
|
+
throw new Error("server sanitizer test could not extract next regex constants");
|
|
37
|
+
}
|
|
38
|
+
const start = source.indexOf("function sanitizeCrcPairNext(raw)");
|
|
39
|
+
const end = source.indexOf("// POST /api/qr-login", start);
|
|
40
|
+
if (start === -1 || end === -1) {
|
|
41
|
+
throw new Error("server sanitizer test could not extract sanitizeCrcPairNext");
|
|
42
|
+
}
|
|
43
|
+
return Function(`${consts.join("\n")}\n${source.slice(start, end)}\nreturn sanitizeCrcPairNext;`)();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertSanitizedNext(sanitize, value, expected) {
|
|
47
|
+
const actual = sanitize(value);
|
|
48
|
+
if (actual !== expected) {
|
|
49
|
+
throw new Error(`sanitizeCrcPairNext(${JSON.stringify(value)}) expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
assertContains(server, "const PAIR_NEXT_REGEX = /^\\/pair\\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;", "server pair regex");
|
|
54
|
+
assertContains(server, "const REMOTE_CONTROL_NEXT_REGEX = /^\\/codex-remote-control\\/", "server remote-control regex");
|
|
55
|
+
assertContains(server, "const DEMO_NEXT_REGEX = /^\\/demo$/;", "server demo regex");
|
|
56
|
+
assertContains(server, "purpose, // \"pair\" | null", "server stores pair purpose");
|
|
57
|
+
assertContains(server, "next: next || null, // sanitized `/pair/<CODE>`, `/codex-remote-control/<UUID>`, `/demo`, or null", "server stores sanitized next");
|
|
58
|
+
assertContains(server, "DEMO_NEXT_REGEX.test(decoded)", "server allows demo next");
|
|
59
|
+
assertContains(server, "json(res, 200, { status: \"approved\", agentId: entry.agentId });", "server strips desktop pair status");
|
|
60
|
+
assertContains(server, "json(res, 200, { ok: true, next: entry.next });", "server returns next to phone approve");
|
|
61
|
+
assertContains(server, "tenantId: entry.tenantId || null,", "server returns QR login tenant");
|
|
62
|
+
assertContains(server, "const verifiedIdentity = identityForApiKey(apiKey);", "server canonicalizes QR approval identity");
|
|
63
|
+
|
|
64
|
+
const sanitizeCrcPairNext = extractServerNextSanitizer(server);
|
|
65
|
+
assertSanitizedNext(sanitizeCrcPairNext, "/demo", "/demo");
|
|
66
|
+
assertSanitizedNext(sanitizeCrcPairNext, "/demo/", null);
|
|
67
|
+
assertSanitizedNext(sanitizeCrcPairNext, "/demo/../admin", null);
|
|
68
|
+
assertSanitizedNext(sanitizeCrcPairNext, "//evil.com", null);
|
|
69
|
+
assertSanitizedNext(sanitizeCrcPairNext, "https://evil", null);
|
|
70
|
+
assertSanitizedNext(sanitizeCrcPairNext, "/demoX", null);
|
|
71
|
+
|
|
72
|
+
for (const file of loginFiles) {
|
|
73
|
+
const html = readFileSync(file, "utf8");
|
|
74
|
+
assertContains(html, "var DEMO_NEXT_REGEX = /^\\/demo$/;", `${file} demo regex`);
|
|
75
|
+
assertContains(html, "function defaultLocalPasskeysOn()", `${file} has local passkey default helper`);
|
|
76
|
+
assertContains(html, "return isMobileDevice();", `${file} defaults local passkeys on for mobile`);
|
|
77
|
+
assertContains(html, "function getLocalPasskeysPreference()", `${file} has local passkey preference helper`);
|
|
78
|
+
assertContains(html, "if (stored === 'on' || stored === 'off') return stored;", `${file} respects stored local passkey preference`);
|
|
79
|
+
assertContains(html, "function needsCustomQR() {\n return !isLocalPasskeysOn();\n}", `${file} uses QR whenever local passkeys are off`);
|
|
80
|
+
assertNotContains(html, "return !isMobileDevice() && !isSafariDesktop() && !isLocalPasskeysOn();", `${file} must not gate QR by browser or mobile status`);
|
|
81
|
+
assertContains(html, "DEMO_NEXT_REGEX.test(raw)", `${file} allowlists demo next`);
|
|
82
|
+
assertContains(html, "function storeDemoHandoffIfNeeded(next, identity, isNewAccount)", `${file} demo handoff helper`);
|
|
83
|
+
assertContains(html, "sessionStorage.setItem('lesa-token', identity.apiKey);", `${file} stores demo token`);
|
|
84
|
+
assertContains(html, "sessionStorage.setItem('lesa-agent', identity.agentId);", `${file} stores demo agent`);
|
|
85
|
+
assertContains(html, "if (identity.tenantId) sessionStorage.setItem('lesa-tenant', identity.tenantId);", `${file} stores demo tenant`);
|
|
86
|
+
assertContains(html, "if (isNewAccount) sessionStorage.setItem('lesa-new-account', 'true');", `${file} stores demo new-account flag conditionally`);
|
|
87
|
+
assertContains(html, "tenantId: result.tenantId,", `${file} sends tenant to QR approval`);
|
|
88
|
+
assertContains(html, "storeDemoHandoffIfNeeded(data.next, data, qrLoginMode === 'register');", `${file} QR status stores demo handoff`);
|
|
89
|
+
assertContains(html, "storeDemoHandoffIfNeeded('/demo', data, qrLoginMode === 'register');", `${file} plain QR success keeps demo handoff`);
|
|
90
|
+
assertContains(html, "storeDemoHandoffIfNeeded('/demo', result, true);", `${file} plain create success keeps demo handoff`);
|
|
91
|
+
assertContains(html, "storeDemoHandoffIfNeeded('/demo', result, false);", `${file} plain sign-in success keeps demo handoff`);
|
|
92
|
+
if (html.includes('onclick="sessionStorage.clear();"')) {
|
|
93
|
+
throw new Error(`${file} Try the Demo button must not clear sessionStorage`);
|
|
94
|
+
}
|
|
95
|
+
assertContains(html, "followPairNextIfPresent(approveResponse, result, true)", `${file} create account marks new account`);
|
|
96
|
+
assertContains(html, "followPairNextIfPresent(approveResponse, result, false)", `${file} sign-in does not mark new account`);
|
|
97
|
+
assertContains(html, "function isPairNextOnDesktop()", `${file} desktop pair helper`);
|
|
98
|
+
assertContains(html, "} else if (isPairNextOnDesktop()) {", `${file} auto-start desktop pair QR`);
|
|
99
|
+
assertContains(html, "startQrLogin('', 'signin');", `${file} pair QR uses sign-in mode`);
|
|
100
|
+
assertContains(html, "if (approveResponse && typeof approveResponse.next === 'string' && isWhitelistedNext(approveResponse.next))", `${file} consumes approve next`);
|
|
101
|
+
assertContains(html, "if (urlNext && PAIR_NEXT_REGEX.test(urlNext))", `${file} desktop pair approved branch`);
|
|
102
|
+
assertBefore(html, "if (isPairNextOnDesktop() && !qrSessionMode)", "if (needsCustomQR() && !qrSessionMode)", `${file} create button forces pair QR before normal QR fallback`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const appLogin = readFileSync("src/hosted-mcp/app/kaleidoscope-login.html", "utf8");
|
|
106
|
+
assertContains(appLogin, "Local passkeys are on by default on mobile devices.", "app login mobile tooltip copy");
|
|
107
|
+
assertContains(appLogin, "This device's passkeys are used for login and device sync.", "app login mobile tooltip current-device copy");
|
|
108
|
+
assertContains(appLogin, "Turn this off to use or save passkeys on a different device.", "app login mobile tooltip off copy");
|
|
109
|
+
assertContains(appLogin, "Local passkeys are off by default on desktop.", "app login desktop tooltip copy");
|
|
110
|
+
assertContains(appLogin, "Your mobile device's passkeys are used for login and device sync.", "app login desktop tooltip mobile-device copy");
|
|
111
|
+
assertContains(appLogin, "Turn this on to use or save passkeys on this computer.", "app login desktop tooltip on copy");
|
|
112
|
+
assertNotContains(appLogin, "phone's passkeys", "app login tooltip must not say phone passkeys");
|
|
113
|
+
assertNotContains(appLogin, "mobile devices' passkeys", "app login tooltip must not use plural possessive");
|
|
114
|
+
|
|
115
|
+
console.log("crc pair login flow checks passed");
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
codexDaemonPubkeyFingerprint,
|
|
4
|
+
createCodexDaemonPubkeyRegistry,
|
|
5
|
+
evaluateCodexDaemonReconnectPubkey,
|
|
6
|
+
} from "../src/hosted-mcp/codex-relay-e2ee-registry.mjs";
|
|
7
|
+
|
|
8
|
+
const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
|
|
9
|
+
const pairHtml = readFileSync("src/hosted-mcp/app/pair.html", "utf8");
|
|
10
|
+
const loginHtml = readFileSync("src/hosted-mcp/app/kaleidoscope-login.html", "utf8");
|
|
11
|
+
const registrySource = readFileSync("src/hosted-mcp/codex-relay-e2ee-registry.mjs", "utf8");
|
|
12
|
+
const ticket = readFileSync("ai/product/bugs/codex-remote-control/2026-05-05--codex--remote-control-pair-relink-audit-and-rotation.md", "utf8");
|
|
13
|
+
|
|
14
|
+
function assertContains(haystack, needle, label) {
|
|
15
|
+
if (!haystack.includes(needle)) {
|
|
16
|
+
throw new Error(`${label} missing expected text: ${needle}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assert(condition, label, detail = "") {
|
|
21
|
+
if (!condition) throw new Error(`${label}${detail ? ": " + detail : ""}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createSilentLogger() {
|
|
25
|
+
return { log() {}, error() {} };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
assertContains(server, "const CODEX_PAIR_PRESENCE_TTL_MS = 2 * 60 * 1000;", "short pair presence ttl");
|
|
29
|
+
assertContains(server, "const codexPairPresenceTokens = new Map();", "pair presence token store");
|
|
30
|
+
assertContains(server, "function generateCodexPairPresenceToken(agentId)", "pair presence token mint");
|
|
31
|
+
assertContains(server, "function consumeCodexPairPresenceToken(token, agentId)", "pair presence token consume");
|
|
32
|
+
assertContains(server, "codex_pair_presence_token: generateCodexPairPresenceToken(agentId)", "registration mints pair presence token");
|
|
33
|
+
assertContains(server, "codex_pair_presence_token: generateCodexPairPresenceToken(entry.agentId)", "authentication mints pair presence token");
|
|
34
|
+
assertContains(server, 'error: "fresh_presence_required"', "pair-complete fresh presence rejection");
|
|
35
|
+
assertContains(server, "consumeCodexPairPresenceToken(pairPresenceToken, identity.agentId)", "pair-complete consumes pair presence token");
|
|
36
|
+
assertContains(server, 'json(res, 404, { error: "invalid or already-used code" });', "pair code reuse rejection");
|
|
37
|
+
assertContains(server, 'json(res, 410, { error: "code expired or already used" });', "pair code expiry rejection");
|
|
38
|
+
assertContains(server, "invalidateCodexBrowserSessionsForAgent(identity.agentId, \"daemon key replaced\")", "daemon replacement invalidates stale browser sessions");
|
|
39
|
+
assertContains(server, "evaluateCodexDaemonReconnectPubkey(", "daemon reconnect checks existing key policy");
|
|
40
|
+
assertContains(server, "daemon key change requires fresh pair", "changed daemon reconnect key requires pair flow");
|
|
41
|
+
assertContains(server, "daemonIdentityAccepted = activateCodexDaemonWs();", "daemon only becomes active after identity is accepted");
|
|
42
|
+
assertContains(server, "daemon already online", "duplicate daemon cannot evict an online daemon");
|
|
43
|
+
assertContains(server, "daemon identity required", "daemon frames require identity before routing");
|
|
44
|
+
assertContains(server, "p.replaced_daemon_key = !!daemonKeyResult?.replaced;", "pair state records replacement status");
|
|
45
|
+
assertContains(server, "replaced_daemon_key: !!p.replaced_daemon_key", "pair-status exposes relink replacement status");
|
|
46
|
+
assertContains(pairHtml, "codex_pair_presence_token: getPairPresenceToken()", "pair page sends pair presence token");
|
|
47
|
+
assertContains(pairHtml, "fresh_presence_required", "pair page handles fresh presence error");
|
|
48
|
+
assertContains(pairHtml, "Remote Control relinked this laptop.", "pair page gives relink message");
|
|
49
|
+
assertContains(loginHtml, "wip_codex_pair_presence_token", "login carries pair presence token into pair page");
|
|
50
|
+
assertContains(registrySource, "CREATE TABLE IF NOT EXISTS codex_daemon_e2ee_key_audit", "pair audit table");
|
|
51
|
+
assertContains(registrySource, "old_pubkey_fingerprint", "audit stores old key fingerprint");
|
|
52
|
+
assertContains(registrySource, "new_pubkey_fingerprint", "audit stores new key fingerprint");
|
|
53
|
+
assertContains(ticket, "status: done", "ticket marked done");
|
|
54
|
+
|
|
55
|
+
const oldFingerprint = codexDaemonPubkeyFingerprint("old-spki-key");
|
|
56
|
+
const newFingerprint = codexDaemonPubkeyFingerprint("new-spki-key");
|
|
57
|
+
assert(oldFingerprint && oldFingerprint.startsWith("sha256:"), "fingerprint has sha256 prefix");
|
|
58
|
+
assert(oldFingerprint !== newFingerprint, "fingerprint changes when daemon key changes");
|
|
59
|
+
|
|
60
|
+
const registry = createCodexDaemonPubkeyRegistry({
|
|
61
|
+
usePrisma: false,
|
|
62
|
+
devMode: false,
|
|
63
|
+
logger: createSilentLogger(),
|
|
64
|
+
});
|
|
65
|
+
const first = await registry.register("acct:test-user-a", "old-spki-key", ["e2ee-v1"], "pair-complete");
|
|
66
|
+
assert(first.registered === true, "first pair registers key");
|
|
67
|
+
assert(first.replaced === false, "first pair is not replacement");
|
|
68
|
+
const second = await registry.register("acct:test-user-a", "new-spki-key", ["e2ee-v1"], "pair-complete");
|
|
69
|
+
assert(second.registered === true, "relink registers new key");
|
|
70
|
+
assert(second.replaced === true, "relink replacement is detected");
|
|
71
|
+
assert(second.old_fingerprint === oldFingerprint, "relink reports old fingerprint");
|
|
72
|
+
assert(second.new_fingerprint === newFingerprint, "relink reports new fingerprint");
|
|
73
|
+
assert(registry.auditLog.length === 2, "registry keeps audit entries");
|
|
74
|
+
assert(registry.auditLog[1].replaced === true, "audit marks replacement");
|
|
75
|
+
assert(registry.auditLog[1].old_pubkey_fingerprint === oldFingerprint, "audit stores old fingerprint");
|
|
76
|
+
assert(registry.auditLog[1].new_pubkey_fingerprint === newFingerprint, "audit stores new fingerprint");
|
|
77
|
+
|
|
78
|
+
const firstReconnectPolicy = evaluateCodexDaemonReconnectPubkey(null, "daemon-reconnect-key");
|
|
79
|
+
assert(firstReconnectPolicy.allowed === true, "daemon reconnect can self-heal when no key is registered");
|
|
80
|
+
assert(firstReconnectPolicy.replaced === false, "first daemon reconnect is not a replacement");
|
|
81
|
+
const sameReconnectPolicy = evaluateCodexDaemonReconnectPubkey({ pubkey: "daemon-reconnect-key" }, "daemon-reconnect-key");
|
|
82
|
+
assert(sameReconnectPolicy.allowed === true, "daemon reconnect can re-register the same key");
|
|
83
|
+
assert(sameReconnectPolicy.replaced === false, "same-key daemon reconnect is not a replacement");
|
|
84
|
+
const changedReconnectPolicy = evaluateCodexDaemonReconnectPubkey({ pubkey: "daemon-reconnect-key" }, "attacker-reconnect-key");
|
|
85
|
+
assert(changedReconnectPolicy.allowed === false, "daemon reconnect cannot replace an existing key");
|
|
86
|
+
assert(changedReconnectPolicy.reason === "fresh_pair_required", "changed daemon reconnect requires fresh pair");
|
|
87
|
+
assert(changedReconnectPolicy.replaced === true, "changed daemon reconnect is detected as replacement");
|
|
88
|
+
assert(changedReconnectPolicy.old_fingerprint === codexDaemonPubkeyFingerprint("daemon-reconnect-key"), "changed reconnect reports old fingerprint");
|
|
89
|
+
assert(changedReconnectPolicy.new_fingerprint === codexDaemonPubkeyFingerprint("attacker-reconnect-key"), "changed reconnect reports new fingerprint");
|
|
90
|
+
const invalidReconnectPolicy = evaluateCodexDaemonReconnectPubkey({ pubkey: "daemon-reconnect-key" }, "");
|
|
91
|
+
assert(invalidReconnectPolicy.allowed === false, "daemon reconnect rejects missing pubkey");
|
|
92
|
+
assert(invalidReconnectPolicy.reason === "invalid_daemon_pubkey", "missing daemon reconnect pubkey has explicit reason");
|
|
93
|
+
const oversizedReconnectPolicy = evaluateCodexDaemonReconnectPubkey(null, "x".repeat(1025));
|
|
94
|
+
assert(oversizedReconnectPolicy.allowed === false, "daemon reconnect rejects oversized pubkey");
|
|
95
|
+
assert(oversizedReconnectPolicy.reason === "invalid_daemon_pubkey", "oversized daemon reconnect pubkey has explicit reason");
|
|
96
|
+
|
|
97
|
+
const executeCalls = [];
|
|
98
|
+
const persistedRegistry = createCodexDaemonPubkeyRegistry({
|
|
99
|
+
usePrisma: true,
|
|
100
|
+
devMode: false,
|
|
101
|
+
logger: createSilentLogger(),
|
|
102
|
+
prisma: {
|
|
103
|
+
async $executeRawUnsafe(sql, ...args) {
|
|
104
|
+
executeCalls.push({ sql, args });
|
|
105
|
+
return 1;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
await persistedRegistry.register("acct:test-user-b", "persisted-spki-key", ["e2ee-v1"], "daemon-reconnect");
|
|
110
|
+
const auditInsert = executeCalls.find((call) => call.sql.includes("INSERT INTO codex_daemon_e2ee_key_audit"));
|
|
111
|
+
assert(auditInsert, "audit insert executes for persisted registry");
|
|
112
|
+
assert(auditInsert.sql.includes("$7::timestamptz"), "audit insert casts registered_at parameter to timestamptz");
|
|
113
|
+
assert(typeof auditInsert.args[6] === "string" && auditInsert.args[6].includes("T"), "audit insert passes ISO registered_at value");
|
|
114
|
+
|
|
115
|
+
function pairCompleteModel({ hasDaemonPublicKey, pairPresenceOk, previousPubkey, nextPubkey }) {
|
|
116
|
+
if (hasDaemonPublicKey && !pairPresenceOk) return { code: 403, error: "fresh_presence_required" };
|
|
117
|
+
const replaced = !!(previousPubkey && nextPubkey && previousPubkey !== nextPubkey);
|
|
118
|
+
return { code: 200, replaced };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
assert(
|
|
122
|
+
pairCompleteModel({
|
|
123
|
+
hasDaemonPublicKey: true,
|
|
124
|
+
pairPresenceOk: false,
|
|
125
|
+
previousPubkey: "old",
|
|
126
|
+
nextPubkey: "new",
|
|
127
|
+
}).code === 403,
|
|
128
|
+
"ck token alone cannot replace daemon key",
|
|
129
|
+
);
|
|
130
|
+
assert(
|
|
131
|
+
pairCompleteModel({
|
|
132
|
+
hasDaemonPublicKey: true,
|
|
133
|
+
pairPresenceOk: true,
|
|
134
|
+
previousPubkey: "old",
|
|
135
|
+
nextPubkey: "new",
|
|
136
|
+
}).replaced === true,
|
|
137
|
+
"fresh pair presence permits relink",
|
|
138
|
+
);
|
|
139
|
+
assert(
|
|
140
|
+
pairCompleteModel({
|
|
141
|
+
hasDaemonPublicKey: true,
|
|
142
|
+
pairPresenceOk: true,
|
|
143
|
+
previousPubkey: null,
|
|
144
|
+
nextPubkey: "new",
|
|
145
|
+
}).replaced === false,
|
|
146
|
+
"fresh pair presence permits first pair",
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
function pairCodeModel(pair, codeKnown, now) {
|
|
150
|
+
if (!codeKnown) return { code: 404, error: "invalid or already-used code" };
|
|
151
|
+
if (!pair || pair.status !== "pending" || now > pair.expires) {
|
|
152
|
+
return { code: 410, error: "code expired or already used" };
|
|
153
|
+
}
|
|
154
|
+
pair.status = "completed";
|
|
155
|
+
return { code: 200 };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const pair = { status: "pending", expires: 100 };
|
|
159
|
+
assert(pairCodeModel(pair, true, 10).code === 200, "first pair-complete succeeds");
|
|
160
|
+
assert(pairCodeModel(pair, true, 20).code === 410, "pair code reuse fails");
|
|
161
|
+
assert(pairCodeModel({ status: "pending", expires: 100 }, true, 200).code === 410, "expired pair code fails");
|
|
162
|
+
assert(pairCodeModel(null, false, 10).code === 404, "unknown pair code fails");
|
|
163
|
+
|
|
164
|
+
console.log("crc pair relink audit and rotation checks passed");
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const server = readFileSync("src/hosted-mcp/server.mjs", "utf8");
|
|
4
|
+
|
|
5
|
+
function assertContains(needle, label) {
|
|
6
|
+
if (!server.includes(needle)) {
|
|
7
|
+
throw new Error(`${label} missing expected text: ${needle}`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function assertNotContains(needle, label) {
|
|
12
|
+
if (server.includes(needle)) {
|
|
13
|
+
throw new Error(`${label} still contains forbidden text: ${needle}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
assertContains("function generateCodexPairPollToken()", "pair poll token generator");
|
|
18
|
+
assertContains('return "ppt_" + randomBytes(32).toString("base64url");', "pair poll token entropy");
|
|
19
|
+
assertContains("function getBearerToken(req)", "bearer token helper");
|
|
20
|
+
assertContains("const pollToken = generateCodexPairPollToken();", "pair-init mints poll token");
|
|
21
|
+
assertContains("poll_token: pollToken,", "pair state stores poll token");
|
|
22
|
+
assertContains("poll_token_used: false,", "pair state tracks token consumption");
|
|
23
|
+
assertContains("pair_poll_token: pollToken,", "pair-init returns poll token to daemon");
|
|
24
|
+
assertContains('json(res, 401, { error: "pair_poll_token_expired" });', "expired token rejected");
|
|
25
|
+
assertContains('json(res, 401, { error: "invalid_pair_poll_token" });', "missing or wrong token rejected");
|
|
26
|
+
assertContains("if (!pollToken || pollToken !== p.poll_token || p.poll_token_used)", "pair-status validates token");
|
|
27
|
+
assertContains("p.poll_token_used = true;", "completed credential response consumes token");
|
|
28
|
+
|
|
29
|
+
function pairStatusModel(pair, bearer, now) {
|
|
30
|
+
if (now > pair.expires) return { code: 401, body: { error: "pair_poll_token_expired" } };
|
|
31
|
+
if (!bearer || bearer !== pair.poll_token || pair.poll_token_used) {
|
|
32
|
+
return { code: 401, body: { error: "invalid_pair_poll_token" } };
|
|
33
|
+
}
|
|
34
|
+
if (pair.status === "completed") {
|
|
35
|
+
pair.poll_token_used = true;
|
|
36
|
+
return { code: 200, body: { status: "completed", api_key: pair.apiKey, handle: pair.handle } };
|
|
37
|
+
}
|
|
38
|
+
return { code: 200, body: { status: pair.status } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const pair = {
|
|
42
|
+
status: "pending",
|
|
43
|
+
expires: 10_000,
|
|
44
|
+
poll_token: "ppt_good",
|
|
45
|
+
poll_token_used: false,
|
|
46
|
+
apiKey: "ck_secret",
|
|
47
|
+
handle: "Parker",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (pairStatusModel({ ...pair }, null, 1).code !== 401) {
|
|
51
|
+
throw new Error("missing poll token should fail");
|
|
52
|
+
}
|
|
53
|
+
if (pairStatusModel({ ...pair }, "ppt_wrong", 1).code !== 401) {
|
|
54
|
+
throw new Error("wrong poll token should fail");
|
|
55
|
+
}
|
|
56
|
+
if (pairStatusModel({ ...pair }, "ppt_good", 20_000).code !== 401) {
|
|
57
|
+
throw new Error("expired poll token should fail");
|
|
58
|
+
}
|
|
59
|
+
if (pairStatusModel({ ...pair }, "ppt_good", 1).body.status !== "pending") {
|
|
60
|
+
throw new Error("correct poll token should return pending before completion");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const completedPair = { ...pair, status: "completed" };
|
|
64
|
+
const completed = pairStatusModel(completedPair, "ppt_good", 1);
|
|
65
|
+
if (completed.code !== 200 || completed.body.api_key !== "ck_secret") {
|
|
66
|
+
throw new Error("correct poll token should return completed credential once");
|
|
67
|
+
}
|
|
68
|
+
const replay = pairStatusModel(completedPair, "ppt_good", 1);
|
|
69
|
+
if (replay.code !== 401) {
|
|
70
|
+
throw new Error("reused completed poll token should fail");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log("crc pair-status poll token checks passed");
|
|
@@ -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");
|