@wipcomputer/wip-ldm-os 0.4.85-alpha.2 → 0.4.85-alpha.21
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 +8 -5
- package/bin/ldm.js +169 -65
- 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/package.json +13 -2
- 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 +40 -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-install-prompt-policy.mjs +60 -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-ldm-status-timeout.mjs +80 -0
- package/shared/templates/install-prompt.md +20 -2
- package/src/hosted-mcp/README.md +15 -0
- package/src/hosted-mcp/app/footer.js +74 -0
- package/src/hosted-mcp/app/kaleidoscope-login.html +846 -0
- package/src/hosted-mcp/app/pair.html +165 -57
- package/src/hosted-mcp/app/sprites.png +0 -0
- package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
- package/src/hosted-mcp/demo/index.html +3 -7
- package/src/hosted-mcp/demo/login.html +318 -20
- package/src/hosted-mcp/deploy.sh +307 -56
- package/src/hosted-mcp/docs/self-host.md +268 -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 +963 -146
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// server.mjs: Hosted MCP server for wip.computer
|
|
2
2
|
// MCP Streamable HTTP transport at /mcp, health check at /health.
|
|
3
|
-
// Auth: Bearer ck-... API key maps to an
|
|
3
|
+
// Auth: Bearer ck-... API key maps to an immutable tenant ID.
|
|
4
4
|
// OAuth 2.0: Minimal flow for Claude iOS custom connector.
|
|
5
5
|
// WebAuthn: Passkey-based signup/login (replaces agent name text form).
|
|
6
6
|
|
|
@@ -23,10 +23,41 @@ import {
|
|
|
23
23
|
import QRCode from "qrcode";
|
|
24
24
|
import { WebSocketServer } from "ws";
|
|
25
25
|
import { parse as parseUrlQs } from "node:querystring";
|
|
26
|
+
import {
|
|
27
|
+
buildCodexBootstrapPayload,
|
|
28
|
+
codexDaemonPubkeyFingerprint,
|
|
29
|
+
createCodexDaemonPubkeyRegistry,
|
|
30
|
+
evaluateCodexDaemonReconnectPubkey,
|
|
31
|
+
} from "./codex-relay-e2ee-registry.mjs";
|
|
26
32
|
|
|
27
33
|
// ── Settings ─────────────────────────────────────────────────────────
|
|
28
34
|
|
|
29
35
|
const PORT = parseInt(process.env.MCP_PORT || "18800", 10);
|
|
36
|
+
// Dev mode: opt-in to JSON-file fallbacks for the data layer and to
|
|
37
|
+
// reading tokens/passkeys from local JSON files. Production must run
|
|
38
|
+
// without this flag set (production fails closed when Prisma is
|
|
39
|
+
// unavailable, and never reads/writes the local JSON token files).
|
|
40
|
+
// Tracked by ai/product/bugs/security/2026-04-28--cc-mini--vps-hosted-mcp-audit.md (F-002, F-005a).
|
|
41
|
+
const DEV_MODE = process.env.LDM_HOSTED_MCP_DEV_MODE === "1";
|
|
42
|
+
// WebSocket Origin allowlist (F-003 in the VPS hosted-mcp audit).
|
|
43
|
+
// Browser-borne web WS upgrades must present an Origin from this list.
|
|
44
|
+
// Comma-separated env var; default is the production origin.
|
|
45
|
+
// Daemon WS upgrades (CLI / agent connections) are NOT gated on Origin
|
|
46
|
+
// because they do not send a browser Origin header.
|
|
47
|
+
const WS_ORIGIN_ALLOWLIST = (process.env.LDM_HOSTED_MCP_WS_ORIGIN_ALLOWLIST || "https://wip.computer")
|
|
48
|
+
.split(",")
|
|
49
|
+
.map(s => s.trim())
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
|
|
52
|
+
function isWsOriginAllowed(origin) {
|
|
53
|
+
if (!origin) return false;
|
|
54
|
+
return WS_ORIGIN_ALLOWLIST.includes(origin);
|
|
55
|
+
}
|
|
56
|
+
// F-001: WS URL-token fallback (browser sends ?token=ck-... on upgrade).
|
|
57
|
+
// Default off in production. Set LDM_HOSTED_MCP_ALLOW_WS_URL_TOKEN=1 to
|
|
58
|
+
// allow the legacy back-compat path. Independent of any other dev flag
|
|
59
|
+
// so that this can be toggled without enabling other dev-mode behavior.
|
|
60
|
+
const ALLOW_WS_URL_TOKEN = process.env.LDM_HOSTED_MCP_ALLOW_WS_URL_TOKEN === "1";
|
|
30
61
|
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
31
62
|
const SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
32
63
|
const OAUTH_CODE_EXPIRY_MS = 10 * 60 * 1000;
|
|
@@ -44,18 +75,19 @@ const RP_ORIGIN = "https://wip.computer";
|
|
|
44
75
|
|
|
45
76
|
// ── Data layer ──────────────────────────────────────────────────────
|
|
46
77
|
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
78
|
+
// Production: Postgres via Prisma is the canonical store. If Prisma
|
|
79
|
+
// cannot connect, the server refuses to start (F-005a).
|
|
49
80
|
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
81
|
+
// Dev mode (LDM_HOSTED_MCP_DEV_MODE=1): JSON files are used as a
|
|
82
|
+
// fallback for tokens and passkeys when Prisma is unavailable, and
|
|
83
|
+
// are also seeded into the in-memory cache on boot. Production must
|
|
84
|
+
// not set this flag.
|
|
52
85
|
|
|
53
86
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
54
87
|
const TOKEN_FILE = join(__dirname, "tokens.json");
|
|
55
88
|
const PASSKEY_FILE = join(__dirname, "passkeys.json");
|
|
56
89
|
const WALLET_FILE_LEGACY = join(__dirname, "wallets.json");
|
|
57
90
|
|
|
58
|
-
// Initialize Prisma (may fail if DATABASE_URL not set)
|
|
59
91
|
let prisma = null;
|
|
60
92
|
let usePrisma = false;
|
|
61
93
|
try {
|
|
@@ -64,30 +96,102 @@ try {
|
|
|
64
96
|
usePrisma = true;
|
|
65
97
|
console.log("Database: Postgres via Prisma");
|
|
66
98
|
} catch (err) {
|
|
67
|
-
|
|
99
|
+
if (!DEV_MODE) {
|
|
100
|
+
console.error("FATAL: Prisma unavailable; refusing to start.");
|
|
101
|
+
console.error("Cause: " + err.message);
|
|
102
|
+
console.error("Set LDM_HOSTED_MCP_DEV_MODE=1 to allow the JSON fallback (dev only).");
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
console.warn("Database: JSON files (DEV MODE; Prisma not available: " + err.message + ")");
|
|
68
106
|
}
|
|
69
107
|
|
|
70
108
|
// ── API Keys ────────────────────────────────────────────────────────
|
|
109
|
+
//
|
|
110
|
+
// Hardcoded production defaults removed (F-002). Production keys live
|
|
111
|
+
// in the Postgres ApiKey table and are loaded on boot. In DEV_MODE,
|
|
112
|
+
// the local tokens.json file is also seeded into the in-memory cache.
|
|
71
113
|
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
114
|
+
const API_KEYS = {};
|
|
115
|
+
const API_KEY_HANDLES = {};
|
|
116
|
+
const ACCOUNT_TENANT_PREFIX = "acct:";
|
|
117
|
+
const LEGACY_API_KEY_TENANT_PREFIX = "key:";
|
|
118
|
+
const OAUTH_API_KEY_TENANT_PREFIX = "oauth:";
|
|
119
|
+
|
|
120
|
+
function isInternalTenantId(id) {
|
|
121
|
+
return typeof id === "string"
|
|
122
|
+
&& (id.startsWith(ACCOUNT_TENANT_PREFIX)
|
|
123
|
+
|| id.startsWith(LEGACY_API_KEY_TENANT_PREFIX)
|
|
124
|
+
|| id.startsWith(OAUTH_API_KEY_TENANT_PREFIX));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function accountTenantIdForUserId(userId) {
|
|
128
|
+
return ACCOUNT_TENANT_PREFIX + userId;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function legacyTenantIdForApiKey(key) {
|
|
132
|
+
return LEGACY_API_KEY_TENANT_PREFIX + createHash("sha256").update(key).digest("base64url").slice(0, 32);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function oauthTenantIdForApiKey(key) {
|
|
136
|
+
return OAUTH_API_KEY_TENANT_PREFIX + createHash("sha256").update(key).digest("base64url").slice(0, 32);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function rememberApiKeyInMemory(key, tenantId, handle = null) {
|
|
140
|
+
API_KEYS[key] = tenantId;
|
|
141
|
+
if (handle) API_KEY_HANDLES[key] = handle;
|
|
142
|
+
else delete API_KEY_HANDLES[key];
|
|
143
|
+
}
|
|
79
144
|
|
|
80
|
-
|
|
81
|
-
const
|
|
145
|
+
function rememberLoadedApiKey(key, storedAgentId) {
|
|
146
|
+
const tenantId = isInternalTenantId(storedAgentId) ? storedAgentId : legacyTenantIdForApiKey(key);
|
|
147
|
+
const handle = isInternalTenantId(storedAgentId) ? null : storedAgentId;
|
|
148
|
+
rememberApiKeyInMemory(key, tenantId, handle);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function identityForApiKey(key) {
|
|
152
|
+
const tenantId = API_KEYS[key];
|
|
153
|
+
if (!tenantId) return null;
|
|
154
|
+
return {
|
|
155
|
+
agentId: tenantId,
|
|
156
|
+
tenantId,
|
|
157
|
+
handle: API_KEY_HANDLES[key] || tenantId,
|
|
158
|
+
apiKey: key,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
82
161
|
|
|
83
|
-
// Load from JSON (fallback)
|
|
84
162
|
function loadTokensFromFile() {
|
|
85
|
-
|
|
163
|
+
let rows = {};
|
|
164
|
+
try { rows = JSON.parse(readFileSync(TOKEN_FILE, "utf8")); } catch { return; }
|
|
165
|
+
for (const [key, storedAgentId] of Object.entries(rows)) {
|
|
166
|
+
rememberLoadedApiKey(key, storedAgentId);
|
|
167
|
+
}
|
|
86
168
|
}
|
|
87
|
-
Object.assign(API_KEYS, loadTokensFromFile());
|
|
88
169
|
|
|
89
|
-
async function
|
|
90
|
-
|
|
170
|
+
async function loadApiKeysFromDb() {
|
|
171
|
+
if (!usePrisma) return;
|
|
172
|
+
try {
|
|
173
|
+
const rows = await prisma.apiKey.findMany();
|
|
174
|
+
for (const row of rows) rememberLoadedApiKey(row.key, row.agentId);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (!DEV_MODE) {
|
|
177
|
+
console.error("FATAL: Prisma loadApiKeys failed; refusing to start.");
|
|
178
|
+
console.error("Cause: " + err.message);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
console.error("Prisma loadApiKeys error (DEV_MODE):", err.message);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (DEV_MODE) {
|
|
186
|
+
loadTokensFromFile();
|
|
187
|
+
}
|
|
188
|
+
await loadApiKeysFromDb();
|
|
189
|
+
|
|
190
|
+
async function saveApiKey(key, agentId, { handle = null } = {}) {
|
|
191
|
+
// Persist before advertising in memory: a newly issued key must not
|
|
192
|
+
// become valid in the in-memory cache if the canonical store did not
|
|
193
|
+
// accept it. Otherwise the key would work for the lifetime of the
|
|
194
|
+
// process and disappear on restart.
|
|
91
195
|
if (usePrisma) {
|
|
92
196
|
try {
|
|
93
197
|
await prisma.apiKey.upsert({
|
|
@@ -97,10 +201,17 @@ async function saveApiKey(key, agentId) {
|
|
|
97
201
|
});
|
|
98
202
|
} catch (err) {
|
|
99
203
|
console.error("Prisma saveApiKey error:", err.message);
|
|
204
|
+
if (!DEV_MODE) throw new Error("saveApiKey persistence failed: " + err.message);
|
|
100
205
|
}
|
|
206
|
+
} else if (!DEV_MODE) {
|
|
207
|
+
// Production should never reach here (boot exits if Prisma is
|
|
208
|
+
// unavailable), but guard explicitly.
|
|
209
|
+
throw new Error("saveApiKey called without Prisma in production");
|
|
210
|
+
}
|
|
211
|
+
rememberApiKeyInMemory(key, agentId, handle);
|
|
212
|
+
if (DEV_MODE) {
|
|
213
|
+
try { writeFileSync(TOKEN_FILE, JSON.stringify(API_KEYS, null, 2) + "\n"); } catch {}
|
|
101
214
|
}
|
|
102
|
-
// Always write JSON as backup
|
|
103
|
-
try { writeFileSync(TOKEN_FILE, JSON.stringify(API_KEYS, null, 2) + "\n"); } catch {}
|
|
104
215
|
}
|
|
105
216
|
|
|
106
217
|
// ── Passkeys ────────────────────────────────────────────────────────
|
|
@@ -113,33 +224,72 @@ function loadPasskeysFromFile() {
|
|
|
113
224
|
}
|
|
114
225
|
|
|
115
226
|
async function loadPasskeysFromDb() {
|
|
116
|
-
if (!usePrisma)
|
|
227
|
+
if (!usePrisma) {
|
|
228
|
+
return DEV_MODE ? loadPasskeysFromFile() : [];
|
|
229
|
+
}
|
|
117
230
|
try {
|
|
118
231
|
const creds = await prisma.credential.findMany({ include: { user: true } });
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
232
|
+
const handleUserIds = new Map();
|
|
233
|
+
for (const c of creds) {
|
|
234
|
+
const handle = c.user?.name || (c.userId ? "user-" + c.userId.slice(0, 8) : "unknown");
|
|
235
|
+
if (!handleUserIds.has(handle)) handleUserIds.set(handle, new Set());
|
|
236
|
+
handleUserIds.get(handle).add(c.userId);
|
|
237
|
+
}
|
|
238
|
+
const out = [];
|
|
239
|
+
for (const c of creds) {
|
|
240
|
+
const handle = c.user?.name || (c.userId ? "user-" + c.userId.slice(0, 8) : "unknown");
|
|
241
|
+
const agentId = accountTenantIdForUserId(c.userId);
|
|
242
|
+
let apiKey = null;
|
|
243
|
+
for (const [key, tenantId] of Object.entries(API_KEYS)) {
|
|
244
|
+
if (tenantId === agentId || (handleUserIds.get(handle)?.size === 1 && API_KEY_HANDLES[key] === handle)) {
|
|
245
|
+
apiKey = key;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (apiKey) {
|
|
250
|
+
API_KEY_HANDLES[apiKey] = handle;
|
|
251
|
+
if (API_KEYS[apiKey] !== agentId) {
|
|
252
|
+
try {
|
|
253
|
+
await saveApiKey(apiKey, agentId, { handle });
|
|
254
|
+
console.log("loadPasskeysFromDb: migrated API key tenant for handle '" + handle + "' to immutable account id");
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error("loadPasskeysFromDb: failed to migrate API key tenant for handle '" + handle + "':", err.message);
|
|
257
|
+
if (!DEV_MODE) throw err;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} else if (handle !== "unknown") {
|
|
261
|
+
console.warn("loadPasskeysFromDb: no ApiKey row for account tenant '" + agentId + "'; auth-verify will mint on next successful login");
|
|
262
|
+
}
|
|
263
|
+
out.push({
|
|
264
|
+
credentialId: c.id,
|
|
265
|
+
publicKey: Buffer.from(c.publicKey).toString("base64url"),
|
|
266
|
+
counter: c.counter,
|
|
267
|
+
userId: c.userId,
|
|
268
|
+
agentId,
|
|
269
|
+
handle,
|
|
270
|
+
apiKey,
|
|
271
|
+
createdAt: c.createdAt.toISOString(),
|
|
272
|
+
transports: c.transports || [],
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
128
276
|
} catch (err) {
|
|
129
277
|
console.error("Prisma loadPasskeys error:", err.message);
|
|
130
|
-
return loadPasskeysFromFile();
|
|
278
|
+
return DEV_MODE ? loadPasskeysFromFile() : [];
|
|
131
279
|
}
|
|
132
280
|
}
|
|
133
281
|
|
|
134
282
|
async function savePasskey(entry) {
|
|
135
|
-
|
|
283
|
+
// Persist before pushing to in-memory: a passkey must not exist in
|
|
284
|
+
// memory if it was never persisted, or it would authenticate for the
|
|
285
|
+
// lifetime of the process and disappear on restart.
|
|
136
286
|
if (usePrisma) {
|
|
137
287
|
try {
|
|
138
288
|
// Ensure user exists
|
|
139
289
|
let user = await prisma.user.findUnique({ where: { id: entry.userId } });
|
|
140
290
|
if (!user) {
|
|
141
291
|
user = await prisma.user.create({
|
|
142
|
-
data: { id: entry.userId, name: entry.
|
|
292
|
+
data: { id: entry.userId, name: entry.handle || "user" },
|
|
143
293
|
});
|
|
144
294
|
}
|
|
145
295
|
await prisma.credential.create({
|
|
@@ -153,15 +303,22 @@ async function savePasskey(entry) {
|
|
|
153
303
|
});
|
|
154
304
|
} catch (err) {
|
|
155
305
|
console.error("Prisma savePasskey error:", err.message);
|
|
306
|
+
if (!DEV_MODE) throw new Error("savePasskey persistence failed: " + err.message);
|
|
156
307
|
}
|
|
308
|
+
} else if (!DEV_MODE) {
|
|
309
|
+
throw new Error("savePasskey called without Prisma in production");
|
|
310
|
+
}
|
|
311
|
+
passkeys.push(entry);
|
|
312
|
+
if (DEV_MODE) {
|
|
313
|
+
try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
|
|
157
314
|
}
|
|
158
|
-
// Always write JSON as backup
|
|
159
|
-
try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
|
|
160
315
|
}
|
|
161
316
|
|
|
162
317
|
async function updatePasskeyCounter(credentialId, newCounter) {
|
|
163
|
-
|
|
164
|
-
|
|
318
|
+
// Persist before updating in-memory. The counter is the WebAuthn
|
|
319
|
+
// replay-protection state; advancing it in memory while the DB row
|
|
320
|
+
// stays behind would let a replayed assertion validate after a
|
|
321
|
+
// restart re-loaded the stale counter.
|
|
165
322
|
if (usePrisma) {
|
|
166
323
|
try {
|
|
167
324
|
await prisma.credential.update({
|
|
@@ -170,9 +327,16 @@ async function updatePasskeyCounter(credentialId, newCounter) {
|
|
|
170
327
|
});
|
|
171
328
|
} catch (err) {
|
|
172
329
|
console.error("Prisma updateCounter error:", err.message);
|
|
330
|
+
if (!DEV_MODE) throw new Error("updatePasskeyCounter persistence failed: " + err.message);
|
|
173
331
|
}
|
|
332
|
+
} else if (!DEV_MODE) {
|
|
333
|
+
throw new Error("updatePasskeyCounter called without Prisma in production");
|
|
334
|
+
}
|
|
335
|
+
const entry = passkeys.find(p => p.credentialId === credentialId);
|
|
336
|
+
if (entry) entry.counter = newCounter;
|
|
337
|
+
if (DEV_MODE) {
|
|
338
|
+
try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
|
|
174
339
|
}
|
|
175
|
-
try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
|
|
176
340
|
}
|
|
177
341
|
|
|
178
342
|
// Boot: load passkeys
|
|
@@ -219,7 +383,7 @@ function authenticate(req) {
|
|
|
219
383
|
const auth = req.headers["authorization"];
|
|
220
384
|
if (!auth?.startsWith("Bearer ")) return null;
|
|
221
385
|
const key = auth.slice(7).trim();
|
|
222
|
-
return
|
|
386
|
+
return identityForApiKey(key);
|
|
223
387
|
}
|
|
224
388
|
|
|
225
389
|
function readBody(req) {
|
|
@@ -275,13 +439,92 @@ function parseUrl(reqUrl) {
|
|
|
275
439
|
return new URL(reqUrl, "http://localhost");
|
|
276
440
|
}
|
|
277
441
|
|
|
442
|
+
// ── Rate limiting (F-008 in the VPS hosted-mcp audit) ───────────────
|
|
443
|
+
//
|
|
444
|
+
// Per-IP, per-bucket fixed-window counter. In-process Map; resets on
|
|
445
|
+
// restart. nginx-side limit_req would be more durable but harder to
|
|
446
|
+
// scope per route; in-process keeps the policy with the code that
|
|
447
|
+
// mints/validates the auth tokens. Defaults are conservative; tune via
|
|
448
|
+
// env. Stale entries are pruned periodically so memory stays bounded.
|
|
449
|
+
//
|
|
450
|
+
// Buckets:
|
|
451
|
+
// mint ... endpoints that issue a credential or ticket
|
|
452
|
+
// validate ... endpoints that consume / verify a credential
|
|
453
|
+
// status ... poll-friendly endpoints (higher limit)
|
|
454
|
+
|
|
455
|
+
const RATE_LIMIT_BUCKETS = {
|
|
456
|
+
mint: { limit: parseInt(process.env.LDM_HOSTED_MCP_RL_MINT || "30", 10), windowMs: 60_000 },
|
|
457
|
+
validate: { limit: parseInt(process.env.LDM_HOSTED_MCP_RL_VALIDATE || "60", 10), windowMs: 60_000 },
|
|
458
|
+
status: { limit: parseInt(process.env.LDM_HOSTED_MCP_RL_STATUS || "120", 10), windowMs: 60_000 },
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const rateLimitState = new Map(); // key: "<bucket>:<ip>" -> { count, windowStart }
|
|
462
|
+
|
|
463
|
+
function getClientIp(req) {
|
|
464
|
+
// Prefer X-Real-IP (nginx overwrites on proxy hop, harder to spoof
|
|
465
|
+
// through the proxy). Fall back to the LAST entry in X-Forwarded-For
|
|
466
|
+
// (nginx appends $remote_addr via proxy_add_x_forwarded_for, so the
|
|
467
|
+
// last entry is the real client IP from nginx's perspective; the
|
|
468
|
+
// first entries are attacker-controlled). Last fallback: socket.
|
|
469
|
+
const xRealIp = req.headers["x-real-ip"];
|
|
470
|
+
if (typeof xRealIp === "string" && xRealIp.length > 0) return xRealIp.trim();
|
|
471
|
+
const xff = req.headers["x-forwarded-for"];
|
|
472
|
+
if (typeof xff === "string" && xff.length > 0) {
|
|
473
|
+
const parts = xff.split(",").map(s => s.trim()).filter(Boolean);
|
|
474
|
+
if (parts.length > 0) return parts[parts.length - 1];
|
|
475
|
+
}
|
|
476
|
+
return req.socket?.remoteAddress || "unknown";
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function rateLimitCheck(req, bucket) {
|
|
480
|
+
const config = RATE_LIMIT_BUCKETS[bucket];
|
|
481
|
+
if (!config) return { ok: true };
|
|
482
|
+
const ip = getClientIp(req);
|
|
483
|
+
const key = bucket + ":" + ip;
|
|
484
|
+
const now = Date.now();
|
|
485
|
+
const entry = rateLimitState.get(key);
|
|
486
|
+
if (!entry || now - entry.windowStart > config.windowMs) {
|
|
487
|
+
rateLimitState.set(key, { count: 1, windowStart: now });
|
|
488
|
+
return { ok: true };
|
|
489
|
+
}
|
|
490
|
+
entry.count += 1;
|
|
491
|
+
if (entry.count > config.limit) {
|
|
492
|
+
const retryAfterSec = Math.max(1, Math.ceil((config.windowMs - (now - entry.windowStart)) / 1000));
|
|
493
|
+
return { ok: false, retryAfterSec };
|
|
494
|
+
}
|
|
495
|
+
return { ok: true };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Returns true if the request is allowed. If limited, writes 429 and
|
|
499
|
+
// returns false; the caller must `return` immediately on false.
|
|
500
|
+
function applyRateLimit(req, res, bucket) {
|
|
501
|
+
const result = rateLimitCheck(req, bucket);
|
|
502
|
+
if (!result.ok) {
|
|
503
|
+
res.setHeader("Retry-After", String(result.retryAfterSec));
|
|
504
|
+
json(res, 429, { error: "rate_limit_exceeded", error_description: "Too many requests. Retry after " + result.retryAfterSec + "s." });
|
|
505
|
+
console.warn("rate-limit hit:", bucket, getClientIp(req), req.method, req.url?.split("?")[0]);
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Keep memory bounded: drop entries older than 2 windows.
|
|
512
|
+
setInterval(() => {
|
|
513
|
+
const now = Date.now();
|
|
514
|
+
for (const [key, entry] of rateLimitState) {
|
|
515
|
+
if (now - entry.windowStart > 2 * 60_000) {
|
|
516
|
+
rateLimitState.delete(key);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}, 5 * 60_000).unref();
|
|
520
|
+
|
|
278
521
|
function esc(s) {
|
|
279
522
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
280
523
|
}
|
|
281
524
|
|
|
282
|
-
function
|
|
525
|
+
function sanitizeDisplayLabel(raw) {
|
|
283
526
|
if (!raw || typeof raw !== "string") return null;
|
|
284
|
-
const cleaned = raw.
|
|
527
|
+
const cleaned = raw.replace(/[\u0000-\u001f\u007f]/g, "").replace(/\s+/g, " ").trim().slice(0, 64);
|
|
285
528
|
return cleaned.length > 0 ? cleaned : null;
|
|
286
529
|
}
|
|
287
530
|
|
|
@@ -406,14 +649,17 @@ async function handleRegisterOptions(req, res) {
|
|
|
406
649
|
let body;
|
|
407
650
|
try { body = await readBody(req); } catch { body = {}; }
|
|
408
651
|
|
|
409
|
-
// Accept
|
|
410
|
-
|
|
652
|
+
// Accept the existing `username` field for wire compatibility, but
|
|
653
|
+
// treat it only as a display label for the passkey prompt. It is not
|
|
654
|
+
// a public username, account handle, or relay tenant boundary.
|
|
655
|
+
// Duplicate display labels are allowed.
|
|
656
|
+
const displayLabel = sanitizeDisplayLabel(body?.displayName || body?.username);
|
|
411
657
|
|
|
412
658
|
const userId = randomBytes(16);
|
|
413
659
|
const userIdB64 = userId.toString("base64url");
|
|
414
660
|
|
|
415
|
-
const userName =
|
|
416
|
-
const displayName =
|
|
661
|
+
const userName = displayLabel || ("user-" + userIdB64.slice(0, 8));
|
|
662
|
+
const displayName = displayLabel || "Memory Crystal User";
|
|
417
663
|
|
|
418
664
|
let options;
|
|
419
665
|
try {
|
|
@@ -442,7 +688,7 @@ async function handleRegisterOptions(req, res) {
|
|
|
442
688
|
challenge: options.challenge,
|
|
443
689
|
type: "registration",
|
|
444
690
|
userId: userIdB64,
|
|
445
|
-
|
|
691
|
+
displayLabel,
|
|
446
692
|
expires: Date.now() + 120000,
|
|
447
693
|
};
|
|
448
694
|
|
|
@@ -495,8 +741,16 @@ async function handleRegisterVerify(req, res) {
|
|
|
495
741
|
|
|
496
742
|
const { credential: cred, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
|
497
743
|
|
|
498
|
-
//
|
|
499
|
-
|
|
744
|
+
// Internal tenancy is the immutable WebAuthn user id. The user-entered
|
|
745
|
+
// display label is metadata only and never owns a relay namespace.
|
|
746
|
+
const agentId = accountTenantIdForUserId(stored.userId);
|
|
747
|
+
// credentialLabel matches the userName passed to
|
|
748
|
+
// generateRegistrationOptions in handleRegisterOptions, which is what
|
|
749
|
+
// iOS Passwords / 1Password show next to the saved passkey. The
|
|
750
|
+
// welcome view should display this, not agentId. Auth semantics are
|
|
751
|
+
// unchanged; only the user-facing label is aligned with the saved
|
|
752
|
+
// credential.
|
|
753
|
+
const credentialLabel = stored.displayLabel || ("user-" + stored.userId.slice(0, 8));
|
|
500
754
|
const apiKey = generateApiKey();
|
|
501
755
|
|
|
502
756
|
const entry = {
|
|
@@ -505,18 +759,32 @@ async function handleRegisterVerify(req, res) {
|
|
|
505
759
|
counter: cred.counter,
|
|
506
760
|
userId: stored.userId,
|
|
507
761
|
agentId,
|
|
762
|
+
handle: credentialLabel,
|
|
508
763
|
apiKey,
|
|
509
764
|
deviceType: credentialDeviceType,
|
|
510
765
|
backedUp: credentialBackedUp,
|
|
511
766
|
transports: credential.response?.transports || [],
|
|
512
767
|
createdAt: new Date().toISOString(),
|
|
513
768
|
};
|
|
514
|
-
|
|
515
|
-
|
|
769
|
+
try {
|
|
770
|
+
await savePasskey(entry);
|
|
771
|
+
await saveApiKey(apiKey, agentId, { handle: credentialLabel });
|
|
772
|
+
} catch (err) {
|
|
773
|
+
console.error("Persistence failure during passkey registration:", err.message);
|
|
774
|
+
json(res, 500, { error: "persistence_failure", error_description: "Could not persist credentials. Try again." });
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
516
777
|
|
|
517
|
-
console.log("WebAuthn: registered passkey for
|
|
778
|
+
console.log("WebAuthn: registered passkey for tenant '" + agentId + "' handle '" + credentialLabel + "' (credId: " + cred.id.slice(0, 16) + "...)");
|
|
518
779
|
|
|
519
|
-
json(res, 200, {
|
|
780
|
+
json(res, 200, {
|
|
781
|
+
success: true,
|
|
782
|
+
agentId: credentialLabel,
|
|
783
|
+
tenantId: agentId,
|
|
784
|
+
apiKey,
|
|
785
|
+
credentialLabel,
|
|
786
|
+
codex_pair_presence_token: generateCodexPairPresenceToken(agentId),
|
|
787
|
+
});
|
|
520
788
|
}
|
|
521
789
|
|
|
522
790
|
// POST /webauthn/auth-options
|
|
@@ -602,12 +870,60 @@ async function handleAuthVerify(req, res) {
|
|
|
602
870
|
return;
|
|
603
871
|
}
|
|
604
872
|
|
|
605
|
-
|
|
606
|
-
|
|
873
|
+
// Persist new counter before mutating in-memory entry. updatePasskeyCounter
|
|
874
|
+
// performs the in-memory update only on success, so the in-memory counter
|
|
875
|
+
// stays consistent with the DB and replay protection holds across restarts.
|
|
876
|
+
try {
|
|
877
|
+
await updatePasskeyCounter(entry.credentialId, verification.authenticationInfo.newCounter);
|
|
878
|
+
} catch (err) {
|
|
879
|
+
console.error("Persistence failure during passkey counter update:", err.message);
|
|
880
|
+
json(res, 500, { error: "persistence_failure", error_description: "Could not persist counter. Try again." });
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
let credentialLabel = entry.handle;
|
|
885
|
+
if (!credentialLabel && entry.agentId && entry.agentId.startsWith("passkey-")) {
|
|
886
|
+
credentialLabel = (typeof entry.userId === "string" && entry.userId.length >= 8)
|
|
887
|
+
? "user-" + entry.userId.slice(0, 8)
|
|
888
|
+
: entry.agentId;
|
|
889
|
+
} else if (!credentialLabel && !isInternalTenantId(entry.agentId)) {
|
|
890
|
+
credentialLabel = entry.agentId;
|
|
891
|
+
} else if (!credentialLabel && typeof entry.userId === "string" && entry.userId.length >= 8) {
|
|
892
|
+
credentialLabel = "user-" + entry.userId.slice(0, 8);
|
|
893
|
+
} else if (!credentialLabel) {
|
|
894
|
+
credentialLabel = "you";
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Recovery path: a passkey reloaded from Postgres after a restart may
|
|
898
|
+
// have entry.apiKey = null if no ApiKey row was found for its agent
|
|
899
|
+
// at boot. Mint a fresh ck- now so the login response always carries
|
|
900
|
+
// a usable token. Without this, the browser would store
|
|
901
|
+
// sessionStorage.wip_api_key = null and Remote Control would 401 on
|
|
902
|
+
// /bootstrap and /ws-ticket.
|
|
903
|
+
if (!entry.apiKey) {
|
|
904
|
+
const newKey = generateApiKey();
|
|
905
|
+
try {
|
|
906
|
+
await saveApiKey(newKey, entry.agentId, { handle: credentialLabel });
|
|
907
|
+
} catch (err) {
|
|
908
|
+
console.error("Persistence failure minting recovery key for tenant '" + entry.agentId + "':", err.message);
|
|
909
|
+
json(res, 500, { error: "persistence_failure", error_description: "Could not mint API key. Try again." });
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
entry.apiKey = newKey;
|
|
913
|
+
entry.handle = credentialLabel;
|
|
914
|
+
console.log("WebAuthn: minted recovery key for tenant '" + entry.agentId + "' (key: " + newKey.slice(0, 6) + "...)");
|
|
915
|
+
}
|
|
607
916
|
|
|
608
|
-
console.log("WebAuthn: authenticated
|
|
917
|
+
console.log("WebAuthn: authenticated tenant '" + entry.agentId + "' handle '" + credentialLabel + "'");
|
|
609
918
|
|
|
610
|
-
json(res, 200, {
|
|
919
|
+
json(res, 200, {
|
|
920
|
+
success: true,
|
|
921
|
+
agentId: credentialLabel,
|
|
922
|
+
tenantId: entry.agentId,
|
|
923
|
+
apiKey: entry.apiKey,
|
|
924
|
+
credentialLabel,
|
|
925
|
+
codex_pair_presence_token: generateCodexPairPresenceToken(entry.agentId),
|
|
926
|
+
});
|
|
611
927
|
}
|
|
612
928
|
|
|
613
929
|
// ---------- Page handlers ----------
|
|
@@ -1018,19 +1334,18 @@ async function handleOAuthToken(req, res) {
|
|
|
1018
1334
|
}
|
|
1019
1335
|
}
|
|
1020
1336
|
|
|
1021
|
-
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
await saveApiKey(apiKey, agentId);
|
|
1337
|
+
const agentHandle = stored.agent_name || "oauth-user";
|
|
1338
|
+
const apiKey = generateApiKey();
|
|
1339
|
+
const agentId = oauthTenantIdForApiKey(apiKey);
|
|
1340
|
+
try {
|
|
1341
|
+
await saveApiKey(apiKey, agentId, { handle: agentHandle });
|
|
1342
|
+
} catch (err) {
|
|
1343
|
+
console.error("Persistence failure during OAuth token issuance:", err.message);
|
|
1344
|
+
json(res, 500, { error: "server_error", error_description: "Could not issue token. Try again." });
|
|
1345
|
+
return;
|
|
1031
1346
|
}
|
|
1032
1347
|
|
|
1033
|
-
console.log("OAuth: issued token for
|
|
1348
|
+
console.log("OAuth: issued token for tenant '" + agentId + "' handle '" + agentHandle + "' (key: " + apiKey.slice(0, 10) + "...)");
|
|
1034
1349
|
|
|
1035
1350
|
json(res, 200, {
|
|
1036
1351
|
access_token: apiKey,
|
|
@@ -1383,12 +1698,53 @@ function handleAgentAuthApprove(req, res) {
|
|
|
1383
1698
|
|
|
1384
1699
|
// ---------- QR Login (Chrome fallback) ----------
|
|
1385
1700
|
|
|
1701
|
+
// `next` whitelist for the QR login flow. Two shapes are allowed; both
|
|
1702
|
+
// land the user on a known phone-side surface after successful sign-in.
|
|
1703
|
+
// Anything else is silently dropped. `next` is NOT a general redirect
|
|
1704
|
+
// primitive.
|
|
1705
|
+
//
|
|
1706
|
+
// 1. PAIR_NEXT_REGEX: /pair/<CODE> using the daemon's real alphabet
|
|
1707
|
+
// (CODEX_PAIR_ALPHABET, length 6, L IS included; I/O/0/1 excluded).
|
|
1708
|
+
// See plan ai/product/plans-prds/codex-remote-control/
|
|
1709
|
+
// 2026-04-30--cc-mini--pair-via-login-qr-flow.md constraints C1,
|
|
1710
|
+
// C8, and round-5. Per C8 the URL fallback for this shape is
|
|
1711
|
+
// mobile-only (desktop must not become the pairing authority).
|
|
1712
|
+
//
|
|
1713
|
+
// 2. REMOTE_CONTROL_NEXT_REGEX: /codex-remote-control/<UUID> for the
|
|
1714
|
+
// Kaleidoscope phone-side remote-control thread surface. Standard
|
|
1715
|
+
// ?next semantics; allowed on both desktop and mobile (this is
|
|
1716
|
+
// navigation continuation, not authority transfer).
|
|
1717
|
+
const PAIR_NEXT_REGEX = /^\/pair\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
|
|
1718
|
+
const REMOTE_CONTROL_NEXT_REGEX = /^\/codex-remote-control\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1719
|
+
|
|
1720
|
+
function sanitizeCrcPairNext(raw) {
|
|
1721
|
+
if (typeof raw !== "string") return null;
|
|
1722
|
+
// Single decode; reject if a second decode would still differ.
|
|
1723
|
+
let decoded;
|
|
1724
|
+
try { decoded = decodeURIComponent(raw); } catch { return null; }
|
|
1725
|
+
// Catch double-encoded payloads.
|
|
1726
|
+
if (decoded !== raw && /%/.test(decoded)) return null;
|
|
1727
|
+
if (!PAIR_NEXT_REGEX.test(decoded) && !REMOTE_CONTROL_NEXT_REGEX.test(decoded)) return null;
|
|
1728
|
+
return decoded;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1386
1731
|
// POST /api/qr-login ... create a QR login session
|
|
1387
1732
|
async function handleQrLoginStart(req, res) {
|
|
1388
1733
|
cleanupExpiredChallenges();
|
|
1389
1734
|
const body = await readBody(req).catch(() => ({}));
|
|
1390
1735
|
const handle = ((body && body.handle) || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 30);
|
|
1391
1736
|
const mode = ((body && body.mode) || "register") === "signin" ? "signin" : "register";
|
|
1737
|
+
// Validate `next` strictly. Invalid next is silently dropped, not
|
|
1738
|
+
// 400'd, so legacy callers still work.
|
|
1739
|
+
//
|
|
1740
|
+
// Only /pair/<CODE> next triggers pair-mode (C6 strip on desktop
|
|
1741
|
+
// status, C8 desktop-no-redirect, the "phone is the actor" model).
|
|
1742
|
+
// /codex-remote-control/<UUID> is a normal post-login continuation:
|
|
1743
|
+
// desktop status returns the full login response (apiKey, handle,
|
|
1744
|
+
// next) so the desktop poll can authenticate and redirect on its
|
|
1745
|
+
// own. The phone also gets next via approve, so both ends can act.
|
|
1746
|
+
const next = sanitizeCrcPairNext(body && body.next);
|
|
1747
|
+
const purpose = (next && PAIR_NEXT_REGEX.test(next)) ? "pair" : null;
|
|
1392
1748
|
const sessionId = randomUUID();
|
|
1393
1749
|
const loginUrl = ISSUER_URL + "/login?s=" + sessionId + "&m=" + mode + (handle ? "&h=" + encodeURIComponent(handle) : "");
|
|
1394
1750
|
const qrBuffer = await QRCode.toBuffer(loginUrl, { type: "png", width: 400, margin: 2 });
|
|
@@ -1399,8 +1755,10 @@ async function handleQrLoginStart(req, res) {
|
|
|
1399
1755
|
apiKey: null,
|
|
1400
1756
|
handle: handle || null,
|
|
1401
1757
|
expires: Date.now() + QR_LOGIN_EXPIRY_MS,
|
|
1758
|
+
purpose, // "pair" | null
|
|
1759
|
+
next: next || null, // sanitized `/pair/<CODE>` or null
|
|
1402
1760
|
};
|
|
1403
|
-
console.log("QR login: created session " + sessionId.slice(0, 8) + "...");
|
|
1761
|
+
console.log("QR login: created session " + sessionId.slice(0, 8) + "..." + (purpose === "pair" ? " (pair-mode)" : ""));
|
|
1404
1762
|
json(res, 200, { sessionId, qrUrl: "/api/qr-login/qr?s=" + sessionId });
|
|
1405
1763
|
}
|
|
1406
1764
|
|
|
@@ -1418,6 +1776,12 @@ function handleQrLoginQR(req, res) {
|
|
|
1418
1776
|
}
|
|
1419
1777
|
|
|
1420
1778
|
// GET /api/qr-login/status?s=XXX ... poll for completion
|
|
1779
|
+
//
|
|
1780
|
+
// Response shape depends on `purpose`:
|
|
1781
|
+
// - Pair-mode (purpose === "pair"): {status, agentId} only on approved.
|
|
1782
|
+
// NEVER returns apiKey or next to the desktop. Phone receives next via
|
|
1783
|
+
// /api/qr-login/approve. Per plan C6 round 4.
|
|
1784
|
+
// - Legacy login mode: {status, agentId, apiKey} on approved (unchanged).
|
|
1421
1785
|
function handleQrLoginStatus(req, res) {
|
|
1422
1786
|
const url = parseUrl(req.url);
|
|
1423
1787
|
const s = url.searchParams.get("s");
|
|
@@ -1427,7 +1791,28 @@ function handleQrLoginStatus(req, res) {
|
|
|
1427
1791
|
return;
|
|
1428
1792
|
}
|
|
1429
1793
|
if (entry.status === "approved") {
|
|
1430
|
-
|
|
1794
|
+
if (entry.purpose === "pair") {
|
|
1795
|
+
// Pair-mode (purpose === "pair", next === /pair/<CODE>):
|
|
1796
|
+
// desktop gets ONLY a display label. No apiKey. No next. Plan
|
|
1797
|
+
// C6 round 4. Desktop never becomes the pairing authority.
|
|
1798
|
+
json(res, 200, { status: "approved", agentId: entry.agentId });
|
|
1799
|
+
} else {
|
|
1800
|
+
// Legacy login mode OR codex-remote-control continuation
|
|
1801
|
+
// (purpose === null). Desktop gets full identity to render the
|
|
1802
|
+
// welcome view OR redirect to next on its own poll.
|
|
1803
|
+
// credentialLabel matches the saved-passkey label (see
|
|
1804
|
+
// register-verify / auth-verify). next is included only if a
|
|
1805
|
+
// sanitized non-pair-mode next was set on the session
|
|
1806
|
+
// (currently /codex-remote-control/<UUID>); legacy login
|
|
1807
|
+
// sessions without next get next === null.
|
|
1808
|
+
json(res, 200, {
|
|
1809
|
+
status: "approved",
|
|
1810
|
+
agentId: entry.agentId,
|
|
1811
|
+
apiKey: entry.apiKey,
|
|
1812
|
+
credentialLabel: entry.credentialLabel || null,
|
|
1813
|
+
next: entry.next || null,
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1431
1816
|
delete qrLoginSessions[s]; // one-time use
|
|
1432
1817
|
} else {
|
|
1433
1818
|
json(res, 200, { status: "pending" });
|
|
@@ -1435,9 +1820,13 @@ function handleQrLoginStatus(req, res) {
|
|
|
1435
1820
|
}
|
|
1436
1821
|
|
|
1437
1822
|
// POST /api/qr-login/approve ... phone calls after passkey created
|
|
1823
|
+
//
|
|
1824
|
+
// In pair-mode, the response includes the sanitized `next` so the phone
|
|
1825
|
+
// can location.replace(next) into /pair/<CODE>. Legacy login mode returns
|
|
1826
|
+
// {ok: true} unchanged.
|
|
1438
1827
|
function handleQrLoginApprove(req, res) {
|
|
1439
1828
|
readBody(req).then(function(body) {
|
|
1440
|
-
const { sessionId, agentId, apiKey } = body || {};
|
|
1829
|
+
const { sessionId, agentId, apiKey, credentialLabel } = body || {};
|
|
1441
1830
|
const entry = qrLoginSessions[sessionId];
|
|
1442
1831
|
if (!entry || Date.now() > entry.expires) {
|
|
1443
1832
|
json(res, 404, { error: "Session not found or expired" });
|
|
@@ -1450,8 +1839,21 @@ function handleQrLoginApprove(req, res) {
|
|
|
1450
1839
|
entry.status = "approved";
|
|
1451
1840
|
entry.agentId = agentId;
|
|
1452
1841
|
entry.apiKey = apiKey;
|
|
1453
|
-
|
|
1454
|
-
|
|
1842
|
+
// Phone-side passes the label it received from register-verify /
|
|
1843
|
+
// auth-verify so the desktop can show the same string the user
|
|
1844
|
+
// just saved on their phone. Optional for back-compat.
|
|
1845
|
+
entry.credentialLabel = (typeof credentialLabel === "string" && credentialLabel.length <= 64) ? credentialLabel : null;
|
|
1846
|
+
console.log("QR login: approved session " + sessionId.slice(0, 8) + "... for '" + agentId + "'" + (entry.purpose === "pair" ? " (pair-mode)" : (entry.next ? " (next=" + entry.next + ")" : "")));
|
|
1847
|
+
// Phone receives next on approve regardless of purpose, so the
|
|
1848
|
+
// phone can redirect to either /pair/<CODE> (pair-mode, phone is
|
|
1849
|
+
// the actor) or /codex-remote-control/<UUID> (continuation, phone
|
|
1850
|
+
// can act). Desktop's separate behavior (strip vs full response)
|
|
1851
|
+
// is handled in handleQrLoginStatus.
|
|
1852
|
+
if (entry.next) {
|
|
1853
|
+
json(res, 200, { ok: true, next: entry.next });
|
|
1854
|
+
} else {
|
|
1855
|
+
json(res, 200, { ok: true });
|
|
1856
|
+
}
|
|
1455
1857
|
}).catch(function() {
|
|
1456
1858
|
json(res, 400, { error: "Invalid request" });
|
|
1457
1859
|
});
|
|
@@ -1876,13 +2278,28 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1876
2278
|
}
|
|
1877
2279
|
|
|
1878
2280
|
if (req.method === "GET" && (path === "/login" || path === "/login/")) {
|
|
1879
|
-
//
|
|
2281
|
+
// Production /login owns its own file at app/kaleidoscope-login.html.
|
|
2282
|
+
//
|
|
2283
|
+
// Earlier this route served demo/login.html, which made production
|
|
2284
|
+
// auth depend on a file under demo/. That coupling is wrong: demo/
|
|
2285
|
+
// is the demo site's domain, not production. The canonical
|
|
2286
|
+
// Kaleidoscope login HTML now lives under app/, where production
|
|
2287
|
+
// owns it.
|
|
2288
|
+
//
|
|
2289
|
+
// Fallback chain (defense in depth):
|
|
2290
|
+
// 1. app/kaleidoscope-login.html ... canonical production file.
|
|
2291
|
+
// 2. demo/login.html ... legacy fallback during the
|
|
2292
|
+
// transition; will be removed in a follow-up once the
|
|
2293
|
+
// production file is verified live.
|
|
2294
|
+
// 3. handleLoginPage ... server-rendered last resort.
|
|
2295
|
+
//
|
|
2296
|
+
// /login/app continues to serve the developed app/login.html flow
|
|
2297
|
+
// (see the next handler).
|
|
1880
2298
|
try {
|
|
1881
|
-
const
|
|
2299
|
+
const html = readFileSync(join(__dirname, "app", "kaleidoscope-login.html"), "utf8");
|
|
1882
2300
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1883
|
-
res.end(
|
|
2301
|
+
res.end(html);
|
|
1884
2302
|
} catch {
|
|
1885
|
-
// Fallback to legacy demo login, then server-rendered.
|
|
1886
2303
|
try {
|
|
1887
2304
|
const legacy = readFileSync(join(__dirname, "demo", "login.html"), "utf8");
|
|
1888
2305
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
@@ -1894,6 +2311,22 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1894
2311
|
return;
|
|
1895
2312
|
}
|
|
1896
2313
|
|
|
2314
|
+
if (req.method === "GET" && (path === "/login/app" || path === "/login/app/")) {
|
|
2315
|
+
// Explicit non-primary route for the app/login.html flow (the
|
|
2316
|
+
// newer two-path "this device or QR-from-phone" copy). This
|
|
2317
|
+
// exists so the developed flow stays reachable without hijacking
|
|
2318
|
+
// /login. If app/login.html is not present, return 404 rather
|
|
2319
|
+
// than silently falling back to the canonical /login page.
|
|
2320
|
+
try {
|
|
2321
|
+
const loginHtml = readFileSync(join(__dirname, "app", "login.html"), "utf8");
|
|
2322
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2323
|
+
res.end(loginHtml);
|
|
2324
|
+
} catch {
|
|
2325
|
+
json(res, 404, { error: "Not found" });
|
|
2326
|
+
}
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
1897
2330
|
// --- Legal pages ---
|
|
1898
2331
|
|
|
1899
2332
|
if (req.method === "GET" && (path === "/legal/privacy/en-ww/" || path === "/legal/privacy/en-ww")) {
|
|
@@ -1917,21 +2350,25 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1917
2350
|
// --- WebAuthn API ---
|
|
1918
2351
|
|
|
1919
2352
|
if (req.method === "POST" && path === "/webauthn/register-options") {
|
|
2353
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
1920
2354
|
await handleRegisterOptions(req, res);
|
|
1921
2355
|
return;
|
|
1922
2356
|
}
|
|
1923
2357
|
|
|
1924
2358
|
if (req.method === "POST" && path === "/webauthn/register-verify") {
|
|
2359
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
1925
2360
|
await handleRegisterVerify(req, res);
|
|
1926
2361
|
return;
|
|
1927
2362
|
}
|
|
1928
2363
|
|
|
1929
2364
|
if (req.method === "POST" && path === "/webauthn/auth-options") {
|
|
2365
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
1930
2366
|
await handleAuthOptions(req, res);
|
|
1931
2367
|
return;
|
|
1932
2368
|
}
|
|
1933
2369
|
|
|
1934
2370
|
if (req.method === "POST" && path === "/webauthn/auth-verify") {
|
|
2371
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
1935
2372
|
await handleAuthVerify(req, res);
|
|
1936
2373
|
return;
|
|
1937
2374
|
}
|
|
@@ -1949,6 +2386,7 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1949
2386
|
}
|
|
1950
2387
|
|
|
1951
2388
|
if (req.method === "POST" && path === "/oauth/register") {
|
|
2389
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
1952
2390
|
await handleOAuthRegister(req, res);
|
|
1953
2391
|
return;
|
|
1954
2392
|
}
|
|
@@ -1959,11 +2397,13 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1959
2397
|
}
|
|
1960
2398
|
|
|
1961
2399
|
if (req.method === "POST" && path === "/oauth/authorize/submit") {
|
|
2400
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
1962
2401
|
await handleOAuthAuthorizeSubmit(req, res);
|
|
1963
2402
|
return;
|
|
1964
2403
|
}
|
|
1965
2404
|
|
|
1966
2405
|
if (req.method === "POST" && path === "/oauth/token") {
|
|
2406
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
1967
2407
|
await handleOAuthToken(req, res);
|
|
1968
2408
|
return;
|
|
1969
2409
|
}
|
|
@@ -1971,21 +2411,25 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1971
2411
|
// --- Agent QR Auth ---
|
|
1972
2412
|
|
|
1973
2413
|
if (req.method === "GET" && path === "/demo/api/agent-auth") {
|
|
2414
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
1974
2415
|
await handleAgentAuthStart(req, res);
|
|
1975
2416
|
return;
|
|
1976
2417
|
}
|
|
1977
2418
|
|
|
1978
2419
|
if (req.method === "GET" && path === "/demo/api/agent-auth/qr") {
|
|
2420
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
1979
2421
|
handleAgentAuthQR(req, res);
|
|
1980
2422
|
return;
|
|
1981
2423
|
}
|
|
1982
2424
|
|
|
1983
2425
|
if (req.method === "GET" && path === "/demo/api/agent-auth/status") {
|
|
2426
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
1984
2427
|
handleAgentAuthStatus(req, res);
|
|
1985
2428
|
return;
|
|
1986
2429
|
}
|
|
1987
2430
|
|
|
1988
2431
|
if (req.method === "POST" && path === "/demo/api/agent-auth/approve") {
|
|
2432
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
1989
2433
|
handleAgentAuthApprove(req, res);
|
|
1990
2434
|
return;
|
|
1991
2435
|
}
|
|
@@ -2017,21 +2461,25 @@ const httpServer = createServer(async (req, res) => {
|
|
|
2017
2461
|
// --- QR Login (Chrome fallback) ---
|
|
2018
2462
|
|
|
2019
2463
|
if (req.method === "POST" && path === "/api/qr-login") {
|
|
2464
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
2020
2465
|
await handleQrLoginStart(req, res);
|
|
2021
2466
|
return;
|
|
2022
2467
|
}
|
|
2023
2468
|
|
|
2024
2469
|
if (req.method === "GET" && path === "/api/qr-login/qr") {
|
|
2470
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
2025
2471
|
handleQrLoginQR(req, res);
|
|
2026
2472
|
return;
|
|
2027
2473
|
}
|
|
2028
2474
|
|
|
2029
2475
|
if (req.method === "GET" && path === "/api/qr-login/status") {
|
|
2476
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
2030
2477
|
handleQrLoginStatus(req, res);
|
|
2031
2478
|
return;
|
|
2032
2479
|
}
|
|
2033
2480
|
|
|
2034
2481
|
if (req.method === "POST" && path === "/api/qr-login/approve") {
|
|
2482
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
2035
2483
|
handleQrLoginApprove(req, res);
|
|
2036
2484
|
return;
|
|
2037
2485
|
}
|
|
@@ -2094,32 +2542,38 @@ const httpServer = createServer(async (req, res) => {
|
|
|
2094
2542
|
// --- Codex Relay (codex-daemon ↔ phone) ---
|
|
2095
2543
|
|
|
2096
2544
|
if (req.method === "POST" && path === "/api/codex-relay/pair-init") {
|
|
2545
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
2097
2546
|
await handleCodexPairInit(req, res);
|
|
2098
2547
|
return;
|
|
2099
2548
|
}
|
|
2100
2549
|
|
|
2101
2550
|
if (req.method === "GET" && path.startsWith("/api/codex-relay/pair-status/")) {
|
|
2551
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
2102
2552
|
handleCodexPairStatus(req, res, path.slice("/api/codex-relay/pair-status/".length));
|
|
2103
2553
|
return;
|
|
2104
2554
|
}
|
|
2105
2555
|
|
|
2106
2556
|
if (req.method === "POST" && path === "/api/codex-relay/pair-complete") {
|
|
2557
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
2107
2558
|
await handleCodexPairComplete(req, res);
|
|
2108
2559
|
return;
|
|
2109
2560
|
}
|
|
2110
2561
|
|
|
2111
2562
|
if (req.method === "GET" && path === "/api/codex-relay/state") {
|
|
2563
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
2112
2564
|
handleCodexRelayState(req, res);
|
|
2113
2565
|
return;
|
|
2114
2566
|
}
|
|
2115
2567
|
|
|
2116
2568
|
if (req.method === "GET" && path.startsWith("/api/codex-relay/bootstrap/")) {
|
|
2569
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
2117
2570
|
const tid = decodeURIComponent(path.slice("/api/codex-relay/bootstrap/".length));
|
|
2118
2571
|
handleCodexBootstrap(req, res, tid);
|
|
2119
2572
|
return;
|
|
2120
2573
|
}
|
|
2121
2574
|
|
|
2122
2575
|
if (req.method === "POST" && path === "/api/codex-relay/ws-ticket") {
|
|
2576
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
2123
2577
|
await handleCodexWsTicket(req, res);
|
|
2124
2578
|
return;
|
|
2125
2579
|
}
|
|
@@ -2131,6 +2585,13 @@ const httpServer = createServer(async (req, res) => {
|
|
|
2131
2585
|
return;
|
|
2132
2586
|
}
|
|
2133
2587
|
|
|
2588
|
+
// /pair/<CODE> ... URL-first pair flow. Per plan C1 + round 5: real daemon
|
|
2589
|
+
// alphabet [ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}, length 6, L IS included.
|
|
2590
|
+
if (req.method === "GET" && /^\/pair\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(path)) {
|
|
2591
|
+
serveAppFile(res, "pair.html");
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2134
2595
|
// /:handle/codex-remote-control/:threadId
|
|
2135
2596
|
const remoteControlMatch = path.match(/^\/([^/]+)\/codex-remote-control\/([^/]+)\/?$/);
|
|
2136
2597
|
if (req.method === "GET" && remoteControlMatch) {
|
|
@@ -2151,21 +2612,25 @@ const httpServer = createServer(async (req, res) => {
|
|
|
2151
2612
|
// ---------- Codex Relay (codex-daemon ↔ phone) ----------
|
|
2152
2613
|
//
|
|
2153
2614
|
// In-memory state. Pairing codes: 6-char, 5-min TTL. Daemons indexed by
|
|
2154
|
-
//
|
|
2155
|
-
// indexed by `
|
|
2156
|
-
// the daemon and
|
|
2157
|
-
//
|
|
2615
|
+
// immutable tenant id (one daemon per tenant; new daemon kicks the old one). Web clients
|
|
2616
|
+
// indexed by `tenantId:threadId`. The server is a transport relay between
|
|
2617
|
+
// the daemon and matching web client(s). The relay injects the route thread
|
|
2618
|
+
// into the E2EE handshake, and the daemon enforces that bound route after
|
|
2619
|
+
// decrypting session commands.
|
|
2158
2620
|
|
|
2159
2621
|
const CODEX_PAIR_EXPIRY_MS = 5 * 60 * 1000;
|
|
2622
|
+
const CODEX_PAIR_PRESENCE_TTL_MS = 2 * 60 * 1000;
|
|
2160
2623
|
const CODEX_PAIR_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
2161
|
-
const codexPairings = {}; // pairing_id -> { code, status, expires, daemon_info, apiKey?, agentId?, daemon_public_key?, crypto_versions? }
|
|
2624
|
+
const codexPairings = {}; // pairing_id -> { code, status, expires, poll_token, poll_token_used?, daemon_info, apiKey?, agentId?, handle?, daemon_public_key?, crypto_versions? }
|
|
2162
2625
|
const codexPairingByCode = {}; // code -> pairing_id (only while pending)
|
|
2626
|
+
const codexPairPresenceTokens = new Map(); // token -> { agentId, expires, used }
|
|
2163
2627
|
const codexDaemons = new Map(); // agentId -> ws
|
|
2164
|
-
const codexWebClients = new Map(); // `${agentId}:${threadId}` -> ws
|
|
2628
|
+
const codexWebClients = new Map(); // `${agentId}:${threadId}` -> Set<ws>
|
|
2629
|
+
const codexE2eeSessionRoutes = new Map(); // `${agentId}:${e2eeSession}` -> { threadId, webKey, ws }
|
|
2165
2630
|
|
|
2166
2631
|
// E2EE substrate (Phase 2.5).
|
|
2167
2632
|
//
|
|
2168
|
-
//
|
|
2633
|
+
// codexDaemonPubkeyRegistry: per tenant id, the most recently paired daemon's
|
|
2169
2634
|
// public key (P-256 SPKI base64url) + supported crypto versions +
|
|
2170
2635
|
// registration timestamp. This is what the browser fetches via
|
|
2171
2636
|
// bootstrap before opening an encrypted session.
|
|
@@ -2174,10 +2639,94 @@ const codexWebClients = new Map(); // `${agentId}:${threadId}` -> ws
|
|
|
2174
2639
|
// ?token=ck-... in the browser WebSocket URL. Bound to a specific
|
|
2175
2640
|
// (agentId, threadId) so a leaked ticket cannot drive a different
|
|
2176
2641
|
// route, even by the same authenticated user.
|
|
2177
|
-
const codexDaemonPubkeys = new Map(); // agentId -> { pubkey, crypto_versions, registered_at }
|
|
2178
2642
|
const codexRelayTickets = new Map(); // ticket -> { agentId, threadId, expires, used }
|
|
2179
2643
|
const CODEX_RELAY_TICKET_TTL_MS = 60 * 1000; // 60s; browser must connect immediately
|
|
2180
2644
|
|
|
2645
|
+
const codexDaemonPubkeyRegistry = createCodexDaemonPubkeyRegistry({
|
|
2646
|
+
usePrisma,
|
|
2647
|
+
prisma,
|
|
2648
|
+
devMode: DEV_MODE,
|
|
2649
|
+
logger: console,
|
|
2650
|
+
});
|
|
2651
|
+
|
|
2652
|
+
await codexDaemonPubkeyRegistry.loadFromDb();
|
|
2653
|
+
|
|
2654
|
+
function codexRelayKey(agentId, id) {
|
|
2655
|
+
return agentId + ":" + id;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
function isCodexE2eeEnvelope(envelope) {
|
|
2659
|
+
return !!(envelope && typeof envelope.type === "string" && envelope.type.startsWith("e2ee."));
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
function registerCodexE2eeSessionRoute(agentId, e2eeSession, threadId, ws) {
|
|
2663
|
+
if (typeof e2eeSession !== "string" || !e2eeSession) return;
|
|
2664
|
+
if (typeof threadId !== "string" || !threadId) return;
|
|
2665
|
+
const webKey = codexRelayKey(agentId, threadId);
|
|
2666
|
+
codexE2eeSessionRoutes.set(codexRelayKey(agentId, e2eeSession), { threadId, webKey, ws });
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
function addCodexWebClient(webKey, ws) {
|
|
2670
|
+
let clients = codexWebClients.get(webKey);
|
|
2671
|
+
if (!clients) {
|
|
2672
|
+
clients = new Set();
|
|
2673
|
+
codexWebClients.set(webKey, clients);
|
|
2674
|
+
}
|
|
2675
|
+
clients.add(ws);
|
|
2676
|
+
return clients.size;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
function removeCodexWebClient(webKey, ws) {
|
|
2680
|
+
const clients = codexWebClients.get(webKey);
|
|
2681
|
+
if (!clients) return 0;
|
|
2682
|
+
clients.delete(ws);
|
|
2683
|
+
if (clients.size === 0) {
|
|
2684
|
+
codexWebClients.delete(webKey);
|
|
2685
|
+
return 0;
|
|
2686
|
+
}
|
|
2687
|
+
return clients.size;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
function openCodexWebClientsForKey(webKey) {
|
|
2691
|
+
const clients = codexWebClients.get(webKey);
|
|
2692
|
+
if (!clients) return [];
|
|
2693
|
+
return [...clients].filter((webWs) => webWs.readyState === webWs.OPEN);
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
function resolveCodexWebClientsForDaemonFrame(agentId, routeId) {
|
|
2697
|
+
const routed = codexE2eeSessionRoutes.get(codexRelayKey(agentId, routeId));
|
|
2698
|
+
if (routed && routed.ws && routed.ws.readyState === routed.ws.OPEN) return [routed.ws];
|
|
2699
|
+
return openCodexWebClientsForKey(codexRelayKey(agentId, routeId));
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
function removeCodexE2eeRoutesForWeb(agentId, threadId, ws) {
|
|
2703
|
+
const webKey = codexRelayKey(agentId, threadId);
|
|
2704
|
+
for (const [routeKey, route] of codexE2eeSessionRoutes) {
|
|
2705
|
+
if (route.webKey === webKey && (!ws || route.ws === ws)) {
|
|
2706
|
+
codexE2eeSessionRoutes.delete(routeKey);
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
function invalidateCodexBrowserSessionsForAgent(agentId, reason) {
|
|
2712
|
+
const prefix = agentId + ":";
|
|
2713
|
+
let closed = 0;
|
|
2714
|
+
for (const routeKey of [...codexE2eeSessionRoutes.keys()]) {
|
|
2715
|
+
if (routeKey.startsWith(prefix)) codexE2eeSessionRoutes.delete(routeKey);
|
|
2716
|
+
}
|
|
2717
|
+
for (const [webKey, clients] of [...codexWebClients]) {
|
|
2718
|
+
if (!webKey.startsWith(prefix)) continue;
|
|
2719
|
+
for (const webWs of clients) {
|
|
2720
|
+
if (webWs.readyState === webWs.OPEN) {
|
|
2721
|
+
closed += 1;
|
|
2722
|
+
try { webWs.close(4001, reason); } catch {}
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
codexWebClients.delete(webKey);
|
|
2726
|
+
}
|
|
2727
|
+
return closed;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2181
2730
|
function generateCodexPairingCode() {
|
|
2182
2731
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
2183
2732
|
let code = "";
|
|
@@ -2190,16 +2739,58 @@ function generateCodexPairingCode() {
|
|
|
2190
2739
|
throw new Error("Could not generate unique codex-relay pairing code");
|
|
2191
2740
|
}
|
|
2192
2741
|
|
|
2742
|
+
function generateCodexPairPollToken() {
|
|
2743
|
+
return "ppt_" + randomBytes(32).toString("base64url");
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
function cleanupCodexPairPresenceTokens() {
|
|
2747
|
+
const now = Date.now();
|
|
2748
|
+
for (const [token, entry] of codexPairPresenceTokens) {
|
|
2749
|
+
if (!entry || entry.used || now > entry.expires) codexPairPresenceTokens.delete(token);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function generateCodexPairPresenceToken(agentId) {
|
|
2754
|
+
cleanupCodexPairPresenceTokens();
|
|
2755
|
+
if (typeof agentId !== "string" || !agentId) return null;
|
|
2756
|
+
const token = "cpt_" + randomBytes(32).toString("base64url");
|
|
2757
|
+
codexPairPresenceTokens.set(token, {
|
|
2758
|
+
agentId,
|
|
2759
|
+
expires: Date.now() + CODEX_PAIR_PRESENCE_TTL_MS,
|
|
2760
|
+
used: false,
|
|
2761
|
+
});
|
|
2762
|
+
return token;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
function consumeCodexPairPresenceToken(token, agentId) {
|
|
2766
|
+
cleanupCodexPairPresenceTokens();
|
|
2767
|
+
const entry = codexPairPresenceTokens.get(token);
|
|
2768
|
+
if (!entry || entry.used || entry.agentId !== agentId || Date.now() > entry.expires) return false;
|
|
2769
|
+
entry.used = true;
|
|
2770
|
+
codexPairPresenceTokens.delete(token);
|
|
2771
|
+
return true;
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
function getBearerToken(req) {
|
|
2775
|
+
const auth = req.headers["authorization"];
|
|
2776
|
+
if (typeof auth !== "string" || !auth.startsWith("Bearer ")) return null;
|
|
2777
|
+
const token = auth.slice(7).trim();
|
|
2778
|
+
return token || null;
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2193
2781
|
async function handleCodexPairInit(req, res) {
|
|
2194
2782
|
let body = {};
|
|
2195
2783
|
try { body = (await readBody(req)) || {}; } catch {}
|
|
2196
2784
|
const code = generateCodexPairingCode();
|
|
2197
2785
|
const pairingId = randomUUID();
|
|
2786
|
+
const pollToken = generateCodexPairPollToken();
|
|
2198
2787
|
const expires = Date.now() + CODEX_PAIR_EXPIRY_MS;
|
|
2199
2788
|
codexPairings[pairingId] = {
|
|
2200
2789
|
code,
|
|
2201
2790
|
status: "pending",
|
|
2202
2791
|
expires,
|
|
2792
|
+
poll_token: pollToken,
|
|
2793
|
+
poll_token_used: false,
|
|
2203
2794
|
daemon_info: {
|
|
2204
2795
|
hostname: typeof body.hostname === "string" ? body.hostname.slice(0, 64) : null,
|
|
2205
2796
|
platform: typeof body.platform === "string" ? body.platform.slice(0, 32) : null,
|
|
@@ -2216,10 +2807,14 @@ async function handleCodexPairInit(req, res) {
|
|
|
2216
2807
|
: null,
|
|
2217
2808
|
};
|
|
2218
2809
|
codexPairingByCode[code] = pairingId;
|
|
2810
|
+
// Per plan: web_url goes through /login first so the existing Kaleidoscope
|
|
2811
|
+
// QR + phone-passkey ceremony handles auth. After phone passkey, phone
|
|
2812
|
+
// (not desktop) redirects to /pair/<CODE> and completes pair-complete.
|
|
2219
2813
|
json(res, 200, {
|
|
2220
2814
|
code,
|
|
2221
2815
|
pairing_id: pairingId,
|
|
2222
|
-
|
|
2816
|
+
pair_poll_token: pollToken,
|
|
2817
|
+
web_url: ISSUER_URL + "/login?next=" + encodeURIComponent("/pair/" + code),
|
|
2223
2818
|
expires_at: new Date(expires).toISOString(),
|
|
2224
2819
|
});
|
|
2225
2820
|
}
|
|
@@ -2227,12 +2822,25 @@ async function handleCodexPairInit(req, res) {
|
|
|
2227
2822
|
function handleCodexPairStatus(req, res, pairingId) {
|
|
2228
2823
|
const p = codexPairings[pairingId];
|
|
2229
2824
|
if (!p) { json(res, 404, { error: "pairing not found" }); return; }
|
|
2230
|
-
if (
|
|
2825
|
+
if (Date.now() > p.expires) {
|
|
2231
2826
|
p.status = "expired";
|
|
2232
2827
|
if (codexPairingByCode[p.code] === pairingId) delete codexPairingByCode[p.code];
|
|
2828
|
+
json(res, 401, { error: "pair_poll_token_expired" });
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
const pollToken = getBearerToken(req);
|
|
2832
|
+
if (!pollToken || pollToken !== p.poll_token || p.poll_token_used) {
|
|
2833
|
+
json(res, 401, { error: "invalid_pair_poll_token" });
|
|
2834
|
+
return;
|
|
2233
2835
|
}
|
|
2234
2836
|
if (p.status === "completed") {
|
|
2235
|
-
|
|
2837
|
+
p.poll_token_used = true;
|
|
2838
|
+
json(res, 200, {
|
|
2839
|
+
status: "completed",
|
|
2840
|
+
api_key: p.apiKey,
|
|
2841
|
+
handle: p.handle || p.agentId,
|
|
2842
|
+
replaced_daemon_key: !!p.replaced_daemon_key,
|
|
2843
|
+
});
|
|
2236
2844
|
} else {
|
|
2237
2845
|
json(res, 200, { status: p.status });
|
|
2238
2846
|
}
|
|
@@ -2245,6 +2853,14 @@ async function handleCodexPairComplete(req, res) {
|
|
|
2245
2853
|
try { body = await readBody(req); } catch { json(res, 400, { error: "bad request" }); return; }
|
|
2246
2854
|
const code = (body && typeof body.code === "string") ? body.code.trim().toUpperCase() : "";
|
|
2247
2855
|
if (!code) { json(res, 400, { error: "missing code" }); return; }
|
|
2856
|
+
// Defensive: reject codes that don't match the daemon's alphabet up front,
|
|
2857
|
+
// before the map lookup, so probe attempts get one uniform reject path
|
|
2858
|
+
// instead of leaking timing/shape info between "wrong-alphabet" and "valid
|
|
2859
|
+
// shape but unknown code." Per plan C3 + round 5.
|
|
2860
|
+
if (!/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(code)) {
|
|
2861
|
+
json(res, 404, { error: "invalid or already-used code" });
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2248
2864
|
const pairingId = codexPairingByCode[code];
|
|
2249
2865
|
if (!pairingId) { json(res, 404, { error: "invalid or already-used code" }); return; }
|
|
2250
2866
|
const p = codexPairings[pairingId];
|
|
@@ -2252,30 +2868,51 @@ async function handleCodexPairComplete(req, res) {
|
|
|
2252
2868
|
json(res, 410, { error: "code expired or already used" });
|
|
2253
2869
|
return;
|
|
2254
2870
|
}
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2871
|
+
const pairPresenceToken = body && typeof body.codex_pair_presence_token === "string"
|
|
2872
|
+
? body.codex_pair_presence_token
|
|
2873
|
+
: "";
|
|
2874
|
+
if (p.daemon_public_key && !consumeCodexPairPresenceToken(pairPresenceToken, identity.agentId)) {
|
|
2875
|
+
json(res, 403, {
|
|
2876
|
+
error: "fresh_presence_required",
|
|
2877
|
+
error_description: "Pairing this daemon requires a fresh passkey confirmation. Sign in again from the pair page.",
|
|
2878
|
+
});
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2258
2881
|
// Phase 2.5: register the daemon's E2EE public key against the
|
|
2259
|
-
// authenticated
|
|
2260
|
-
//
|
|
2882
|
+
// authenticated immutable tenant id. The display handle is returned
|
|
2883
|
+
// as metadata only.
|
|
2884
|
+
let daemonKeyResult = null;
|
|
2261
2885
|
if (p.daemon_public_key) {
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2886
|
+
daemonKeyResult = await codexDaemonPubkeyRegistry.register(identity.agentId, p.daemon_public_key, p.crypto_versions, "pair-complete");
|
|
2887
|
+
if (daemonKeyResult?.replaced) {
|
|
2888
|
+
const closed = invalidateCodexBrowserSessionsForAgent(identity.agentId, "daemon key replaced");
|
|
2889
|
+
console.log(
|
|
2890
|
+
"codex-relay: replaced daemon E2EE key for tenant " + identity.agentId
|
|
2891
|
+
+ " old=" + daemonKeyResult.old_fingerprint
|
|
2892
|
+
+ " new=" + daemonKeyResult.new_fingerprint
|
|
2893
|
+
+ " closed_browser_sessions=" + closed
|
|
2894
|
+
);
|
|
2895
|
+
}
|
|
2268
2896
|
}
|
|
2897
|
+
p.status = "completed";
|
|
2898
|
+
p.apiKey = identity.apiKey;
|
|
2899
|
+
p.agentId = identity.agentId;
|
|
2900
|
+
p.handle = identity.handle;
|
|
2901
|
+
p.replaced_daemon_key = !!daemonKeyResult?.replaced;
|
|
2269
2902
|
delete codexPairingByCode[code];
|
|
2270
|
-
console.log("codex-relay: paired daemon for " + identity.agentId);
|
|
2271
|
-
json(res, 200, {
|
|
2903
|
+
console.log("codex-relay: paired daemon for tenant " + identity.agentId + " handle " + identity.handle);
|
|
2904
|
+
json(res, 200, {
|
|
2905
|
+
ok: true,
|
|
2906
|
+
handle: identity.handle,
|
|
2907
|
+
replaced_daemon_key: !!daemonKeyResult?.replaced,
|
|
2908
|
+
});
|
|
2272
2909
|
}
|
|
2273
2910
|
|
|
2274
2911
|
function handleCodexRelayState(req, res) {
|
|
2275
2912
|
const identity = authenticate(req);
|
|
2276
2913
|
if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
|
|
2277
2914
|
json(res, 200, {
|
|
2278
|
-
handle: identity.
|
|
2915
|
+
handle: identity.handle,
|
|
2279
2916
|
daemon_online: codexDaemons.has(identity.agentId),
|
|
2280
2917
|
});
|
|
2281
2918
|
}
|
|
@@ -2289,16 +2926,8 @@ function handleCodexBootstrap(req, res, threadId) {
|
|
|
2289
2926
|
if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
|
|
2290
2927
|
if (!threadId) { json(res, 400, { error: "missing threadId" }); return; }
|
|
2291
2928
|
const daemonOnline = codexDaemons.has(identity.agentId);
|
|
2292
|
-
const daemonKey =
|
|
2293
|
-
json(res, 200, {
|
|
2294
|
-
handle: identity.agentId,
|
|
2295
|
-
thread_id: threadId,
|
|
2296
|
-
daemon_online: daemonOnline,
|
|
2297
|
-
daemon_public_key: daemonKey ? daemonKey.pubkey : null,
|
|
2298
|
-
daemon_crypto_versions: daemonKey ? daemonKey.crypto_versions : null,
|
|
2299
|
-
supported_crypto_versions: ["e2ee-v1"],
|
|
2300
|
-
e2ee_available: !!daemonKey,
|
|
2301
|
-
});
|
|
2929
|
+
const daemonKey = codexDaemonPubkeyRegistry.get(identity.agentId);
|
|
2930
|
+
json(res, 200, buildCodexBootstrapPayload({ identity, threadId, daemonOnline, daemonKey }));
|
|
2302
2931
|
}
|
|
2303
2932
|
|
|
2304
2933
|
// POST /api/codex-relay/ws-ticket
|
|
@@ -2318,6 +2947,7 @@ async function handleCodexWsTicket(req, res) {
|
|
|
2318
2947
|
const expires = Date.now() + CODEX_RELAY_TICKET_TTL_MS;
|
|
2319
2948
|
codexRelayTickets.set(ticket, {
|
|
2320
2949
|
agentId: identity.agentId,
|
|
2950
|
+
handle: identity.handle,
|
|
2321
2951
|
threadId,
|
|
2322
2952
|
expires,
|
|
2323
2953
|
used: false,
|
|
@@ -2342,7 +2972,7 @@ function consumeCodexRelayTicket(ticket, threadId) {
|
|
|
2342
2972
|
if (Date.now() > entry.expires) { codexRelayTickets.delete(ticket); return null; }
|
|
2343
2973
|
if (entry.threadId !== threadId) return null; // bound to specific route
|
|
2344
2974
|
entry.used = true;
|
|
2345
|
-
return { agentId: entry.agentId };
|
|
2975
|
+
return { agentId: entry.agentId, handle: entry.handle || entry.agentId };
|
|
2346
2976
|
}
|
|
2347
2977
|
|
|
2348
2978
|
function serveAppFile(res, relPath) {
|
|
@@ -2368,23 +2998,64 @@ function serveAppFile(res, relPath) {
|
|
|
2368
2998
|
}
|
|
2369
2999
|
}
|
|
2370
3000
|
|
|
2371
|
-
|
|
3001
|
+
// authenticateWs verifies a WS upgrade request against the API_KEYS
|
|
3002
|
+
// map. Default behavior is HEADER ONLY: Authorization: Bearer ck-...
|
|
3003
|
+
// is accepted; ?token=ck-... in the URL is ignored.
|
|
3004
|
+
//
|
|
3005
|
+
// Set { allowQueryToken: true } only on the explicit web-side
|
|
3006
|
+
// back-compat branch that runs inside ALLOW_WS_URL_TOKEN. Daemon
|
|
3007
|
+
// connections never enable this; CLI clients can always set
|
|
3008
|
+
// Authorization, and a daemon accepting URL-token would be a needless
|
|
3009
|
+
// attack surface (URL leaks via referrer / log scrape are not relevant
|
|
3010
|
+
// for daemons, but the asymmetry of "header-only on the daemon path"
|
|
3011
|
+
// keeps the policy clean and auditable).
|
|
3012
|
+
function authenticateWs(req, { allowQueryToken = false } = {}) {
|
|
2372
3013
|
const auth = req.headers["authorization"];
|
|
2373
3014
|
if (auth && auth.startsWith("Bearer ")) {
|
|
2374
3015
|
const key = auth.slice(7).trim();
|
|
2375
|
-
|
|
3016
|
+
const identity = identityForApiKey(key);
|
|
3017
|
+
if (identity) return identity;
|
|
2376
3018
|
}
|
|
2377
|
-
|
|
3019
|
+
if (!allowQueryToken) return null;
|
|
3020
|
+
|
|
3021
|
+
// Web back-compat path only. Browsers cannot set Authorization on a
|
|
3022
|
+
// WebSocket() handshake, so legacy clients put ck- in the URL.
|
|
3023
|
+
// parseUrl returns a WHATWG URL, which has no .query getter (only
|
|
3024
|
+
// .search/.searchParams). Strip the leading "?" off .search and
|
|
3025
|
+
// parse with querystring so the array/string handling below works.
|
|
2378
3026
|
const u = parseUrl(req.url);
|
|
2379
|
-
const qs = u.
|
|
3027
|
+
const qs = u.search ? parseUrlQs(u.search.slice(1)) : {};
|
|
2380
3028
|
const tokenParam = Array.isArray(qs.token) ? qs.token[0] : qs.token;
|
|
2381
|
-
if (typeof tokenParam === "string"
|
|
2382
|
-
return
|
|
3029
|
+
if (typeof tokenParam === "string") {
|
|
3030
|
+
return identityForApiKey(tokenParam);
|
|
2383
3031
|
}
|
|
2384
3032
|
return null;
|
|
2385
3033
|
}
|
|
2386
3034
|
|
|
2387
|
-
|
|
3035
|
+
// F-001: subprotocol-based WS auth for the codex-relay surface. The web
|
|
3036
|
+
// client sends Sec-WebSocket-Protocol: ldm-codex-relay.v1, ticket.<v>.
|
|
3037
|
+
// Server echoes only the protocol name (not the ticket-bearing entry).
|
|
3038
|
+
// Daemon connections do not use a subprotocol, so empty Sets pass.
|
|
3039
|
+
const codexRelayWss = new WebSocketServer({
|
|
3040
|
+
noServer: true,
|
|
3041
|
+
handleProtocols: (protocols /*, request */) => {
|
|
3042
|
+
if (!protocols || protocols.size === 0) return undefined;
|
|
3043
|
+
if (protocols.has("ldm-codex-relay.v1")) return "ldm-codex-relay.v1";
|
|
3044
|
+
return false;
|
|
3045
|
+
},
|
|
3046
|
+
});
|
|
3047
|
+
|
|
3048
|
+
function getTicketFromSubprotocol(req) {
|
|
3049
|
+
const header = req.headers["sec-websocket-protocol"];
|
|
3050
|
+
if (!header) return null;
|
|
3051
|
+
const tokens = header.split(",").map(s => s.trim()).filter(Boolean);
|
|
3052
|
+
for (const t of tokens) {
|
|
3053
|
+
if (t.startsWith("ticket.")) {
|
|
3054
|
+
return t.slice("ticket.".length);
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
return null;
|
|
3058
|
+
}
|
|
2388
3059
|
|
|
2389
3060
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
2390
3061
|
const u = parseUrl(req.url);
|
|
@@ -2393,21 +3064,64 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
|
2393
3064
|
const isWeb = path.startsWith("/api/codex-relay/web/");
|
|
2394
3065
|
if (!isDaemon && !isWeb) return; // let other listeners (or default) handle it
|
|
2395
3066
|
|
|
3067
|
+
// F-003: enforce Origin allowlist for browser-borne web upgrades.
|
|
3068
|
+
// Runs BEFORE ticket consumption / authenticateWs so a request from
|
|
3069
|
+
// a disallowed origin cannot burn a valid one-time ticket or trigger
|
|
3070
|
+
// an auth check side effect. Daemon path is exempt because CLI
|
|
3071
|
+
// clients do not send a browser Origin header. Requires nginx to
|
|
3072
|
+
// pass the Origin header through unchanged on the upgrade hop;
|
|
3073
|
+
// verified in Lane B B2 of the audit doc.
|
|
3074
|
+
if (isWeb) {
|
|
3075
|
+
const origin = req.headers["origin"];
|
|
3076
|
+
if (!isWsOriginAllowed(origin)) {
|
|
3077
|
+
console.warn("WS upgrade rejected: bad origin (" + (origin || "<none>") + ") for " + path);
|
|
3078
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
3079
|
+
socket.destroy();
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
|
|
2396
3084
|
// Daemon side keeps the existing Bearer ck- token auth.
|
|
2397
|
-
// Web side: prefer
|
|
2398
|
-
// to ?
|
|
3085
|
+
// Web side: prefer ticket via Sec-WebSocket-Protocol (F-001), fall
|
|
3086
|
+
// back to ?ticket= query string. The legacy ?token=ck- URL fallback
|
|
3087
|
+
// is gated behind LDM_HOSTED_MCP_ALLOW_WS_URL_TOKEN=1; production
|
|
3088
|
+
// does not accept it.
|
|
2399
3089
|
let identity = null;
|
|
2400
3090
|
if (isDaemon) {
|
|
2401
|
-
|
|
3091
|
+
// Daemon connections must use Authorization: Bearer ck-. URL-token
|
|
3092
|
+
// is never accepted on the daemon path (allowQueryToken: false).
|
|
3093
|
+
identity = authenticateWs(req, { allowQueryToken: false });
|
|
2402
3094
|
} else {
|
|
2403
3095
|
const threadId = decodeURIComponent(path.slice("/api/codex-relay/web/".length));
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
3096
|
+
|
|
3097
|
+
// Preferred path: ticket carried in Sec-WebSocket-Protocol. Avoids
|
|
3098
|
+
// URL/log/referrer exposure of the ticket value.
|
|
3099
|
+
const subTicket = getTicketFromSubprotocol(req);
|
|
3100
|
+
if (subTicket) {
|
|
3101
|
+
const consumed = consumeCodexRelayTicket(subTicket, threadId);
|
|
3102
|
+
if (consumed) identity = { agentId: consumed.agentId, handle: consumed.handle, viaTicket: true };
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
// Back-compat: ?ticket= query string. Same single-use binding.
|
|
3106
|
+
if (!identity) {
|
|
3107
|
+
// parseUrl returns a WHATWG URL with .search/.searchParams, no
|
|
3108
|
+
// .query. Strip the leading "?" off .search and parse with
|
|
3109
|
+
// querystring so the array/string handling below still works.
|
|
3110
|
+
const qs = u.search ? parseUrlQs(u.search.slice(1)) : {};
|
|
3111
|
+
const ticketParam = Array.isArray(qs.ticket) ? qs.ticket[0] : qs.ticket;
|
|
3112
|
+
if (typeof ticketParam === "string" && ticketParam) {
|
|
3113
|
+
const consumed = consumeCodexRelayTicket(ticketParam, threadId);
|
|
3114
|
+
if (consumed) identity = { agentId: consumed.agentId, handle: consumed.handle, viaTicket: true };
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
// Legacy ?token=ck- URL fallback: dev/back-compat only. Production
|
|
3119
|
+
// refuses long-lived bearer in WS URLs (gate condition 2). The
|
|
3120
|
+
// URL-token branch in authenticateWs is gated by allowQueryToken,
|
|
3121
|
+
// and we only set it true here, only when ALLOW_WS_URL_TOKEN is on.
|
|
3122
|
+
if (!identity && ALLOW_WS_URL_TOKEN) {
|
|
3123
|
+
identity = authenticateWs(req, { allowQueryToken: true });
|
|
2409
3124
|
}
|
|
2410
|
-
if (!identity) identity = authenticateWs(req);
|
|
2411
3125
|
}
|
|
2412
3126
|
|
|
2413
3127
|
if (!identity) {
|
|
@@ -2418,18 +3132,109 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
|
2418
3132
|
|
|
2419
3133
|
if (isDaemon) {
|
|
2420
3134
|
codexRelayWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
3135
|
+
let daemonIdentityAccepted = false;
|
|
3136
|
+
function activateCodexDaemonWs() {
|
|
3137
|
+
const previous = codexDaemons.get(identity.agentId);
|
|
3138
|
+
if (previous && previous !== ws && previous.readyState === previous.OPEN) {
|
|
3139
|
+
console.warn("codex-relay: rejected duplicate daemon reconnect for online tenant " + identity.agentId);
|
|
3140
|
+
try { ws.close(4004, "daemon already online"); } catch {}
|
|
3141
|
+
return false;
|
|
3142
|
+
}
|
|
3143
|
+
if (previous && previous !== ws) try { previous.close(4000, "replaced"); } catch {}
|
|
3144
|
+
codexDaemons.set(identity.agentId, ws);
|
|
3145
|
+
console.log("codex-relay: daemon online for " + identity.agentId);
|
|
3146
|
+
return true;
|
|
3147
|
+
}
|
|
3148
|
+
// F-001 per-thread isolation. Daemon -> web routing must NOT
|
|
3149
|
+
// fan out every frame to every same-agent web socket; that
|
|
3150
|
+
// breaks isolation when one user has multiple threads open.
|
|
3151
|
+
// Parse the OUTER envelope only to read the routing field
|
|
3152
|
+
// (session/sessionId). The encrypted ciphertext (or any inner
|
|
3153
|
+
// session.* payload) is never inspected, so gate 3a still
|
|
3154
|
+
// holds: the relay sees only routing metadata on the envelope.
|
|
3155
|
+
// No-session frames are an explicit allowlist (control/presence
|
|
3156
|
+
// types) or are dropped with a redacted warning. We never
|
|
3157
|
+
// broadcast unknown frames.
|
|
3158
|
+
const BROADCAST_TYPES = new Set([
|
|
3159
|
+
"presence",
|
|
3160
|
+
"presence.web",
|
|
3161
|
+
"presence.daemon",
|
|
3162
|
+
"daemon.online",
|
|
3163
|
+
"daemon.offline",
|
|
3164
|
+
]);
|
|
2425
3165
|
ws.on("message", (data) => {
|
|
2426
3166
|
const text = data.toString();
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
3167
|
+
let envelope = null;
|
|
3168
|
+
try { envelope = JSON.parse(text); } catch {}
|
|
3169
|
+
if (envelope?.type === "daemon.identity") {
|
|
3170
|
+
const reconnectPolicy = evaluateCodexDaemonReconnectPubkey(
|
|
3171
|
+
codexDaemonPubkeyRegistry.get(identity.agentId),
|
|
3172
|
+
envelope.daemon_public_key,
|
|
3173
|
+
);
|
|
3174
|
+
if (!reconnectPolicy.allowed) {
|
|
3175
|
+
console.warn(
|
|
3176
|
+
"codex-relay: rejected daemon reconnect E2EE key for tenant " + identity.agentId
|
|
3177
|
+
+ " reason=" + reconnectPolicy.reason
|
|
3178
|
+
+ " old=" + (reconnectPolicy.old_fingerprint || "<none>")
|
|
3179
|
+
+ " new=" + (reconnectPolicy.new_fingerprint || codexDaemonPubkeyFingerprint(envelope.daemon_public_key) || "<none>"),
|
|
3180
|
+
);
|
|
3181
|
+
const closeReason = reconnectPolicy.replaced
|
|
3182
|
+
? "daemon key change requires fresh pair"
|
|
3183
|
+
: "invalid daemon identity";
|
|
3184
|
+
try { ws.close(reconnectPolicy.replaced ? 4003 : 1008, closeReason); } catch {}
|
|
3185
|
+
return;
|
|
2431
3186
|
}
|
|
3187
|
+
void codexDaemonPubkeyRegistry.register(
|
|
3188
|
+
identity.agentId,
|
|
3189
|
+
envelope.daemon_public_key,
|
|
3190
|
+
envelope.crypto_versions,
|
|
3191
|
+
"daemon-reconnect",
|
|
3192
|
+
).then((result) => {
|
|
3193
|
+
if (!result?.registered) {
|
|
3194
|
+
try { ws.close(1011, "daemon identity persistence failed"); } catch {}
|
|
3195
|
+
return;
|
|
3196
|
+
}
|
|
3197
|
+
daemonIdentityAccepted = activateCodexDaemonWs();
|
|
3198
|
+
}).catch(() => {
|
|
3199
|
+
try { ws.close(1011, "daemon identity persistence failed"); } catch {}
|
|
3200
|
+
});
|
|
3201
|
+
return;
|
|
2432
3202
|
}
|
|
3203
|
+
if (!daemonIdentityAccepted) {
|
|
3204
|
+
try { ws.close(1008, "daemon identity required"); } catch {}
|
|
3205
|
+
return;
|
|
3206
|
+
}
|
|
3207
|
+
const sessionId = envelope?.session || envelope?.sessionId || envelope?.threadId;
|
|
3208
|
+
if (sessionId) {
|
|
3209
|
+
const targets = resolveCodexWebClientsForDaemonFrame(identity.agentId, sessionId);
|
|
3210
|
+
for (const target of targets) {
|
|
3211
|
+
target.send(text);
|
|
3212
|
+
}
|
|
3213
|
+
// No matching web client: drop silently. Daemon-emitted
|
|
3214
|
+
// frames for a thread the user has not opened in any browser
|
|
3215
|
+
// are not interesting to fan out elsewhere.
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
const type = envelope?.type;
|
|
3219
|
+
if (type && BROADCAST_TYPES.has(type)) {
|
|
3220
|
+
// Allowlisted agent-level frame (presence, online status).
|
|
3221
|
+
// Fan out within agent, never across agents.
|
|
3222
|
+
const prefix = identity.agentId + ":";
|
|
3223
|
+
for (const [key, webClients] of codexWebClients) {
|
|
3224
|
+
if (key.startsWith(prefix)) {
|
|
3225
|
+
for (const webWs of webClients) {
|
|
3226
|
+
if (webWs.readyState === webWs.OPEN) webWs.send(text);
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
// Parse failed, missing session, or unknown type: drop. Log a
|
|
3233
|
+
// redacted notice so the operator can see if a daemon is
|
|
3234
|
+
// emitting unrouteable frames. We never log envelope/payload
|
|
3235
|
+
// bytes; only the agent and the type (or "no-type").
|
|
3236
|
+
const typeMarker = type ? String(type).slice(0, 32) : "no-type";
|
|
3237
|
+
console.warn("codex-relay: dropped unroutable daemon frame for " + identity.agentId + " (type=" + typeMarker + ")");
|
|
2433
3238
|
});
|
|
2434
3239
|
ws.on("close", () => {
|
|
2435
3240
|
if (codexDaemons.get(identity.agentId) === ws) {
|
|
@@ -2452,21 +3257,32 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
|
2452
3257
|
return;
|
|
2453
3258
|
}
|
|
2454
3259
|
codexRelayWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2455
|
-
const key = identity.agentId
|
|
2456
|
-
const
|
|
2457
|
-
|
|
2458
|
-
codexWebClients.set(key, ws);
|
|
2459
|
-
console.log("codex-relay: web online " + key);
|
|
3260
|
+
const key = codexRelayKey(identity.agentId, threadId);
|
|
3261
|
+
const clientCount = addCodexWebClient(key, ws);
|
|
3262
|
+
console.log("codex-relay: web online " + key + " clients=" + clientCount);
|
|
2460
3263
|
ws.on("message", (data) => {
|
|
3264
|
+
let text = data.toString();
|
|
3265
|
+
let envelope = null;
|
|
3266
|
+
try { envelope = JSON.parse(text); } catch {}
|
|
3267
|
+
if (isCodexE2eeEnvelope(envelope) && envelope.session) {
|
|
3268
|
+
// The browser cannot be allowed to choose this value. The relay
|
|
3269
|
+
// owns the route because it consumed the ticket for this URL
|
|
3270
|
+
// thread. The daemon uses this metadata to bind the encrypted
|
|
3271
|
+
// session before it decrypts any session.* command.
|
|
3272
|
+
envelope.route_thread_id = threadId;
|
|
3273
|
+
text = JSON.stringify(envelope);
|
|
3274
|
+
registerCodexE2eeSessionRoute(identity.agentId, envelope.session, threadId, ws);
|
|
3275
|
+
}
|
|
2461
3276
|
const daemonWs = codexDaemons.get(identity.agentId);
|
|
2462
3277
|
if (daemonWs && daemonWs.readyState === daemonWs.OPEN) {
|
|
2463
|
-
daemonWs.send(
|
|
3278
|
+
daemonWs.send(text);
|
|
2464
3279
|
} else {
|
|
2465
3280
|
try { ws.send(JSON.stringify({ type: "error", message: "daemon offline" })); } catch {}
|
|
2466
3281
|
}
|
|
2467
3282
|
});
|
|
2468
3283
|
ws.on("close", () => {
|
|
2469
|
-
|
|
3284
|
+
removeCodexE2eeRoutesForWeb(identity.agentId, threadId, ws);
|
|
3285
|
+
removeCodexWebClient(key, ws);
|
|
2470
3286
|
});
|
|
2471
3287
|
ws.on("error", (err) => {
|
|
2472
3288
|
console.error("codex-relay web ws error:", err.message);
|
|
@@ -2476,6 +3292,7 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
|
2476
3292
|
|
|
2477
3293
|
httpServer.listen(PORT, SERVER_BIND, () => {
|
|
2478
3294
|
console.log(SERVER_NAME + " v" + SERVER_VERSION + " listening on " + SERVER_BIND + ":" + PORT);
|
|
3295
|
+
console.log("WS origin allowlist: " + WS_ORIGIN_ALLOWLIST.join(", "));
|
|
2479
3296
|
console.log("Health: http://localhost:" + PORT + "/health");
|
|
2480
3297
|
console.log("MCP: http://localhost:" + PORT + "/mcp");
|
|
2481
3298
|
console.log("OAuth: http://localhost:" + PORT + "/.well-known/oauth-authorization-server");
|