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 12fa962020..3233ef5871 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -212,6 +212,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 d0acc18dde..727af6b86b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -223,6 +223,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 983ac2a23b..3e7c903e94 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -214,6 +214,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 05215462be..80ae789acc 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -221,6 +221,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 f3cd6fe9ec..0d6a9c3211 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -216,6 +216,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 2cf73ba065..fc80ffaff0 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -221,6 +221,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 4b84ddeaf5..7c953c0a5d 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -222,6 +222,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 f4d226c4dd..2e7559b9fc 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -119,6 +119,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 02c774fc16..f10dce8898 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -216,6 +216,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 5198801889..caff3eedee 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,44 @@ 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 }); + if (!response.ok) return { configDivergence: null }; + const data = await response.json() as { + residentVersion?: unknown; + diskVersion?: unknown; + diverged?: unknown; + }; + const residentVersion = data.residentVersion ?? null; + const diskVersion = data.diskVersion ?? null; + 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 9e2c4cda3a..5a96d4eafc 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 a1ccca9890..9e3430fe02 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", () => { @@ -179,6 +183,86 @@ 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 { + globalThis.fetch = (async () => 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 }, + }); + 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 }); + 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 }); + } 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 57d5c85c53..a3cb15aa3f 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -877,6 +877,10 @@ 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) { + console.log(" ⚠️ config.json changed on disk after the running proxy loaded it; the proxy is still serving the older settings."); + console.log(" Action: restart the service to apply: 'ocx service restart'."); + } 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 9c9f577e0e..4710735051 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 { 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, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; import { directLocalHttpFetch } from "../server/direct-local-http"; @@ -54,6 +56,7 @@ export type CliStatusJson = { source: "default" | "file" | "fallback"; error: string | null; }; + configDivergence: CliConfigDivergence; service: { summary: string }; codexShim: { summary: string }; codexPlugins: CodexPluginsDiagnostic; @@ -72,6 +75,39 @@ export type CliStatusJson = { codexHome: OrcaCodexHomeDiagnostic; }; +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 ?? null; + const diskVersion = record.diskVersion ?? null; + const diverged = record.diverged; + if ( + !(residentVersion === null || typeof residentVersion === "string") + || !(diskVersion === null || typeof diskVersion === "string") + || typeof diverged !== "boolean" + ) return unavailable; + return { available: true, residentVersion, diskVersion, diverged }; +} + export type CliStatusView = { json: CliStatusJson; proxyLabel: string; @@ -175,6 +211,24 @@ export async function collectStatus(): Promise { label: `${listen.healthUrl} ok (live)`, } : await checkProxyHealth(listen); + // 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 @@ -319,6 +373,7 @@ export async function collectStatus(): Promise { source: configDiagnostics.source, error: configDiagnostics.error, }, + configDivergence, service: { summary: serviceSummary }, codexShim: { summary: codexShimSummary }, codexPlugins, diff --git a/src/codex/plan-from-token.ts b/src/codex/plan-from-token.ts index be2585ec4e..d3df580094 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,12 @@ 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). + if (adoptedAny) refreshResidentConfigIdentity(runtimeConfig); } /** diff --git a/src/config.ts b/src/config.ts index 10032fcbcf..118a1209d7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1756,17 +1756,30 @@ 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; 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"); + } + const rawWithBom = fileBytes.toString("utf-8"); + const raw = rawWithBom.replace(/^\uFEFF/, ""); const parsed = JSON.parse(raw); sanitizeRetryOn429ForLoad(parsed); sanitizeModelCostsForLoad(parsed); @@ -1832,9 +1845,11 @@ export function loadConfig(): OcxConfig { } // Merge couldn't fix it — truly broken config warnAndBackupInvalidConfig(configPath, result.error); + if (options?.captureResident) residentConfigSha256 = null; return getDefaultConfig(); } catch (error) { warnAndBackupInvalidConfig(configPath, error); + if (options?.captureResident) residentConfigSha256 = null; return getDefaultConfig(); } } @@ -2211,9 +2226,59 @@ 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 only when a resident version exists and differs from the current disk bytes. */ + 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; + return { + residentVersion: residentConfigSha256, + diskVersion, + diverged: residentConfigSha256 !== null && diskVersion !== null && residentConfigSha256 !== diskVersion, + }; +} + 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; + +/** Test-only seam: reset the resident identity so isolated test files cannot leak state. */ +export function setResidentConfigSha256ForTests(value: string | null): void { + residentConfigSha256 = value; +} + +/** + * 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; + residentConfigSha256 = createHash("sha256").update(JSON.stringify(config, null, 2) + "\n").digest("hex"); +} export class ConfigMutationLockError extends Error { readonly code = "CONFIG_MUTATION_LOCK_UNAVAILABLE"; @@ -2416,7 +2481,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(); // External editors can add provider rows the live config deliberately does // not route with yet; merge them at the serialization boundary so an @@ -2438,6 +2521,15 @@ function persistConfigUnlocked(config: OcxConfig): boolean { 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). + const servedSnapshot = options.servedSnapshot ?? config; + if (options.refreshResident !== false) { + residentConfigSha256 = createHash("sha256").update(JSON.stringify(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); @@ -2536,7 +2628,9 @@ export function mutatePersistedConfig( commitBase.diagnostics.config, 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" }; @@ -2589,6 +2683,10 @@ const persistedLiveServerBinding = new WeakMap { const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret(); - const config = runModelRenameStartupMigration(runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig()))); + const config = runModelRenameStartupMigration(runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig({ captureResident: true })))); warnAgentTaskRecoveryStartup(config); setLiveStateStoreConfig(config); applyProxyEnv(config); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index b1cbb8cd44..e5fd7acd74 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -12,6 +12,7 @@ import { mutatePersistedConfig, providerBaseUrlConfigError, providerHeadersConfigError, + refreshResidentConfigIdentity, saveConfigPreservingClaudeCode, subagentDefaultSyncEffective, } from "../../config"; @@ -121,6 +122,10 @@ function persistDesktopProfileField( // `unavailable` outcome must not leave the snapshot claiming a saved profile. if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile }; + // The long-lived server snapshot now serves the persisted value, so the resident + // divergence identity must follow it (the disk-first mutation itself skipped the + // refresh because it could not know whether the caller would adopt the write). + refreshResidentConfigIdentity(config); return { ok: true }; } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index e54cb5a3c0..067605f616 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -11,6 +11,7 @@ import { providerBaseUrlConfigError, providerHeadersConfigError, saveConfigPreservingClaudeCode, + readConfigDivergenceStatus, } from "../../config"; import { clearLoginState, @@ -247,6 +248,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { }; defaultProvider?: unknown; config?: { source?: unknown; error?: unknown }; + configDivergence?: { + available?: unknown; + residentVersion?: unknown; + diskVersion?: unknown; + diverged?: unknown; + }; service?: { summary?: unknown }; codexShim?: { summary?: unknown }; codexRuntime?: { @@ -111,6 +117,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"); @@ -325,4 +339,44 @@ 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); + }); + + 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-plan.test.ts b/tests/codex-plan.test.ts index 0c4b9c0936..9cb48a1f5c 100644 --- a/tests/codex-plan.test.ts +++ b/tests/codex-plan.test.ts @@ -8,7 +8,12 @@ import { reconcileCodexPlansFromTokens, resetJwtPlanNotesForTests, } from "../src/codex/plan-from-token"; -import { loadConfig, saveConfig } from "../src/config"; +import { + armClaudeCodeBaseline, + loadConfig, + readConfigDivergenceStatus, + saveConfig, +} from "../src/config"; import type { OcxConfig } from "../src/types"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-plan-test"); @@ -89,6 +94,37 @@ 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("leaves a non-JWT pool credential's stored plan alone", () => { const config: OcxConfig = { port: 10100, @@ -197,4 +233,3 @@ describe("WHAM-wins plan provenance gate (release-audit fix)", () => { expect(loadConfig().codexAccounts?.[0]?.plan).toBe("pro"); }); }); - diff --git a/tests/config-divergence.test.ts b/tests/config-divergence.test.ts new file mode 100644 index 0000000000..4fc90b8913 --- /dev/null +++ b/tests/config-divergence.test.ts @@ -0,0 +1,321 @@ +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 reports no divergence 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 the file: no disk identity to compare, so no divergence claim — but + // the read must NOT destroy the resident identity (the GUI polls every 15s). + rmSync(getConfigPath()); + let status = readConfigDivergenceStatus(); + expect(status.diskVersion).toBeNull(); + expect(status.diverged).toBe(false); + 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("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("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("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"); + }); +});