@wipcomputer/wip-ldm-os 0.4.81 → 0.4.83

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.
@@ -21,6 +21,8 @@ import {
21
21
  verifyAuthenticationResponse,
22
22
  } from "@simplewebauthn/server";
23
23
  import QRCode from "qrcode";
24
+ import { WebSocketServer } from "ws";
25
+ import { parse as parseUrlQs } from "node:querystring";
24
26
 
25
27
  // ── Settings ─────────────────────────────────────────────────────────
26
28
 
@@ -1874,14 +1876,20 @@ const httpServer = createServer(async (req, res) => {
1874
1876
  }
1875
1877
 
1876
1878
  if (req.method === "GET" && (path === "/login" || path === "/login/")) {
1877
- // Serve the production login page (Kaleidoscope design)
1879
+ // Serve the new app/ login (two-path: this device or QR-from-phone).
1878
1880
  try {
1879
- const loginHtml = readFileSync(join(__dirname, "demo", "login.html"), "utf8");
1881
+ const loginHtml = readFileSync(join(__dirname, "app", "login.html"), "utf8");
1880
1882
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1881
1883
  res.end(loginHtml);
1882
1884
  } catch {
1883
- // Fallback to old server-rendered login
1884
- handleLoginPage(req, res);
1885
+ // Fallback to legacy demo login, then server-rendered.
1886
+ try {
1887
+ const legacy = readFileSync(join(__dirname, "demo", "login.html"), "utf8");
1888
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1889
+ res.end(legacy);
1890
+ } catch {
1891
+ handleLoginPage(req, res);
1892
+ }
1885
1893
  }
1886
1894
  return;
1887
1895
  }
@@ -1987,6 +1995,25 @@ const httpServer = createServer(async (req, res) => {
1987
1995
  return;
1988
1996
  }
1989
1997
 
1998
+ // --- Generic QR generator (encode any same-origin URL) ---
1999
+
2000
+ if (req.method === "GET" && path === "/api/qr") {
2001
+ const target = url.searchParams.get("url");
2002
+ if (!target) { json(res, 400, { error: "missing url" }); return; }
2003
+ if (target.length > 2048) { json(res, 400, { error: "url too long" }); return; }
2004
+ QRCode.toBuffer(target, { type: "png", width: 320, margin: 2 })
2005
+ .then((buffer) => {
2006
+ res.writeHead(200, {
2007
+ "Content-Type": "image/png",
2008
+ "Content-Length": buffer.length,
2009
+ "Cache-Control": "no-store",
2010
+ });
2011
+ res.end(buffer);
2012
+ })
2013
+ .catch(() => json(res, 500, { error: "QR generation failed" }));
2014
+ return;
2015
+ }
2016
+
1990
2017
  // --- QR Login (Chrome fallback) ---
1991
2018
 
1992
2019
  if (req.method === "POST" && path === "/api/qr-login") {
@@ -2064,17 +2091,398 @@ const httpServer = createServer(async (req, res) => {
2064
2091
  return;
2065
2092
  }
2066
2093
 
2094
+ // --- Codex Relay (codex-daemon ↔ phone) ---
2095
+
2096
+ if (req.method === "POST" && path === "/api/codex-relay/pair-init") {
2097
+ await handleCodexPairInit(req, res);
2098
+ return;
2099
+ }
2100
+
2101
+ if (req.method === "GET" && path.startsWith("/api/codex-relay/pair-status/")) {
2102
+ handleCodexPairStatus(req, res, path.slice("/api/codex-relay/pair-status/".length));
2103
+ return;
2104
+ }
2105
+
2106
+ if (req.method === "POST" && path === "/api/codex-relay/pair-complete") {
2107
+ await handleCodexPairComplete(req, res);
2108
+ return;
2109
+ }
2110
+
2111
+ if (req.method === "GET" && path === "/api/codex-relay/state") {
2112
+ handleCodexRelayState(req, res);
2113
+ return;
2114
+ }
2115
+
2116
+ if (req.method === "GET" && path.startsWith("/api/codex-relay/bootstrap/")) {
2117
+ const tid = decodeURIComponent(path.slice("/api/codex-relay/bootstrap/".length));
2118
+ handleCodexBootstrap(req, res, tid);
2119
+ return;
2120
+ }
2121
+
2122
+ if (req.method === "POST" && path === "/api/codex-relay/ws-ticket") {
2123
+ await handleCodexWsTicket(req, res);
2124
+ return;
2125
+ }
2126
+
2127
+ // --- Codex Remote Control pages (Phase 2c/2e, post-/demo) ---
2128
+
2129
+ if (req.method === "GET" && (path === "/pair" || path === "/pair/")) {
2130
+ serveAppFile(res, "pair.html");
2131
+ return;
2132
+ }
2133
+
2134
+ // /:handle/codex-remote-control/:threadId
2135
+ const remoteControlMatch = path.match(/^\/([^/]+)\/codex-remote-control\/([^/]+)\/?$/);
2136
+ if (req.method === "GET" && remoteControlMatch) {
2137
+ serveAppFile(res, "codex-remote-control/index.html");
2138
+ return;
2139
+ }
2140
+
2141
+ if (req.method === "GET" && path.startsWith("/app/")) {
2142
+ const rel = path.slice("/app/".length);
2143
+ if (rel.includes("..")) { json(res, 400, { error: "bad path" }); return; }
2144
+ serveAppFile(res, rel);
2145
+ return;
2146
+ }
2147
+
2067
2148
  json(res, 404, { error: "Not found" });
2068
2149
  });
2069
2150
 
2151
+ // ---------- Codex Relay (codex-daemon ↔ phone) ----------
2152
+ //
2153
+ // 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.
2158
+
2159
+ const CODEX_PAIR_EXPIRY_MS = 5 * 60 * 1000;
2160
+ const CODEX_PAIR_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
2161
+ const codexPairings = {}; // pairing_id -> { code, status, expires, daemon_info, apiKey?, agentId?, daemon_public_key?, crypto_versions? }
2162
+ const codexPairingByCode = {}; // code -> pairing_id (only while pending)
2163
+ const codexDaemons = new Map(); // agentId -> ws
2164
+ const codexWebClients = new Map(); // `${agentId}:${threadId}` -> ws
2165
+
2166
+ // E2EE substrate (Phase 2.5).
2167
+ //
2168
+ // codexDaemonPubkeys: per agentId, the most recently paired daemon's
2169
+ // public key (P-256 SPKI base64url) + supported crypto versions +
2170
+ // registration timestamp. This is what the browser fetches via
2171
+ // bootstrap before opening an encrypted session.
2172
+ //
2173
+ // codexRelayTickets: short-lived single-use tickets that replace
2174
+ // ?token=ck-... in the browser WebSocket URL. Bound to a specific
2175
+ // (agentId, threadId) so a leaked ticket cannot drive a different
2176
+ // route, even by the same authenticated user.
2177
+ const codexDaemonPubkeys = new Map(); // agentId -> { pubkey, crypto_versions, registered_at }
2178
+ const codexRelayTickets = new Map(); // ticket -> { agentId, threadId, expires, used }
2179
+ const CODEX_RELAY_TICKET_TTL_MS = 60 * 1000; // 60s; browser must connect immediately
2180
+
2181
+ function generateCodexPairingCode() {
2182
+ for (let attempt = 0; attempt < 100; attempt += 1) {
2183
+ let code = "";
2184
+ const bytes = randomBytes(6);
2185
+ for (let i = 0; i < 6; i += 1) {
2186
+ code += CODEX_PAIR_ALPHABET[bytes[i] % CODEX_PAIR_ALPHABET.length];
2187
+ }
2188
+ if (!codexPairingByCode[code]) return code;
2189
+ }
2190
+ throw new Error("Could not generate unique codex-relay pairing code");
2191
+ }
2192
+
2193
+ async function handleCodexPairInit(req, res) {
2194
+ let body = {};
2195
+ try { body = (await readBody(req)) || {}; } catch {}
2196
+ const code = generateCodexPairingCode();
2197
+ const pairingId = randomUUID();
2198
+ const expires = Date.now() + CODEX_PAIR_EXPIRY_MS;
2199
+ codexPairings[pairingId] = {
2200
+ code,
2201
+ status: "pending",
2202
+ expires,
2203
+ daemon_info: {
2204
+ hostname: typeof body.hostname === "string" ? body.hostname.slice(0, 64) : null,
2205
+ platform: typeof body.platform === "string" ? body.platform.slice(0, 32) : null,
2206
+ arch: typeof body.arch === "string" ? body.arch.slice(0, 16) : null,
2207
+ },
2208
+ // Phase 2.5: daemon publishes its E2EE identity pubkey + supported
2209
+ // crypto versions on pair-init. The browser later fetches these via
2210
+ // /api/codex-relay/bootstrap/:threadId before opening an encrypted
2211
+ // session. Both fields are optional for back-compat with pre-E2EE
2212
+ // daemons; absent pubkey means "no E2EE on this pair, legacy only."
2213
+ daemon_public_key: typeof body.daemon_public_key === "string" ? body.daemon_public_key.slice(0, 1024) : null,
2214
+ crypto_versions: Array.isArray(body.crypto_versions)
2215
+ ? body.crypto_versions.filter((v) => typeof v === "string" && v.length <= 32).slice(0, 8)
2216
+ : null,
2217
+ };
2218
+ codexPairingByCode[code] = pairingId;
2219
+ json(res, 200, {
2220
+ code,
2221
+ pairing_id: pairingId,
2222
+ web_url: ISSUER_URL + "/pair",
2223
+ expires_at: new Date(expires).toISOString(),
2224
+ });
2225
+ }
2226
+
2227
+ function handleCodexPairStatus(req, res, pairingId) {
2228
+ const p = codexPairings[pairingId];
2229
+ if (!p) { json(res, 404, { error: "pairing not found" }); return; }
2230
+ if (p.status === "pending" && Date.now() > p.expires) {
2231
+ p.status = "expired";
2232
+ if (codexPairingByCode[p.code] === pairingId) delete codexPairingByCode[p.code];
2233
+ }
2234
+ if (p.status === "completed") {
2235
+ json(res, 200, { status: "completed", api_key: p.apiKey, handle: p.agentId });
2236
+ } else {
2237
+ json(res, 200, { status: p.status });
2238
+ }
2239
+ }
2240
+
2241
+ async function handleCodexPairComplete(req, res) {
2242
+ const identity = authenticate(req);
2243
+ if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
2244
+ let body;
2245
+ try { body = await readBody(req); } catch { json(res, 400, { error: "bad request" }); return; }
2246
+ const code = (body && typeof body.code === "string") ? body.code.trim().toUpperCase() : "";
2247
+ if (!code) { json(res, 400, { error: "missing code" }); return; }
2248
+ const pairingId = codexPairingByCode[code];
2249
+ if (!pairingId) { json(res, 404, { error: "invalid or already-used code" }); return; }
2250
+ const p = codexPairings[pairingId];
2251
+ if (!p || p.status !== "pending" || Date.now() > p.expires) {
2252
+ json(res, 410, { error: "code expired or already used" });
2253
+ return;
2254
+ }
2255
+ p.status = "completed";
2256
+ p.apiKey = identity.apiKey;
2257
+ p.agentId = identity.agentId;
2258
+ // 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).
2261
+ 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);
2268
+ }
2269
+ delete codexPairingByCode[code];
2270
+ console.log("codex-relay: paired daemon for " + identity.agentId);
2271
+ json(res, 200, { ok: true, handle: identity.agentId });
2272
+ }
2273
+
2274
+ function handleCodexRelayState(req, res) {
2275
+ const identity = authenticate(req);
2276
+ if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
2277
+ json(res, 200, {
2278
+ handle: identity.agentId,
2279
+ daemon_online: codexDaemons.has(identity.agentId),
2280
+ });
2281
+ }
2282
+
2283
+ // GET /api/codex-relay/bootstrap/:threadId
2284
+ // Browser calls this after passkey auth + before opening the encrypted
2285
+ // WebSocket. Returns enough metadata for the browser to know whether
2286
+ // E2EE is available with this daemon and which crypto version to use.
2287
+ function handleCodexBootstrap(req, res, threadId) {
2288
+ const identity = authenticate(req);
2289
+ if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
2290
+ if (!threadId) { json(res, 400, { error: "missing threadId" }); return; }
2291
+ 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
+ });
2302
+ }
2303
+
2304
+ // POST /api/codex-relay/ws-ticket
2305
+ // Browser exchanges its long-lived ck- key for a short-lived single-use
2306
+ // relay ticket bound to a specific (agentId, threadId). The browser then
2307
+ // connects to /api/codex-relay/web/:threadId?ticket=... instead of
2308
+ // putting ck- in the URL.
2309
+ async function handleCodexWsTicket(req, res) {
2310
+ const identity = authenticate(req);
2311
+ if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
2312
+ let body;
2313
+ try { body = (await readBody(req)) || {}; } catch { body = {}; }
2314
+ const threadId = (body && typeof body.thread_id === "string") ? body.thread_id.trim() : "";
2315
+ if (!threadId) { json(res, 400, { error: "missing thread_id" }); return; }
2316
+ if (threadId.length > 256) { json(res, 400, { error: "thread_id too long" }); return; }
2317
+ const ticket = "rt_" + randomBytes(24).toString("base64url");
2318
+ const expires = Date.now() + CODEX_RELAY_TICKET_TTL_MS;
2319
+ codexRelayTickets.set(ticket, {
2320
+ agentId: identity.agentId,
2321
+ threadId,
2322
+ expires,
2323
+ used: false,
2324
+ });
2325
+ // Lazy cleanup: schedule eviction after TTL.
2326
+ setTimeout(() => {
2327
+ const t = codexRelayTickets.get(ticket);
2328
+ if (t && t.expires <= Date.now()) codexRelayTickets.delete(ticket);
2329
+ }, CODEX_RELAY_TICKET_TTL_MS + 5_000);
2330
+ json(res, 200, {
2331
+ ticket,
2332
+ expires_at: new Date(expires).toISOString(),
2333
+ ttl_seconds: Math.floor(CODEX_RELAY_TICKET_TTL_MS / 1000),
2334
+ });
2335
+ }
2336
+
2337
+ function consumeCodexRelayTicket(ticket, threadId) {
2338
+ if (typeof ticket !== "string" || !ticket) return null;
2339
+ const entry = codexRelayTickets.get(ticket);
2340
+ if (!entry) return null;
2341
+ if (entry.used) return null;
2342
+ if (Date.now() > entry.expires) { codexRelayTickets.delete(ticket); return null; }
2343
+ if (entry.threadId !== threadId) return null; // bound to specific route
2344
+ entry.used = true;
2345
+ return { agentId: entry.agentId };
2346
+ }
2347
+
2348
+ function serveAppFile(res, relPath) {
2349
+ const filePath = join(__dirname, "app", relPath);
2350
+ try {
2351
+ const content = readFileSync(filePath);
2352
+ const ext = (relPath.split(".").pop() || "").toLowerCase();
2353
+ const mimeTypes = {
2354
+ html: "text/html",
2355
+ css: "text/css",
2356
+ js: "text/javascript",
2357
+ svg: "image/svg+xml",
2358
+ png: "image/png",
2359
+ json: "application/json",
2360
+ ico: "image/x-icon",
2361
+ };
2362
+ const mime = mimeTypes[ext] || "application/octet-stream";
2363
+ const charset = (ext === "html" || ext === "css" || ext === "js" || ext === "svg" || ext === "json") ? "; charset=utf-8" : "";
2364
+ res.writeHead(200, { "Content-Type": mime + charset });
2365
+ res.end(content);
2366
+ } catch {
2367
+ json(res, 404, { error: "Not found" });
2368
+ }
2369
+ }
2370
+
2371
+ function authenticateWs(req) {
2372
+ const auth = req.headers["authorization"];
2373
+ if (auth && auth.startsWith("Bearer ")) {
2374
+ const key = auth.slice(7).trim();
2375
+ if (API_KEYS[key]) return { agentId: API_KEYS[key], apiKey: key };
2376
+ }
2377
+ // Browsers can't set Authorization on WebSocket(): accept ?token= fallback.
2378
+ const u = parseUrl(req.url);
2379
+ const qs = u.query ? parseUrlQs(u.query) : {};
2380
+ 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 };
2383
+ }
2384
+ return null;
2385
+ }
2386
+
2387
+ const codexRelayWss = new WebSocketServer({ noServer: true });
2388
+
2389
+ httpServer.on("upgrade", (req, socket, head) => {
2390
+ const u = parseUrl(req.url);
2391
+ const path = u.pathname || "";
2392
+ const isDaemon = path === "/api/codex-relay/daemon";
2393
+ const isWeb = path.startsWith("/api/codex-relay/web/");
2394
+ if (!isDaemon && !isWeb) return; // let other listeners (or default) handle it
2395
+
2396
+ // 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.
2399
+ let identity = null;
2400
+ if (isDaemon) {
2401
+ identity = authenticateWs(req);
2402
+ } else {
2403
+ 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 };
2409
+ }
2410
+ if (!identity) identity = authenticateWs(req);
2411
+ }
2412
+
2413
+ if (!identity) {
2414
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
2415
+ socket.destroy();
2416
+ return;
2417
+ }
2418
+
2419
+ if (isDaemon) {
2420
+ 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);
2425
+ ws.on("message", (data) => {
2426
+ 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);
2431
+ }
2432
+ }
2433
+ });
2434
+ ws.on("close", () => {
2435
+ if (codexDaemons.get(identity.agentId) === ws) {
2436
+ codexDaemons.delete(identity.agentId);
2437
+ console.log("codex-relay: daemon offline for " + identity.agentId);
2438
+ }
2439
+ });
2440
+ ws.on("error", (err) => {
2441
+ console.error("codex-relay daemon ws error:", err.message);
2442
+ });
2443
+ });
2444
+ return;
2445
+ }
2446
+
2447
+ // Web side: /api/codex-relay/web/<threadId>
2448
+ const threadId = decodeURIComponent(path.slice("/api/codex-relay/web/".length));
2449
+ if (!threadId || threadId.includes("/")) {
2450
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
2451
+ socket.destroy();
2452
+ return;
2453
+ }
2454
+ 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);
2460
+ ws.on("message", (data) => {
2461
+ const daemonWs = codexDaemons.get(identity.agentId);
2462
+ if (daemonWs && daemonWs.readyState === daemonWs.OPEN) {
2463
+ daemonWs.send(data.toString());
2464
+ } else {
2465
+ try { ws.send(JSON.stringify({ type: "error", message: "daemon offline" })); } catch {}
2466
+ }
2467
+ });
2468
+ ws.on("close", () => {
2469
+ if (codexWebClients.get(key) === ws) codexWebClients.delete(key);
2470
+ });
2471
+ ws.on("error", (err) => {
2472
+ console.error("codex-relay web ws error:", err.message);
2473
+ });
2474
+ });
2475
+ });
2476
+
2070
2477
  httpServer.listen(PORT, SERVER_BIND, () => {
2071
2478
  console.log(SERVER_NAME + " v" + SERVER_VERSION + " listening on " + SERVER_BIND + ":" + PORT);
2072
- console.log("Health: http://localhost:" + PORT + "/health");
2073
- console.log("MCP: http://localhost:" + PORT + "/mcp");
2074
- console.log("OAuth: http://localhost:" + PORT + "/.well-known/oauth-authorization-server");
2075
- console.log("Signup: http://localhost:" + PORT + "/signup");
2076
- console.log("Login: http://localhost:" + PORT + "/login");
2077
- console.log("Demo: http://localhost:" + PORT + "/demo/");
2479
+ console.log("Health: http://localhost:" + PORT + "/health");
2480
+ console.log("MCP: http://localhost:" + PORT + "/mcp");
2481
+ console.log("OAuth: http://localhost:" + PORT + "/.well-known/oauth-authorization-server");
2482
+ console.log("Signup: http://localhost:" + PORT + "/signup");
2483
+ console.log("Login: http://localhost:" + PORT + "/login");
2484
+ console.log("Pair (codex): http://localhost:" + PORT + "/pair");
2485
+ console.log("Demo (legacy): http://localhost:" + PORT + "/demo/");
2078
2486
  console.log("Passkeys stored: " + passkeys.length);
2079
2487
  console.log("Session timeout: " + (SESSION_TIMEOUT_MS / 60000) + " min");
2080
2488
  });