import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { basename, join } from "node:path"; import { unzipSync } from "fflate"; import { atomicWriteFile, getConfigDir } from "../../vendor/codex-chatgpt-web/config.ts"; export const CHATGPT_WEB_CODEX_TUNNEL_VERSION = "0.0.10"; const RELEASE_BASE = `https://github.com/openai/tunnel-client/releases/download/v${CHATGPT_WEB_CODEX_TUNNEL_VERSION}`; const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024; type InstallManifest = { version: 1; tunnelClientVersion: string; asset: string; archiveSha256: string; binarySha256: string; }; export type TunnelRuntimeConfig = { tunnelId: string; runtimeKey: string; brokerSocketPath: string; alias?: string; profile?: string; }; export type TunnelRuntimeStatus = { ok: boolean; processRunning: boolean; healthy: boolean; ready: boolean; state?: string; detail: string; }; type SupervisorLease = { version: 1; pid: number; startedAt: string; }; function sha256(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } export function tunnelPlatformAsset(platform = process.platform, arch = process.arch): string { const os = platform === "darwin" ? "darwin" : platform === "linux" ? "linux" : platform === "win32" ? "windows" : null; const cpu = arch === "arm64" ? "arm64" : arch === "x64" ? "amd64" : null; if (!os || !cpu) { throw new Error(`openai/tunnel-client has no pinned build for ${platform}/${arch}`); } return `tunnel-client-v${CHATGPT_WEB_CODEX_TUNNEL_VERSION}-${os}-${cpu}.zip`; } export function parseTunnelChecksum(text: string, asset: string): string { const entry = text .split(/\r?\n/) .map((line) => line.trim()) .find((line) => line.endsWith(asset)); const checksum = entry?.split(/\s+/)[0]?.toLowerCase(); if (!checksum || !/^[a-f0-9]{64}$/.test(checksum)) { throw new Error(`SHA256SUMS.txt has no valid entry for ${asset}`); } return checksum; } async function download(url: string): Promise { const response = await fetch(url, { redirect: "follow" }); if (!response.ok) throw new Error(`Tunnel download failed (${response.status})`); const declared = Number(response.headers.get("content-length") || "0"); if (Number.isFinite(declared) && declared > MAX_DOWNLOAD_BYTES) { throw new Error("Tunnel download exceeds the size limit"); } const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { throw new Error("Tunnel download exceeds the size limit"); } return bytes; } export function tunnelClientPaths() { const root = join(getConfigDir(), "tunnel-client"); return { root, binary: join(root, process.platform === "win32" ? "tunnel-client.exe" : "tunnel-client"), manifest: join(root, "manifest.json"), profileDir: join(root, "profiles"), supervisorLease: join(root, "supervisor-lease.json"), }; } function safeDetail(value: unknown): string { const text = typeof value === "string" ? value : JSON.stringify(value); return String(text || "") .replace(/tunnel_[a-f0-9]{32}/g, "[tunnel-id]") .replace(/(?:sk-|rt_|rk_)[A-Za-z0-9_-]{8,}/g, "[redacted-key]") .replace(/runtime-key-[A-Fa-f0-9]+/g, "runtime-key-[redacted]") .slice(0, 2_000); } function processIsAlive(pid: number): boolean { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } } let ownsSupervisorLease = false; export function acquireTunnelSupervisorLease(): void { if (ownsSupervisorLease) return; const paths = tunnelClientPaths(); mkdirSync(paths.root, { recursive: true, mode: 0o700 }); const path = paths.supervisorLease; for (let attempt = 0; attempt < 2; attempt += 1) { try { const fd = openSync(path, "wx", 0o600); try { const lease: SupervisorLease = { version: 1, pid: process.pid, startedAt: new Date().toISOString(), }; writeFileSync(fd, `${JSON.stringify(lease)}\n`); } finally { closeSync(fd); } ownsSupervisorLease = true; return; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; let ownerPid = 0; try { const lease = JSON.parse(readFileSync(path, "utf8")) as Partial; ownerPid = Number(lease.pid) || 0; } catch { ownerPid = 0; } if (ownerPid === process.pid) { ownsSupervisorLease = true; return; } if (processIsAlive(ownerPid)) { throw new Error(`ChatGPT Web (Codex) supervisor is already owned by process ${ownerPid}`); } rmSync(path, { force: true }); } } throw new Error("ChatGPT Web (Codex) supervisor lease could not be acquired"); } export function tunnelSupervisorLeaseStatus(): { ownedByCurrentProcess: boolean; conflict: boolean; ownerPid?: number; } { const path = tunnelClientPaths().supervisorLease; if (!existsSync(path)) return { ownedByCurrentProcess: false, conflict: false }; try { const lease = JSON.parse(readFileSync(path, "utf8")) as Partial; const ownerPid = Number(lease.pid) || undefined; return { ownedByCurrentProcess: ownerPid === process.pid, conflict: Boolean(ownerPid && ownerPid !== process.pid && processIsAlive(ownerPid)), ...(ownerPid ? { ownerPid } : {}), }; } catch { return { ownedByCurrentProcess: false, conflict: false }; } } export function releaseTunnelSupervisorLease(): void { if (!ownsSupervisorLease) return; const status = tunnelSupervisorLeaseStatus(); if (status.ownedByCurrentProcess) rmSync(tunnelClientPaths().supervisorLease, { force: true }); ownsSupervisorLease = false; } export async function ensureTunnelClientInstalled(): Promise { const paths = tunnelClientPaths(); if (existsSync(paths.binary) && existsSync(paths.manifest)) { const manifest = JSON.parse(readFileSync(paths.manifest, "utf8")) as Partial; const actual = sha256(readFileSync(paths.binary)); if ( manifest.version === 1 && manifest.tunnelClientVersion === CHATGPT_WEB_CODEX_TUNNEL_VERSION && manifest.binarySha256 === actual ) { return paths.binary; } throw new Error("Existing tunnel-client failed integrity validation"); } const asset = tunnelPlatformAsset(); const [archive, checksumFile] = await Promise.all([ download(`${RELEASE_BASE}/${asset}`), download(`${RELEASE_BASE}/SHA256SUMS.txt`), ]); const expected = parseTunnelChecksum(new TextDecoder().decode(checksumFile), asset); const archiveSha256 = sha256(archive); if (archiveSha256 !== expected) throw new Error(`Checksum mismatch for ${asset}`); const files = unzipSync(archive); const executableName = process.platform === "win32" ? "tunnel-client.exe" : "tunnel-client"; const entry = Object.entries(files).find(([name]) => basename(name) === executableName); if (!entry) throw new Error(`${asset} does not contain ${executableName}`); atomicWriteFile(paths.binary, entry[1]); if (process.platform !== "win32") chmodSync(paths.binary, 0o700); const manifest: InstallManifest = { version: 1, tunnelClientVersion: CHATGPT_WEB_CODEX_TUNNEL_VERSION, asset, archiveSha256, binarySha256: sha256(entry[1]), }; atomicWriteFile(paths.manifest, `${JSON.stringify(manifest, null, 2)}\n`); const version = spawnSync(paths.binary, ["--version"], { encoding: "utf8" }); if ( version.status !== 0 || !`${version.stdout}\n${version.stderr}`.includes(CHATGPT_WEB_CODEX_TUNNEL_VERSION) ) { throw new Error("Installed tunnel-client did not report the pinned version"); } return paths.binary; } function validateRuntimeConfig(config: TunnelRuntimeConfig) { if (!/^tunnel_[a-f0-9]{32}$/.test(config.tunnelId)) { throw new Error("Tunnel ID must be tunnel_ followed by 32 lowercase hexadecimal characters"); } if (!config.runtimeKey.trim() || config.runtimeKey.length > 64 * 1024) { throw new Error("Tunnel Runtime-Key is missing or too large"); } for (const value of [ config.alias ?? "omniroute-chatgpt-web-codex", config.profile ?? "omniroute", ]) { if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error("Tunnel alias/profile is invalid"); } } export async function startTunnelRuntime(config: TunnelRuntimeConfig): Promise { validateRuntimeConfig(config); acquireTunnelSupervisorLease(); const binary = await ensureTunnelClientInstalled(); const paths = tunnelClientPaths(); const runtimeKeyFile = join( paths.root, `runtime-key-${createHash("sha256").update(config.tunnelId).digest("hex").slice(0, 16)}` ); atomicWriteFile(runtimeKeyFile, config.runtimeKey.trim()); runtimeKeyFiles.add(runtimeKeyFile); const alias = config.alias ?? "omniroute-chatgpt-web-codex"; const profile = config.profile ?? "omniroute"; const mcpCommand = [ process.execPath, join(process.cwd(), "bin", "chatgpt-web-codex-mcp.mjs"), "--broker-socket", config.brokerSocketPath, ] .map((value) => JSON.stringify(value)) .join(" "); return spawn( binary, [ "runtimes", "connect", "--alias", alias, "--profile", profile, "--profile-dir", paths.profileDir, "--tunnel-client-bin", binary, "--tunnel-id", config.tunnelId, "--runtime-api-key", `file:${runtimeKeyFile}`, "--mcp-command", mcpCommand, "--json", ], { stdio: ["ignore", "pipe", "pipe"], env: process.env } ); } export function parseTunnelRuntimeStatus(output: string, exitStatus = 0): TunnelRuntimeStatus { if (exitStatus !== 0) { return { ok: false, processRunning: false, healthy: false, ready: false, detail: safeDetail(output), }; } try { const parsed = JSON.parse(output) as Record; const processRunning = parsed.process_running === true; const healthy = parsed.healthy === true; const ready = parsed.ready === true || parsed.runtime_state === "ready"; const state = typeof parsed.runtime_state === "string" ? parsed.runtime_state : typeof parsed.status === "string" ? parsed.status : undefined; const ok = processRunning && healthy && ready; return { ok, processRunning, healthy, ready, ...(state ? { state } : {}), detail: ok ? "process_running=true healthy=true ready=true" : safeDetail( `process_running=${processRunning}; healthy=${healthy}; ready=${ready}` + (state ? `; state=${state}` : "") ), }; } catch { return { ok: false, processRunning: false, healthy: false, ready: false, detail: `tunnel-client returned non-JSON status: ${safeDetail(output)}`, }; } } export async function getTunnelRuntimeStatus( config: Pick ): Promise { const binary = await ensureTunnelClientInstalled(); const paths = tunnelClientPaths(); const alias = config.alias ?? "omniroute-chatgpt-web-codex"; const profile = config.profile ?? "omniroute"; const result = spawnSync( binary, [ "runtimes", "status", alias, "--profile", profile, "--profile-dir", paths.profileDir, "--json", ], { encoding: "utf8", timeout: 5_000 } ); return parseTunnelRuntimeStatus(String(result.stdout || result.stderr || ""), result.status ?? 1); } const connectedRuntimes = new Map>(); const runtimeKeyFiles = new Set(); function runtimeIdentity(config: TunnelRuntimeConfig): string { return createHash("sha256") .update( JSON.stringify({ tunnelId: config.tunnelId, alias: config.alias ?? "omniroute-chatgpt-web-codex", profile: config.profile ?? "omniroute", brokerSocketPath: config.brokerSocketPath, }) ) .digest("hex"); } export function ensureTunnelRuntimeReady( config: TunnelRuntimeConfig, timeoutMs = 30_000 ): Promise { const identity = runtimeIdentity(config); const existing = connectedRuntimes.get(identity); if (existing) return existing; const connecting = (async () => { const child = await startTunnelRuntime(config); await new Promise((resolve, reject) => { let stderr = ""; const timer = setTimeout(() => { child.kill("SIGTERM"); reject(new Error("Tunnel runtime startup timed out")); }, timeoutMs); child.stderr?.on("data", (chunk) => { stderr = `${stderr}${String(chunk)}`.slice(-4_096); }); child.once("error", (error) => { clearTimeout(timer); reject(error); }); child.once("exit", (code, signal) => { clearTimeout(timer); if (code === 0 && !signal) resolve(); else reject( new Error(`Tunnel runtime startup failed (${code ?? signal}): ${safeDetail(stderr)}`) ); }); }); const deadline = Date.now() + timeoutMs; let status = await getTunnelRuntimeStatus(config); while (!status.ok && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 250)); status = await getTunnelRuntimeStatus(config); } if (!status.ok) throw new Error(`Tunnel runtime is not ready: ${status.detail}`); })(); connectedRuntimes.set(identity, connecting); void connecting.catch(() => connectedRuntimes.delete(identity)); return connecting; } export async function stopChatGptWebCodexTunnelRuntime(): Promise { const paths = tunnelClientPaths(); if (ownsSupervisorLease && existsSync(paths.binary)) { spawnSync( paths.binary, [ "runtimes", "stop", "omniroute-chatgpt-web-codex", "--profile", "omniroute", "--profile-dir", paths.profileDir, "--json", ], { encoding: "utf8", timeout: 10_000 } ); } connectedRuntimes.clear(); for (const runtimeKeyFile of runtimeKeyFiles) rmSync(runtimeKeyFile, { force: true }); runtimeKeyFiles.clear(); releaseTunnelSupervisorLease(); }