@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.
Files changed (37) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +8 -5
  3. package/bin/ldm.js +169 -65
  4. package/docs/universal-installer/SPEC.md +16 -3
  5. package/docs/universal-installer/TECHNICAL.md +4 -4
  6. package/lib/deploy.mjs +104 -20
  7. package/lib/detect.mjs +35 -4
  8. package/package.json +13 -2
  9. package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
  10. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  11. package/scripts/test-crc-e2ee-session-route.mjs +129 -0
  12. package/scripts/test-crc-pair-login-flow.mjs +40 -0
  13. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  14. package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
  15. package/scripts/test-install-prompt-policy.mjs +60 -0
  16. package/scripts/test-installer-skill-directory.mjs +55 -0
  17. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  18. package/scripts/test-installer-target-self-update.mjs +131 -0
  19. package/scripts/test-ldm-status-timeout.mjs +80 -0
  20. package/shared/templates/install-prompt.md +20 -2
  21. package/src/hosted-mcp/README.md +15 -0
  22. package/src/hosted-mcp/app/footer.js +74 -0
  23. package/src/hosted-mcp/app/kaleidoscope-login.html +846 -0
  24. package/src/hosted-mcp/app/pair.html +165 -57
  25. package/src/hosted-mcp/app/sprites.png +0 -0
  26. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  27. package/src/hosted-mcp/demo/index.html +3 -7
  28. package/src/hosted-mcp/demo/login.html +318 -20
  29. package/src/hosted-mcp/deploy.sh +307 -56
  30. package/src/hosted-mcp/docs/self-host.md +268 -0
  31. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  32. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  33. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  34. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  35. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  36. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  37. 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 agent ID.
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
- // Primary: Postgres via Prisma (production).
48
- // Fallback: JSON files (if DATABASE_URL is not set, e.g. local dev without Postgres).
78
+ // Production: Postgres via Prisma is the canonical store. If Prisma
79
+ // cannot connect, the server refuses to start (F-005a).
49
80
  //
