diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 3dd10384d..d1f3ff23e 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -191,6 +191,11 @@ If a model is missing from Codex, or the catalog order/visibility looks wrong, c independently of other providers. 5. **Cache and `ocx sync`** — live catalogs are cached for about five minutes (`modelCacheTtlMs`, default `300000`). Run `ocx sync` to force a fresh fetch and rewrite the catalog immediately. +6. **Running Codex `app-server`** — rewriting the on-disk catalog is not enough while a long-lived + Codex `app-server` (Desktop / CLI background host) keeps the previous list in memory. `ocx sync` + and `ocx sync-cache` warn when those processes are detected. Restart them with + `ocx sync --restart-codex` (or stop the matching `app-server` processes yourself), then let Codex + recreate them so the new list appears. :::caution[Other local writers] Catalog writes (`opencodex-catalog.json`, `config.toml`) are atomic **inside** opencodex, which only diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index eaa2d991a..5cd838aef 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -142,14 +142,21 @@ opencodex local config only if all restore steps succeeded. `remove` is an alias ## Models & Codex -### `ocx sync` +### `ocx sync [--restart-codex]` Fetch the live model list from every configured provider and re-inject the merged catalog into Codex. Run it after adding a provider or to refresh available models. -### `ocx sync-cache` +If long-lived Codex `app-server` processes are still running, `ocx sync` warns that they may keep +serving the previous in-memory model list even though `opencodex-catalog.json` / `models_cache.json` +were updated. Pass `--restart-codex` to send `SIGTERM` only to matching `codex … app-server` and +`codex-code-mode-host` processes owned by the current user (active turns may be interrupted). Broad +`pkill -f codex` matching is intentionally avoided. -Invalidate Codex's local model picker cache so it is rebuilt from the active opencodex catalog. +### `ocx sync-cache [--restart-codex]` + +Invalidate Codex's local model picker cache so it is rebuilt from the active opencodex catalog. The +same stale-`app-server` warning and optional `--restart-codex` behavior as `ocx sync` apply. ### `ocx v2 [subcommand]` diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 38630dceb..2d60e210c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -189,7 +189,7 @@ export const de: Record = { "dash.syncModels": "Modelle synchronisieren", "dash.syncing": "Synchronisiere…", "dash.syncOk": "Synchronisierung abgeschlossen. {count} Modell(e) angehängt.", - "dash.syncStaleHint": "Falls die Codex-App weiterhin eine alte Liste zeigt, starte ihren langlebigen App-Server-Prozess neu.", + "dash.syncStaleHint": "Falls Codex weiterhin eine alte Liste zeigt, starte den langlebigen App-Server neu ({cmd}).", "dash.syncFailed": "Synchronisierung fehlgeschlagen: {error}", "dash.projectConfigTitle": "Projekt-Codex-Konfig umgeht OpenCodex", "dash.projectConfigHint": "Diese repo-lokalen Einstellungen überschreiben den OpenCodex-Proxy (z. B. direkt zu OpenCode Go routen). Entferne sie, damit die Routing aus ~/.codex/config.toml in diesem Projekt greift.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index cc30bf3e5..c819f06b4 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -199,7 +199,7 @@ export const en = { "dash.syncModels": "Sync models", "dash.syncing": "Syncing…", "dash.syncOk": "Sync complete. {count} model(s) appended.", - "dash.syncStaleHint": "If Codex App still shows an older list, restart its long-lived app-server process.", + "dash.syncStaleHint": "If Codex still shows an older list, restart its long-lived app-server ({cmd}).", "dash.syncFailed": "Sync failed: {error}", "dash.projectConfigTitle": "Project Codex config bypasses OpenCodex", "dash.projectConfigHint": "These repo-local settings override the OpenCodex proxy (e.g. route to OpenCode Go directly). Remove them so ~/.codex/config.toml routing applies in that project.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3573457c7..ac052a088 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -198,7 +198,7 @@ export const ja: Record = { "dash.syncModels": "モデルを同期", "dash.syncing": "同期中…", "dash.syncOk": "同期完了。{count} 個のモデルを追加しました。", - "dash.syncStaleHint": "Codex App がまだ古いリストを表示する場合、長時間稼働の app-server プロセスを再起動してください。", + "dash.syncStaleHint": "Codex がまだ古いリストを表示する場合、長時間稼働の app-server を再起動してください({cmd})。", "dash.syncFailed": "同期失敗: {error}", "dash.projectConfigTitle": "プロジェクトの Codex 設定が OpenCodex をバイパスします", "dash.projectConfigHint": "これらのリポジローカル設定は OpenCodex プロキシを上書きします(例: OpenCode Go に直接ルーティング)。~/.codex/config.toml のルーティングがそのプロジェクトで適用されるように削除してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 462161977..0d44b89db 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -193,7 +193,7 @@ export const ko: Record = { "dash.syncModels": "모델 동기화", "dash.syncing": "동기화 중…", "dash.syncOk": "동기화 완료. {count}개 모델이 추가됐습니다.", - "dash.syncStaleHint": "Codex App에 여전히 예전 목록이 보이면 오래 실행 중인 app-server 프로세스를 재시작하세요.", + "dash.syncStaleHint": "Codex에 여전히 예전 목록이 보이면 오래 실행 중인 app-server를 재시작하세요 ({cmd}).", "dash.syncFailed": "동기화 실패: {error}", "dash.projectConfigTitle": "프로젝트 Codex 설정이 OpenCodex를 우회합니다", "dash.projectConfigHint": "저장소 로컬 설정이 OpenCodex 프록시를 덮어씁니다(예: OpenCode Go로 직접 라우팅). 해당 프로젝트에서 ~/.codex/config.toml 프록시를 쓰려면 제거하세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 462c42e8d..711e84e01 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -198,7 +198,7 @@ export const ru: Record = { "dash.syncModels": "Синхронизировать модели", "dash.syncing": "Синхронизация…", "dash.syncOk": "Синхронизация завершена. Добавлено моделей: {count}.", - "dash.syncStaleHint": "Если Codex App всё ещё показывает старый список, перезапустите его долгоживущий процесс app-server.", + "dash.syncStaleHint": "Если Codex всё ещё показывает старый список, перезапустите долгоживущий app-server ({cmd}).", "dash.syncFailed": "Ошибка синхронизации: {error}", "dash.projectConfigTitle": "Конфигурация Codex в проекте обходит OpenCodex", "dash.projectConfigHint": "Эти локальные настройки репозитория переопределяют прокси OpenCodex (например, направляют запросы напрямую в OpenCode Go). Удалите их, чтобы в этом проекте действовала маршрутизация из ~/.codex/config.toml.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 90a0c077c..f579b7633 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -193,7 +193,7 @@ export const zh: Record = { "dash.syncModels": "同步模型", "dash.syncing": "同步中…", "dash.syncOk": "同步完成。已追加 {count} 个模型。", - "dash.syncStaleHint": "如果 Codex App 仍显示旧列表,请重启长期运行的 app-server 进程。", + "dash.syncStaleHint": "如果 Codex 仍显示旧列表,请重启长期运行的 app-server({cmd})。", "dash.syncFailed": "同步失败:{error}", "dash.projectConfigTitle": "项目 Codex 配置绕过了 OpenCodex", "dash.projectConfigHint": "这些仓库级设置会覆盖 OpenCodex 代理(例如直接走 OpenCode Go)。请移除它们,以便该项目使用 ~/.codex/config.toml 的代理路由。", diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index fb12ceb0c..accaeb169 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -1,4 +1,5 @@ import { IconAlert, IconExternal, IconInfo, IconRefresh } from "../icons"; +import { Trans } from "../i18n/provider"; import { Select } from "../ui"; import { EFFORT_CAP_LEVELS, requireJson, sidecarBackendForModel, updateJobLabel } from "./dashboard-shared"; import type { useDashboardData } from "./use-dashboard-data"; @@ -221,7 +222,7 @@ export function DashboardMaintenancePanel({ d }: { d: Dash }) { {t("dash.syncOk", { count: syncResult.added })} {syncResult.warning ? ` ${syncResult.warning}` : ""} - {syncResult.staleAppServerHint ? ` ${t("dash.syncStaleHint")}` : ""} + {syncResult.staleAppServerHint ? <>{" "} : null} )} diff --git a/src/cli/help.ts b/src/cli/help.ts index ce6cb8509..02158154d 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -59,8 +59,22 @@ const helpEntries: Record = { ], }, ensure: { usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." }, - sync: { usage: "ocx sync", summary: "Fetch provider models and inject them into Codex config." }, - "sync-cache": { usage: "ocx sync-cache", summary: "Refresh Codex's model cache from the active catalog." }, + sync: { + usage: "ocx sync [--restart-codex]", + summary: "Fetch provider models and inject them into Codex config.", + details: [ + "After writing the catalog, warns if long-lived Codex app-server processes are still running.", + "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).", + ], + }, + "sync-cache": { + usage: "ocx sync-cache [--restart-codex]", + summary: "Refresh Codex's model cache from the active catalog.", + details: [ + "Warns when Codex app-server processes still hold an in-memory model list.", + "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).", + ], + }, status: { usage: "ocx status", summary: "Check proxy server status." }, doctor: { usage: "ocx doctor", summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability)." }, debug: { @@ -186,8 +200,9 @@ Usage: ocx codex-shim Auto-start proxy when \`codex\` launches (install|status|uninstall|remove) ocx tray Windows status tray (install|start|stop|status|uninstall) ocx ensure Ensure the proxy is running and Codex config/cache are current - ocx sync Fetch models from providers and inject into Codex config - ocx sync-cache Refresh Codex's model cache from the active catalog + ocx sync [--restart-codex] Fetch models from providers and inject into Codex config + ocx sync-cache [--restart-codex] + Refresh Codex's model cache from the active catalog ocx status Check proxy server status ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability) ocx debug [provider|usage ...] diff --git a/src/cli/index.ts b/src/cli/index.ts index 49100b270..adee5442c 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -94,21 +94,6 @@ async function waitForProxy(timeoutMs = 8_000): Promise { return null; } -/** - * A Grok fence sync that throws is best-effort by design — it must never block startup. - * Reporting nothing, however, is what lets a STALE fence survive: `~/.grok/config.toml` - * keeps naming whatever port the last successful sync wrote, and once that listener is - * gone every grok turn retries against a refused connection while our own log stays - * silent (2026-07-27 field report: 8 entries pinned to a dead 127.0.0.1:4179). - * So say what failed and name the single command that repairs it. - */ -function grokSyncFailureMessage(err: unknown): string { - const detail = err instanceof Error ? err.message : String(err); - return `Grok Build config sync failed: ${detail}. ` - + "~/.grok/config.toml may still point at a previous proxy port — " - + "run 'ocx ensure' (or apply from the dashboard's Grok page) to repoint it."; -} - /** Argv for detached `start`, optionally hard-pinning the listen port. */ function startArgv(port?: number): string[] { const args = [process.argv[1], "start"]; @@ -314,14 +299,7 @@ async function handleStart(options: { block?: boolean } = {}) { const r = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}); if (r.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); else if (!r.ok) console.error(`⚠️ ${r.message}`); - } catch (err) { - // Best-effort: grok integration must never block startup. But swallowing the error - // silently is how a stale fence survives unnoticed — ~/.grok/config.toml keeps - // pointing at whatever port the LAST successful sync wrote, and if that listener is - // gone every grok turn retries against a refused connection with nothing in our log - // to explain it. Name the failure and the one command that repairs it. - console.error(`⚠️ ${grokSyncFailureMessage(err)}`); - } + } catch { /* best-effort — grok integration must never block startup */ } if (options.block ?? true) { setInterval(() => {}, 60_000); await new Promise(() => {}); @@ -349,7 +327,7 @@ async function handleEnsure() { const g = await syncGrokConfig(live.port, config, live.hostname ? { hostname: live.hostname } : {}); if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); else if (!g.ok) console.error(`⚠️ ${g.message}`); - } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); } + } catch { /* best-effort */ } console.log(`✅ Proxy running on port ${live.port}`); return; } @@ -376,7 +354,7 @@ async function handleEnsure() { const g = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}); if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); else if (!g.ok) console.error(`⚠️ ${g.message}`); - } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); } + } catch { /* best-effort */ } // Always sync the LIVE port: after a fallback-port start, config.port still names the // busy preferred port — syncing that would point Codex at a dead listener. await syncModelsToCodex(port).catch(e => { @@ -766,7 +744,13 @@ switch (command) { break; } case "sync": { - await syncModelsToCodex((await findLiveProxy())?.port); + const restartCodex = args.slice(1).includes("--restart-codex"); + const syncResult = await syncModelsToCodex((await findLiveProxy())?.port); + // Only warn/restart when a catalog or models_cache write actually happened. + if (syncResult.catalogWritten || syncResult.cacheSynced) { + const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes"); + afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); + } break; } case "v2": { @@ -775,8 +759,13 @@ switch (command) { break; } case "sync-cache": { + const restartCodex = args.slice(1).includes("--restart-codex"); const { invalidateCodexModelsCache } = await import("../codex/catalog"); - invalidateCodexModelsCache(); + // Only warn/restart when models_cache was actually rewritten from a readable catalog. + if (invalidateCodexModelsCache()) { + const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes"); + afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); + } break; } case "gui": { diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts new file mode 100644 index 000000000..bba1041eb --- /dev/null +++ b/src/codex/app-server-processes.ts @@ -0,0 +1,511 @@ +/** + * Detect / optionally terminate long-lived Codex app-server processes that keep an + * in-memory model catalog after `ocx sync` rewrites on-disk files (#476). + * + * Matching is intentionally narrow: require `app-server` as the Codex subcommand + * (not merely as a substring in some later argument) or `codex-code-mode-host`. + * Never match broad `*codex*` patterns that hit unrelated tools such as + * `hermes-codex-bridge-mcp`. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { isProcessAlive, waitForExit } from "../lib/process-control"; + +export const STALE_CODEX_APP_SERVER_HINT = + "If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex)."; + +/** Attach the shared dashboard hint only after a catalog or models_cache write. */ +export function attachStaleAppServerHint(result: T): T & { staleAppServerHint?: string } { + if (result.catalogWritten || result.cacheSynced) { + return { ...result, staleAppServerHint: STALE_CODEX_APP_SERVER_HINT }; + } + return { ...result }; +} +/** + * Rust-style target-triple body on official platform-baked Codex binaries + * (e.g. `x86_64-unknown-linux-musl`, `aarch64-apple-darwin`, + * `x86_64-pc-windows-msvc`). Requires arch-vendor-os with an optional env + * segment — not a broad `codex-*` wildcard. + */ +const CODEX_TARGET_TRIPLE_BODY = "[a-z0-9_]+-[a-z0-9_]+-[a-z0-9_]+(?:-[a-z0-9_]+)?"; + +/** + * Narrow Win32_Process CommandLine pre-filter (JS + .NET compatible). + * Allows an optional closing quote after the executable basename so paths like + * `"C:\Program Files\...\codex.exe" app-server` still reach GetOwner. + * Also admits official target-triple basenames such as + * `codex-x86_64-pc-windows-msvc.exe`. + */ +export const WINDOWS_CODEX_BASENAME_CANDIDATE_RE = new RegExp( + `(^|[/\\\\\\s'"=])codex(-${CODEX_TARGET_TRIPLE_BODY})?([.]exe|[.]cmd)?['"]?(\\s|$)`, + "i", +); + +export const WINDOWS_CODEX_CODE_MODE_HOST_CANDIDATE_RE = /codex-code-mode-host/i; + +/** Basename of an official Codex release binary (plain or target-triple). */ +const CODEX_TARGET_TRIPLE_BASENAME_RE = new RegExp( + `^codex-${CODEX_TARGET_TRIPLE_BODY}(?:\\.exe|\\.cmd)?$`, +); + +/** True when a Windows CommandLine is worth paying GetOwner for (current-user scoped later). */ +export function isWindowsCodexCandidateCommandLine(commandLine: string): boolean { + return WINDOWS_CODEX_BASENAME_CANDIDATE_RE.test(commandLine) + || WINDOWS_CODEX_CODE_MODE_HOST_CANDIDATE_RE.test(commandLine); +} + +/** Embed a regex source in a PowerShell single-quoted -match operand (`''` escapes `'`). */ +function powerShellSingleQuotedIgnoreCaseMatch(patternSource: string): string { + return `'(?i)${patternSource.replace(/'/g, "''")}'`; +} + +export interface CodexAppServerProcess { + pid: number; + commandLine: string; +} + +export interface ProcessSnapshot { + pid: number; + commandLine: string; + uid?: number; + owner?: string; +} + +export interface CodexAppServerProcessIo { + platform?: NodeJS.Platform; + getuid?: () => number | undefined; + listSnapshots?: () => ProcessSnapshot[]; + isAlive?: (pid: number) => boolean; + kill?: (pid: number, signal: NodeJS.Signals) => void; + waitExit?: (pid: number, timeoutMs: number) => boolean; + now?: () => number; +} + +/** Split a process command line into argv-like tokens (handles simple quotes). */ +export function tokenizeCommandLine(commandLine: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: '"' | "'" | null = null; + for (let i = 0; i < commandLine.length; i++) { + const ch = commandLine[i]!; + if (quote) { + if (ch === quote) quote = null; + else current += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (/\s/.test(ch)) { + if (current) { + tokens.push(current); + current = ""; + } + continue; + } + current += ch; + } + if (current) tokens.push(current); + return tokens; +} + +function tokenBasename(token: string): string { + return token.toLowerCase().replace(/\\/g, "/").split("/").pop() ?? ""; +} + +function isCodexExecutableToken(token: string): boolean { + const base = tokenBasename(token); + return base === "codex" || base === "codex.exe" || base === "codex.cmd" + || CODEX_TARGET_TRIPLE_BASENAME_RE.test(base); +} + +function isCodeModeHostToken(token: string): boolean { + const base = tokenBasename(token); + return base === "codex-code-mode-host" || base === "codex-code-mode-host.exe"; +} + +function isInterpreterToken(token: string): boolean { + const base = tokenBasename(token); + return base === "node" || base === "node.exe" + || base === "bun" || base === "bun.exe" + || base === "deno" || base === "deno.exe"; +} + +/** + * Codex global options that take a following value when written without `=`. + * Keep this list explicit so unknown flags stay boolean (narrow matching). + */ +const CODEX_GLOBAL_OPTIONS_WITH_VALUE = new Set([ + "--enable", + "--disable", + "--config", + "-c", + "--profile", + "-p", + "--model", + "-m", + "--sandbox", + "-s", + "--ask-for-approval", + "-a", + "--local-provider", + "--add-dir", + "--cd", + "-C", + "--color", + "--image", + "-i", + "--output-schema", + "--output-last-message", + "-o", +]); + +/** Parse a CLI option token into its flag name and whether a value is inline (`--opt=value`). */ +function splitCliOptionToken(token: string): { name: string; hasInlineValue: boolean } | null { + if (!token.startsWith("-") || token === "-" || token === "--") return null; + if (token.startsWith("--")) { + const eq = token.indexOf("="); + if (eq >= 0) return { name: token.slice(0, eq).toLowerCase(), hasInlineValue: true }; + return { name: token.toLowerCase(), hasInlineValue: false }; + } + // Preserve short-option case: `-c` (config) vs `-C` (cd). + const eq = token.indexOf("="); + if (eq >= 0) return { name: token.slice(0, eq), hasInlineValue: true }; + return { name: token, hasInlineValue: false }; +} + +/** Advance past one argv token, consuming a value for known Codex global options. */ +function advancePastCodexGlobalOption(tokens: readonly string[], index: number): number { + const option = splitCliOptionToken(tokens[index]!); + if (!option) return index + 1; + let next = index + 1; + if ( + !option.hasInlineValue + && CODEX_GLOBAL_OPTIONS_WITH_VALUE.has(option.name) + && next < tokens.length + && !tokens[next]!.startsWith("-") + ) { + next += 1; // skip the option value + } + return next; +} + +/** True when code-mode-host is the process executable or interpreter entrypoint, not a later arg. */ +function isCodeModeHostProcess(tokens: readonly string[]): boolean { + if (tokens.length === 0) return false; + if (isCodeModeHostToken(tokens[0]!)) return true; + return isInterpreterToken(tokens[0]!) && tokens.length > 1 && isCodeModeHostToken(tokens[1]!); +} + +/** Stable identity for PID reuse checks: pid + normalized command line. */ +export function codexAppServerProcessIdentity(proc: Pick): string { + return `${proc.pid}\0${proc.commandLine.trim().replace(/\s+/g, " ")}`; +} + +/** True when the command line is a Codex app-server (or code-mode host) worth restarting. */ +export function isCodexAppServerCommandLine(commandLine: string): boolean { + const tokens = tokenizeCommandLine(commandLine.trim()); + if (tokens.length === 0) return false; + if (isCodeModeHostProcess(tokens)) return true; + + // Require Codex as argv0 so later-argument occurrences stay unmatched + // (e.g. `node worker.js codex app-server`). + if (!isCodexExecutableToken(tokens[0]!)) return false; + + let i = 1; + while (i < tokens.length) { + const token = tokens[i]!; + if (token.startsWith("-")) { + i = advancePastCodexGlobalOption(tokens, i); + continue; + } + // First non-option after globals is the Codex subcommand. + return token.toLowerCase() === "app-server"; + } + return false; +} + +function parseUnixProcStatusUid(status: string): number | undefined { + const match = /^Uid:\s+(\d+)/m.exec(status); + if (!match) return undefined; + const uid = Number(match[1]); + return Number.isSafeInteger(uid) ? uid : undefined; +} + +function listUnixProcSnapshots(uid: number | undefined): ProcessSnapshot[] { + if (!existsSync("/proc")) return []; + const out: ProcessSnapshot[] = []; + for (const ent of readdirSync("/proc")) { + if (!/^\d+$/.test(ent)) continue; + const pid = Number(ent); + if (!Number.isSafeInteger(pid) || pid <= 1) continue; + try { + const status = readFileSync(`/proc/${pid}/status`, "utf8"); + const processUid = parseUnixProcStatusUid(status); + if (uid !== undefined && processUid !== undefined && processUid !== uid) continue; + const commandLine = readFileSync(`/proc/${pid}/cmdline`) + .toString("utf8") + .replace(/\0/g, " ") + .trim(); + if (!commandLine) continue; + out.push({ pid, commandLine, uid: processUid }); + } catch { + /* process exited mid-scan */ + } + } + return out; +} + +function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] { + const out: ProcessSnapshot[] = []; + try { + const output = uid !== undefined + ? execFileSync("ps", ["-u", String(uid), "-o", "pid=,command="], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 8_000, + }) + : execFileSync("ps", ["-axo", "pid=,uid=,command="], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 8_000, + }); + for (const raw of output.split(/\r?\n/)) { + const line = raw.trim(); + if (!line) continue; + if (uid !== undefined) { + const match = /^(\d+)\s+(.*)$/.exec(line); + if (!match) continue; + const pid = Number(match[1]); + const commandLine = match[2]?.trim() ?? ""; + if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) continue; + out.push({ pid, commandLine, uid }); + continue; + } + const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(line); + if (!match) continue; + const pid = Number(match[1]); + const processUid = Number(match[2]); + const commandLine = match[3]?.trim() ?? ""; + if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) continue; + out.push({ pid, commandLine, uid: Number.isSafeInteger(processUid) ? processUid : undefined }); + } + } catch { + return out; + } + return out; +} + +/** + * Windows snapshots scoped to the invoking user via Win32_Process GetOwner. + * PowerShell is the sole path: WMIC lacks reliable owner data and is disabled on + * many Windows 11 installs; returning unscoped rows would contradict the + * current-user restart contract. + * + * CIM instance methods must use Invoke-CimMethod (direct .GetOwner() calls fail). + * Candidates are pre-filtered to Codex basename / code-mode-host command lines + * so we do not pay GetOwner per every process on the machine. + * Exported for the Windows integration regression that exercises the real + * PowerShell enumeration. + */ +export function listWindowsSnapshots(): ProcessSnapshot[] { + const out: ProcessSnapshot[] = []; + // Newlines keep -Command as a real script (space-joined statements need ';'). + // Double-quoted format string so `t expands to a real tab. + // Codex candidates only: basename token codex / codex.exe / codex.cmd / + // official target-triple binaries (optional closing quote after the + // basename), or code-mode-host — not incidental substrings like a repo + // path with "opencodex". + const basenameMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_BASENAME_CANDIDATE_RE.source); + const codeModeMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_CODE_MODE_HOST_CANDIDATE_RE.source); + const psCommand = [ + "$ErrorActionPreference='SilentlyContinue'", + "$me=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name", + "Get-CimInstance Win32_Process | Where-Object {", + " -not [string]::IsNullOrWhiteSpace($_.CommandLine) -and (", + ` $_.CommandLine -match ${basenameMatch} -or`, + ` $_.CommandLine -match ${codeModeMatch}`, + " )", + "} | ForEach-Object {", + " try {", + " $o=Invoke-CimMethod -InputObject $_ -MethodName GetOwner -ErrorAction Stop", + " if($null -eq $o -or $o.ReturnValue -ne 0 -or [string]::IsNullOrWhiteSpace($o.User)){return}", + " $owner=if($o.Domain){\"$($o.Domain)\\$($o.User)\"}else{$o.User}", + " if($owner -ine $me){return}", + " $cmd=($_.CommandLine -replace \"`t\",\" \")", + " \"{0}`t{1}`t{2}\" -f $_.ProcessId, $cmd, $owner", + " } catch { }", + "}", + ].join("\n"); + try { + const output = execFileSync("powershell.exe", [ + "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden", + "-Command", + psCommand, + ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 12_000, windowsHide: true }); + for (const line of output.split(/\r?\n/)) { + const tab = line.indexOf("\t"); + if (tab <= 0) continue; + const tab2 = line.indexOf("\t", tab + 1); + if (tab2 <= tab) continue; + const pid = Number(line.slice(0, tab)); + const commandLine = line.slice(tab + 1, tab2).trim(); + const owner = line.slice(tab2 + 1).trim(); + if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine || !owner) continue; + out.push({ pid, commandLine, owner }); + } + } catch { + return out; + } + return out; +} + +function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | undefined): ProcessSnapshot[] { + if (platform === "win32") return listWindowsSnapshots(); + if (platform === "darwin") return listDarwinSnapshots(getuid()); + return listUnixProcSnapshots(getuid()); +} + +export function listCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): CodexAppServerProcess[] { + const platform = io.platform ?? process.platform; + const getuid = io.getuid ?? (() => { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } + }); + const snapshots = io.listSnapshots?.() ?? defaultListSnapshots(platform, getuid); + const seen = new Set(); + const matched: CodexAppServerProcess[] = []; + for (const snapshot of snapshots) { + if (seen.has(snapshot.pid)) continue; + if (!isCodexAppServerCommandLine(snapshot.commandLine)) continue; + seen.add(snapshot.pid); + matched.push({ pid: snapshot.pid, commandLine: snapshot.commandLine }); + } + return matched; +} + +export function formatStaleCodexAppServerWarning(processes: readonly CodexAppServerProcess[]): string { + const pids = processes.map(process => process.pid).join(", "); + return ( + `WARNING: ${processes.length} Codex app-server process(es) still running (PID${processes.length === 1 ? "" : "s"}: ${pids}). ` + + "Disk catalog/cache were updated, but Codex may keep showing the old model list until those processes restart. " + + "Re-run with `ocx sync --restart-codex` (or `ocx sync-cache --restart-codex`) to send SIGTERM only to matching app-server processes. " + + "Active turns may be interrupted." + ); +} + +export interface RestartCodexAppServersResult { + requested: number[]; + stopped: number[]; + surviving: number[]; + failed: Array<{ pid: number; error: string }>; +} + +/** Send SIGTERM to matched processes and wait briefly; never escalates to SIGKILL. */ +export function restartCodexAppServers( + processes: readonly CodexAppServerProcess[] = listCodexAppServerProcesses(), + io: CodexAppServerProcessIo = {}, +): RestartCodexAppServersResult { + const isAlive = io.isAlive ?? isProcessAlive; + const kill = io.kill ?? ((pid, signal) => { process.kill(pid, signal); }); + const wait = io.waitExit ?? waitForExit; + const now = io.now ?? Date.now; + const requested = processes.map(process => process.pid); + const stopped: number[] = []; + const surviving: number[] = []; + const failed: Array<{ pid: number; error: string }> = []; + + // Re-resolve immediately before signaling so a recycled PID is never killed. + // Require the same pid+command-line identity as the original match — a new + // Codex-shaped process that reused the PID must not receive SIGTERM. + const liveByPid = new Map( + listCodexAppServerProcesses(io).map(process => [process.pid, process] as const), + ); + const signaled: CodexAppServerProcess[] = []; + + for (const proc of processes) { + const live = liveByPid.get(proc.pid); + if (!live || codexAppServerProcessIdentity(live) !== codexAppServerProcessIdentity(proc)) { + // Original target exited (or identity changed); do not signal a replacement. + if (!isAlive(proc.pid)) stopped.push(proc.pid); + continue; + } + try { + kill(proc.pid, "SIGTERM"); + signaled.push(proc); + } catch (error) { + if (isAlive(proc.pid)) { + failed.push({ + pid: proc.pid, + error: error instanceof Error ? error.message : String(error), + }); + surviving.push(proc.pid); + } else { + stopped.push(proc.pid); + } + } + } + + // Shared deadline so N survivors wait ~2s total, not N×2s. + const deadline = now() + 2_000; + for (const proc of signaled) { + const remaining = Math.max(0, deadline - now()); + if (wait(proc.pid, remaining) || !isAlive(proc.pid)) stopped.push(proc.pid); + else surviving.push(proc.pid); + } + + return { requested, stopped, surviving, failed }; +} + +export interface AfterCatalogWriteAppServerOptions { + restart: boolean; + log?: Pick | null; + io?: CodexAppServerProcessIo; +} + +export interface AfterCatalogWriteAppServerResult { + processes: CodexAppServerProcess[]; + warned: boolean; + restart?: RestartCodexAppServersResult; + hint: string; +} + +/** Warn about stale app-servers after catalog/cache writes, or restart them when requested. */ +export function afterCatalogWriteHandleAppServers( + options: AfterCatalogWriteAppServerOptions, +): AfterCatalogWriteAppServerResult { + const processes = listCodexAppServerProcesses(options.io); + const hint = STALE_CODEX_APP_SERVER_HINT; + if (processes.length === 0) { + return { processes, warned: false, hint }; + } + if (!options.restart) { + options.log?.error(formatStaleCodexAppServerWarning(processes)); + return { processes, warned: true, hint }; + } + options.log?.log( + `Stopping Codex app-server process(es): ${processes.map(process => process.pid).join(", ")} ` + + "(active turns may be interrupted).", + ); + const restart = restartCodexAppServers(processes, options.io); + if (restart.stopped.length > 0) { + options.log?.log(`Stopped Codex app-server PID(s): ${restart.stopped.join(", ")}`); + } + for (const failure of restart.failed) { + options.log?.error(`Failed to stop Codex app-server PID ${failure.pid}: ${failure.error}`); + } + if (restart.surviving.length > 0) { + options.log?.error( + `Codex app-server PID(s) still running after SIGTERM: ${restart.surviving.join(", ")}. ` + + "Stop them manually if the model list stays stale.", + ); + } + return { processes, warned: false, restart, hint }; +} diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 4127c5d61..877c18471 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -457,11 +457,12 @@ export function mergeCatalogEntriesForSync( export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string; + catalogWritten: boolean; comboOmissions: ComboCatalogOmission[]; }> { const catalogPath = readCodexCatalogPath(); const catalog = loadCatalogForSync(catalogPath); - if (!catalog) return { added: 0, path: catalogPath, comboOmissions: [] }; + if (!catalog) return { added: 0, path: catalogPath, catalogWritten: false, comboOmissions: [] }; const template = findNativeTemplate(catalog); @@ -500,7 +501,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ clampCatalogModelsToCodexSupport(catalog.models); atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n"); - return { added: goEntries.length, path: catalogPath, comboOmissions }; + return { added: goEntries.length, path: catalogPath, catalogWritten: true, comboOmissions }; } export function restoreCodexCatalog(): { removed: number; kept: number; path: string } { @@ -531,10 +532,11 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st return { removed, kept: native.length, path: catalogPath }; } -export function invalidateCodexModelsCache(): void { +/** Force Codex's models_cache stale from the on-disk catalog. Returns whether a cache write occurred. */ +export function invalidateCodexModelsCache(): boolean { try { const catalogPath = readCodexCatalogPath(); - if (!existsSync(catalogPath)) return; + if (!existsSync(catalogPath)) return false; const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); const models = catalog.models ?? catalog; const wrapper = { @@ -543,5 +545,8 @@ export function invalidateCodexModelsCache(): void { models, }; atomicWriteFile(activeCodexModelsCachePath(), JSON.stringify(wrapper, null, 2) + "\n"); - } catch { /* best-effort */ } + return true; + } catch { + return false; + } } diff --git a/src/codex/refresh.ts b/src/codex/refresh.ts index 7036c99e4..1cb582e8e 100644 --- a/src/codex/refresh.ts +++ b/src/codex/refresh.ts @@ -9,6 +9,7 @@ export interface CodexCatalogRefreshResult { added: number; path: string; catalogExists: boolean; + catalogWritten: boolean; cacheSynced: boolean; comboOmissions: ComboCatalogOmission[]; } @@ -42,8 +43,11 @@ export async function refreshCodexModelCatalog( ): Promise { const result = await deps.syncCatalogModels(config); const catalogExists = deps.existsSync(result.path); + const catalogWritten = result.catalogWritten === true; const comboOmissions = result.comboOmissions ?? []; - if (!catalogExists) return { ...result, catalogExists, cacheSynced: false, comboOmissions }; - deps.invalidateCodexModelsCache(); - return { ...result, catalogExists, cacheSynced: true, comboOmissions }; + if (!catalogExists) { + return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions }; + } + const cacheSynced = deps.invalidateCodexModelsCache(); + return { ...result, catalogExists, catalogWritten, cacheSynced, comboOmissions }; } diff --git a/src/codex/sync.ts b/src/codex/sync.ts index d60aa3215..27040f92d 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -11,6 +11,7 @@ export interface CodexSyncResult { added: number; catalogPath: string | null; catalogExists: boolean; + catalogWritten: boolean; cacheSynced: boolean; message: string; warning?: string; @@ -61,6 +62,7 @@ export async function syncModelsToCodex( added: 0, catalogPath: null, catalogExists: false, + catalogWritten: false, cacheSynced: false, message: result.message, }; @@ -71,6 +73,7 @@ export async function syncModelsToCodex( let catalogPath: string | null = null; let catalogPathForInjection: string | null | undefined; let catalogExists = false; + let catalogWritten = false; let cacheSynced = false; let warning: string | undefined; let comboOmissions: ComboCatalogOmission[] = []; @@ -79,6 +82,7 @@ export async function syncModelsToCodex( const cat = await deps.refreshCodexModelCatalog(config); added = cat.added; catalogExists = cat.catalogExists; + catalogWritten = cat.catalogWritten; cacheSynced = cat.cacheSynced; catalogPathForInjection = cat.catalogExists ? cat.path : null; catalogPath = catalogPathForInjection; @@ -110,6 +114,7 @@ export async function syncModelsToCodex( added, catalogPath, catalogExists, + catalogWritten, cacheSynced, message: result.message, ...(warning ? { warning } : {}), diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 4703d0625..1119b6f46 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -226,11 +226,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { + test("matches Codex app-server and code-mode-host command lines", () => { + expect(isCodexAppServerCommandLine("codex app-server --listen unix:///tmp/codex.sock")).toBe(true); + expect(isCodexAppServerCommandLine("/usr/local/bin/codex app-server proxy")).toBe(true); + expect(isCodexAppServerCommandLine("C:\\Users\\a\\AppData\\codex.exe app-server --listen pipe")).toBe(true); + expect(isCodexAppServerCommandLine("\"C:\\Program Files\\nodejs\\codex.exe\" app-server")).toBe(true); + expect(isCodexAppServerCommandLine("\"C:\\Program Files\\nodejs\\codex.cmd\" app-server --listen pipe")).toBe(true); + expect(isCodexAppServerCommandLine("codex --verbose app-server")).toBe(true); + expect(isCodexAppServerCommandLine("codex-code-mode-host --session 1")).toBe(true); + expect(isCodexAppServerCommandLine("node /opt/codex-code-mode-host --session 1")).toBe(true); + }); + + test("matches official platform-baked Codex target-triple basenames", () => { + expect(isCodexAppServerCommandLine( + "/opt/codex/codex-x86_64-unknown-linux-musl app-server --listen unix:///tmp/c.sock", + )).toBe(true); + expect(isCodexAppServerCommandLine( + "/Applications/Codex.app/Contents/Resources/codex-aarch64-apple-darwin app-server", + )).toBe(true); + expect(isCodexAppServerCommandLine( + "C:\\Users\\a\\.codex\\bin\\codex-x86_64-pc-windows-msvc.exe app-server --listen pipe", + )).toBe(true); + expect(isCodexAppServerCommandLine( + "\"C:\\Program Files\\Codex\\codex-aarch64-pc-windows-msvc.exe\" app-server", + )).toBe(true); + expect(isCodexAppServerCommandLine( + "codex-x86_64-apple-darwin --profile prod app-server", + )).toBe(true); + }); + + test("matches app-server after value-taking Codex global options", () => { + expect(isCodexAppServerCommandLine("codex --enable js_repl app-server")).toBe(true); + expect(isCodexAppServerCommandLine("codex --enable=js_repl app-server")).toBe(true); + expect(isCodexAppServerCommandLine("codex --disable multi_agent_v2 app-server --listen unix://x")).toBe(true); + expect(isCodexAppServerCommandLine("codex --config model=gpt-5.4 app-server")).toBe(true); + expect(isCodexAppServerCommandLine("codex -c model=gpt-5.4 app-server")).toBe(true); + expect(isCodexAppServerCommandLine("codex --profile production app-server")).toBe(true); + expect(isCodexAppServerCommandLine("codex -p production app-server")).toBe(true); + expect(isCodexAppServerCommandLine("codex -a never app-server")).toBe(true); + expect(isCodexAppServerCommandLine("codex --ask-for-approval on-request app-server")).toBe(true); + expect(isCodexAppServerCommandLine("codex --oss --local-provider ollama app-server")).toBe(true); + expect(isCodexAppServerCommandLine("codex --add-dir /tmp app-server")).toBe(true); + expect(isCodexAppServerCommandLine( + "codex --enable js_repl --profile prod -c model=gpt-5.4 app-server --listen stdio://", + )).toBe(true); + }); + + test("rejects unrelated processes and app-server / code-mode-host only in later arguments", () => { + expect(isCodexAppServerCommandLine("hermes-codex-bridge-mcp --port 9")).toBe(false); + expect(isCodexAppServerCommandLine("hermes-codex-x86_64-unknown-linux-gnu app-server")).toBe(false); + expect(isCodexAppServerCommandLine("node ./opencodex/src/cli/index.ts start")).toBe(false); + expect(isCodexAppServerCommandLine("opencodex app-server")).toBe(false); + expect(isCodexAppServerCommandLine("/usr/bin/opencodex app-server")).toBe(false); + // Broad codex-* tools without a Rust target-triple shape must stay unmatched. + expect(isCodexAppServerCommandLine("codex-bridge app-server")).toBe(false); + expect(isCodexAppServerCommandLine("codex-helper-tool app-server")).toBe(false); + expect(isCodexAppServerCommandLine("codex exec 'hello'")).toBe(false); + expect(isCodexAppServerCommandLine("codex exec \"debug app-server behavior\"")).toBe(false); + expect(isCodexAppServerCommandLine("codex exec debug app-server behavior")).toBe(false); + expect(isCodexAppServerCommandLine("codex exec app-server")).toBe(false); + expect(isCodexAppServerCommandLine("node worker.js codex app-server")).toBe(false); + expect(isCodexAppServerCommandLine("something-app-server-without-codex-bin")).toBe(false); + expect(isCodexAppServerCommandLine("node worker.js codex-code-mode-host")).toBe(false); + expect(isCodexAppServerCommandLine("bash -c codex-code-mode-host")).toBe(false); + }); + + test("Windows candidate pre-filter matches quoted Install-path executables", () => { + expect(isWindowsCodexCandidateCommandLine( + "\"C:\\Program Files\\nodejs\\codex.exe\" app-server", + )).toBe(true); + expect(isWindowsCodexCandidateCommandLine( + "\"C:\\Program Files\\nodejs\\codex.cmd\" app-server --listen pipe", + )).toBe(true); + // Closing quote immediately after the executable basename. + expect(isWindowsCodexCandidateCommandLine("codex.exe\" app-server")).toBe(true); + expect(isWindowsCodexCandidateCommandLine("codex.cmd' app-server")).toBe(true); + expect(isWindowsCodexCandidateCommandLine("codex app-server")).toBe(true); + expect(isWindowsCodexCandidateCommandLine( + "\"C:\\Program Files\\Codex\\codex-x86_64-pc-windows-msvc.exe\" app-server", + )).toBe(true); + expect(isWindowsCodexCandidateCommandLine( + "C:\\Users\\a\\.codex\\bin\\codex-aarch64-pc-windows-msvc.exe app-server", + )).toBe(true); + // Stay narrow: incidental "opencodex" paths must not pay GetOwner. + expect(isWindowsCodexCandidateCommandLine( + "node C:\\Users\\a\\opencodex\\src\\cli\\index.ts start", + )).toBe(false); + expect(isWindowsCodexCandidateCommandLine("opencodex app-server")).toBe(false); + expect(isWindowsCodexCandidateCommandLine("hermes-codex-bridge-mcp")).toBe(false); + expect(isWindowsCodexCandidateCommandLine("hermes-codex-x86_64-pc-windows-msvc.exe")).toBe(false); + expect(isWindowsCodexCandidateCommandLine("codex-bridge app-server")).toBe(false); + }); + + test("listCodexAppServerProcesses filters injected snapshots", () => { + const matched = listCodexAppServerProcesses({ + listSnapshots: () => [ + { pid: 11, commandLine: "hermes-codex-bridge-mcp" }, + { pid: 22, commandLine: "codex app-server --listen unix://x" }, + { pid: 22, commandLine: "codex app-server --listen unix://x" }, + { pid: 33, commandLine: "codex-code-mode-host" }, + { pid: 44, commandLine: "codex exec hi" }, + { pid: 55, commandLine: "codex exec \"debug app-server behavior\"" }, + { pid: 66, commandLine: "node worker.js codex-code-mode-host" }, + ], + }); + expect(matched.map(process => process.pid)).toEqual([22, 33]); + }); + + test("restartCodexAppServers signals all first, shared wait deadline, no SIGKILL", () => { + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const waits: number[] = []; + const alive = new Set([100, 200]); + const snapshots = [ + { pid: 100, commandLine: "codex app-server" }, + { pid: 200, commandLine: "codex-code-mode-host" }, + ]; + let now = 1_000; + const result = restartCodexAppServers( + snapshots, + { + listSnapshots: () => snapshots, + kill: (pid, signal) => { + signals.push({ pid, signal }); + if (pid === 100) alive.delete(100); + }, + isAlive: pid => alive.has(pid), + waitExit: (pid, timeoutMs) => { + waits.push(timeoutMs); + now += 500; + return !alive.has(pid); + }, + now: () => now, + }, + ); + expect(signals).toEqual([ + { pid: 100, signal: "SIGTERM" }, + { pid: 200, signal: "SIGTERM" }, + ]); + // Shared deadline: second wait gets the remaining budget, not another full 2s. + expect(waits).toEqual([2_000, 1_500]); + expect(result.stopped).toEqual([100]); + expect(result.surviving).toEqual([200]); + expect(result.failed).toEqual([]); + }); + + test("restartCodexAppServers treats kill-throw on already-dead pid as stopped", () => { + const result = restartCodexAppServers( + [{ pid: 9, commandLine: "codex app-server" }], + { + listSnapshots: () => [{ pid: 9, commandLine: "codex app-server" }], + kill: () => { + throw new Error("ESRCH"); + }, + isAlive: () => false, + waitExit: () => true, + }, + ); + expect(result.stopped).toEqual([9]); + expect(result.failed).toEqual([]); + expect(result.surviving).toEqual([]); + }); + + test("restartCodexAppServers skips PIDs whose identity changed before signal", () => { + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const result = restartCodexAppServers( + [{ pid: 42, commandLine: "codex app-server" }], + { + listSnapshots: () => [{ pid: 42, commandLine: "vim README.md" }], + kill: (pid, signal) => { + signals.push({ pid, signal }); + }, + isAlive: () => true, + waitExit: () => false, + }, + ); + expect(signals).toEqual([]); + expect(result.stopped).toEqual([]); + expect(result.surviving).toEqual([]); + expect(result.failed).toEqual([]); + }); + + test("restartCodexAppServers skips recycled PID that matches a different Codex process", () => { + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const result = restartCodexAppServers( + [{ pid: 42, commandLine: "codex app-server --listen unix://old" }], + { + // Same PID, still Codex-shaped, but a different process identity. + listSnapshots: () => [{ pid: 42, commandLine: "codex-code-mode-host --session 9" }], + kill: (pid, signal) => { + signals.push({ pid, signal }); + }, + isAlive: () => true, + waitExit: () => false, + }, + ); + expect(signals).toEqual([]); + expect(result.stopped).toEqual([]); + expect(result.surviving).toEqual([]); + expect(result.failed).toEqual([]); + }); + + test("afterCatalogWriteHandleAppServers warns by default and restarts when requested", () => { + const errors: string[] = []; + const logs: string[] = []; + const snapshots = [{ pid: 7, commandLine: "codex app-server --listen unix://x" }]; + const io = { + listSnapshots: () => snapshots, + kill: () => {}, + isAlive: () => false, + waitExit: () => true, + }; + + const warned = afterCatalogWriteHandleAppServers({ + restart: false, + log: { log: line => logs.push(String(line)), error: line => errors.push(String(line)) }, + io, + }); + expect(warned.warned).toBe(true); + expect(errors[0]).toContain(formatStaleCodexAppServerWarning(warned.processes)); + expect(errors[0]).toContain("ocx sync --restart-codex"); + + const restarted = afterCatalogWriteHandleAppServers({ + restart: true, + log: { log: line => logs.push(String(line)), error: line => errors.push(String(line)) }, + io, + }); + expect(restarted.warned).toBe(false); + expect(restarted.restart?.stopped).toEqual([7]); + expect(logs.some(line => line.includes("Stopping Codex app-server"))).toBe(true); + }); +}); + +describe("CLI /api sync wiring for stale app-servers (#476)", () => { + const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); + const configRoutesSource = readFileSync( + join(import.meta.dir, "..", "src", "server", "management", "config-routes.ts"), + "utf8", + ); + + test("ocx sync only handles app-servers after a catalog/cache write and forwards --restart-codex", () => { + const syncCase = cliSource.slice(cliSource.indexOf('case "sync":'), cliSource.indexOf('case "v2":')); + expect(syncCase).toContain('args.slice(1).includes("--restart-codex")'); + expect(syncCase).toContain("syncResult.catalogWritten || syncResult.cacheSynced"); + expect(syncCase).toContain("afterCatalogWriteHandleAppServers"); + expect(syncCase).toContain("restart: restartCodex"); + expect(syncCase.indexOf("catalogWritten || syncResult.cacheSynced")) + .toBeLessThan(syncCase.indexOf("afterCatalogWriteHandleAppServers")); + // No-write path must not call the handler outside the gate. + const gatedBlock = syncCase.slice(syncCase.indexOf("if (syncResult.catalogWritten")); + expect(gatedBlock).toContain("afterCatalogWriteHandleAppServers"); + expect(syncCase.replace(gatedBlock, "")).not.toContain("afterCatalogWriteHandleAppServers"); + }); + + test("ocx sync-cache only handles app-servers after a successful models_cache write", () => { + const syncCacheCase = cliSource.slice( + cliSource.indexOf('case "sync-cache":'), + cliSource.indexOf('case "gui":'), + ); + expect(syncCacheCase).toContain("invalidateCodexModelsCache()"); + expect(syncCacheCase).toContain("if (invalidateCodexModelsCache())"); + expect(syncCacheCase).toContain("afterCatalogWriteHandleAppServers"); + expect(syncCacheCase.indexOf("if (invalidateCodexModelsCache())")) + .toBeLessThan(syncCacheCase.indexOf("afterCatalogWriteHandleAppServers")); + const gatedBlock = syncCacheCase.slice(syncCacheCase.indexOf("if (invalidateCodexModelsCache())")); + expect(gatedBlock).toContain("afterCatalogWriteHandleAppServers"); + expect(syncCacheCase.replace(gatedBlock, "")).not.toContain("afterCatalogWriteHandleAppServers"); + }); + + test("POST /api/sync attaches staleAppServerHint only after a write and never enumerates processes", () => { + const syncHandler = configRoutesSource.slice( + configRoutesSource.indexOf('url.pathname === "/api/sync"'), + configRoutesSource.indexOf('url.pathname === "/api/update/check"'), + ); + expect(syncHandler).toContain("attachStaleAppServerHint(result)"); + expect(syncHandler).not.toContain("listCodexAppServerProcesses"); + expect(syncHandler).not.toContain("afterCatalogWriteHandleAppServers"); + expect(STALE_CODEX_APP_SERVER_HINT).toContain("ocx sync --restart-codex"); + }); + + test("no-write sync responses omit staleAppServerHint", () => { + const omitted = attachStaleAppServerHint({ + ok: true, + catalogWritten: false, + cacheSynced: false, + message: "noop", + }); + expect(omitted).toEqual({ + ok: true, + catalogWritten: false, + cacheSynced: false, + message: "noop", + }); + expect("staleAppServerHint" in omitted).toBe(false); + }); + + test("successful catalog or cache writes include the shared staleAppServerHint", () => { + expect(attachStaleAppServerHint({ + ok: true, + catalogWritten: true, + cacheSynced: false, + }).staleAppServerHint).toBe(STALE_CODEX_APP_SERVER_HINT); + + expect(attachStaleAppServerHint({ + ok: true, + catalogWritten: false, + cacheSynced: true, + }).staleAppServerHint).toBe(STALE_CODEX_APP_SERVER_HINT); + + expect(attachStaleAppServerHint({ + ok: true, + catalogWritten: true, + cacheSynced: true, + }).staleAppServerHint).toBe(STALE_CODEX_APP_SERVER_HINT); + }); +}); + +describe("Windows Win32_Process owner enumeration (#476)", () => { + const processSource = readFileSync( + join(import.meta.dir, "..", "src", "codex", "app-server-processes.ts"), + "utf8", + ); + + test("PowerShell uses Invoke-CimMethod GetOwner and fails closed on ReturnValue", () => { + expect(processSource).toContain( + "Invoke-CimMethod -InputObject $_ -MethodName GetOwner -ErrorAction Stop", + ); + expect(processSource).toContain("$o.ReturnValue -ne 0"); + expect(processSource).toContain(".join(\"\\n\")"); + expect(processSource).not.toMatch(/\$o=\$_\.GetOwner\(\)/); + // Shared candidate regex (optional closing quote after basename) drives -match. + expect(processSource).toContain("WINDOWS_CODEX_BASENAME_CANDIDATE_RE.source"); + expect(processSource).toContain("powerShellSingleQuotedIgnoreCaseMatch"); + expect(WINDOWS_CODEX_BASENAME_CANDIDATE_RE.source).toContain("['\"]?"); + }); + + test.skipIf(process.platform !== "win32")( + "listWindowsSnapshots returns a current-user Codex-shaped process via real PowerShell enumeration", + () => { + // Keep a live process whose CommandLine contains a Codex basename token. + const child = spawn( + "powershell.exe", + [ + "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden", + "-Command", + "Start-Sleep -Seconds 25 # codex app-server integration-probe", + ], + { stdio: "ignore", windowsHide: true }, + ); + try { + expect(child.pid).toBeGreaterThan(1); + // Brief settle so Win32_Process can observe the child. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250); + const snapshots = listWindowsSnapshots(); + const match = snapshots.find(snapshot => snapshot.pid === child.pid); + expect(match).toBeDefined(); + expect(match!.owner).toMatch(/\\/); + expect(match!.commandLine.toLowerCase()).toContain("codex app-server"); + expect(snapshots.every(snapshot => (snapshot.owner?.trim().length ?? 0) > 0)).toBe(true); + } finally { + try { + child.kill(); + } catch { + /* already exited */ + } + } + }, + { timeout: 30_000 }, + ); +}); diff --git a/tests/codex-models-cache-invalidate.test.ts b/tests/codex-models-cache-invalidate.test.ts new file mode 100644 index 000000000..e013ac226 --- /dev/null +++ b/tests/codex-models-cache-invalidate.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { invalidateCodexModelsCache } from "../src/codex/catalog"; +import { afterCatalogWriteHandleAppServers } from "../src/codex/app-server-processes"; +import { refreshCodexModelCatalog } from "../src/codex/refresh"; +import { syncModelsToCodex } from "../src/codex/sync"; +import type { OcxConfig } from "../src/types"; + +const emptyConfig = { + port: 10100, + defaultProvider: "openai", + providers: {}, +} as OcxConfig; + +describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { + let previousCodexHome: string | undefined; + let previousOpenCodexHome: string | undefined; + let codexHome = ""; + let opencodexHome = ""; + + beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpenCodexHome = process.env.OPENCODEX_HOME; + codexHome = mkdtempSync(join(tmpdir(), "ocx-invalidate-codex-")); + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-invalidate-ocx-")); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + }); + + afterEach(() => { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpenCodexHome; + rmSync(codexHome, { recursive: true, force: true }); + rmSync(opencodexHome, { recursive: true, force: true }); + }); + + test("returns true and writes models_cache when catalog.json is readable", () => { + writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-5.5" }], + }, null, 2) + "\n"); + + expect(invalidateCodexModelsCache()).toBe(true); + const cachePath = join(codexHome, "models_cache.json"); + expect(existsSync(cachePath)).toBe(true); + const cache = JSON.parse(readFileSync(cachePath, "utf8")) as { + fetched_at: string; + models: Array<{ slug: string }>; + }; + expect(cache.fetched_at).toBe("2000-01-01T00:00:00Z"); + expect(cache.models).toEqual([{ slug: "gpt-5.5" }]); + }); + + test("returns false for a missing catalog and does not warn/restart app-servers", () => { + const errors: string[] = []; + const logs: string[] = []; + let listed = 0; + + expect(invalidateCodexModelsCache()).toBe(false); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); + + // Mirrors ocx sync-cache: only call the handler when invalidate wrote. + if (invalidateCodexModelsCache()) { + afterCatalogWriteHandleAppServers({ + restart: true, + log: { log: line => logs.push(String(line)), error: line => errors.push(String(line)) }, + io: { + listSnapshots: () => { + listed += 1; + return [{ pid: 7, commandLine: "codex app-server" }]; + }, + kill: () => {}, + isAlive: () => false, + waitExit: () => true, + }, + }); + } + + expect(listed).toBe(0); + expect(errors).toEqual([]); + expect(logs).toEqual([]); + }); + + test("returns false for invalid catalog JSON and does not warn/restart app-servers", () => { + writeFileSync(join(codexHome, "opencodex-catalog.json"), "{ not-json"); + const errors: string[] = []; + const logs: string[] = []; + let listed = 0; + + expect(invalidateCodexModelsCache()).toBe(false); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); + + if (invalidateCodexModelsCache()) { + afterCatalogWriteHandleAppServers({ + restart: false, + log: { log: line => logs.push(String(line)), error: line => errors.push(String(line)) }, + io: { + listSnapshots: () => { + listed += 1; + return [{ pid: 7, commandLine: "codex app-server" }]; + }, + }, + }); + } + + expect(listed).toBe(0); + expect(errors).toEqual([]); + expect(logs).toEqual([]); + }); + + test("ocx sync --restart-codex neither warns nor restarts when catalog exists but is unreadable", async () => { + // Non-default catalog path that exists on disk but cannot be read or rewritten as JSON. + // (A directory at the catalog path: existsSync true, load/write both fail.) + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "broken.json"\n', "utf8"); + mkdirSync(join(codexHome, "broken.json")); + + const syncResult = await syncModelsToCodex(10100, emptyConfig, null, { + refreshCodexModelCatalog, + injectCodexConfig: async () => ({ success: true, message: "injected" }), + currentExternalCodexModelProvider: () => null, + }); + + expect(syncResult.catalogExists).toBe(true); + expect(syncResult.catalogWritten).toBe(false); + expect(syncResult.cacheSynced).toBe(false); + + const errors: string[] = []; + const logs: string[] = []; + let listed = 0; + + // Mirrors `ocx sync --restart-codex`: only handle app-servers after a real write. + if (syncResult.catalogWritten || syncResult.cacheSynced) { + afterCatalogWriteHandleAppServers({ + restart: true, + log: { log: line => logs.push(String(line)), error: line => errors.push(String(line)) }, + io: { + listSnapshots: () => { + listed += 1; + return [{ pid: 7, commandLine: "codex app-server" }]; + }, + kill: () => {}, + isAlive: () => false, + waitExit: () => true, + }, + }); + } + + expect(listed).toBe(0); + expect(errors).toEqual([]); + expect(logs).toEqual([]); + }); + + test("ocx sync --restart-codex neither warns nor restarts when catalog JSON is malformed", async () => { + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "broken.json"\n', "utf8"); + writeFileSync(join(codexHome, "broken.json"), "{ not-json", "utf8"); + + // Real sync may rematerialize bundled content over a writable malformed file; the + // regression target is the CLI gate using catalogWritten, not bundled recovery. + const syncResult = await syncModelsToCodex(10100, emptyConfig, null, { + refreshCodexModelCatalog: async () => ({ + added: 0, + path: join(codexHome, "broken.json"), + catalogExists: true, + catalogWritten: false, + cacheSynced: false, + comboOmissions: [], + }), + injectCodexConfig: async () => ({ success: true, message: "injected" }), + currentExternalCodexModelProvider: () => null, + }); + + expect(syncResult.catalogExists).toBe(true); + expect(syncResult.catalogWritten).toBe(false); + expect(syncResult.cacheSynced).toBe(false); + + const errors: string[] = []; + const logs: string[] = []; + let listed = 0; + + if (syncResult.catalogWritten || syncResult.cacheSynced) { + afterCatalogWriteHandleAppServers({ + restart: true, + log: { log: line => logs.push(String(line)), error: line => errors.push(String(line)) }, + io: { + listSnapshots: () => { + listed += 1; + return [{ pid: 7, commandLine: "codex app-server" }]; + }, + kill: () => {}, + isAlive: () => false, + waitExit: () => true, + }, + }); + } + + expect(listed).toBe(0); + expect(errors).toEqual([]); + expect(logs).toEqual([]); + }); +}); diff --git a/tests/codex-refresh.test.ts b/tests/codex-refresh.test.ts index 057b7d118..876816b80 100644 --- a/tests/codex-refresh.test.ts +++ b/tests/codex-refresh.test.ts @@ -12,8 +12,16 @@ describe("Codex catalog refresh", () => { test("writes an expired Codex models cache whenever the materialized catalog exists", async () => { let invalidated = 0; const result = await refreshCodexModelCatalog(config, { - syncCatalogModels: async () => ({ added: 0, path: "/tmp/opencodex-catalog.json", comboOmissions: [] }), - invalidateCodexModelsCache: () => { invalidated += 1; }, + syncCatalogModels: async () => ({ + added: 0, + path: "/tmp/opencodex-catalog.json", + catalogWritten: true, + comboOmissions: [], + }), + invalidateCodexModelsCache: () => { + invalidated += 1; + return true; + }, existsSync: () => true, }); @@ -21,6 +29,7 @@ describe("Codex catalog refresh", () => { added: 0, path: "/tmp/opencodex-catalog.json", catalogExists: true, + catalogWritten: true, cacheSynced: true, comboOmissions: [], }); @@ -30,14 +39,58 @@ describe("Codex catalog refresh", () => { test("does not touch the cache when no Codex catalog can be materialized", async () => { let invalidated = 0; const result = await refreshCodexModelCatalog(config, { - syncCatalogModels: async () => ({ added: 0, path: "/tmp/missing-catalog.json", comboOmissions: [] }), - invalidateCodexModelsCache: () => { invalidated += 1; }, + syncCatalogModels: async () => ({ + added: 0, + path: "/tmp/missing-catalog.json", + catalogWritten: false, + comboOmissions: [], + }), + invalidateCodexModelsCache: () => { + invalidated += 1; + return true; + }, existsSync: () => false, }); expect(result.catalogExists).toBe(false); + expect(result.catalogWritten).toBe(false); expect(result.cacheSynced).toBe(false); expect(result.comboOmissions).toEqual([]); expect(invalidated).toBe(0); }); + + test("reports cacheSynced false when invalidate cannot write", async () => { + const result = await refreshCodexModelCatalog(config, { + syncCatalogModels: async () => ({ + added: 0, + path: "/tmp/opencodex-catalog.json", + catalogWritten: true, + comboOmissions: [], + }), + invalidateCodexModelsCache: () => false, + existsSync: () => true, + }); + + expect(result.catalogExists).toBe(true); + expect(result.catalogWritten).toBe(true); + expect(result.cacheSynced).toBe(false); + expect(result.comboOmissions).toEqual([]); + }); + + test("preserves catalogWritten false when the catalog path exists but sync did not write", async () => { + const result = await refreshCodexModelCatalog(config, { + syncCatalogModels: async () => ({ + added: 0, + path: "/tmp/broken-catalog.json", + catalogWritten: false, + comboOmissions: [], + }), + invalidateCodexModelsCache: () => false, + existsSync: () => true, + }); + + expect(result.catalogExists).toBe(true); + expect(result.catalogWritten).toBe(false); + expect(result.cacheSynced).toBe(false); + }); }); diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 1e8d8aed0..31d23ff4b 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -53,6 +53,7 @@ describe("GUI/CLI Codex sync backend", () => { added: 3, path: "/tmp/opencodex-catalog.json", catalogExists: true, + catalogWritten: true, cacheSynced: true, comboOmissions: [], }), @@ -72,6 +73,7 @@ describe("GUI/CLI Codex sync backend", () => { added: 3, catalogPath: "/tmp/opencodex-catalog.json", catalogExists: true, + catalogWritten: true, cacheSynced: true, message: "injected", }); @@ -93,6 +95,7 @@ describe("GUI/CLI Codex sync backend", () => { added: 1, path: "/tmp/opencodex-catalog.json", catalogExists: true, + catalogWritten: true, cacheSynced: true, comboOmissions: [omission], }), @@ -121,6 +124,7 @@ describe("GUI/CLI Codex sync backend", () => { added: 0, path: "/tmp/opencodex-catalog.json", catalogExists: true, + catalogWritten: true, cacheSynced: true, comboOmissions: [omission], }), @@ -192,6 +196,7 @@ describe("GUI/CLI Codex sync backend", () => { added: 0, catalogPath: null, catalogExists: false, + catalogWritten: false, cacheSynced: false, message: "external provider preserved", }); diff --git a/tests/injection-model-api.test.ts b/tests/injection-model-api.test.ts index b3bc8713d..7e345f18b 100644 --- a/tests/injection-model-api.test.ts +++ b/tests/injection-model-api.test.ts @@ -249,9 +249,9 @@ describe("/api/injection-model guidance kill switch + partial update", () => { await refreshCodexModelCatalog(config, { syncCatalogModels: async syncedConfig => { flagSeenBySync = syncedConfig.multiAgentGuidanceEnabled; - return { added: 0, path: join(tempHome!, "missing-catalog.json") }; + return { added: 0, path: join(tempHome!, "missing-catalog.json"), catalogWritten: false }; }, - invalidateCodexModelsCache: () => {}, + invalidateCodexModelsCache: () => false, existsSync: () => false, }); expect(flagSeenBySync).toBe(false);