mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 12:42:21 +03:00
* Bypass proxy compaction for native Codex context
* Add native ChatGPT Web provider pipeline
* Add managed browser and tunnel deployment
* Add ChatGPT Web setup and doctor UI
* Document and test ChatGPT Web integration
* fix(security): register chatgpt-web-codex-doctor in LOCAL_ONLY_API_PATTERNS
The diagnostic route under /api/providers/{id}/chatgpt-web-codex-doctor
was not registered in the spawn-capable route guard. Adding it for
parity with the existing /login pattern.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(providers): route chatgpt-web-codex admin routes through a service boundary
The provider CRUD/doctor routes imported chatgpt-web-codex helpers
(finalizeValidatedChatGptWebCodexSecrets, encode/decodeChatGptWebCodexSecrets,
getChatGptWebCodexDoctorStatus) directly from open-sse/executors/**, which
no-restricted-imports (EXECUTOR_IMPORT_RESTRICTION) forbids for src/app/**
files — executor implementations must stay behind an open-sse handler or
service boundary.
Add open-sse/services/chatgptWebCodexAdmin.ts as a thin re-export boundary
(mirroring the existing tokenRefresh.ts re-export pattern) and import from
there instead. No behavior change.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
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<Uint8Array> {
|
|
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<SupervisorLease>;
|
|
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<SupervisorLease>;
|
|
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<string> {
|
|
const paths = tunnelClientPaths();
|
|
if (existsSync(paths.binary) && existsSync(paths.manifest)) {
|
|
const manifest = JSON.parse(readFileSync(paths.manifest, "utf8")) as Partial<InstallManifest>;
|
|
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<ChildProcess> {
|
|
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<string, unknown>;
|
|
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<TunnelRuntimeConfig, "alias" | "profile">
|
|
): Promise<TunnelRuntimeStatus> {
|
|
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<string, Promise<void>>();
|
|
const runtimeKeyFiles = new Set<string>();
|
|
|
|
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<void> {
|
|
const identity = runtimeIdentity(config);
|
|
const existing = connectedRuntimes.get(identity);
|
|
if (existing) return existing;
|
|
const connecting = (async () => {
|
|
const child = await startTunnelRuntime(config);
|
|
await new Promise<void>((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<void> {
|
|
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();
|
|
}
|