50
- // The demo and all API endpoints use the db.* functions below.
51
- // They try Prisma first, fall back to JSON if Prisma isn't available.
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
- console.log("Database: JSON files (Prisma not available: " + err.message + ")");
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
- // Hardcoded defaults (always available, even without DB)
73
- const DEFAULT_API_KEYS = {
74
- "ck-test-001": "test-agent",
75
- "ck-e04df46877aa3672e21c4e33149bacc4": "cc-mini",
76
- "ck-f1986e957e21cbb40dc100bc05dc78ec": "lesa",
77
- "ck-c2849eef903407c877bc6e79bf8794aa": "parker",
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
- // In-memory cache (populated from DB or JSON on boot)
81
- const API_KEYS = { ...DEFAULT_API_KEYS };
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
- try { return JSON.parse(readFileSync(TOKEN_FILE, "utf8")); } catch { return {}; }
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 saveApiKey(key, agentId) {
90
- API_KEYS[key] = agentId;
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) return loadPasskeysFromFile();
227
+ if (!usePrisma) {
228
+ return DEV_MODE ? loadPasskeysFromFile() : [];
229
+ }
117
230
  try {
118
231
  const creds = await prisma.credential.findMany({ include: { user: true } });
119
- return creds.map(c => ({
120
- credentialId: c.id,
121
- publicKey: Buffer.from(c.publicKey).toString("base64url"),
122
- counter: c.counter,
123
- userId: c.userId,
124
- agentId: c.user?.name ? "passkey-" + c.user.name.slice(0, 12) : "unknown",
125
- createdAt: c.createdAt.toISOString(),
126
- transports: c.transports || [],
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
- passkeys.push(entry);
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.agentId || "user" },
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
- const entry = passkeys.find(p => p.credentialId === credentialId);
164
- if (entry) entry.counter = newCounter;
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 API_KEYS[key] ? { agentId: API_KEYS[key], apiKey: key } : null;
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
280
523
  }
281
524
 
282
- function sanitizeUsername(raw) {
525
+ function sanitizeDisplayLabel(raw) {
283
526
  if (!raw || typeof raw !== "string") return null;
284
- const cleaned = raw.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 30);
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 optional username from request body
410
- const username = sanitizeUsername(body?.username);
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 = username || ("user-" + userIdB64.slice(0, 8));
416
- const displayName = username || "Memory Crystal User";
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
- username: username,
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
- // Use provided username as agentId, or fall back to passkey-<id>
499
- const agentId = stored.username || ("passkey-" + stored.userId.slice(0, 12));
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
- await savePasskey(entry);
515
- await saveApiKey(apiKey, agentId);
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 agent '" + agentId + "' (credId: " + cred.id.slice(0, 16) + "...)");
778
+ console.log("WebAuthn: registered passkey for tenant '" + agentId + "' handle '" + credentialLabel + "' (credId: " + cred.id.slice(0, 16) + "...)");
518
779
 
519
- json(res, 200, { success: true, agentId, apiKey });
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
- entry.counter = verification.authenticationInfo.newCounter;
606
- await updatePasskeyCounter(entry.credentialId, entry.counter);
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 agent '" + entry.agentId + "'");
917
+ console.log("WebAuthn: authenticated tenant '" + entry.agentId + "' handle '" + credentialLabel + "'");
609
918
 
610
- json(res, 200, { success: true, agentId: entry.agentId, apiKey: entry.apiKey });
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
- // Check if agent already has an API key (from passkey registration)
1022
- const agentId = stored.agent_name || "oauth-user";
1023
- let apiKey;
1024
-
1025
- const existingKey = Object.entries(API_KEYS).find(([k, v]) => v === agentId);
1026
- if (existingKey) {
1027
- apiKey = existingKey[0];
1028
- } else {
1029
- apiKey = generateApiKey();
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 agent '" + agentId + "' (key: " + apiKey.slice(0, 10) + "...)");
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
- json(res, 200, { status: "approved", agentId: entry.agentId, apiKey: entry.apiKey });
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
- console.log("QR login: approved session " + sessionId.slice(0, 8) + "... for '" + agentId + "'");
1454
- json(res, 200, { ok: true });
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
- // Serve the new app/ login (two-path: this device or QR-from-phone).
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 loginHtml = readFileSync(join(__dirname, "app", "login.html"), "utf8");
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(loginHtml);
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
- // agentId (one daemon per agentId; new daemon kicks the old one). Web clients
2155
- // indexed by `agentId:threadId`. Server is a transparent passthrough between
2156
- // the daemon and the matching web client(s); thread routing is enforced
2157
- // purely client-side via session.send/sessionId payloads.
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
- // codexDaemonPubkeys: per agentId, the most recently paired daemon's
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
- web_url: ISSUER_URL + "/pair",
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 (p.status === "pending" && Date.now() > p.expires) {
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
- json(res, 200, { status: "completed", api_key: p.apiKey, handle: p.agentId });
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
- p.status = "completed";
2256
- p.apiKey = identity.apiKey;
2257
- p.agentId = identity.agentId;
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 handle. Replaces any previous key for this handle
2260
- // (rotate-key implicitly happens here on a re-pair).
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
- codexDaemonPubkeys.set(identity.agentId, {
2263
- pubkey: p.daemon_public_key,
2264
- crypto_versions: p.crypto_versions && p.crypto_versions.length ? p.crypto_versions : ["e2ee-v1"],
2265
- registered_at: new Date().toISOString(),
2266
- });
2267
- console.log("codex-relay: registered E2EE pubkey for " + identity.agentId);
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, { ok: true, handle: identity.agentId });
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.agentId,
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 = codexDaemonPubkeys.get(identity.agentId) || null;
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
- function authenticateWs(req) {
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
- if (API_KEYS[key]) return { agentId: API_KEYS[key], apiKey: key };
3016
+ const identity = identityForApiKey(key);
3017
+ if (identity) return identity;
2376
3018
  }
2377
- // Browsers can't set Authorization on WebSocket(): accept ?token= fallback.
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.query ? parseUrlQs(u.query) : {};
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" && API_KEYS[tokenParam]) {
2382
- return { agentId: API_KEYS[tokenParam], apiKey: tokenParam };
3029
+ if (typeof tokenParam === "string") {
3030
+ return identityForApiKey(tokenParam);
2383
3031
  }
2384
3032
  return null;
2385
3033
  }
2386
3034
 
2387
- const codexRelayWss = new WebSocketServer({ noServer: true });
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 single-use ?ticket= bound to the route; fall back
2398
- // to ?token=ck- for back-compat with the pre-2.5 alpha.
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
- identity = authenticateWs(req);
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
- const qs = u.query ? parseUrlQs(u.query) : {};
2405
- const ticketParam = Array.isArray(qs.ticket) ? qs.ticket[0] : qs.ticket;
2406
- if (typeof ticketParam === "string" && ticketParam) {
2407
- const consumed = consumeCodexRelayTicket(ticketParam, threadId);
2408
- if (consumed) identity = { agentId: consumed.agentId, viaTicket: true };
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
- const previous = codexDaemons.get(identity.agentId);
2422
- if (previous && previous !== ws) try { previous.close(4000, "replaced"); } catch {}
2423
- codexDaemons.set(identity.agentId, ws);
2424
- console.log("codex-relay: daemon online for " + identity.agentId);
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
- const prefix = identity.agentId + ":";
2428
- for (const [key, webWs] of codexWebClients) {
2429
- if (key.startsWith(prefix) && webWs.readyState === webWs.OPEN) {
2430
- webWs.send(text);
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 + ":" + threadId;
2456
- const previous = codexWebClients.get(key);
2457
- if (previous && previous !== ws) try { previous.close(4000, "replaced"); } catch {}
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(data.toString());
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
- if (codexWebClients.get(key) === ws) codexWebClients.delete(key);
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");