@wipcomputer/wip-ldm-os 0.4.82-alpha.1 → 0.4.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/SKILL.md +1 -1
- package/bin/ldm.js +140 -0
- package/docs/skills/README.md +2 -0
- package/docs/universal-installer/README.md +1 -1
- package/docs/universal-installer/SPEC.md +189 -16
- package/docs/universal-installer/TECHNICAL.md +84 -29
- package/lib/bin-manifest.mjs +257 -0
- package/package.json +35 -2
- package/scripts/test-bin-manifest.mjs +282 -0
- package/scripts/test-doctor-cron-target.mjs +172 -0
- package/scripts/test-ldm-install-preserves-foreign-bin.mjs +112 -0
- package/scripts/validate-bin-manifest.mjs +41 -0
- package/src/hosted-mcp/app/codex-remote-control/index.html +254 -0
- package/src/hosted-mcp/app/login.html +176 -0
- package/src/hosted-mcp/app/pair.html +118 -0
- package/src/hosted-mcp/nginx/wip.computer.conf +88 -0
- package/src/hosted-mcp/package-lock.json +22 -0
- package/src/hosted-mcp/package.json +1 -0
- package/src/hosted-mcp/server.mjs +418 -10
|
@@ -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
|
|
1879
|
+
// Serve the new app/ login (two-path: this device or QR-from-phone).
|
|
1878
1880
|
try {
|
|
1879
|
-
const loginHtml = readFileSync(join(__dirname, "
|
|
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
|
|
1884
|
-
|
|
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:
|
|
2073
|
-
console.log("MCP:
|
|
2074
|
-
console.log("OAuth:
|
|
2075
|
-
console.log("Signup:
|
|
2076
|
-
console.log("Login:
|
|
2077
|
-
console.log("
|
|
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
|
});
|