diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index ac197b615a..47b32828bd 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -46,12 +46,12 @@ export async function applyProfile( ): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { // Explicit apply is an enable action. Persist intent before any Desktop write // so a process crash cannot leave a gateway profile that startup immediately removes. - const desired = setIntegrationEnabled("claude-desktop", true); + const desired = setIntegrationEnabled("claude-desktop", true, { surface: "cli", detail: "ocx claude desktop apply" }); if (!desired.ok) return { ok: false, path: "", reason: desired.message }; const config = loadConfig(); const state = await buildClaudeDesktopState(config, profile); config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile }; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "cli", detail: "ocx claude desktop apply" }); const live = await (deps.findLiveProxyImpl ?? findLiveProxy)(); if (live) { // #859: the Desktop alias reverse-map is process-local. Applying through the @@ -165,7 +165,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf if (!state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`); const profile = moveDesktopRoute(state.profile, route, familyRaw, flags.includes("--default")); config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile }; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "cli", detail: "ocx claude desktop move" }); console.log(`${route} 모델을 ${familyRaw} 그룹으로 옮겼습니다.`); return 0; } @@ -176,7 +176,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf if (route && !state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`); const profile = setDesktopFamilyDefault(state.profile, familyRaw, route); config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile }; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "cli", detail: "ocx claude desktop default" }); console.log(`${familyRaw} 기본 모델을 ${route ?? "없음"}으로 지정했습니다.`); return 0; } @@ -195,7 +195,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf const profile = parseDesktopProfile(JSON.parse(readFileSync(resolve(source), "utf8"))); const reconciled = (await buildClaudeDesktopState(config, profile)).profile; config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconciled }; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "cli", detail: "ocx claude desktop import" }); if (flags.includes("--apply")) { const result = await applyProfile(reconciled, "static", deps); if (!result.ok) { console.error(`프로필은 저장했지만 Desktop 적용에 실패했습니다: ${result.reason ?? "unknown error"}`); return 1; } diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 796b503f0e..fdba9a64a3 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -160,7 +160,7 @@ export async function handleConfigCommand(argv: string[]): Promise { } Object.assign(fresh, config); return { changed: JSON.stringify(fresh) !== before, value: undefined }; - }); + }, { surface: "cli", detail: `ocx config ${action}` }); if (outcome.status === "unavailable") { throw new Error(outcome.reason === "conflict" ? "config changed while applying this update; retry" @@ -198,7 +198,7 @@ export async function handleConfigCommand(argv: string[]): Promise { if (!path) throw new CliUsageError("import path is required", USAGE); if (!yes) throw new CliUsageError("import requires --yes", USAGE); rejectArgs(args, USAGE); - saveConfig(validate(loadInput(path))); + saveConfig(validate(loadInput(path)), { surface: "cli", detail: "ocx config import" }); printData({ ok: true, source: path }, wantsJson, [`Imported config from ${path}. Restart or run ocx sync if needed.`]); return; } diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 609cd44717..4d2f2b0ee2 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -79,7 +79,7 @@ const commandRunners: Record = { console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically."); return 1; } - const desired = setIntegrationEnabled("codex", true); + const desired = setIntegrationEnabled("codex", true, { surface: "cli", detail: "ocx inject" }); if (!desired.ok) { console.error(`Codex desired state was not saved (${desired.reason}).`); return desired.reason === "conflict" ? 2 : 1; @@ -97,7 +97,7 @@ const commandRunners: Record = { console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`); return 0; } - const desired = setIntegrationEnabled("codex", false); + const desired = setIntegrationEnabled("codex", false, { surface: "cli", detail: "ocx restore" }); if (!desired.ok) { if (restoreJson) { // Machine-readable contract: every restore --json outcome emits one diff --git a/src/cli/index.ts b/src/cli/index.ts index 1b2c6b5668..8583de9a40 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -186,7 +186,7 @@ async function chooseListenPort(requestedPort?: number): Promise { } if (shouldPersistSelectedPort(config.port, selected, preferred)) { config.port = selected; - saveConfig(config); + saveConfig(config, { surface: "cli", detail: "ocx start (port selection)" }); } return selected; } catch (err) { diff --git a/src/cli/init.ts b/src/cli/init.ts index 32c7ade936..9cfba695d1 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -167,7 +167,7 @@ export async function runInit(): Promise { defaultProvider: providerName, }; - saveConfig(config); + saveConfig(config, { surface: "cli", detail: "ocx init" }); // Init writes a fresh config, so a stale pre-migration backup from a previous // installation would make the next `ocx start` crash on a stale-backup // collision (issue #257). But only a STALE backup (unparseable, or already a diff --git a/src/cli/models.ts b/src/cli/models.ts index a20ba18472..b7d501aeb0 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -240,7 +240,7 @@ async function handleCustomAdd(args: string[]): Promise { addedAt: new Date().toISOString(), }; config.customModels = [...existing, entry]; - saveConfig(config); + saveConfig(config, { surface: "cli", detail: "ocx models add" }); await syncCustomModelsIfLive(); console.log(`Added custom model ${slug} (${entry.id}).`); } @@ -286,7 +286,7 @@ async function handleCustomRemove(args: string[]): Promise { const next = existing.filter((_, modelIndex) => modelIndex !== index); config.customModels = next.length > 0 ? next : undefined; - saveConfig(config); + saveConfig(config, { surface: "cli", detail: "ocx models remove" }); await syncCustomModelsIfLive(); console.log(`Removed custom model ${routedSlug(model.provider, model.modelId)}.`); } diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 725db2963a..6447a6a156 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -68,7 +68,7 @@ function validateAndSave(config: ReturnType): void { console.error(`Error: defaultProvider "${config.defaultProvider}" does not exist in providers. Aborting.`); process.exit(1); } - saveConfig(config); + saveConfig(config, { surface: "cli", detail: "ocx provider set" }); } // --------------------------------------------------------------------------- diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 9071fa183c..07ed915bee 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -195,7 +195,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () } if (modeArg === "default") delete cfg.multiAgentMode; else cfg.multiAgentMode = modeArg as "v1" | "v2"; - saveConfig(cfg); + saveConfig(cfg, { surface: "cli", detail: "ocx v2 mode" }); try { const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex; await sync(findPort ? await findPort() : undefined); @@ -218,7 +218,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () const already = cfg.keepNativeChatGptOnV1 === true === next; if (next) cfg.keepNativeChatGptOnV1 = true; else delete cfg.keepNativeChatGptOnV1; - saveConfig(cfg); + saveConfig(cfg, { surface: "cli", detail: "ocx v2 keep-native-v1" }); try { const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex; await sync(findPort ? await findPort() : undefined); diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index b9757a9889..7a70186ddc 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -142,7 +142,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): try { // Persist first for durable configs. Destructive cleanup below must never run for a // deletion that failed to commit. Transient configs intentionally skip this write. - saveConfigPreservingClaudeCode(runtimeConfig); + saveConfigPreservingClaudeCode(runtimeConfig, { surface: "internal", detail: "account lifecycle: remove account" }); } catch (error) { restoreRuntimeConfig(runtimeConfig, previousConfig); try { diff --git a/src/codex/desired-state.ts b/src/codex/desired-state.ts index bd6d864d77..4edc4d2c51 100644 --- a/src/codex/desired-state.ts +++ b/src/codex/desired-state.ts @@ -21,6 +21,7 @@ * Design record: devlog/_fin/260803_codex_desktop_toggle/030_desired_state.md. */ import { loadConfig, mutatePersistedConfig } from "../config"; +import type { ConfigMutationSource } from "../config"; import type { OcxClientIntegrationsConfig, OcxConfig } from "../types"; import { runStartupReadinessSync, type ReadinessGate, type SyncOutcomeLike } from "../server/readiness"; @@ -99,6 +100,7 @@ export function codexIntegrationEnabledNow(): boolean { export function setIntegrationEnabled( client: DurableIntentClientId, enabled: boolean, + source: ConfigMutationSource = { surface: "internal", detail: "desired-state: setIntegrationEnabled" }, ): CodexDesiredStateResult { const outcome = mutatePersistedConfig(config => { const current = integrationEnabled(config, client); @@ -117,7 +119,7 @@ export function setIntegrationEnabled( if (Object.keys(integrations).length === 0) delete config.clientIntegrations; else config.clientIntegrations = integrations; return { changed: true, value: enabled }; - }); + }, source); if (outcome.status !== "unavailable") { return { ok: true, status: outcome.status, enabled }; @@ -139,12 +141,18 @@ export function setIntegrationEnabled( }; } -export function setCodexIntegrationEnabled(enabled: boolean): CodexDesiredStateResult { - return setIntegrationEnabled("codex", enabled); +export function setCodexIntegrationEnabled( + enabled: boolean, + source?: ConfigMutationSource, +): CodexDesiredStateResult { + return setIntegrationEnabled("codex", enabled, source); } -export function setGrokIntegrationEnabled(enabled: boolean): CodexDesiredStateResult { - return setIntegrationEnabled("grok", enabled); +export function setGrokIntegrationEnabled( + enabled: boolean, + source?: ConfigMutationSource, +): CodexDesiredStateResult { + return setIntegrationEnabled("grok", enabled, source); } /** Whether Claude Desktop's managed gateway profile is wanted. */ @@ -157,8 +165,11 @@ export function claudeDesktopIntegrationEnabledNow(): boolean { return claudeDesktopIntegrationEnabled(loadConfig()); } -export function setClaudeDesktopIntegrationEnabled(enabled: boolean): CodexDesiredStateResult { - return setIntegrationEnabled("claude-desktop", enabled); +export function setClaudeDesktopIntegrationEnabled( + enabled: boolean, + source?: ConfigMutationSource, +): CodexDesiredStateResult { + return setIntegrationEnabled("claude-desktop", enabled, source); } /** diff --git a/src/codex/plan-from-token.ts b/src/codex/plan-from-token.ts index be2585ec4e..73b2c1183e 100644 --- a/src/codex/plan-from-token.ts +++ b/src/codex/plan-from-token.ts @@ -84,7 +84,7 @@ function persistJwtPlanUpdates(runtimeConfig: OcxConfig, updates: FreshPoolPlanU } } return { changed, value: accepted }; - }); + }, { surface: "internal", detail: "wham: jwt plan updates" }); } catch (error) { if (error instanceof ConfigMutationLockError) return; throw error; diff --git a/src/codex/routing.ts b/src/codex/routing.ts index fa6fa63d83..6a4e1e976e 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1230,7 +1230,7 @@ function setActiveCodexAccount(config: OcxConfig, accountId: string): void { const releasedPin = releaseCodexAccountPinFor(config, accountId); if (config.activeCodexAccountId === accountId && !releasedPin) return; config.activeCodexAccountId = accountId; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "internal", detail: "routing: active codex account selection" }); } /** Quota strategy persists; RR/fill-first keep a process-local cursor only. */ @@ -1322,7 +1322,7 @@ function releaseDrainedCodexAccountPin(config: OcxConfig): void { || !hasCodexQuotaHeadroom(config, pinned); if (!drained) return; clearCodexAccountPin(config); - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "internal", detail: "routing: clear drained codex account pin" }); } function applyQuotaAutoSwitch( diff --git a/src/config.ts b/src/config.ts index dcf34313a4..958fdbf2c4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; @@ -49,7 +49,7 @@ import { recordOwnedConfigPath } from "./lib/config-ownership"; import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { isLocalAttestationSecret } from "./lib/local-management-attestation"; import { providerDestinationConfigError } from "./lib/destination-policy"; -import { redactSecretString } from "./lib/redact"; +import { REDACTED_SECRET, redactSecretString, redactSecrets } from "./lib/redact"; import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsSystemDirectory, @@ -2638,6 +2638,59 @@ export function readConfigAdmissionSnapshot(): ConfigAdmissionSnapshot { const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite"; const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const; + +/** + * Who performed a persisted config mutation. `surface` separates the two human-facing + * entry points (management API vs CLI) from internal/automatic writers so an operator + * can tell a GUI edit from a background migration at a glance. + */ +export interface ConfigMutationSource { + readonly surface: "cli" | "api" | "internal"; + /** Human-readable route or command, e.g. "PUT /api/providers/blsc" or "ocx provider set". */ + readonly detail: string; +} + +export interface ConfigMutationAuditRow { + id: number; + createdAt: number; + surface: "cli" | "api" | "internal"; + detail: string; + fields: string[]; + before: Record; + after: Record; +} + +/** Bounded audit retention: newest N rows survive each insert; older rows are pruned. */ +const CONFIG_AUDIT_MAX_ROWS = 5_000; +let configAuditMaxRows = CONFIG_AUDIT_MAX_ROWS; + +/** Test-only seam: shrink the audit retention bound without building a 5k-row fixture. */ +export function setConfigAuditMaxRowsForTests(value: number | null): void { + configAuditMaxRows = value ?? CONFIG_AUDIT_MAX_ROWS; +} +/** A single changed-field path list is capped so a wholesale rewrite cannot bloat the row. */ +const CONFIG_AUDIT_MAX_FIELDS = 64; +/** Redacted before/after values are capped per entry; longer values are truncated. */ +const CONFIG_AUDIT_MAX_VALUE_CHARS = 4_096; +/** + * Durable write-ahead marker for one config write. Written (atomically) BEFORE the + * config.json rename and removed AFTER the audit row commits, so a crash between the + * rename and the commit can be replayed instead of leaving a changed config with no + * audit record. + */ +const CONFIG_MUTATION_PENDING_AUDIT_FILENAME = "config-mutation-pending.json"; + +type PendingConfigMutationAudit = { + createdAt: number; + surface: ConfigMutationSource["surface"]; + detail: string; + fields: string[]; + before: Record; + after: Record; + /** SHA-256 of the exact config.json bytes the pending write produced. */ + afterSha256: string; +}; + let warnedConfigMutationDirectoryAcl = false; export class ConfigMutationLockError extends Error { @@ -2685,6 +2738,19 @@ function configMutationDatabasePath(): string { let configMutationLockDepth = 0; let configMutationDatabase: Database | null = null; +/** Marker deletion is deferred until the surrounding transaction commits. */ +let pendingConfigMutationAuditCleanup = false; +/** Test-only seam: fail the config.json atomic write AFTER the pending marker is persisted. */ +let failConfigAtomicWriteForTests: (() => Error) | null = null; + +/** + * Test-only one-shot seam: make the next changed config.json persist throw after + * writing its pending audit marker but before the config rename lands. Mirrors a + * crash/power loss between the marker write and the atomic config write. + */ +export function setConfigAtomicWriteFailureForTests(factory: (() => Error) | null): void { + failConfigAtomicWriteForTests = factory; +} /** * Serialize synchronous config and Codex credential-generation commits across processes with an @@ -2712,6 +2778,23 @@ export function withConfigMutationLockSync(fn: () => T): T { database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); transactionOpen = true; initializeConfigGeneration(database); + ensureConfigMutationAuditTable(database); + // Replay any interrupted write (config renamed but audit row not committed) + // in its OWN transaction before this mutation starts. A recovered marker's + // row commits here, and the marker is removed, so a later failure in the new + // mutation can neither roll the recovered row back nor let a new marker + // overwrite the one whose replay has not yet committed. + if (existsSync(configMutationPendingAuditPath())) { + reconcilePendingConfigMutationAudit(database); + database.exec("COMMIT"); + transactionOpen = false; + if (pendingConfigMutationAuditCleanup) { + pendingConfigMutationAuditCleanup = false; + deletePendingConfigMutationAudit(); + } + database.exec("BEGIN IMMEDIATE"); + transactionOpen = true; + } } catch (cause) { if (transactionOpen) { try { database?.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ } @@ -2732,20 +2815,356 @@ export function withConfigMutationLockSync(fn: () => T): T { const value = fn(); database.exec("COMMIT"); transactionOpen = false; + if (pendingConfigMutationAuditCleanup) { + pendingConfigMutationAuditCleanup = false; + deletePendingConfigMutationAudit(); + } return value; } catch (error) { if (transactionOpen) { try { database.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ } transactionOpen = false; } + pendingConfigMutationAuditCleanup = false; throw error; } finally { configMutationLockDepth = 0; configMutationDatabase = null; + pendingConfigMutationAuditCleanup = false; try { database.close(); } catch { /* the OS lock is released with the handle */ } } } +const CONFIG_MUTATION_AUDIT_TABLE_SQL = ` + CREATE TABLE IF NOT EXISTS config_mutation_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at INTEGER NOT NULL, + surface TEXT NOT NULL, + detail TEXT NOT NULL, + fields TEXT NOT NULL, + before_json TEXT NOT NULL, + after_json TEXT NOT NULL + ) +`; + +function ensureConfigMutationAuditTable(database: Database): void { + database.exec(CONFIG_MUTATION_AUDIT_TABLE_SQL); +} + +/** + * Insert one audit row with a deterministic dedupe key (all six stored fields), then + * prune retention. Used by the live write path and by crash recovery, so a replayed + * row can never duplicate a row that already committed. + */ +function insertConfigMutationAuditRow( + database: Database, + createdAt: number, + source: Pick, + fields: string[], + before: Record, + after: Record, +): void { + ensureConfigMutationAuditTable(database); + const fieldsJson = JSON.stringify(fields); + const beforeJson = JSON.stringify(before); + const afterJson = JSON.stringify(after); + const existing = database.prepare(` + SELECT 1 FROM config_mutation_audit + WHERE created_at = ? AND surface = ? AND detail = ? AND fields = ? AND before_json = ? AND after_json = ? + LIMIT 1 + `).get(createdAt, source.surface, source.detail, fieldsJson, beforeJson, afterJson); + if (existing) return; + database.prepare(` + INSERT INTO config_mutation_audit (created_at, surface, detail, fields, before_json, after_json) + VALUES (?, ?, ?, ?, ?, ?) + `).run(createdAt, source.surface, source.detail, fieldsJson, beforeJson, afterJson); + // Keep the newest CONFIG_AUDIT_MAX_ROWS rows: delete every row at or below the id of + // the (N+1)-th newest entry. COALESCE keeps a small table a no-op. + database.prepare(` + DELETE FROM config_mutation_audit + WHERE id <= COALESCE(( + SELECT id FROM config_mutation_audit ORDER BY id DESC LIMIT 1 OFFSET ? + ), 0) + `).run(configAuditMaxRows); +} + + +function configMutationPendingAuditPath(): string { + return join(getConfigDir(), CONFIG_MUTATION_PENDING_AUDIT_FILENAME); +} + +function writePendingConfigMutationAudit(payload: PendingConfigMutationAudit): void { + const path = configMutationPendingAuditPath(); + atomicWriteFile(path, JSON.stringify(payload)); + // Order the marker ahead of the config.json rename across power loss, not just + // across process termination. Directory fsync is best-effort: some platforms + // refuse it, and process crashes are already covered by the page cache. + try { + const dir = openSync(getConfigDir(), "r"); + try { fsyncSync(dir); } finally { closeSync(dir); } + } catch { /* best-effort */ } +} + +function deletePendingConfigMutationAudit(): void { + try { unlinkSync(configMutationPendingAuditPath()); } catch (error) { + if (!isMissingPathError(error)) throw error; + } +} + +function readPendingConfigMutationAudit(): PendingConfigMutationAudit | null { + try { + const parsed = JSON.parse( + readFileSync(configMutationPendingAuditPath(), "utf8"), + ) as Partial; + if (typeof parsed.createdAt !== "number" || !Number.isSafeInteger(parsed.createdAt)) return null; + if (parsed.surface !== "cli" && parsed.surface !== "api" && parsed.surface !== "internal") return null; + if (typeof parsed.detail !== "string") return null; + if (!Array.isArray(parsed.fields)) return null; + if (typeof parsed.afterSha256 !== "string" || parsed.afterSha256.length === 0) return null; + if (!parsed.before || typeof parsed.before !== "object" || Array.isArray(parsed.before)) return null; + if (!parsed.after || typeof parsed.after !== "object" || Array.isArray(parsed.after)) return null; + return parsed as PendingConfigMutationAudit; + } catch (error) { + if (!isMissingPathError(error)) { + // A malformed marker must not block future writes; drop it and continue. + try { unlinkSync(configMutationPendingAuditPath()); } catch { /* best-effort */ } + } + return null; + } +} + +/** + * Reconcile an interrupted write inside an OPEN writable transaction: if the config + * bytes on disk match the pending marker, the rename landed before the crash, so the + * audit row is replayed (deduped). Otherwise the rename never landed and the marker is + * dropped. Either way the marker is removed so future writes start clean. + */ +function reconcilePendingConfigMutationAudit(database: Database): void { + const pending = readPendingConfigMutationAudit(); + if (!pending) return; + const configPath = getConfigPath(); + let currentHash: string | null = null; + try { + currentHash = createHash("sha256").update(readFileSync(configPath)).digest("hex"); + } catch (error) { + if (!isMissingPathError(error)) throw error; + } + if (currentHash === pending.afterSha256) { + insertConfigMutationAuditRow( + database, + pending.createdAt, + pending, + pending.fields, + pending.before, + pending.after, + ); + } + // The unlink cannot participate in the transaction, so schedule it for the + // post-COMMIT step. A marker that survives a ROLLBACK is replayed next time. + pendingConfigMutationAuditCleanup = true; +} + +/** Record the row described by the marker inside the CURRENT open transaction, then remove it. */ +function recordPendingConfigMutationAuditNow(): void { + if (configMutationLockDepth < 1 || !configMutationDatabase) return; + const pending = readPendingConfigMutationAudit(); + if (!pending) return; + insertConfigMutationAuditRow( + configMutationDatabase, + pending.createdAt, + pending, + pending.fields, + pending.before, + pending.after, + ); + pendingConfigMutationAuditCleanup = true; +} + +/** Best-effort read-path recovery: replay an orphaned marker when the DB already exists. */ +function reconcilePendingConfigMutationAuditOnRead(path: string): void { + if (!existsSync(configMutationPendingAuditPath())) return; + try { + const writable = new Database(path); + try { + reconcilePendingConfigMutationAudit(writable); + } finally { + writable.close(); + } + // The implicit autocommit committed before close; only now is the marker + // deletion safe (a failed insert leaves the marker for the next write). + if (pendingConfigMutationAuditCleanup) { + pendingConfigMutationAuditCleanup = false; + deletePendingConfigMutationAudit(); + } + } catch { + pendingConfigMutationAuditCleanup = false; + // A concurrent writer may hold the SQLite lock; the next mutation reconciles. + } +} + +/** + * Read the bounded audit trail, newest first. Defaults to 100 rows; the cap is 1000. + * Missing database or table yields an empty trail, never an error. + */ +export function readConfigMutationAudit(limit = 100): { rows: ConfigMutationAuditRow[]; maxRows: number } { + const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, 1000) : 100; + let database: Database | undefined; + try { + // A management read must never create or harden the coordinator directory, so + // resolve the path without the write-side effects of configMutationDatabasePath(). + const path = configMutationDatabasePathForRead(); + if (!existsSync(path)) return { rows: [], maxRows: configAuditMaxRows }; + reconcilePendingConfigMutationAuditOnRead(path); + database = new Database(path, { readonly: true }); + const rows = database.prepare(` + SELECT id, created_at AS createdAt, surface, detail, fields, + before_json AS beforeJson, after_json AS afterJson + FROM config_mutation_audit + ORDER BY id DESC + LIMIT ? + `).all(safeLimit).map((row: unknown) => { + const record = row as Record; + return { + id: Number(record.id), + createdAt: Number(record.createdAt), + surface: String(record.surface) as ConfigMutationAuditRow["surface"], + detail: String(record.detail), + fields: JSON.parse(String(record.fields)) as string[], + before: JSON.parse(String(record.beforeJson)) as Record, + after: JSON.parse(String(record.afterJson)) as Record, + }; + }); + return { rows, maxRows: configAuditMaxRows }; + } catch { + return { rows: [], maxRows: configAuditMaxRows }; + } finally { + try { database?.close(); } catch { /* read path is best-effort */ } + } +} + +function isPlainConfigObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Path resolution with no directory creation or ACL side effects, for read-only callers. */ +function configMutationDatabasePathForRead(): string { + return join(getConfigDir(), CONFIG_MUTATION_DB_FILENAME); +} + +/** Collect changed paths as segment arrays, descending at most three object levels (e.g. providers..). */ +function collectConfigDiffPaths( + before: unknown, + after: unknown, + prefix: string[], + depth: number, + out: string[][], +): void { + if (out.length >= CONFIG_AUDIT_MAX_FIELDS) return; + const beforeObject = isPlainConfigObject(before); + const afterObject = isPlainConfigObject(after); + if (beforeObject && afterObject && depth < 3) { + const keys = new Set([ + ...Object.keys(before), + ...Object.keys(after), + ]); + for (const key of keys) { + collectConfigDiffPaths(before[key], after[key], [...prefix, key], depth + 1, out); + } + return; + } + if (!deepEqual(before, after)) out.push(prefix); +} + +function extractConfigValueAtPath(root: unknown, segments: readonly string[]): unknown { + let current = root; + for (const part of segments) { + if (!isPlainConfigObject(current)) return undefined; + current = current[part]; + if (current === undefined) return undefined; + } + return current; +} + +/** Redact the admission-secret `key` field of every apiKeys entry, including degraded rows. */ +function redactApiKeyEntries(value: unknown, inApiKeysSubtree = false): unknown { + if (Array.isArray(value)) { + return value.map(item => redactApiKeyEntries(item, inApiKeysSubtree)); + } + if (!isPlainConfigObject(value)) return value; + const out: Record = {}; + for (const [entryKey, entryValue] of Object.entries(value)) { + out[entryKey] = redactApiKeyEntries(entryValue, inApiKeysSubtree || entryKey === "apiKeys"); + } + // OcxApiKeyEntry.key is the data-plane admission secret. Leaf-name redaction + // cannot see it (the field is `key`, not `apiKey`), so the whole subtree is + // masked before the row is persisted or echoed by GET /api/config/mutations. + // The schema deliberately salvages degraded entries (missing id/name/createdAt + // metadata), so match by context plus a string key rather than the full + // happy-path shape; outside the apiKeys subtree require a plausible entry. + if ( + typeof out.key === "string" + && (inApiKeysSubtree || [out.id, out.name, out.createdAt].some(v => typeof v === "string")) + ) { + out.key = REDACTED_SECRET; + } + return out; +} + +/** Redact secrets and bound the serialized size of one audit value. */ +function boundAuditValue(value: unknown, key: string): unknown { + // Wrap in an object so redactSecrets can see the field name: a bare string leaf + // like `sk-old` has no context of its own and would otherwise survive unmasked. + const wrapped = redactSecrets({ [key]: value }); + // Apply the apiKeys-entry mask to the WHOLE extracted subtree: a first-ever + // save snapshots the entire config under the root label, so the admission + // key must be redacted even when the outer path is not apiKeys.*. + const redacted = redactApiKeyEntries((wrapped as Record)[key]); + const text = JSON.stringify(redacted); + if (text === undefined) return null; + return text.length <= CONFIG_AUDIT_MAX_VALUE_CHARS + ? redacted + : `${text.slice(0, CONFIG_AUDIT_MAX_VALUE_CHARS)}…[truncated]`; +} + +/** + * Build the bounded, redacted before/after snapshot for one config write. Both inputs + * must be parsed config objects (raw JSON text must be JSON.parsed by the caller). + */ +export function buildConfigMutationSnapshot( + beforeRaw: unknown, + afterRaw: unknown, +): { fields: string[]; before: Record; after: Record } { + const segmentPaths: string[][] = []; + collectConfigDiffPaths(beforeRaw, afterRaw, [], 0, segmentPaths); + // Config keys are caller-controlled and can be token-shaped (see the provider-name + // redaction at the schema boundary). Redact every segment before it is persisted + // and echoed by GET /api/config/mutations; extraction keeps the raw segments. + const fields = segmentPaths.map(segments => segments.map(redactSecretString).join(".") || ""); + // Redaction can collapse distinct paths (two token-shaped provider names both + // become providers.[REDACTED].), and a dotted key can collide with a + // dotted passthrough name. Give duplicates a deterministic, non-secret + // occurrence suffix so no before/after record overwrites another. + const labelCounts = new Map(); + for (const label of fields) labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1); + const labelSeen = new Map(); + const uniqueFields = fields.map(label => { + const count = labelCounts.get(label) ?? 1; + if (count <= 1) return label; + const seen = (labelSeen.get(label) ?? 0) + 1; + labelSeen.set(label, seen); + return seen === 1 ? label : `${label}#${seen}`; + }); + const before: Record = {}; + const after: Record = {}; + segmentPaths.forEach((segments, index) => { + const label = uniqueFields[index]!; + const key = segments.at(-1) ?? label; + before[label] = boundAuditValue(extractConfigValueAtPath(beforeRaw, segments), key); + after[label] = boundAuditValue(extractConfigValueAtPath(afterRaw, segments), key); + }); + return { fields: uniqueFields, before, after }; +} + function bumpGenerationForCooperatingConfigWrite(): void { if (!configMutationDatabase) { throw new Error("A cooperating config write requires the config mutation transaction."); @@ -2837,11 +3256,14 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync /** * Atomic config.json write WITHOUT the mutation lock; callers must hold - * `withConfigMutationLockSync`. Returns true when bytes changed. Refreshes the - * cost-overlay registry from the persisted config so runtime estimates follow - * every save path. + * `withConfigMutationLockSync`. Returns the exact persisted document when bytes + * changed and null when the save was byte-identical. Refreshes the cost-overlay + * registry from the persisted config so runtime estimates follow every save path. */ -function persistConfigUnlocked(config: OcxConfig): boolean { +function persistConfigUnlocked( + config: OcxConfig, + audit?: { before: unknown; source: ConfigMutationSource }, +): OcxConfig | null { 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 @@ -2860,22 +3282,47 @@ function persistConfigUnlocked(config: OcxConfig): boolean { // adopt the overlay without waiting for a changed save or restart. if (unchanged) { refreshUserCostOverlays(persisted); - return false; + return null; + } + if (audit) { + // Write-ahead marker: persisted BEFORE the rename so a crash between the rename + // and the audit-row commit is recovered (replayed or dropped) on the next access. + const snapshot = buildConfigMutationSnapshot(audit.before, persisted); + writePendingConfigMutationAudit({ + createdAt: Date.now(), + surface: audit.source.surface, + detail: audit.source.detail, + fields: snapshot.fields, + before: snapshot.before, + after: snapshot.after, + afterSha256: createHash("sha256").update(bytes).digest("hex"), + }); + } + const failWrite = failConfigAtomicWriteForTests; + if (failWrite) { + failConfigAtomicWriteForTests = null; + throw failWrite(); } atomicWriteFile(configPath, bytes); // For changed saves, refresh only AFTER the write succeeded so a failed // write cannot leave estimates reflecting configuration never persisted. refreshUserCostOverlays(persisted); - return true; + if (audit) recordPendingConfigMutationAuditNow(); + return persisted; } /** Persist `config` to config.json under the config-mutation lock. */ -export function saveConfig(config: OcxConfig): void { +export function saveConfig( + config: OcxConfig, + source: ConfigMutationSource = { surface: "internal", detail: "saveConfig" }, +): void { // Keep the real-home assertion ahead of even lock-directory preparation. assertNotRealHomeUnderTest(getConfigDir()); withConfigMutationLockSync(() => { - const projected = projectCustomModelCatalogMigration(readRawConfigJson(), config); - if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); + const beforeRaw = readRawConfigJson(); + const projected = projectCustomModelCatalogMigration(beforeRaw, config); + const persisted = persistConfigUnlocked(projected, { before: beforeRaw, source }); + if (persisted) bumpGenerationForCooperatingConfigWrite(); adoptCustomModelCatalogMigration(config, projected); }); } @@ -2911,6 +3358,7 @@ function unavailableConfigMutationReason(snapshot: ConfigFileSnapshot): "missing */ export function mutatePersistedConfig( mutate: (config: OcxConfig) => PersistedConfigMutation, + source: ConfigMutationSource = { surface: "internal", detail: "mutatePersistedConfig" }, ): PersistedConfigMutationOutcome { // Avoid creating/opening the coordinator database for a read-path update that already knows // there is no valid config. The same check runs again under the transaction for authority. @@ -2961,7 +3409,14 @@ export function mutatePersistedConfig( commitBase.diagnostics.config, confirmedConfig, ); - if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); + const persisted = persistConfigUnlocked(projected, { + // The exact persisted document, matching saveConfig and + // saveConfigPreservingClaudeCode. The parsed config carries schema + // defaults and degraded fields that were never on disk. + before: readRawConfigJson(), + source, + }); + if (persisted) bumpGenerationForCooperatingConfigWrite(); return { status: "committed", value: confirmed.value }; } return { status: "unavailable", reason: "conflict" }; @@ -3289,7 +3744,10 @@ function readPersistedServerBinding( * Custom-model rows are merged by their stable `id`, preserving independent * edits and deletions across stale whole-config saves. */ -export function saveConfigPreservingClaudeCode(config: OcxConfig): void { +export function saveConfigPreservingClaudeCode( + config: OcxConfig, + source: ConfigMutationSource = { surface: "internal", detail: "saveConfigPreservingClaudeCode" }, +): void { withConfigMutationLockSync(() => { const bindingBaseline = persistedLiveServerBinding.get(config); // One authoritative pre-write read feeds both the live-config reconciliation and @@ -3347,10 +3805,12 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port }; if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; else persistedConfig.hostname = persistedBinding.hostname; - if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite(); + const persisted = persistConfigUnlocked(persistedConfig, { before: onDisk, source }); + if (persisted) bumpGenerationForCooperatingConfigWrite(); persistedLiveServerBinding.set(config, persistedBinding); } else { - if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite(); + const persisted = persistConfigUnlocked(projectedConfig, { before: onDisk, source }); + if (persisted) bumpGenerationForCooperatingConfigWrite(); } adoptCustomModelCatalogMigration(config, projectedConfig); if (claudeCodeBaseline.has(config)) { diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 9f9bb4af44..dd5b73a814 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -1,6 +1,6 @@ export const REDACTED_SECRET = "[REDACTED]"; -const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; +const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|api[-_]?key[-_]?pool|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|oauth[-_]?client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i; /** * Colon-labelled credential headers echoed back inside an error body diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 28e326fda9..b4c9c52341 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -10,6 +10,7 @@ import { loadConfig, multiAgentGuidanceEnabled, mutatePersistedConfig, + type ConfigMutationSource, providerBaseUrlConfigError, providerHeadersConfigError, saveConfigPreservingClaudeCode, @@ -112,11 +113,12 @@ function mirrorDesiredEnabledOntoSnapshot(config: OcxConfig, client: "claude-des function persistDesktopProfileField( config: OcxConfig, desktopProfile: NonNullable["desktopProfile"], + source: ConfigMutationSource = { surface: "api", detail: "PUT /api/native-integrations/claude-desktop" }, ): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" } { const outcome = mutatePersistedConfig(persisted => { persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopProfile }; return { changed: true, value: true }; - }); + }, source); // Only mirror into memory once the durable write actually landed; an // `unavailable` outcome must not leave the snapshot claiming a saved profile. if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; @@ -216,7 +218,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise ); if (result.written && result.fingerprint) { current.claudeCode = { ...current.claudeCode, desktopProfile: { ...current.claudeCode.desktopProfile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } }; - saveConfigPreservingClaudeCode(current); + saveConfigPreservingClaudeCode(current, { surface: "internal", detail: "auto-apply desktop fingerprint" }); } } catch { /* best-effort */ } } @@ -342,13 +344,13 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (wantsMode) { if (mode === "default") delete config.multiAgentMode; else config.multiAgentMode = mode; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "api", detail: "PUT /api/v2 (mode)" }); warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`); } if (wantsKeepNative) { if (body.keepNativeChatGptOnV1 === true) config.keepNativeChatGptOnV1 = true; else delete config.keepNativeChatGptOnV1; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "api", detail: "PUT /api/v2 (keep-native-v1)" }); const effectiveMode = mode ?? config.multiAgentMode ?? "default"; warnings.push(body.keepNativeChatGptOnV1 === true ? (effectiveMode === "v2" @@ -566,7 +568,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (nextPrompt) config.injectionPrompt = nextPrompt; else delete config.injectionPrompt; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "api", detail: "PUT /api/injection-model" }); return jsonResponse({ ok: true, multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config), @@ -601,7 +603,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } config[key] = value; } - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "api", detail: "PUT /api/effort-caps" }); return jsonResponse({ ok: true, effortCap: config.effortCap ?? null, subagentEffortCap: config.subagentEffortCap ?? null }); } @@ -648,7 +650,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const chosen = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string").slice(0, 5) : []; config.subagentModels = chosen; const { saveConfigPreservingClaudeCode: save } = await import("../../config"); - save(config); + save(config, { surface: "api", detail: "PUT /api/subagent-models" }); const catalogRefresh = await convergeCodexCatalog(); await syncClaudeAgentDefsBestEffort(); await autoApplyDesktopBestEffort(); @@ -717,7 +719,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise else delete config.subagentModelFallback; if (nextPollMs !== undefined) config.subagentModelFallbackPollMs = nextPollMs; else delete config.subagentModelFallbackPollMs; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "api", detail: "PUT /api/subagent-model-fallback" }); return jsonResponse({ ok: true, models: config.subagentModelFallback ?? [], @@ -759,7 +761,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (excluded.length > 2000) return jsonResponse({ error: "excluded list is too large" }, 400); if (excluded.length === 0) delete config.grokExcludedModels; else config.grokExcludedModels = excluded; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "api", detail: "PUT /api/grok/selection" }); return jsonResponse({ ok: true, excluded }); } @@ -816,7 +818,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } const state = await buildClaudeDesktopState(config, parsed); config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconcileDesktopProfile(state.profile, state.models) }; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "api", detail: "PUT /api/native-integrations/claude-desktop (profile)" }); const saved = await buildClaudeDesktopState(config); const runtimePort = Number(url.port) || config.port; return jsonResponse({ ok: true, ...saved, port: runtimePort }); @@ -858,7 +860,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } } const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); - const desired = setIntegrationEnabled("claude-desktop", true); + const desired = setIntegrationEnabled("claude-desktop", true, { surface: "api", detail: "POST /api/claude-desktop/apply" }); if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); // Disk now says ON; the reused server snapshot must agree, or the native // GET reports OFF and a later whole-snapshot save undoes this transition. @@ -869,7 +871,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // its stale `clientIntegrations` back over that write and turn the enable // action into an immediate self-cancelling OFF — the guard below would then // refuse the apply it was asked to perform. Persist ONLY the profile field. - const profileSaved = persistDesktopProfileField(config, state.profile); + const profileSaved = persistDesktopProfileField(config, state.profile, { surface: "api", detail: "POST /api/claude-desktop/apply" }); if (!profileSaved.ok) { return jsonResponse({ error: `Claude Desktop profile could not be saved (${profileSaved.reason}); nothing was applied.`, @@ -916,7 +918,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise ...state.profile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString(), - }); + }, { surface: "api", detail: "POST /api/claude-desktop/apply (fingerprint)" }); if (!marked.ok) { return jsonResponse({ ok: true, @@ -1313,7 +1315,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // auto would survive exactly one proxy lifetime with no way back. if (!next.authModeMigratedAt) next.authModeMigratedAt = new Date().toISOString(); const { saveConfigPreservingClaudeCode: save } = await import("../../config"); - save(config); + save(config, { surface: "api", detail: "PUT /api/claude-code" }); const warnings: string[] = []; // authMode changes must reconcile the injected system env too: switching back to // Subscription has to remove the opencodex-owned dummy ANTHROPIC_AUTH_TOKEN diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index d347bef449..d0e75a34d2 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -224,7 +224,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise; try { @@ -442,7 +460,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { const enabled = body.enabled; const { setCodexIntegrationEnabled } = await import("../../codex/desired-state"); - const persisted = setCodexIntegrationEnabled(enabled); + const persisted = setCodexIntegrationEnabled(enabled, { surface: "api", detail: "PUT /api/native-integrations/codex" }); /* * `missing` does not block the switch — see the Grok route for the reasoning. * A user with no config file yet still gets the artifact change; what they @@ -430,7 +430,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { * on, where the other order leaves artifacts the next start undoes. */ const { setGrokIntegrationEnabled } = await import("../../codex/desired-state"); - const persisted = setGrokIntegrationEnabled(enabled); + const persisted = setGrokIntegrationEnabled(enabled, { surface: "api", detail: "PUT /api/native-integrations/grok" }); /* * `missing` does NOT block the toggle here. * @@ -618,7 +618,7 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise k.id === body.id); if (!entry) return jsonResponse({ error: "key not found" }, 404, req, config); entry.name = nameField.value; - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "api", detail: "PATCH /api/keys" }); reconcileLiveStateStores(); // Never echo key material from a rename. return jsonResponse({ id: entry.id, name: entry.name, createdAt: entry.createdAt }, 200, req, config); @@ -629,7 +629,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< config.apiKeys = (config.apiKeys ?? []).filter(k => k.id !== body.id); // A stale id must not read as a successful revocation. if (config.apiKeys.length === before) return jsonResponse({ error: "key not found" }, 404, req, config); - saveConfigPreservingClaudeCode(config); + saveConfigPreservingClaudeCode(config, { surface: "api", detail: "DELETE /api/keys" }); reconcileLiveStateStores(); return jsonResponse({ success: true }, 200, req, config); } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 47909e50f0..05eb07facb 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -570,7 +570,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 0) config.routingProfiles = nextProfiles; else delete config.routingProfiles; const saveConfigPreservingClaudeCodeSafe = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; - saveConfigPreservingClaudeCodeSafe(config); + saveConfigPreservingClaudeCodeSafe(config, { surface: "api", detail: "DELETE /api/routing-profiles" }); reconcileLiveStateStores(); const catalogRefresh = await convergeCodexCatalog(); return jsonResponse({ success: true, id, catalogRefresh }, 200, req, config); diff --git a/tests/config-mutation-audit.test.ts b/tests/config-mutation-audit.test.ts new file mode 100644 index 0000000000..982333cfbf --- /dev/null +++ b/tests/config-mutation-audit.test.ts @@ -0,0 +1,490 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + buildConfigMutationSnapshot, + loadConfig, + mutatePersistedConfig, + readConfigMutationAudit, + saveConfig, + saveConfigPreservingClaudeCode, + setConfigAtomicWriteFailureForTests, + setConfigAuditMaxRowsForTests, +} from "../src/config"; +import type { OcxConfig } from "../src/types"; +import { handleManagementAPI } from "../src/server/management-api"; +import { + resetPreservedDiskOnlyProvidersForTests, + setPreservedDiskOnlyProviders, +} from "../src/usage/user-cost-overlays"; +import type { OcxProviderConfig } from "../src/types"; + +let testRoot = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testRoot = mkdtempSync(join(import.meta.dir, ".tmp-config-audit-")); + process.env.OPENCODEX_HOME = testRoot; + setConfigAuditMaxRowsForTests(5); +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + setConfigAuditMaxRowsForTests(null); + setConfigAtomicWriteFailureForTests(null); + resetPreservedDiskOnlyProvidersForTests(); + rmSync(testRoot, { recursive: true, force: true }); +}); + +function configWithProvider(port = 10100): OcxConfig { + return { + port, + defaultProvider: "blsc", + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn/v1", + authMode: "key", + apiKey: "sk-super-secret-value", + }, + }, + } as unknown as OcxConfig; +} + +describe("config mutation audit log", () => { + test("saveConfig records source, changed fields, and redacts secrets", () => { + saveConfig(configWithProvider(), { surface: "cli", detail: "ocx test write" }); + const { rows } = readConfigMutationAudit(); + expect(rows).toHaveLength(1); + expect(rows[0].surface).toBe("cli"); + expect(rows[0].detail).toBe("ocx test write"); + expect(rows[0].fields).toEqual([""]); + expect(JSON.stringify(rows[0].after)).not.toContain("sk-super-secret-value"); + expect(JSON.stringify(rows[0].after)).toContain("[REDACTED]"); + expect(JSON.stringify(rows[0].before)).toBe(JSON.stringify({ "": null })); + }); + + test("a byte-identical save records nothing", () => { + saveConfig(configWithProvider()); + const before = readConfigMutationAudit().rows.length; + saveConfig(configWithProvider()); + expect(readConfigMutationAudit().rows.length).toBe(before); + }); + + test("mutatePersistedConfig records fine-grained fields with redaction", () => { + saveConfig(configWithProvider()); + const outcome = mutatePersistedConfig(persisted => { + persisted.providers.blsc.apiKey = "sk-new-secret-value"; + return { changed: true, value: true }; + }, { surface: "api", detail: "PUT /api/providers/blsc" }); + expect(outcome.status).toBe("committed"); + const { rows } = readConfigMutationAudit(); + expect(rows).toHaveLength(2); + expect(rows[0].surface).toBe("api"); + expect(rows[0].detail).toBe("PUT /api/providers/blsc"); + expect(rows[0].fields).toContain("providers.blsc.apiKey"); + expect(JSON.stringify(rows[0].before)).not.toContain("sk-super-secret-value"); + expect(JSON.stringify(rows[0].after)).not.toContain("sk-new-secret-value"); + expect(JSON.stringify(rows[0].after)).toContain("[REDACTED]"); + }); + + test("saveConfigPreservingClaudeCode records the changed top-level field", () => { + saveConfig(configWithProvider()); + const live = loadConfig(); + live.streamMode = "eager-relay"; + saveConfigPreservingClaudeCode(live, { surface: "api", detail: "PUT /api/settings" }); + const { rows } = readConfigMutationAudit(); + expect(rows[0].surface).toBe("api"); + expect(rows[0].detail).toBe("PUT /api/settings"); + expect(rows[0].fields).toContain("streamMode"); + expect(rows[0].after.streamMode).toBe("eager-relay"); + }); + + test("retention keeps only the newest bounded rows", () => { + setConfigAuditMaxRowsForTests(3); + for (let i = 0; i < 5; i += 1) saveConfig(configWithProvider(10100 + i)); + const { rows, maxRows } = readConfigMutationAudit(); + expect(maxRows).toBe(3); + expect(rows).toHaveLength(3); + // Newest first: the last three ports survive. + expect(rows[0].fields).toContain("port"); + expect(rows.map(row => row.after.port)).toEqual([10104, 10103, 10102]); + expect(rows.map(row => row.before.port)).not.toContain(10100); + }); + + test("buildConfigMutationSnapshot is bounded and redacts secrets", () => { + const snapshot = buildConfigMutationSnapshot( + { providers: { a: { apiKey: "sk-old", baseUrl: "u" } }, port: 1 }, + { providers: { a: { apiKey: "sk-new", baseUrl: "u" } }, port: 2 }, + ); + expect(snapshot.fields.sort()).toEqual(["port", "providers.a.apiKey"]); + expect(JSON.stringify(snapshot.before)).not.toContain("sk-old"); + expect(JSON.stringify(snapshot.after)).not.toContain("sk-new"); + expect(JSON.stringify(snapshot.after)).toContain("[REDACTED]"); + }); + + test("a secret-shaped provider name is redacted in the changed-field paths", () => { + saveConfig(configWithProvider()); + const outcome = mutatePersistedConfig(persisted => { + persisted.providers["sk-live-abcdefghijklmnopqrstuvwxyz012345"] = { + adapter: "openai-chat", + baseUrl: "https://example.invalid/v1", + } as unknown as OcxConfig["providers"][string]; + return { changed: true, value: true }; + }, { surface: "api", detail: "PUT /api/providers" }); + expect(outcome.status).toBe("committed"); + const { rows } = readConfigMutationAudit(); + expect(JSON.stringify(rows[0].fields)).not.toContain("sk-live-abcdefghijklmnopqrstuvwxyz012345"); + }); + + test("dotted provider names keep their before/after values", () => { + saveConfig(configWithProvider()); + const outcome = mutatePersistedConfig(persisted => { + persisted.providers["my.provider"] = { + adapter: "openai-chat", + baseUrl: "https://example.invalid/v1", + } as unknown as OcxConfig["providers"][string]; + return { changed: true, value: true }; + }, { surface: "api", detail: "PUT /api/providers" }); + expect(outcome.status).toBe("committed"); + const { rows } = readConfigMutationAudit(); + expect(rows[0].fields).toContain("providers.my.provider"); + expect(rows[0].after["providers.my.provider"]).toEqual({ + adapter: "openai-chat", + baseUrl: "https://example.invalid/v1", + }); + }); + + test("credential-shaped leaves are redacted by key matcher", () => { + saveConfig(configWithProvider()); + const outcome = mutatePersistedConfig(persisted => { + persisted.providers.blsc = { + ...persisted.providers.blsc, + apiKeyPool: [{ key: "sk-pool-secret-value" }], + oauthClientSecret: "oauth-client-secret-value", + } as unknown as OcxConfig["providers"][string]; + return { changed: true, value: true }; + }, { surface: "api", detail: "PUT /api/providers/blsc" }); + expect(outcome.status).toBe("committed"); + const { rows } = readConfigMutationAudit(); + const serialized = JSON.stringify(rows[0]); + expect(serialized).not.toContain("sk-pool-secret-value"); + expect(serialized).not.toContain("oauth-client-secret-value"); + expect(serialized).toContain("[REDACTED]"); + }); + + test("apiKeys entry key is redacted even inside a whole-config snapshot", () => { + const rawAdmissionKey = "ocx_data_admission_secret_do_not_leak"; + saveConfig({ + ...configWithProvider(), + apiKeys: [{ + id: "admission-1", + name: "benchmark key", + key: rawAdmissionKey, + createdAt: "2026-08-23T00:00:00.000Z", + }], + }, { surface: "api", detail: "PUT /api/admission-keys" }); + const first = readConfigMutationAudit(); + expect(first.rows).toHaveLength(1); + expect(JSON.stringify(first.rows[0])).not.toContain(rawAdmissionKey); + expect(JSON.stringify(first.rows[0])).toContain("[REDACTED]"); + // A later mutation that changes only the key field must also be masked. + const outcome = mutatePersistedConfig(persisted => { + persisted.apiKeys = [{ + id: "admission-1", + name: "benchmark key", + key: "ocx_data_second_secret_do_not_leak", + createdAt: "2026-08-23T00:00:00.000Z", + }]; + return { changed: true, value: true }; + }, { surface: "api", detail: "PUT /api/admission-keys" }); + expect(outcome.status).toBe("committed"); + const second = readConfigMutationAudit(); + expect(JSON.stringify(second.rows[0])).not.toContain("ocx_data_second_secret_do_not_leak"); + expect(second.rows[0].fields).toContain("apiKeys"); + }); + + test("degraded apiKeys entries (missing metadata) are redacted in before and after", () => { + const rawAdmissionKey = "ocx_data_degraded_secret_do_not_leak"; + // A hand-edited / older row with only key+name: the schema salvages it, and the + // before snapshot is built from the RAW disk bytes, so the mask must not depend + // on the full happy-path entry shape. + const configPath = join(testRoot, "config.json"); + writeFileSync(configPath, JSON.stringify({ + ...configWithProvider(), + apiKeys: [{ key: rawAdmissionKey, name: "degraded" }], + }, null, 2) + "\n"); + const outcome = mutatePersistedConfig(persisted => { + persisted.apiKeys = [{ + id: "degraded-1", + name: "degraded", + key: rawAdmissionKey, + createdAt: "2026-08-23T00:00:00.000Z", + }]; + return { changed: true, value: true }; + }, { surface: "api", detail: "PUT /api/admission-keys" }); + expect(outcome.status).toBe("committed"); + const { rows } = readConfigMutationAudit(); + const serialized = JSON.stringify(rows[0]); + expect(serialized).not.toContain(rawAdmissionKey); + expect(serialized).toContain("[REDACTED]"); + }); + + test("the pending marker never contains the raw apiKeys admission secret", () => { + setConfigAtomicWriteFailureForTests(() => new Error("stop after marker")); + expect(() => saveConfig({ + ...configWithProvider(), + apiKeys: [{ + id: "marker-1", + name: "marker key", + key: "ocx_data_marker_secret_do_not_leak", + createdAt: "2026-08-23T00:00:00.000Z", + }], + }, { surface: "api", detail: "PUT /api/admission-keys" })).toThrow("stop after marker"); + const marker = readFileSync(join(testRoot, "config-mutation-pending.json"), "utf8"); + expect(marker).not.toContain("ocx_data_marker_secret_do_not_leak"); + expect(marker).toContain("[REDACTED]"); + }); + + test("apiKeyPool rows are redacted by key name even without an sk- prefix", () => { + saveConfig(configWithProvider()); + const outcome = mutatePersistedConfig(persisted => { + persisted.providers.blsc.apiKeyPool = [{ key: "plain-pool-secret-value" }]; + return { changed: true, value: true }; + }, { surface: "api", detail: "PUT /api/providers/blsc" }); + expect(outcome.status).toBe("committed"); + const { rows } = readConfigMutationAudit(); + const serialized = JSON.stringify(rows[0]); + expect(serialized).not.toContain("plain-pool-secret-value"); + expect(serialized).toContain("[REDACTED]"); + }); + + test("redacted field labels stay unique when distinct paths collapse", () => { + saveConfig(configWithProvider()); + const outcome = mutatePersistedConfig(persisted => { + persisted.providers["sk-live-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] = { + adapter: "openai-chat", + baseUrl: "https://a.invalid/v1", + } as unknown as OcxConfig["providers"][string]; + persisted.providers["sk-live-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] = { + adapter: "openai-chat", + baseUrl: "https://b.invalid/v1", + } as unknown as OcxConfig["providers"][string]; + return { changed: true, value: true }; + }, { surface: "api", detail: "PUT /api/providers" }); + expect(outcome.status).toBe("committed"); + const { rows } = readConfigMutationAudit(); + const fields = rows[0].fields; + // Distinct paths that both redact to providers.[REDACTED]. keep unique labels. + expect(new Set(fields).size).toBe(fields.length); + const redactedFields = fields.filter(field => field.includes("[REDACTED]")); + expect(redactedFields.length).toBeGreaterThanOrEqual(2); + for (const field of redactedFields) { + expect(rows[0].after[field]).toBeDefined(); + } + }); + + test("a disk-only provider is not reported as deleted", () => { + saveConfig(configWithProvider()); + + // Simulate an external editor adding a provider the in-memory config never saw. + const configPath = join(testRoot, "config.json"); + const onDisk = JSON.parse(readFileSync(configPath, "utf-8")) as Record; + const staging = { adapter: "openai-chat", baseUrl: "https://staging.invalid/v1" }; + onDisk.providers.staging = staging; + writeFileSync(configPath, JSON.stringify(onDisk, null, 2) + "\n"); + // Mirror the running server: the admission snapshot has already seen the disk-only row. + setPreservedDiskOnlyProviders({ staging } as Record); + + saveConfig(configWithProvider(10500), { surface: "cli", detail: "ocx port change" }); + + const { rows } = readConfigMutationAudit(); + expect(rows[0].fields).not.toContain("providers.staging"); + // The provider must still be on disk. + const after = JSON.parse(readFileSync(configPath, "utf-8")) as Record; + expect(after.providers.staging).toBeDefined(); + }); + + test("a crash after the config rename is replayed from the pending marker", () => { + saveConfig(configWithProvider(10100), { surface: "cli", detail: "ocx first" }); + const configPath = join(testRoot, "config.json"); + // Simulate a crash between the config.json rename and the audit-row commit: + // disk already carries the new bytes, the audit row does not exist yet. + const next = configWithProvider(10500); + const bytes = JSON.stringify(next, null, 2) + "\n"; + writeFileSync(configPath, bytes); + writeFileSync(join(testRoot, "config-mutation-pending.json"), JSON.stringify({ + createdAt: 1234567890, + surface: "api", + detail: "PUT /api/test-crash", + fields: ["port"], + before: { port: 10100 }, + after: { port: 10500 }, + afterSha256: createHash("sha256").update(bytes).digest("hex"), + })); + + // The next write (even a byte-identical retry) reconciles the marker first. + saveConfig(configWithProvider(10500), { surface: "cli", detail: "ocx retry" }); + + const { rows } = readConfigMutationAudit(); + expect(rows.some(row => row.detail === "PUT /api/test-crash")).toBe(true); + // The byte-identical retry records nothing of its own. + expect(rows.some(row => row.detail === "ocx retry")).toBe(false); + expect(existsSync(join(testRoot, "config-mutation-pending.json"))).toBe(false); + }); + + test("a pending marker whose rename never landed is dropped without a phantom row", () => { + saveConfig(configWithProvider(10100), { surface: "cli", detail: "ocx first" }); + const bytes = JSON.stringify(configWithProvider(10500), null, 2) + "\n"; + writeFileSync(join(testRoot, "config-mutation-pending.json"), JSON.stringify({ + createdAt: 1234567890, + surface: "api", + detail: "PUT /api/test-never-landed", + fields: ["port"], + before: { port: 10100 }, + after: { port: 10500 }, + afterSha256: createHash("sha256").update(bytes).digest("hex"), + })); + + saveConfig(configWithProvider(10600), { surface: "cli", detail: "ocx next" }); + + const { rows } = readConfigMutationAudit(); + expect(rows.some(row => row.detail === "PUT /api/test-never-landed")).toBe(false); + expect(rows[0].detail).toBe("ocx next"); + expect(existsSync(join(testRoot, "config-mutation-pending.json"))).toBe(false); + }); + + test("the read path replays an orphaned pending marker without duplicating", () => { + saveConfig(configWithProvider(10100), { surface: "cli", detail: "ocx first" }); + const configPath = join(testRoot, "config.json"); + const bytes = JSON.stringify(configWithProvider(10500), null, 2) + "\n"; + writeFileSync(configPath, bytes); + writeFileSync(join(testRoot, "config-mutation-pending.json"), JSON.stringify({ + createdAt: 1234567890, + surface: "api", + detail: "PUT /api/test-crash", + fields: ["port"], + before: { port: 10100 }, + after: { port: 10500 }, + afterSha256: createHash("sha256").update(bytes).digest("hex"), + })); + + const first = readConfigMutationAudit(); + const second = readConfigMutationAudit(); + expect(first.rows.filter(row => row.detail === "PUT /api/test-crash")).toHaveLength(1); + expect(second.rows.filter(row => row.detail === "PUT /api/test-crash")).toHaveLength(1); + expect(existsSync(join(testRoot, "config-mutation-pending.json"))).toBe(false); + }); + + test("a rollback after reconciliation keeps the recovered audit row committed", () => { + saveConfig(configWithProvider(10100), { surface: "cli", detail: "ocx first" }); + const configPath = join(testRoot, "config.json"); + const bytes = JSON.stringify(configWithProvider(10500), null, 2) + "\n"; + writeFileSync(configPath, bytes); + const markerPath = join(testRoot, "config-mutation-pending.json"); + writeFileSync(markerPath, JSON.stringify({ + createdAt: 1234567890, + surface: "api", + detail: "PUT /api/test-crash", + fields: ["port"], + before: { port: 10100 }, + after: { port: 10500 }, + afterSha256: createHash("sha256").update(bytes).digest("hex"), + })); + + // Reconciliation commits the recovered row in its own transaction, so a + // mutation that fails afterwards cannot roll the row back or let a new marker + // overwrite the marker whose replay already committed. + expect(() => mutatePersistedConfig(() => { + throw new Error("mutation failed after reconciliation"); + }, { surface: "api", detail: "PUT /api/fails" })).toThrow(); + let audit = readConfigMutationAudit(); + expect(audit.rows.some(row => row.detail === "PUT /api/test-crash")).toBe(true); + expect(existsSync(markerPath)).toBe(false); + + saveConfig(configWithProvider(10600), { surface: "cli", detail: "ocx next" }); + audit = readConfigMutationAudit(); + expect(audit.rows.some(row => row.detail === "PUT /api/test-crash")).toBe(true); + expect(audit.rows.filter(row => row.detail === "PUT /api/test-crash")).toHaveLength(1); + expect(audit.rows[0].detail).toBe("ocx next"); + }); + + test("a failed config write cannot let a new marker replace a recovered row", () => { + saveConfig(configWithProvider(10100), { surface: "cli", detail: "ocx first" }); + const configPath = join(testRoot, "config.json"); + // Simulate a crash after an earlier rename: disk carries the new bytes and the + // audit row has not committed yet (recovered row C1). + const interruptedBytes = JSON.stringify(configWithProvider(10500), null, 2) + "\n"; + writeFileSync(configPath, interruptedBytes); + writeFileSync(join(testRoot, "config-mutation-pending.json"), JSON.stringify({ + createdAt: 1234567890, + surface: "api", + detail: "PUT /api/test-crash", + fields: ["port"], + before: { port: 10100 }, + after: { port: 10500 }, + afterSha256: createHash("sha256").update(interruptedBytes).digest("hex"), + })); + + // The next save writes a NEW marker (P2), then fails before the config rename + // lands. P2 must not be able to clobber the C1 row that already committed. + setConfigAtomicWriteFailureForTests(() => new Error("simulated config write failure")); + expect(() => saveConfig(configWithProvider(10600), { + surface: "api", + detail: "PUT /api/failed-write", + })).toThrow("simulated config write failure"); + // The failed write left its marker behind and the config rename never landed. + const markerPath = join(testRoot, "config-mutation-pending.json"); + expect(existsSync(markerPath)).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe(interruptedBytes); + + const audit = readConfigMutationAudit(); + // The recovered C1 row survives exactly once; the failed write recorded nothing. + expect(audit.rows.filter(row => row.detail === "PUT /api/test-crash")).toHaveLength(1); + expect(audit.rows.some(row => row.detail === "PUT /api/failed-write")).toBe(false); + + // The next successful save reconciles P2 (rename never landed -> dropped) and + // C1 remains committed exactly once. + saveConfig(configWithProvider(10700), { surface: "cli", detail: "ocx next" }); + const after = readConfigMutationAudit(); + expect(after.rows.filter(row => row.detail === "PUT /api/test-crash")).toHaveLength(1); + expect(after.rows.some(row => row.detail === "PUT /api/failed-write")).toBe(false); + expect(after.rows[0].detail).toBe("ocx next"); + expect(existsSync(markerPath)).toBe(false); + }); +}); + +describe("config mutation audit management API", () => { + test("GET /api/config/mutations returns the bounded trail newest-first", async () => { + saveConfig(configWithProvider(10100), { surface: "cli", detail: "ocx first" }); + saveConfig(configWithProvider(10200), { surface: "api", detail: "PUT /api/test" }); + const url = new URL("http://127.0.0.1:10100/api/config/mutations?limit=1"); + const response = await handleManagementAPI( + new Request(url, { headers: { Host: "127.0.0.1:10100" } }), + url, + loadConfig(), + {}, + "admin-token", + ); + expect(response).not.toBeNull(); + const body = await response!.json() as { mutations: Array<{ detail: string }>; retention: { maxRows: number } }; + expect(body.mutations).toHaveLength(1); + expect(body.mutations[0].detail).toBe("PUT /api/test"); + expect(body.retention.maxRows).toBe(5); + }); + + test("GET /api/config/mutations rejects anonymous and unauthorized principals", async () => { + saveConfig(configWithProvider(), { surface: "cli", detail: "ocx first" }); + const url = new URL("http://127.0.0.1:10100/api/config/mutations"); + const request = () => new Request(url, { headers: { Host: "127.0.0.1:10100" } }); + const anonymous = await handleManagementAPI(request(), url, loadConfig()); + expect(anonymous?.status).toBe(401); + const capability = await handleManagementAPI(request(), url, loadConfig(), {}, "local-read-capability"); + expect(capability?.status).toBe(403); + const admin = await handleManagementAPI(request(), url, loadConfig(), {}, "admin-token"); + expect(admin?.status).toBe(200); + }); +}); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 335392ea8d..ea23733778 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -630,6 +630,20 @@ describe("management and data-plane credential separation", () => { errorSpy.mockRestore(); } }); + test("GET /api/config/mutations requires the management token", async () => { + saveConfig(remoteConfig()); + const server = startServer(0); + try { + const anonymous = await fetch(new URL("/api/config/mutations", server.url)); + expect(anonymous.status).toBe(401); + const authorized = await fetch(new URL("/api/config/mutations", server.url), { + headers: { "x-opencodex-api-key": "admin-secret" }, + }); + expect(authorized.status).toBe(200); + } finally { + await server.stop(true); + } + }); test("a management token that matches the data environment token closes only the management plane", async () => { process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "data-secret"; saveConfig(remoteConfig()); @@ -1136,4 +1150,4 @@ describe("codex app-server restart routes ride the management gate", () => { await server.stop(true); } }); -}); \ No newline at end of file +});