diff --git a/README.md b/README.md index 5f5a43214b..879381a8a8 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ ocx start [--port 10100] # start the proxy ocx stop # stop + restore native Codex ocx restore # restore without stopping (alias: ocx eject) ocx sync # refresh models + re-inject into Codex +ocx codex-shim install # auto-start the proxy whenever `codex` is launched ocx status # is the proxy running? ocx login # OAuth login ocx logout # remove a stored login diff --git a/src/cli.ts b/src/cli.ts index d55bd05628..7b97cc3aab 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ Usage: ocx stop Stop the proxy AND restore native Codex (plain codex works again) ocx restore Restore native Codex without stopping (alias: eject) ocx service Run as a background service (install|start|stop|status|uninstall) + ocx codex-shim Auto-start proxy when \`codex\` launches (install|status|uninstall) ocx sync Fetch models from providers and inject into Codex config ocx status Check proxy server status ocx login OAuth login (xai) — opens browser, stores token in ~/.opencodex/auth.json @@ -215,6 +216,29 @@ switch (command) { case "service": serviceCommand(args[1]); break; + case "codex-shim": { + const { codexShimStatus, installCodexShim, uninstallCodexShim } = await import("./codex-shim"); + switch (args[1]) { + case "install": { + const r = installCodexShim(); + console.log(r.installed ? `✅ ${r.message}` : `⚠️ ${r.message}`); + break; + } + case "status": + console.log(codexShimStatus()); + break; + case "uninstall": + case "remove": { + const r = uninstallCodexShim(); + console.log(r.removed ? `✅ ${r.message}` : `⚠️ ${r.message}`); + break; + } + default: + console.error("Usage: ocx codex-shim "); + process.exit(1); + } + break; + } case "update": { const { runUpdate } = await import("./update"); runUpdate(); diff --git a/src/codex-catalog.ts b/src/codex-catalog.ts index 340fe5685c..cb595e2ec2 100644 --- a/src/codex-catalog.ts +++ b/src/codex-catalog.ts @@ -1,4 +1,4 @@ -import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { atomicWriteFile, websocketsEnabled } from "./config"; @@ -560,12 +560,15 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st } /** - * Delete Codex's models cache ($CODEX_HOME/models_cache.json) so the next turn re-fetches /v1/models. - * Codex caches the model list for 5 min (DEFAULT_MODEL_CACHE_TTL); invalidating makes catalog edits - * (enable/disable, subagent reorder) apply on the next turn instead of waiting for the TTL. + * Refresh Codex's models cache ($CODEX_HOME/models_cache.json) from the active catalog. + * Codex caches the model list for 5 min (DEFAULT_MODEL_CACHE_TTL); copying the injected catalog + * makes catalog edits (enable/disable, subagent reorder) apply on the next turn instead of waiting. */ export function invalidateCodexModelsCache(): void { try { - if (existsSync(CODEX_MODELS_CACHE_PATH)) unlinkSync(CODEX_MODELS_CACHE_PATH); + const catalogPath = readCodexCatalogPath(); + if (!existsSync(catalogPath)) return; + const catalog = readFileSync(catalogPath, "utf8"); + atomicWriteFile(CODEX_MODELS_CACHE_PATH, catalog.endsWith("\n") ? catalog : `${catalog}\n`); } catch { /* best-effort */ } } diff --git a/src/codex-shim.ts b/src/codex-shim.ts new file mode 100644 index 0000000000..b26205810a --- /dev/null +++ b/src/codex-shim.ts @@ -0,0 +1,145 @@ +import { delimiter, dirname, extname, join } from "node:path"; +import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { getConfigDir } from "./config"; + +const SHIM_MARKER = "opencodex codex autostart shim"; +const STATE_PATH = join(getConfigDir(), "codex-shim.json"); + +interface ShimState { + platform: NodeJS.Platform; + wrapperPath: string; + originalPath: string; + backupPath: string; +} + +function cliEntry(): { bun: string; cli: string } { + return { bun: process.execPath, cli: join(import.meta.dir, "cli.ts") }; +} + +function commandNames(name: string): string[] { + if (process.platform !== "win32") return [name]; + const exts = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD;.PS1").split(";").filter(Boolean); + return [name, ...exts.flatMap(ext => [`${name}${ext.toLowerCase()}`, `${name}${ext.toUpperCase()}`])]; +} + +function isShim(path: string): boolean { + try { + return readFileSync(path, "utf8").includes(SHIM_MARKER); + } catch { + return false; + } +} + +function findCodexOnPath(): string | null { + for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) { + for (const name of commandNames("codex")) { + const path = join(dir, name); + if (!existsSync(path) || isShim(path)) continue; + try { + if (!lstatSync(path).isDirectory()) return path; + } catch { + continue; + } + } + } + return null; +} + +function backupPathFor(path: string): string { + const ext = extname(path); + return ext ? `${path.slice(0, -ext.length)}.opencodex-real${ext}` : `${path}.opencodex-real`; +} + +export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string { + return `#!/usr/bin/env sh +# ${SHIM_MARKER} +if [ -z "$OCX_SHIM_BYPASS" ]; then + if ! "${bunPath}" "${cliPath}" status 2>/dev/null | grep -q "Proxy running"; then + mkdir -p "$HOME/.opencodex" + nohup env OCX_SERVICE=1 "${bunPath}" "${cliPath}" start >> "$HOME/.opencodex/shim.log" 2>&1 & + i=0 + while [ "$i" -lt 50 ]; do + if "${bunPath}" "${cliPath}" status 2>/dev/null | grep -q "Proxy running"; then + break + fi + sleep 0.1 + i=$((i + 1)) + done + fi +fi +exec "${realCodexPath}" "$@" +`; +} + +export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string { + return `@echo off\r +rem ${SHIM_MARKER}\r +if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r +"${bunPath}" "${cliPath}" status 2>nul | findstr /C:"Proxy running" >nul\r +if %ERRORLEVEL% EQU 0 goto run_codex\r +if not exist "%USERPROFILE%\\.opencodex" mkdir "%USERPROFILE%\\.opencodex"\r +start "" /b cmd /c "set OCX_SERVICE=1 && ""${bunPath}"" ""${cliPath}"" start >> ""%USERPROFILE%\\.opencodex\\shim.log"" 2>&1"\r +for /l %%i in (1,1,50) do (\r + "${bunPath}" "${cliPath}" status 2>nul | findstr /C:"Proxy running" >nul\r + if not errorlevel 1 goto run_codex\r + powershell -NoProfile -Command "Start-Sleep -Milliseconds 100" >nul 2>nul\r +)\r +:run_codex\r +"${realCodexPath}" %*\r +`; +} + +function readState(): ShimState | null { + try { + return JSON.parse(readFileSync(STATE_PATH, "utf8")) as ShimState; + } catch { + return null; + } +} + +function writeState(state: ShimState): void { + if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); + writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + "\n", "utf8"); +} + +export function installCodexShim(): { installed: boolean; message: string } { + const existing = readState(); + if (existing && existsSync(existing.wrapperPath) && existsSync(existing.backupPath)) { + return { installed: false, message: `Codex autostart shim already installed at ${existing.wrapperPath}.` }; + } + + const originalPath = findCodexOnPath(); + if (!originalPath) return { installed: false, message: "Could not find a codex executable on PATH." }; + + const backupPath = backupPathFor(originalPath); + if (existsSync(backupPath)) return { installed: false, message: `Refusing to overwrite existing backup: ${backupPath}` }; + + const { bun, cli } = cliEntry(); + const wrapperPath = process.platform === "win32" ? join(dirname(originalPath), "codex.cmd") : originalPath; + renameSync(originalPath, backupPath); + if (process.platform === "win32") { + writeFileSync(wrapperPath, buildWindowsCodexShim(backupPath, bun, cli), "utf8"); + } else { + writeFileSync(wrapperPath, buildUnixCodexShim(backupPath, bun, cli), "utf8"); + chmodSync(wrapperPath, 0o755); + } + writeState({ platform: process.platform, wrapperPath, originalPath, backupPath }); + return { installed: true, message: `Codex autostart shim installed at ${wrapperPath}. Original saved at ${backupPath}.` }; +} + +export function uninstallCodexShim(): { removed: boolean; message: string } { + const state = readState(); + if (!state) return { removed: false, message: "Codex autostart shim is not installed." }; + if (existsSync(state.wrapperPath) && isShim(state.wrapperPath)) unlinkSync(state.wrapperPath); + if (existsSync(state.backupPath) && !existsSync(state.originalPath)) renameSync(state.backupPath, state.originalPath); + if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH); + return { removed: true, message: `Codex autostart shim removed. Restored ${state.originalPath}.` }; +} + +export function codexShimStatus(): string { + const state = readState(); + if (!state) return "Codex autostart shim is not installed."; + const wrapper = existsSync(state.wrapperPath) ? "present" : "missing"; + const backup = existsSync(state.backupPath) ? "present" : "missing"; + return `Codex autostart shim: wrapper ${wrapper} at ${state.wrapperPath}; original backup ${backup} at ${state.backupPath}.`; +} diff --git a/src/server.ts b/src/server.ts index b33f426ea6..1c0fb4d5ba 100644 --- a/src/server.ts +++ b/src/server.ts @@ -568,6 +568,7 @@ export function startServer(port?: number) { const server = Bun.serve({ port: listenPort, + idleTimeout: 255, async fetch(req) { const url = new URL(req.url); diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts new file mode 100644 index 0000000000..f4e96fdde4 --- /dev/null +++ b/tests/codex-shim.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { buildUnixCodexShim, buildWindowsCodexShim } from "../src/codex-shim"; + +describe("Codex autostart shim", () => { + test("builds a Unix shim that starts ocx before execing Codex", () => { + const script = buildUnixCodexShim("/usr/local/bin/codex-real", "/usr/local/bin/bun", "/opt/opencodex/src/cli.ts"); + + expect(script).toContain("opencodex codex autostart shim"); + expect(script).toContain("status"); + expect(script).toContain("OCX_SERVICE=1"); + expect(script).toContain("start"); + expect(script).toContain('exec "/usr/local/bin/codex-real" "$@"'); + }); + + test("builds a Windows shim that starts ocx before running Codex", () => { + const script = buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts"); + + expect(script).toContain("opencodex codex autostart shim"); + expect(script).toContain("findstr"); + expect(script).toContain("OCX_SERVICE=1"); + expect(script).toContain('"C:\\Tools\\codex-real.exe" %*'); + }); +});