diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 0328a59899..905ebf3adb 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -57,6 +57,10 @@ ocx status ocx gui # open the dashboard on the live port ``` +If you edit `config.json` while the proxy is running, `ocx status` and the dashboard warn that the +file no longer matches the loaded configuration. The proxy does not auto-reload it — restart to apply +the file (see [Web Dashboard](/guides/web-dashboard/#config-changes-while-the-proxy-is-running)). + ## 3. Use Codex Codex now talks to opencodex transparently: diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 67224fbcf2..644f8898dd 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -117,6 +117,15 @@ observation yet, loading, failed lookup with last-known values, and measured zer states. **Quota check completed** means the read settled—not that a passive observation became new or that every upstream measurement was refreshed. +### Config changes while the proxy is running + +Saves made through OpenCodex — the CLI, management API, or dashboard — and live mutations the proxy +adopts update the resident configuration while it runs; no restart is needed for those. Only when +`config.json` is edited directly on disk — by another process or by hand — does the running proxy +keep serving its earlier snapshot. In that case the dashboard shows a config-change warning on the +Overview instead of pretending the new settings are live, and `ocx status` prints the same warning; +restart (`ocx service restart`, or `ocx restart` for a foreground proxy) to apply the on-disk edits. + ## Model visibility The **Models** switches show final Codex visibility: a routed model is on only when its provider allowlist includes it (or no allowlist is set) and it is not disabled. Turning a model on reconciles both filters atomically; **All on** clears the provider allowlist so newly discovered models are also on. diff --git a/docs/pr-assets/config-divergence-card.png b/docs/pr-assets/config-divergence-card.png new file mode 100644 index 0000000000..71fe1fb132 Binary files /dev/null and b/docs/pr-assets/config-divergence-card.png differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1ee335120d..17d95caa26 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -215,6 +215,7 @@ export const de: Record = { "dash.providers": "Anbieter", "dash.tokens30d": "Tokens (30d)", "dash.coverage": "{pct} Abdeckung", + "dash.configDiverged": "config.json wurde nach dem Laden durch den Proxy extern geändert — kein automatisches Neuladen; Neustart zum Anwenden", "dash.mem.title": "Speicherbeobachtung", "dash.mem.hint": "Schreibgeschützte Laufzeitdiagnose. Beobachteter Speicher ist max(RSS, external, ArrayBuffers), damit Windows-Working-Set-Trimming gebundenen Speicher nicht versteckt.", "dash.mem.rss": "Resident Set (RSS)", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index addd46b549..d5460f3f74 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -226,6 +226,7 @@ export const en = { "dash.providers": "Providers", "dash.tokens30d": "Tokens (30d)", "dash.coverage": "{pct} coverage", + "dash.configDiverged": "config.json changed on disk after the proxy loaded it — no automatic reload; restart to apply", // memory observability card (read-only /api/system/memory) "dash.mem.title": "Memory observability", "dash.mem.hint": "Read-only runtime diagnostics. Observed memory is max(RSS, external, ArrayBuffers) so Windows working-set trimming does not hide committed retention.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 57b74dbe23..cf5115c816 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -217,6 +217,7 @@ export const fr: Record = { "dash.providers": "Fournisseurs", "dash.tokens30d": "Jetons (30 j)", "dash.coverage": "Couverture : {pct}", + "dash.configDiverged": "config.json a changé sur le disque après le chargement du proxy — pas de rechargement automatique ; redémarrez pour appliquer", "dash.mem.title": "Observabilité de la mémoire", "dash.mem.hint": "Diagnostics d’exécution en lecture seule. La mémoire observée correspond à max(RSS, external, ArrayBuffers), afin que la réduction de l’ensemble de travail Windows ne masque pas la rétention allouée.", "dash.mem.rss": "Ensemble résident (RSS)", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index fd15b0aeef..f6091dfd79 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -224,6 +224,7 @@ export const ja: Record = { "dash.providers": "プロバイダー", "dash.tokens30d": "トークン (30日)", "dash.coverage": "{pct} カバレッジ", + "dash.configDiverged": "config.json がプロキシ読み込み後に変更されました — 自動再読み込みはされません。再起動して適用", "dash.mem.title": "メモリ可観測性", "dash.mem.hint": "読み取り専用のランタイム診断。観測メモリは max(RSS, external, ArrayBuffers) で、Windows の working set trimming がコミット済み保持を隠さないようにします。", "dash.mem.rss": "常駐メモリ (RSS)", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f320b5785a..55c5a107cc 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -219,6 +219,7 @@ export const ko: Record = { "dash.providers": "프로바이더", "dash.tokens30d": "토큰 (30일)", "dash.coverage": "커버리지 {pct}", + "dash.configDiverged": "config.json이 프록시가 로드된 후 변경되었습니다 — 자동으로 다시 로드되지 않습니다. 변경 사항을 적용하려면 재시작하세요.", "dash.mem.title": "메모리 관찰", "dash.mem.hint": "읽기 전용 런타임 진단. 관측 메모리는 max(RSS, external, ArrayBuffers)라 Windows working set trimming이 커밋된 보존 메모리를 숨기지 못합니다.", "dash.mem.rss": "상주 메모리 (RSS)", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 6df77b0a79..e1e16047da 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -224,6 +224,7 @@ export const ru: Record = { "dash.providers": "Провайдеры", "dash.tokens30d": "Токены (30 дн.)", "dash.coverage": "{pct} покрытия", + "dash.configDiverged": "config.json изменился на диске после загрузки прокси — автоперезагрузки нет; перезапустите для применения", "dash.mem.title": "Наблюдение за памятью", "dash.mem.hint": "Диагностика среды выполнения только для чтения. Наблюдаемая память — max(RSS, external, ArrayBuffers), чтобы trimming рабочего набора Windows не скрывал удержанную память.", "dash.mem.rss": "Резидентная память (RSS)", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7b1f913c45..cb367e2bd2 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -225,6 +225,7 @@ export const tr: Record = { "dash.providers": "Sağlayıcılar", "dash.tokens30d": "Jetonlar (30 gün)", "dash.coverage": "%{pct} kapsam", + "dash.configDiverged": "config.json, proxy yüklendikten sonra diskte değişti — otomatik yeniden yükleme yok; uygulamak için yeniden başlatın", "dash.mem.title": "Bellek izlenebilirliği", "dash.mem.hint": "Salt okunur çalışma zamanı tanılamaları. Gözlemlenen bellek max(RSS, harici, ArrayBuffers) değeridir.", "dash.mem.rss": "Yerleşik küme (RSS)", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ec764ebda2..d726ec1622 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -120,6 +120,7 @@ export const zhTW: Record = { "dash.providers": "供應商", "dash.tokens30d": "Token (30 天)", "dash.coverage": "覆蓋率 {pct}", + "dash.configDiverged": "config.json 在代理載入後被修改 — 不會自動重載;請重啟後生效", "dash.mem.title": "記憶體可觀測性", "dash.mem.hint": "只讀執行時診斷。觀測記憶體為 max(RSS, external, ArrayBuffers),避免 Windows working set trimming 隱藏已提交的保留記憶體。", "dash.mem.rss": "常駐記憶體 (RSS)", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 2fca1a9e68..df68c0a2c8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -219,6 +219,7 @@ export const zh: Record = { "dash.providers": "提供方", "dash.tokens30d": "Token (30 天)", "dash.coverage": "覆盖率 {pct}", + "dash.configDiverged": "config.json 在代理加载后被修改 — 不会自动重载;请重启后生效", "dash.mem.title": "内存可观测性", "dash.mem.hint": "只读运行时诊断。观测内存为 max(RSS, external, ArrayBuffers),避免 Windows working set trimming 隐藏已提交的保留内存。", "dash.mem.rss": "常驻内存 (RSS)", diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index f6bb653452..ee3e5d10f0 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -76,6 +76,16 @@ export type DashboardSettingsPoll = { startupHealthSeed: SettingsData["startupHealth"] | null | undefined; }; +export type DashboardConfigStatusPoll = { + /** Null when the endpoint is unavailable (older server) or the fetch failed. */ + configDivergence: { + available: boolean; + residentVersion: string | null; + diskVersion: string | null; + diverged: boolean; + } | null; +}; + export type DashboardMaModePoll = { maMode: "v1" | "default" | "v2"; }; @@ -248,6 +258,51 @@ export async function fetchDashboardMaMode( } } +/** + * Resident-vs-disk config divergence from the running proxy. Older servers without + * /api/config/status report null; the dashboard must keep showing the normal state. + */ +export async function fetchDashboardConfigStatus( + apiBase: string, + signal: AbortSignal, +): Promise { + try { + const response = await fetch(`${apiBase}/api/config/status`, { signal, cache: "no-store" }); + if (!response.ok) return { configDivergence: null }; + const data = await response.json() as { + residentVersion?: unknown; + diskVersion?: unknown; + diverged?: unknown; + }; + // A status payload must actually carry both version fields; a malformed + // { diverged: true } body must not be mistaken for an available status. The + // CLI normalizer enforces the same presence rule (cli/status.ts). + if ( + !Object.prototype.hasOwnProperty.call(data, "residentVersion") + || !Object.prototype.hasOwnProperty.call(data, "diskVersion") + ) return { configDivergence: null }; + const residentVersion = data.residentVersion as string | null | undefined; + const diskVersion = data.diskVersion as string | null | undefined; + const diverged = data.diverged; + if ( + !(residentVersion === null || typeof residentVersion === "string") + || !(diskVersion === null || typeof diskVersion === "string") + || typeof diverged !== "boolean" + ) return { configDivergence: null }; + return { + configDivergence: { + available: true, + residentVersion, + diskVersion, + diverged, + }, + }; + } catch (error) { + if (isAbortError(error, signal)) throw error; + return { configDivergence: null }; + } +} + export async function fetchDashboardOverview( apiBase: string, signal: AbortSignal, diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx index 9cccc0e26c..74557f5f81 100644 --- a/gui/src/pages/dashboard-overview-head.tsx +++ b/gui/src/pages/dashboard-overview-head.tsx @@ -16,14 +16,15 @@ export function DashboardOverviewHead({ healthLoading, startupHealth, projectConfigWarnings, + configDivergence, maMode, maBusy, maHelpTriggerRef, maHelpOpen, setMaHelpOpen, - switchMaMode, + switchMaMode, maError, -}: Pick) { +}: Pick) { const t = useT(); const online = health?.status === "ok"; @@ -111,6 +112,14 @@ export function DashboardOverviewHead({ )} + {configDivergence?.available === true && configDivergence.diverged && ( +
+ +
+ )} {projectConfigWarnings.length > 0 && ( diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 6f84950ce1..de943625fe 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -18,10 +18,12 @@ import { fetchDashboardSettings, fetchDashboardSidecars, fetchDashboardUsage, + fetchDashboardConfigStatus, fetchProjectConfigDiagnostics, fetchStartupHealth, normalizeInjectionSelection, type DashboardEpochRefs, + type DashboardConfigStatusPoll, } from "./dashboard-core-poll"; import { usageSummary30dResourceKey } from "../usage-summary-resource"; import { @@ -124,6 +126,7 @@ export function useDashboardData(apiBase: string) { const [settingsSaving, setSettingsSaving] = useState(false); const [syncing, setSyncing] = useState(false); const [maMode, setMaMode] = useState(() => cachedMaMode ?? "default"); + const [configDivergence, setConfigDivergence] = useState(null); const [maBusy, setMaBusy] = useState(false); const [maError, setMaError] = useState(null); const [maHelpOpen, setMaHelpOpen] = useState(false); @@ -234,6 +237,13 @@ export function useDashboardData(apiBase: string) { { pollMs: 5000 }, ); + const configStatusPoll = useKeyedClientResource( + `dashboard-config-status:${apiBase}`, + [apiBase], + (signal) => fetchDashboardConfigStatus(apiBase, signal), + { pollMs: 15000 }, + ); + const sidecarPoll = useKeyedClientResource( `dashboard-sidecars:${apiBase}`, [apiBase], @@ -324,6 +334,11 @@ export function useDashboardData(apiBase: string) { writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, maModePoll.data.maMode); }, [maModePoll.data, apiBase]); + useEffect(() => { + if (configStatusPoll.data === undefined) return; + setConfigDivergence(configStatusPoll.data.configDivergence); + }, [configStatusPoll.data]); + // Derived — avoids setState-on-prop-change for the resolved flag. Cache / poll / optimistic // save (which writes the same cache key) all count as resolved for MA UI. const maModeResolved = maModePoll.data !== undefined || cachedMaMode !== null; @@ -774,6 +789,7 @@ export function useDashboardData(apiBase: string) { modelQuery, setModelQuery, expandedProviders, setExpandedProviders, health, startupHealth, providers, models, settings, sidecar, shadowCall, usage30d, + configDivergence, usageLoading: usagePoll.loading && !usage30d, healthLoading: overviewPoll.loading && !health, sidecarSaving, shadowCallSaving, modelsLoading, settingsSaving, syncing, diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index 6b7ec2c6a6..98d2ce0496 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -1,6 +1,10 @@ import { expect, test } from "bun:test"; +import { createElement, type ComponentProps } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { en } from "../src/i18n/en"; +import { I18nContext, type I18nContextValue } from "../src/i18n/shared"; import { normalizeInjectionSelection } from "../src/pages/dashboard-core-poll"; +import { DashboardOverviewHead } from "../src/pages/dashboard-overview-head"; import { PROJECT_CONFIG_DIAGNOSTICS_POLL_MS, beginPollEpoch, beginPollEpochs } from "../src/startup-health-ui"; test("project-config diagnostics poll cadence is owned by the shared constant", () => { @@ -180,6 +184,105 @@ test("fetchStartupHealth does not map abort into a sticky error status", async ( } }); +test("dashboard config-divergence status is fetched from /api/config/status and wired into the hook", async () => { + const core = await Bun.file(new URL("../src/pages/dashboard-core-poll.ts", import.meta.url)).text(); + const hook = await Bun.file(new URL("../src/pages/use-dashboard-data.ts", import.meta.url)).text(); + const head = await Bun.file(new URL("../src/pages/dashboard-overview-head.tsx", import.meta.url)).text(); + expect(core).toContain("/api/config/status"); + expect(core).toContain("export async function fetchDashboardConfigStatus"); + expect(hook).toContain("fetchDashboardConfigStatus(apiBase, signal)"); + expect(hook).toContain("configStatusPoll"); + expect(hook).toContain("configDivergence,"); + expect(head).toContain("configDivergence?.available === true"); + expect(head).toContain("dash.configDiverged"); +}); + +test("fetchDashboardConfigStatus normalizes diverged and tolerates old servers", async () => { + const { fetchDashboardConfigStatus } = await import("../src/pages/dashboard-core-poll"); + const originalFetch = globalThis.fetch; + try { + let capturedInit: RequestInit | undefined; + globalThis.fetch = (async (_input, init) => { + capturedInit = init; + return Response.json({ residentVersion: "a", diskVersion: "b", diverged: true }); + }) as typeof fetch; + await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({ + configDivergence: { available: true, residentVersion: "a", diskVersion: "b", diverged: true }, + }); + // The dashboard must not reuse a stale cached /api/config/status answer. + expect(capturedInit?.cache).toBe("no-store"); + globalThis.fetch = (async () => new Response("nope", { status: 404 })) as typeof fetch; + await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({ configDivergence: null }); + + // Malformed successful payloads must not be trusted as available data. + globalThis.fetch = (async () => Response.json({})) as typeof fetch; + await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({ configDivergence: null }); + // A diverged-only payload is still malformed: the version fields must be present. + globalThis.fetch = (async () => Response.json({ diverged: true })) as typeof fetch; + await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({ configDivergence: null }); + // One-missing payloads are malformed too (CLI normalizer presence rule). + globalThis.fetch = (async () => Response.json({ residentVersion: "a", diverged: true })) as typeof fetch; + await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({ configDivergence: null }); + globalThis.fetch = (async () => Response.json({ diskVersion: "b", diverged: true })) as typeof fetch; + await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({ configDivergence: null }); + globalThis.fetch = (async () => Response.json({ residentVersion: "a", diskVersion: "b", diverged: "yes" })) as typeof fetch; + await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({ configDivergence: null }); + // Explicit nulls on BOTH present fields remain a valid available status. + globalThis.fetch = (async () => Response.json({ residentVersion: null, diskVersion: null, diverged: false })) as typeof fetch; + await expect(fetchDashboardConfigStatus("http://test", new AbortController().signal)).resolves.toEqual({ + configDivergence: { available: true, residentVersion: null, diskVersion: null, diverged: false }, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("dashboard config-divergence alert renders from a fake /api/config/status response", async () => { + const { fetchDashboardConfigStatus } = await import("../src/pages/dashboard-core-poll"); + const originalFetch = globalThis.fetch; + const t: I18nContextValue["t"] = key => key; + const i18n: I18nContextValue = { locale: "en", setLocale: () => {}, t }; + const baseProps = { + locale: "en", + health: { status: "ok", version: "9.9.9", uptime: 60 }, + providers: [], + usage30d: null, + usageLoading: false, + healthLoading: false, + startupHealth: "protected", + projectConfigWarnings: [], + maMode: "v1", + maBusy: false, + maHelpTriggerRef: { current: null }, + maHelpOpen: false, + setMaHelpOpen: () => {}, + switchMaMode: async () => {}, + maError: null, + } satisfies ComponentProps; + try { + globalThis.fetch = (async () => Response.json({ residentVersion: "a", diskVersion: "b", diverged: true })) as typeof fetch; + const { configDivergence } = await fetchDashboardConfigStatus("http://test", new AbortController().signal); + const html = renderToStaticMarkup( + createElement(I18nContext.Provider, { value: i18n }, + createElement(DashboardOverviewHead, { ...baseProps, configDivergence })), + ); + expect(html).toContain("dash.configDiverged"); + expect(html).toContain("role=\"alert\""); + + // A settled (non-diverged) answer must not render the alert. + globalThis.fetch = (async () => Response.json({ residentVersion: "a", diskVersion: "a", diverged: false })) as typeof fetch; + const settled = await fetchDashboardConfigStatus("http://test", new AbortController().signal); + const settledHtml = renderToStaticMarkup( + createElement(I18nContext.Provider, { value: i18n }, + createElement(DashboardOverviewHead, { ...baseProps, configDivergence: settled.configDivergence })), + ); + expect(settledHtml).not.toContain("dash.configDiverged"); + } finally { + globalThis.fetch = originalFetch; + } +}); + + // The chip used to sit on the server's conservative placeholder until the next 30s tick, which is // why an unrelated action (refresh quota, tab hop) looked like the thing that fixed it. The probe // has to carry `stale` through so the caller can re-ask in seconds. diff --git a/src/cli/index.ts b/src/cli/index.ts index 06478ba3a3..892b6a042f 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -48,7 +48,7 @@ import { pendingTeardownPathFor, quarantinePendingTeardown, } from "../config/pending-teardown"; -import { collectStatus, unusedProxyWarningLines } from "./status"; +import { collectStatus, configDivergenceActionLine, unusedProxyWarningLines } from "./status"; import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; import { takeFlag } from "./runtime-api"; @@ -1374,6 +1374,17 @@ async function handleStatus() { console.log(` ⚠️ ${status.json.codexHome.warning}`); console.log(` Action: ${status.json.codexHome.action}`); } + if (status.json.configDivergence.available && status.json.configDivergence.diverged) { + if (status.json.configDivergence.diskVersion === null) { + console.log(" ⚠️ config.json was removed after the running proxy loaded it; the proxy is still serving the loaded settings."); + } else if (status.json.configDivergence.residentVersion === null) { + console.log(" ⚠️ config.json appeared after the running proxy started with defaults; restart to apply it."); + } else { + console.log(" ⚠️ config.json changed on disk after the running proxy loaded it; the proxy is still serving the older settings."); + } + const serviceInstalled = status.json.startup.serviceInstalled && !status.json.startup.serviceConflict; + console.log(configDivergenceActionLine(serviceInstalled)); + } console.log(` Catalog clamp: ${status.json.codexRuntime.catalogClamp.active ? "active" : "inactive"}`); if (status.json.codexRuntime.catalogClamp.removedEfforts.length > 0) { console.log(` Removed efforts: ${status.json.codexRuntime.catalogClamp.removedEfforts.join(", ")}`); diff --git a/src/cli/status.ts b/src/cli/status.ts index 02e39f7fbe..313a3035ad 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -1,6 +1,8 @@ import { durableBunRuntime } from "../lib/bun-runtime"; import { codexAutoStartEnabled, getConfigPath, readConfigDiagnostics } from "../config"; -import { getPidPath, readPid, readRuntimePort, type RuntimePortState } from "../config/process-state"; +import { getPidPath, readPid, readPidFileValue, readRuntimePort, type RuntimePortState } from "../config/process-state"; +import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability"; +import { fetchBoundLocalManagementRead } from "../server/local-management-read-client"; import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor"; import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; @@ -68,6 +70,7 @@ export type CliStatusJson = { catalogAgeSeconds?: number; credentialFile: "owned" | "missing" | "changed" | "unsafe"; }; + configDivergence: CliConfigDivergence; service: { summary: string }; codexShim: { summary: string }; codexPlugins: CodexPluginsDiagnostic; @@ -97,6 +100,53 @@ export type CliStatusJson = { versionSkew: VersionSkew; }; +export type CliConfigDivergence = { + /** True when the running proxy answered; false when it is down or unreadable. */ + available: boolean; + residentVersion: string | null; + diskVersion: string | null; + diverged: boolean; +}; + +/** + * Normalize the proxy's /api/config/status body into the CLI JSON contract. A + * missing/down proxy, a 404 from an older server, or a malformed body all collapse + * to available:false so the CLI never fabricates a divergence claim. + */ +export function normalizeConfigDivergence(body: unknown): CliConfigDivergence { + const unavailable: CliConfigDivergence = { + available: false, + residentVersion: null, + diskVersion: null, + diverged: false, + }; + if (!body || typeof body !== "object") return unavailable; + const record = body as { residentVersion?: unknown; diskVersion?: unknown; diverged?: unknown }; + const residentVersion = record.residentVersion; + const diskVersion = record.diskVersion; + const diverged = record.diverged; + if ( + !Object.prototype.hasOwnProperty.call(record, "residentVersion") + || !Object.prototype.hasOwnProperty.call(record, "diskVersion") + || !(residentVersion === null || typeof residentVersion === "string") + || !(diskVersion === null || typeof diskVersion === "string") + || typeof diverged !== "boolean" + ) return unavailable; + return { available: true, residentVersion, diskVersion, diverged }; +} + +/** + * Human-readable restart guidance for a divergent resident config. A foreground + * proxy (started with 'ocx start') is restarted with 'ocx restart'; the + * background-service path ('ocx service restart') only applies when an installed + * service is actually present. + */ +export function configDivergenceActionLine(serviceInstalled: boolean): string { + return serviceInstalled + ? " Action: restart the running proxy with 'ocx restart', or refresh the installed service with 'ocx service restart'." + : " Action: restart the running proxy with 'ocx restart'."; +} + export type CliStatusView = { json: CliStatusJson; proxyLabel: string; @@ -212,6 +262,24 @@ export async function collectStatus(): Promise { port: config.port, hostname: config.hostname, }); + // The resident config identity lives only in the proxy process. Ask it directly via + // the loopback management-read capability; a down/unreadable proxy means "unknown", + // never a fabricated divergence claim. + const configDivergence = await (async () => { + const unavailable = normalizeConfigDivergence(null); + if (!live) return unavailable; + try { + const read = await fetchBoundLocalManagementRead( + live, + LOCAL_MANAGEMENT_READ_PATHS.configStatus, + { timeoutMs: 4_000 }, + ); + if (read.kind !== "response" || !read.response.ok) return unavailable; + return normalizeConfigDivergence(await read.response.json()); + } catch { + return unavailable; + } + })(); const bunRuntime = durableBunRuntime(); const service = diagnoseService(); // A service can be registered and still not serve: the manager reports the job @@ -369,6 +437,7 @@ export async function collectStatus(): Promise { ...(clientConnection.catalogAgeSeconds !== undefined ? { catalogAgeSeconds: clientConnection.catalogAgeSeconds } : {}), credentialFile: clientConnection.token, }, + configDivergence, service: { summary: serviceSummary }, codexShim: { summary: codexShimSummary }, codexPlugins, diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 865711ba86..1262cd46f6 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -2,6 +2,7 @@ import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, + refreshResidentConfigIdentity, saveConfigPreservingClaudeCode, withConfigMutationLockSync, } from "../config"; @@ -1064,7 +1065,7 @@ export interface CodexAuthAccountDto { mainAccountHardLock?: MainAccountHardLockStatus; } -interface FreshPoolPlanUpdate { +export interface FreshPoolPlanUpdate { accountId: string; plan: string; credentialGeneration: number; @@ -1076,7 +1077,7 @@ interface FreshPoolPlanUpdate { * WHAM requests were in flight. Missing or malformed files fail closed: a read path must not * recreate a deleted config from the server's older in-memory snapshot. */ -function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void { +export function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void { if (updates.length === 0) return; let outcome: ReturnType>; try { @@ -1112,6 +1113,7 @@ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: Fresh throw error; } if (outcome.status === "unavailable") return; + let adoptedAny = false; for (const update of outcome.value) { // A replacement immediately after the durable commit is allowed to supersede the result, but // the long-lived object must never be updated from that stale generation. @@ -1121,8 +1123,14 @@ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: Fresh liveAccount.plan = update.plan; liveAccount.planSource = "wham"; liveAccount.planCredentialGeneration = update.credentialGeneration; + adoptedAny = true; } } + // The long-lived runtime config now serves the persisted plan, so the resident + // divergence identity must follow it (the disk-first mutation skipped the refresh). + // Only a committed write may re-anchor: an unchanged refresh must not replace the + // raw-byte digest with the normalized projection (would fabricate divergence). + if (adoptedAny && outcome.status === "committed") refreshResidentConfigIdentity(runtimeConfig); } diff --git a/src/codex/plan-from-token.ts b/src/codex/plan-from-token.ts index be2585ec4e..10d8934c79 100644 --- a/src/codex/plan-from-token.ts +++ b/src/codex/plan-from-token.ts @@ -2,6 +2,7 @@ import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, + refreshResidentConfigIdentity, } from "../config"; import type { CodexAccount, OcxConfig } from "../types"; import { isSelectableCodexPoolAccount, isValidCodexAccountId } from "./account-id"; @@ -90,6 +91,7 @@ function persistJwtPlanUpdates(runtimeConfig: OcxConfig, updates: FreshPoolPlanU throw error; } if (outcome.status === "unavailable") return; + let adoptedAny = false; for (const update of outcome.value) { if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue; const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId); @@ -98,8 +100,14 @@ function persistJwtPlanUpdates(runtimeConfig: OcxConfig, updates: FreshPoolPlanU liveAccount.planSource = "jwt"; liveAccount.planCredentialGeneration = update.credentialGeneration; appliedJwtPlans.set(update.accountId, update.plan); + adoptedAny = true; } } + // The long-lived runtime config now serves the persisted plan, so the resident + // divergence identity must follow it (the disk-first mutation skipped the refresh). + // Only a committed write may re-anchor: an unchanged refresh must not replace the + // raw-byte digest with the normalized projection (would fabricate divergence). + if (adoptedAny && outcome.status === "committed") refreshResidentConfigIdentity(runtimeConfig); } /** diff --git a/src/config.ts b/src/config.ts index fdcda9547c..ecf82942d8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2162,17 +2162,34 @@ function warnInheritedFastWireConflicts(configPath: string, config: OcxConfig): * until a valid config or a genuinely missing file is observed. A partially- * invalid config is merged with defaults so providers and pool accounts survive. */ -export function loadConfig(): OcxConfig { +export function loadConfig(options?: { captureResident?: boolean }): OcxConfig { const dir = getConfigDir(); const configPath = getConfigPath(); hardenConfigDir(); hardenExistingSecret(configPath); hardenExistingSecret(join(dir, "auth.json")); if (!existsSync(configPath)) { + // No file means the process is serving defaults, not the previously loaded bytes; + // a reload after deletion must not retain the old resident identity. + if (options?.captureResident) { + residentConfigSha256 = null; + residentConfigSource = "default"; + } return withRefreshedCostOverlays(getDefaultConfig()); } try { - const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); + // Keep the pre-strip bytes: the resident identity must hash exactly what the + // process parsed, including a leading BOM, so it matches the admission digest. + // Hash the RAW bytes (not the decoded string): decoding can map malformed + // UTF-8 sequences onto replacement characters, which would make the digest + // disagree with the file's true byte SHA-256 and misreport divergence. + const fileBytes = readFileSync(configPath); + if (options?.captureResident) { + residentConfigSha256 = createHash("sha256").update(fileBytes).digest("hex"); + residentConfigSource = "file"; + } + const rawWithBom = fileBytes.toString("utf-8"); + const raw = rawWithBom.replace(/^\uFEFF/, ""); const parsed = JSON.parse(raw); sanitizeAliasesForLoad(parsed); sanitizeModelDisplayNamesForLoad(parsed); @@ -2252,9 +2269,21 @@ export function loadConfig(): OcxConfig { } // Merge couldn't fix it — truly broken config warnAndBackupInvalidConfig(configPath, result.error); + if (options?.captureResident) { + residentConfigSha256 = null; + // The process serves getDefaultConfig(), not this file. Record a + // defaults-backed resident so a later repair is reported as divergent. + residentConfigSource = "default"; + } return getDefaultConfig(); } catch (error) { warnAndBackupInvalidConfig(configPath, error); + if (options?.captureResident) { + residentConfigSha256 = null; + // The process serves getDefaultConfig(), not this file. Record a + // defaults-backed resident so a later repair is reported as divergent. + residentConfigSource = "default"; + } return getDefaultConfig(); } } @@ -2847,9 +2876,77 @@ export function readConfigAdmissionSnapshot(): ConfigAdmissionSnapshot { }; } +export interface ConfigDivergenceStatus { + /** SHA-256 of the config bytes the running process last loaded or wrote. */ + residentVersion: string | null; + /** SHA-256 of the current config.json bytes on disk (null when unreadable). */ + diskVersion: string | null; + /** True when a restart would serve something different: bytes changed, a file-backed + * resident lost its file, or a defaults-backed resident gained a config.json. */ + diverged: boolean; +} + +/** + * Compare the running process's resident config identity to the current file. A CLI + * process without an armed live config reports `residentVersion: null` and never claims + * divergence; only the proxy process can answer this truthfully. + */ +export function readConfigDivergenceStatus(): ConfigDivergenceStatus { + const admission = readConfigAdmissionSnapshot(); + const diskVersion = admission.kind === "read" ? admission.contentSha256 : null; + const diskFile = admission.kind === "read"; + let diverged = false; + if (residentConfigSource === "file" && residentConfigSha256 !== null) { + // A file-backed resident diverges when the file vanished/unreadable or its + // bytes differ: a restart would serve something else. + diverged = !diskFile || diskVersion !== residentConfigSha256; + } else if (residentConfigSource === "default") { + // A defaults-backed resident diverges when a config.json now exists. + diverged = diskFile; + } + return { + residentVersion: residentConfigSha256, + diskVersion, + diverged, + }; +} + const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite"; const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const; let warnedConfigMutationDirectoryAcl = false; +// SHA-256 of the config bytes the running process last loaded or wrote (armed at server +// start, refreshed on every changed in-process save). Compared to the current file digest +// by `ocx status` / the dashboard to warn when config.json changed without a reload. +// Only the server admission load (loadConfig({ captureResident: true })) arms this +// identity; incidental loads during runtime must not wipe it. +let residentConfigSha256: string | null = null; +/** Whether the resident identity came from a real config.json or from serving defaults. */ +let residentConfigSource: "file" | "default" | null = null; + +/** Test-only seam: reset the resident identity so isolated test files cannot leak state. */ +export function setResidentConfigSha256ForTests(value: string | null): void { + residentConfigSha256 = value; + residentConfigSource = value === null ? null : "file"; +} + +/** + * Re-anchor the resident identity to the exact serialized served snapshot. + * + * Disk-first mutations (\`mutatePersistedConfig\`) deliberately skip the resident + * refresh because the server has not adopted their document yet. Adopters that + * then mirror the committed change into the long-lived config must call this so + * \`ocx status\` / the dashboard stop claiming divergence once disk matches what + * the running process actually serves. No-op when no identity is armed (CLI + * processes cannot truthfully claim divergence). + */ +export function refreshResidentConfigIdentity(config: OcxConfig): void { + if (residentConfigSha256 === null) return; + residentConfigSource = "file"; + // Hash the projected provenance form (sorted deletion keys), matching the + // exact bytes persistConfigUnlocked commits, so an adopter's refresh cannot + // diverge merely because the runtime object kept an unsorted array order. + residentConfigSha256 = createHash("sha256").update(JSON.stringify(projectConfigRebaseProvenance(config), null, 2) + "\n").digest("hex"); +} export class ConfigMutationLockError extends Error { readonly code = "CONFIG_MUTATION_LOCK_UNAVAILABLE"; @@ -3075,7 +3172,25 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync * cost-overlay registry from the persisted config so runtime estimates follow * every save path. */ -function persistConfigUnlocked(config: OcxConfig): boolean { +export type PersistConfigUnlockedOptions = { + /** + * The served snapshot whose serialization owns the resident divergence digest. + * Defaults to `config`; pass the pre-binding live projection when the persisted + * document deliberately carries a different desired next-start binding. + */ + servedSnapshot?: OcxConfig; + /** + * Set false for disk-first writers whose result the live server has not adopted + * (for example mutatePersistedConfig): the resident identity must stay bound to + * what the running process actually serves. + */ + refreshResident?: boolean; +}; + +function persistConfigUnlocked( + config: OcxConfig, + options: PersistConfigUnlockedOptions = {}, +): boolean { const configPath = getConfigPath(); const rawBeforeWrite = readRawConfigJson(); const clientPersistenceError = failClosedClientPersistenceError(rawBeforeWrite, config); @@ -3096,15 +3211,39 @@ function persistConfigUnlocked(config: OcxConfig): boolean { } catch (error) { if (!isMissingPathError(error)) throw error; } + // Served snapshot first so the unchanged branch can compare it against the + // persisted bytes too (see the re-anchor guard below). + const servedSnapshot = options.servedSnapshot ?? config; // Keep the runtime overlay registry in sync with EVERY persist path, // including byte-identical saves: a cooperating CLI process may have written // the same bytes (e.g. before a proxy notification), and Logs/Usage must // adopt the overlay without waiting for a changed save or restart. if (unchanged) { + // A cooperating writer can canonicalize the served document to the exact bytes + // this save would produce. When the served snapshot itself serializes to those + // bytes, the stale resident digest can be re-anchored; when disk-only preserved + // rows or a next-start binding make the served serialization differ from the + // file, divergence must stay visible. + const servedBytes = JSON.stringify(projectConfigRebaseProvenance(servedSnapshot), null, 2) + "\n"; + if (servedBytes === bytes && options.refreshResident !== false) { + residentConfigSource = "file"; + residentConfigSha256 = createHash("sha256").update(servedBytes).digest("hex"); + } refreshUserCostOverlays(persisted); return false; } atomicWriteFile(configPath, bytes); + // Keep the resident identity bound to the SERVED document, not the merged bytes: + // withPreservedDiskOnlyProviders() folds hand-added disk rows into the file, but + // those rows are still unrouted until the process reloads. Hashing the pre-merge + // config keeps diverged=true for exactly that gap (and matches the file hash when + // there is nothing disk-only to preserve). + if (options.refreshResident !== false) { + residentConfigSource = "file"; + // Same projection as the committed bytes: unsorted deletedTopLevelKeys in the + // runtime object must not produce a resident digest different from the file. + residentConfigSha256 = createHash("sha256").update(JSON.stringify(projectConfigRebaseProvenance(servedSnapshot), null, 2) + "\n").digest("hex"); + } // For changed saves, refresh only AFTER the write succeeded so a failed // write cannot leave estimates reflecting configuration never persisted. refreshUserCostOverlays(persisted); @@ -3209,7 +3348,9 @@ export function mutatePersistedConfig( commitBase.diagnostics.config, projectConfigRebaseProvenance(confirmedConfig), ); - if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); + // Disk-first mutation: the live server has not adopted this snapshot, so the + // resident divergence identity must stay bound to the served config. + if (persistConfigUnlocked(projected, { refreshResident: false })) bumpGenerationForCooperatingConfigWrite(); return { status: "committed", value: confirmed.value }; } return { status: "unavailable", reason: "conflict" }; @@ -3294,6 +3435,10 @@ const persistedLiveServerBinding = new WeakMap { expect(statusFacade).not.toHaveProperty("checkProxyHealth"); }); + test("divergent foreground proxy without an installed service recommends ocx restart", () => { + // 'ocx service restart' maps to repairService(), which requires an installed + // background service; a foreground proxy must be told to use 'ocx restart'. + expect(configDivergenceActionLine(false)).toContain("'ocx restart'"); + expect(configDivergenceActionLine(false)).not.toContain("ocx service restart"); + // The installed-service path may still mention the service restart. + expect(configDivergenceActionLine(true)).toContain("'ocx restart'"); + expect(configDivergenceActionLine(true)).toContain("'ocx service restart'"); + }); + test("status --json prints valid read-only diagnostics without secrets", () => { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-status-json-")); try { @@ -87,6 +105,12 @@ describe("CLI status JSON", () => { credentialFile?: unknown; catalog?: unknown; }; + configDivergence?: { + available?: unknown; + residentVersion?: unknown; + diskVersion?: unknown; + diverged?: unknown; + }; service?: { summary?: unknown }; codexShim?: { summary?: unknown }; codexRuntime?: { @@ -132,6 +156,14 @@ describe("CLI status JSON", () => { expect(parsed.defaultProvider).toBe("openai"); expect(parsed.config?.source).toBe("file"); expect(parsed.config?.error).toBeNull(); + // No live proxy: the CLI JSON must still carry the configDivergence contract + // and report it as unavailable rather than omitting or fabricating a claim. + expect(parsed.configDivergence).toEqual({ + available: false, + residentVersion: null, + diskVersion: null, + diverged: false, + }); expect(typeof parsed.service?.summary).toBe("string"); expect(typeof parsed.codexShim?.summary).toBe("string"); expect(typeof parsed.codexRuntime?.path).toBe("string"); @@ -385,6 +417,50 @@ describe("CLI status JSON", () => { expect(target.healthUrl).toBe("http://127.0.0.1:10100/healthz"); expect(target.dashboardUrl).toBe("http://localhost:10100/"); }); + + test("normalizeConfigDivergence maps a missing/404/empty body to unavailable", () => { + const unavailable = { + available: false, + residentVersion: null, + diskVersion: null, + diverged: false, + }; + // A 404 from an older server yields a non-ok response (body never parsed); an + // empty object body is the malformed-success case. Both must collapse the same way. + expect(normalizeConfigDivergence(undefined)).toEqual(unavailable); + expect(normalizeConfigDivergence(null)).toEqual(unavailable); + expect(normalizeConfigDivergence({})).toEqual(unavailable); + expect(normalizeConfigDivergence("nope")).toEqual(unavailable); + // Malformed field types must not be trusted as available data. + expect(normalizeConfigDivergence({ residentVersion: "a", diskVersion: "b", diverged: "yes" })).toEqual(unavailable); + // A response omitting either version field must never fabricate an available + // "started with defaults" state (e.g. diskVersion-only input). + expect(normalizeConfigDivergence({ diskVersion: "sha-b", diverged: true })).toEqual(unavailable); + expect(normalizeConfigDivergence({ residentVersion: "sha-a", diverged: true })).toEqual(unavailable); + }); + + test("normalizeConfigDivergence keeps a valid proxy answer available", () => { + expect(normalizeConfigDivergence({ + residentVersion: "sha-a", + diskVersion: "sha-b", + diverged: true, + })).toEqual({ + available: true, + residentVersion: "sha-a", + diskVersion: "sha-b", + diverged: true, + }); + expect(normalizeConfigDivergence({ + residentVersion: null, + diskVersion: null, + diverged: false, + })).toEqual({ + available: true, + residentVersion: null, + diskVersion: null, + diverged: false, + }); + }); }); /** diff --git a/tests/codex-integration/codex-plan.test.ts b/tests/codex-integration/codex-plan.test.ts index df02a8167e..b4f92130cd 100644 --- a/tests/codex-integration/codex-plan.test.ts +++ b/tests/codex-integration/codex-plan.test.ts @@ -2,13 +2,21 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { reconcileFreshPoolAccountPlans, type FreshPoolPlanUpdate } from "../../src/codex/auth-api"; import { getMainAccountPlan, setMainAccountPlan } from "../../src/codex/main-account"; import { extractChatgptPlanType } from "../../src/codex/plan"; import { reconcileCodexPlansFromTokens, resetJwtPlanNotesForTests, } from "../../src/codex/plan-from-token"; -import { loadConfig, saveConfig } from "../../src/config"; +import { resetPreservedDiskOnlyProvidersForTests } from "../../src/usage/user-cost-overlays"; +import { + armClaudeCodeBaseline, + loadConfig, + readConfigDivergenceStatus, + saveConfig, + setResidentConfigSha256ForTests, +} from "../../src/config"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -34,6 +42,8 @@ beforeEach(() => { mkdirSync(TEST_CODEX_HOME, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; process.env.CODEX_HOME = TEST_CODEX_HOME; + setResidentConfigSha256ForTests(null); + resetPreservedDiskOnlyProvidersForTests(); setMainAccountPlan(null); resetJwtPlanNotesForTests(); }); @@ -90,6 +100,67 @@ describe("reconcileCodexPlansFromTokens", () => { expect(loadConfig().codexAccounts?.[0]?.plan).toBe("pro"); }); + test("re-anchors the resident divergence identity after adopting a JWT plan write", () => { + const config: OcxConfig = { + port: 10100, + providers: {}, + defaultProvider: "openai", + codexAccounts: [{ id: "pool-jwt-plan", email: "pool@example.test", plan: "free", isMain: false }], + }; + saveConfig(config); + saveCodexAccountCredential("pool-jwt-plan", { + accessToken: chatgptPlanJwt("pro", "acct-pool-jwt-plan"), + refreshToken: "refresh-pool-jwt-plan", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-pool-jwt-plan", + }); + + // The server armed its resident identity at admission and reuses the same + // long-lived object for runtime reconciliation. + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + expect(readConfigDivergenceStatus().diverged).toBe(false); + + reconcileCodexPlansFromTokens(armed); + + expect(armed.codexAccounts?.[0]?.plan).toBe("pro"); + const status = readConfigDivergenceStatus(); + // The plan write was adopted into the served snapshot, so the identity must + // follow it instead of reporting a persistent false-positive divergence. + expect(status.diverged).toBe(false); + expect(status.residentVersion).toBe(status.diskVersion); + }); + + test("a no-op JWT plan write keeps the resident identity in sync on noncanonical bytes", () => { + const config: OcxConfig = { + port: 10100, + providers: {}, + defaultProvider: "openai", + codexAccounts: [{ + id: "pool-jwt-noop", email: "noop@example.test", plan: "pro", + planSource: "jwt", planCredentialGeneration: 1, isMain: false, + }], + }; + saveCodexAccountCredential("pool-jwt-noop", { + accessToken: chatgptPlanJwt("pro", "acct-pool-jwt-noop"), + refreshToken: "refresh-pool-jwt-noop", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-pool-jwt-noop", + }); + // Noncanonical raw bytes: pretty-printed with a trailing newline. A re-anchor + // to the normalized projection would fabricate a divergence for a no-op write. + writeFileSync(join(TEST_DIR, "config.json"), JSON.stringify(config, null, 2) + "\n"); + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + expect(readConfigDivergenceStatus().diverged).toBe(false); + + reconcileCodexPlansFromTokens(armed); + + const status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(false); + expect(status.residentVersion).toBe(status.diskVersion); + }); + test("leaves a non-JWT pool credential's stored plan alone", () => { const config: OcxConfig = { port: 10100, @@ -127,6 +198,75 @@ describe("getMainAccountPlan JWT fallback", () => { }); describe("WHAM-wins plan provenance gate (release-audit fix)", () => { + test("WHAM plan adoption re-anchors the resident config identity", () => { + const config: OcxConfig = { + port: 10100, + providers: {}, + defaultProvider: "openai", + codexAccounts: [{ + id: "pool-wham-reanchor", email: "reanchor@example.test", plan: "pro", + planSource: "wham", planCredentialGeneration: 1, isMain: false, + }], + }; + saveConfig(config); + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + expect(readConfigDivergenceStatus().diverged).toBe(false); + saveCodexAccountCredential("pool-wham-reanchor", { + accessToken: chatgptPlanJwt("plus", "acct-pool-wham-reanchor"), + refreshToken: "refresh-pool-wham-reanchor", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-pool-wham-reanchor", + }); + // A WHAM refresh persists plan=plus and adopts it into the long-lived config; + // without re-anchoring, status would keep claiming a divergence. + const updates: FreshPoolPlanUpdate[] = [{ + accountId: "pool-wham-reanchor", plan: "plus", credentialGeneration: 1, + }]; + reconcileFreshPoolAccountPlans(armed, updates); + expect(armed.codexAccounts?.[0]?.plan).toBe("plus"); + const status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(false); + expect(status.residentVersion).toBe(status.diskVersion); + }); + + test("a no-op WHAM refresh keeps the resident identity in sync on noncanonical bytes", () => { + const config: OcxConfig = { + port: 10100, + providers: {}, + defaultProvider: "openai", + codexAccounts: [{ + id: "pool-wham-noop", email: "noop@example.test", plan: "pro", + planSource: "wham", planCredentialGeneration: 1, isMain: false, + }], + }; + // Noncanonical raw bytes: pretty-printed with a trailing newline. A re-anchor + // to the normalized projection would fabricate a divergence for a no-op write. + writeFileSync(join(TEST_DIR, "config.json"), JSON.stringify(config, null, 2) + "\n"); + // beforeEach removed TEST_DIR, so seed the generation-1 credential the WHAM + // freshness gate requires (matching the committed WHAM fixture setup). + saveCodexAccountCredential("pool-wham-noop", { + accessToken: chatgptPlanJwt("pro", "acct-pool-wham-noop"), + refreshToken: "refresh-pool-wham-noop", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-pool-wham-noop", + }); + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + expect(readConfigDivergenceStatus().diverged).toBe(false); + + // Plan and provenance already match: the WHAM refresh must stay write-free and + // must NOT re-anchor the resident digest to the normalized projection. + const updates: FreshPoolPlanUpdate[] = [{ + accountId: "pool-wham-noop", plan: "pro", credentialGeneration: 1, + }]; + reconcileFreshPoolAccountPlans(armed, updates); + + const status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(false); + expect(status.residentVersion).toBe(status.diskVersion); + }); + test("a same-generation JWT cannot overwrite a WHAM-sourced plan", () => { const config: OcxConfig = { port: 10100, @@ -198,8 +338,6 @@ describe("WHAM-wins plan provenance gate (release-audit fix)", () => { expect(loadConfig().codexAccounts?.[0]?.plan).toBe("pro"); }); }); - - describe("rotated-JWT plan reconciliation across propagated aliases (#2892 gap 3)", () => { test("a plan-changing rotated JWT reconciles the alias, not just the refresh owner", async () => { const { getValidCodexToken, readCodexAccountRecord } = await import("../../src/codex/account-store"); diff --git a/tests/config-divergence.test.ts b/tests/config-divergence.test.ts new file mode 100644 index 0000000000..175caf57e7 --- /dev/null +++ b/tests/config-divergence.test.ts @@ -0,0 +1,418 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { + armClaudeCodeBaseline, + getConfigPath, + loadConfig, + mutatePersistedConfig, + readConfigDivergenceStatus, + reconcileLiveConfigFromDisk, + refreshResidentConfigIdentity, + saveConfig, + saveConfigPreservingClaudeCode, + setResidentConfigSha256ForTests, +} from "../src/config"; +import { handleManagementAPI } from "../src/server/management-api"; +import { reconcileUserCostOverlaysFromDisk } from "../src/usage/user-cost-overlay-reconciler"; +import type { OcxConfig } from "../src/types"; + +let testRoot = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testRoot = mkdtempSync(join(import.meta.dir, ".tmp-config-divergence-")); + process.env.OPENCODEX_HOME = testRoot; + setResidentConfigSha256ForTests(null); +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(testRoot, { recursive: true, force: true }); +}); + +function config(port = 10100): OcxConfig { + return { + port, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + apiKey: "sk-secret", + }, + }, + } as unknown as OcxConfig; +} + +describe("config divergence status", () => { + test("unarmed process reports no divergence (resident unknown)", () => { + // A process that never armed a live baseline nor saved (e.g. `ocx status`) has no + // resident identity to compare; it must never claim divergence. + writeFileSync(getConfigPath(), JSON.stringify(config(), null, 2) + "\n"); + const status = readConfigDivergenceStatus(); + expect(status.residentVersion).toBeNull(); + expect(status.diskVersion).not.toBeNull(); + expect(status.diverged).toBe(false); + }); + + test("a missing config file after arming is divergent and a different restore is caught", async () => { + const bytes = JSON.stringify(config(), null, 2) + "\n"; + writeFileSync(getConfigPath(), bytes); + const loaded = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(loaded); + expect(readConfigDivergenceStatus().residentVersion).not.toBeNull(); + // Deleting a file-backed config diverges: a restart would serve defaults, so + // the read must report it AND keep the resident identity (the GUI polls every 15s). + rmSync(getConfigPath()); + let status = readConfigDivergenceStatus(); + expect(status.diskVersion).toBeNull(); + expect(status.diverged).toBe(true); + expect(status.residentVersion).not.toBeNull(); + // Restoring the original bytes: still in sync. + writeFileSync(getConfigPath(), bytes); + status = readConfigDivergenceStatus(); + expect(status.diskVersion).not.toBeNull(); + expect(status.diverged).toBe(false); + // Restoring DIFFERENT bytes without a reload must flip diverged: the running + // process still serves the admission snapshot. + writeFileSync(getConfigPath(), JSON.stringify(config(20200), null, 2) + "\n"); + status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(true); + }); + + test("a config that appears after a defaults-backed start is divergent", () => { + // No config.json exists yet: the server arms a defaults-backed resident. + const loaded = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(loaded); + expect(readConfigDivergenceStatus().diverged).toBe(false); + // A config.json now appears: a restart would serve it instead of defaults. + writeFileSync(getConfigPath(), JSON.stringify(config(), null, 2) + "\n"); + const status = readConfigDivergenceStatus(); + expect(status.residentVersion).toBeNull(); + expect(status.diskVersion).not.toBeNull(); + expect(status.diverged).toBe(true); + }); + + test("a corrupt config repaired after arming a defaults-backed resident is divergent", () => { + // A broken config falls back to getDefaultConfig(); the resident must be + // recorded as defaults-backed so a later repair (which a restart would now + // serve) is reported instead of silently staying "in sync" forever. + writeFileSync(getConfigPath(), "{ this is not valid json !!"); + const loaded = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(loaded); + // The operator repairs config.json: a restart would now serve the real file. + writeFileSync(getConfigPath(), JSON.stringify(config(), null, 2) + "\n"); + const status = readConfigDivergenceStatus(); + expect(status.residentVersion).toBeNull(); + expect(status.diskVersion).not.toBeNull(); + expect(status.diverged).toBe(true); + }); + + test("external disk edit after arming flips diverged", async () => { + saveConfig(config()); + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + expect(readConfigDivergenceStatus().diverged).toBe(false); + // Simulate an external editor / another process rewriting config.json. + const path = getConfigPath(); + const current = JSON.parse(await Bun.file(path).text()); + current.port = 20200; + writeFileSync(path, JSON.stringify(current, null, 2) + "\n"); + const status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(true); + expect(status.residentVersion).not.toBe(status.diskVersion); + }); + + test("an incidental loadConfig call does not wipe the armed divergence", () => { + saveConfig(config()); + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + expect(readConfigDivergenceStatus().diverged).toBe(false); + // Simulate an external editor / another process rewriting config.json. + const path = getConfigPath(); + const current = JSON.parse(readFileSync(path, "utf8")) as { port: number }; + current.port = 20200; + writeFileSync(path, JSON.stringify(current, null, 2) + "\n"); + expect(readConfigDivergenceStatus().diverged).toBe(true); + // Runtime components re-read the file (sync, catalog, injection) without capture; + // that must not make the armed process believe it already runs the new bytes. + loadConfig(); + const status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(true); + expect(status.residentVersion).not.toBe(status.diskVersion); + }); + + test("a disk-first mutation keeps divergence for an unrouted disk-only row", () => { + saveConfig(config()); + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + // An external editor adds a provider the live config never saw. + const path = getConfigPath(); + const current = JSON.parse(readFileSync(path, "utf8")) as { providers: Record }; + current.providers.diskOnly = { adapter: "openai-chat", baseUrl: "https://disk.example/v1", apiKey: "sk-disk" }; + writeFileSync(path, JSON.stringify(current, null, 2) + "\n"); + // A disk-first mutation preserves the disk-only row but does not adopt it into + // the running config; the resident identity must not be refreshed to the file. + const outcome = mutatePersistedConfig(persisted => { + persisted.port = 10600; + return { changed: true, value: true }; + }); + expect(outcome.status).toBe("committed"); + expect(readConfigDivergenceStatus().diverged).toBe(true); + }); + + test("an adopter re-anchors the resident identity after mirroring a disk-first mutation", () => { + saveConfig(config()); + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + // A disk-first mutation writes the change but cannot refresh the resident + // identity: the server may or may not adopt the document. + const outcome = mutatePersistedConfig(persisted => { + persisted.port = 10600; + return { changed: true, value: true }; + }); + expect(outcome.status).toBe("committed"); + expect(readConfigDivergenceStatus().diverged).toBe(true); + // The adopter mirrors the committed change into the long-lived served config + // and then re-anchors the identity to that served snapshot. + armed.port = 10600; + refreshResidentConfigIdentity(armed); + const status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(false); + expect(status.residentVersion).toBe(status.diskVersion); + }); + + test("the refresh helper keeps divergence while a preserved disk-only row is unrouted", () => { + saveConfig(config()); + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + // An external editor adds a provider the live config never saw. + const path = getConfigPath(); + const current = JSON.parse(readFileSync(path, "utf8")) as { providers: Record }; + current.providers.diskOnly = { adapter: "openai-chat", baseUrl: "https://disk.example/v1", apiKey: "sk-disk" }; + writeFileSync(path, JSON.stringify(current, null, 2) + "\n"); + const outcome = mutatePersistedConfig(persisted => { + persisted.port = 10600; + return { changed: true, value: true }; + }); + expect(outcome.status).toBe("committed"); + // The adopter mirrors only the mutation it knows about; the disk-only row is + // still not routed, so re-anchoring to the served snapshot must keep diverged. + armed.port = 10600; + refreshResidentConfigIdentity(armed); + expect(readConfigDivergenceStatus().diverged).toBe(true); + }); + + test("adopter refresh matches the sorted committed provenance bytes", () => { + // The file carries unsorted deletion keys; the persisted projection sorts + // them, so the adopter's re-anchor must hash the projected form instead of + // the raw runtime object (which retains the original array order). + writeFileSync(getConfigPath(), JSON.stringify({ + ...config(), + configRebaseProvenance: { version: 1, deletedTopLevelKeys: ["providers", "combos"] }, + }, null, 2) + "\n"); + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + const outcome = mutatePersistedConfig(persisted => { + persisted.port = 10600; + return { changed: true, value: true }; + }); + expect(outcome.status).toBe("committed"); + expect(readConfigDivergenceStatus().diverged).toBe(true); + // The adopter mirrors the committed change and re-anchors. + armed.port = 10600; + refreshResidentConfigIdentity(armed); + const status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(false); + expect(status.residentVersion).toBe(status.diskVersion); + }); + + test("the refresh helper no-ops when no resident identity is armed", () => { + // A fresh process that never saved nor ran an admission load has no identity. + writeFileSync(getConfigPath(), JSON.stringify(config(), null, 2) + "\n"); + const unarmed = loadConfig(); + refreshResidentConfigIdentity(unarmed); + expect(readConfigDivergenceStatus().residentVersion).toBeNull(); + }); + + test("a guarded save keeps divergence when the persisted binding differs from the live socket", () => { + saveConfig(config()); + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + // Another process changes the disk port: the desired next-start binding. + const path = getConfigPath(); + const current = JSON.parse(readFileSync(path, "utf8")) as { port: number }; + current.port = 20200; + writeFileSync(path, JSON.stringify(current, null, 2) + "\n"); + // OAuth reconciliation adopts the new desired binding on disk while the live + // socket keeps its actual binding. + reconcileLiveConfigFromDisk(armed, structuredClone(armed)); + armed.streamMode = "eager-relay"; + saveConfigPreservingClaudeCode(armed); + const status = readConfigDivergenceStatus(); + // The file says port 20200; the running proxy still serves port 10100, so the + // resident digest must follow the served snapshot and keep diverged=true. + expect(status.diverged).toBe(true); + }); + + test("an in-process save refreshes the resident version", () => { + saveConfig(config()); + const armed = loadConfig(); + armClaudeCodeBaseline(armed); + armed.port = 20200; + saveConfig(armed); + const status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(false); + expect(status.residentVersion).toBe(status.diskVersion); + }); + + test("a byte-identical save re-anchors the resident identity when the served bytes match the file", () => { + writeFileSync(getConfigPath(), JSON.stringify(config(), null, 2) + "\n"); + const live = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(live); + // Simulate a stale resident identity: the running object equals the current + // file, but the resident digest was armed from an older document. + const staleDigest = createHash("sha256").update(JSON.stringify(config(20200), null, 2) + "\n").digest("hex"); + setResidentConfigSha256ForTests(staleDigest); + expect(readConfigDivergenceStatus().diverged).toBe(true); + + // An unrelated save that is byte-identical must re-anchor the resident digest: + // the served snapshot serializes to the exact persisted bytes, so the stale + // digest is a false positive (external canonicalization already applied). + saveConfigPreservingClaudeCode(live); + const status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(false); + expect(status.residentVersion).toBe(status.diskVersion); + }); + + test("an unchanged save with preserved disk-only bytes keeps divergence when the served snapshot omits them", () => { + // A later byte-identical save must not re-anchor the resident digest to the + // merged file bytes: the served snapshot still omits the disk-only row, so + // re-anchoring would report a false negative for an actual divergence. + writeFileSync(getConfigPath(), JSON.stringify(config(), null, 2) + "\n"); + const loaded = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(loaded); + // An external editor adds a provider the live config never saw. + const path = getConfigPath(); + const current = JSON.parse(readFileSync(path, "utf8")) as { providers: Record }; + current.providers.diskOnly = { adapter: "openai-chat", baseUrl: "https://disk.example/v1", apiKey: "sk-disk" }; + writeFileSync(path, JSON.stringify(current, null, 2) + "\n"); + reconcileUserCostOverlaysFromDisk(loaded); + // First save establishes the persisted bytes (the disk-only row survives at + // the serialization boundary while the served snapshot omits it). + loaded.streamMode = "eager-relay"; + saveConfigPreservingClaudeCode(loaded); + expect(readConfigDivergenceStatus().diverged).toBe(true); + // Second, byte-identical save reaches the unchanged branch with + // servedBytes !== bytes; the guard must leave divergence visible. + saveConfigPreservingClaudeCode(loaded); + const status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(true); + expect(status.residentVersion).not.toBe(status.diskVersion); + }); + + test("resident digest comes from the loaded bytes, not a post-load re-read", async () => { + const first = JSON.stringify(config(), null, 2) + "\n"; + writeFileSync(getConfigPath(), first); + const loaded = loadConfig({ captureResident: true }); + // The file changes between load and arming (e.g. another process saves during startup). + const second = JSON.stringify(config(20200), null, 2) + "\n"; + writeFileSync(getConfigPath(), second); + armClaudeCodeBaseline(loaded); + const status = readConfigDivergenceStatus(); + // Resident identity is the bytes that PRODUCED the live config (first), so the + // newer disk bytes are a real divergence the running process has not applied. + expect(status.residentVersion).toBe(createHash("sha256").update(first).digest("hex")); + expect(status.diverged).toBe(true); + }); + + test("resident digest hashes the raw file bytes, not the decoded string", () => { + // A malformed UTF-8 byte inside a JSON string value decodes to U+FFFD; the + // digest must still match the file's exact byte SHA-256. + const rawBytes = Buffer.concat([ + Buffer.from('{"port":10100,"note":"'), + Buffer.from([0xff]), + Buffer.from('"}\n'), + ]); + writeFileSync(getConfigPath(), rawBytes); + loadConfig({ captureResident: true }); + const status = readConfigDivergenceStatus(); + const byteDigest = createHash("sha256").update(rawBytes).digest("hex"); + const decodedDigest = createHash("sha256").update(rawBytes.toString("utf-8")).digest("hex"); + expect(byteDigest).not.toBe(decodedDigest); + expect(status.residentVersion).toBe(byteDigest); + }); + + test("a save that preserves disk-only providers stays diverged until the row is routed", async () => { + // Start with only the existing provider; the disk-only row arrives as an EXTERNAL + // edit after the process armed its resident identity. + writeFileSync(getConfigPath(), JSON.stringify(config(), null, 2) + "\n"); + const loaded = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(loaded); + writeFileSync(getConfigPath(), JSON.stringify({ + ...config(), + providers: { + ...(config().providers as Record), + diskOnly: { adapter: "openai-chat", baseUrl: "https://disk.example/v1", apiKey: "sk-disk" }, + }, + }, null, 2) + "\n"); + // The production server notices the external edit via its cost-overlay reconciler + // poll; drive that same step so the save below preserves the disk-only row. + reconcileUserCostOverlaysFromDisk(loaded); + // Live edit adds a provider; persistConfigUnlocked preserves the disk-only row. + loaded.providers.live = { adapter: "openai-chat", baseUrl: "https://live.example/v1", apiKey: "sk-live" }; + saveConfig(loaded); + const status = readConfigDivergenceStatus(); + // The served config does not route diskOnly yet, so the warning stays on even + // though the merged file on disk includes the row (Option A: resident identity + // is bound to the served snapshot, not the last merged write). + expect(status.diverged).toBe(true); + expect(status.residentVersion).not.toBe(status.diskVersion); + const persisted = JSON.parse(await Bun.file(getConfigPath()).text()) as { providers: Record }; + expect(persisted.providers.diskOnly).toBeDefined(); + }); + + test("an external edit to a preserved disk-only row still flips diverged", async () => { + writeFileSync(getConfigPath(), JSON.stringify(config(), null, 2) + "\n"); + const loaded = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(loaded); + writeFileSync(getConfigPath(), JSON.stringify({ + ...config(), + providers: { + ...(config().providers as Record), + diskOnly: { adapter: "openai-chat", baseUrl: "https://disk.example/v1", apiKey: "sk-disk" }, + }, + }, null, 2) + "\n"); + reconcileUserCostOverlaysFromDisk(loaded); + saveConfig(loaded); + // The file the proxy last wrote includes the preserved disk-only row; editing that + // row is still a real file change that a restart applies (the row becomes live + // routing after restart), so the warning must not be hidden. + const path = getConfigPath(); + const current = JSON.parse(await Bun.file(path).text()) as { providers: Record }; + current.providers.diskOnly.baseUrl = "https://disk.example/v2"; + writeFileSync(path, JSON.stringify(current, null, 2) + "\n"); + const status = readConfigDivergenceStatus(); + expect(status.diverged).toBe(true); + }); + + test("GET /api/config/status exposes resident and disk versions", async () => { + saveConfig(config()); + const armed = loadConfig({ captureResident: true }); + armClaudeCodeBaseline(armed); + const response = await handleManagementAPI( + new Request("http://127.0.0.1:10100/api/config/status", { headers: { Host: "127.0.0.1:10100" } }), + new URL("http://127.0.0.1:10100/api/config/status"), + armed, + ); + expect(response).not.toBeNull(); + const body = await response!.json() as { residentVersion?: unknown; diskVersion?: unknown; diverged?: unknown }; + expect(body.diverged).toBe(false); + expect(typeof body.residentVersion).toBe("string"); + expect(typeof body.diskVersion).toBe("string"); + }); +});