From d30c7ecf6c37366b8249e9eee09c66de06857c00 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:59:12 +0900 Subject: [PATCH 1/6] fix(history): refuse external writes to paginated Codex rollouts --- src/codex/history-provider.ts | 38 +++++++++++++++++++ structure/catalog.md | 4 ++ structure/codex-home.md | 4 ++ structure/config.md | 4 ++ structure/gui-and-management-api.md | 4 ++ structure/ops/docs-and-release.md | 4 ++ structure/providers/openai-tiers.md | 4 ++ structure/runtime.md | 4 ++ structure/subagents.md | 4 ++ .../codex-history-provider.test.ts | 38 +++++++++++++++++++ 10 files changed, 108 insertions(+) diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index d7f1832e8fc..95440ff615c 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -75,6 +75,11 @@ function openStateDb(stateDbPath: string): Database { * rollout's updated_at), and forcing it backwards could hide a real edit from list ordering. */ function appendRolloutLine(path: string, line: string): Buffer { + assertLegacyHistoryWritable(path); + const record = JSON.parse(line) as { ordinal?: unknown; payload?: { history_mode?: unknown } }; + if (Object.hasOwn(record, "ordinal") || record.payload?.history_mode === "paginated") { + throw new CodexHistoryIntegrityError("history_paginated_requires_native_writer"); + } const fd = openSync(path, "a"); const buf = Buffer.from(line.endsWith("\n") ? line : `${line}\n`, "utf8"); try { @@ -221,6 +226,25 @@ class CodexHistoryIntegrityError extends Error { } } +/** Paginated ordinals and projection offsets belong to Codex's live writer. + * O_APPEND does not allocate an ordinal or update that writer's in-memory cursor. + * Refuse before changing the DB, manifest, or first-line provider; never guess N+1. + */ +function assertLegacyHistoryWritable(path: string): void { + if (!path || !existsSync(path)) return; + const fd = openSync(path, "r"); + try { + const first = readFirstRolloutLine(fd); + if (!first) return; + const record = JSON.parse(first) as { ordinal?: unknown; payload?: { history_mode?: unknown } }; + if (Object.hasOwn(record, "ordinal") || record.payload?.history_mode === "paginated") { + throw new CodexHistoryIntegrityError("history_paginated_requires_native_writer"); + } + } finally { + closeSync(fd); + } +} + function integrityFailureResult(error: CodexHistoryIntegrityError): CodexHistorySyncResult { return { rows: error.progress.rows, @@ -1040,6 +1064,11 @@ function updateSessionMeta( if (!latest) return { changed: false, durableProvider: false }; const record = latest.record; + assertLegacyHistoryWritable(path); + if (Object.hasOwn(record, "ordinal") || record.payload.history_mode === "paginated") { + throw new CodexHistoryIntegrityError("history_paginated_requires_native_writer"); + } + const latestProvider = typeof record.payload.model_provider === "string" && record.payload.model_provider ? record.payload.model_provider : "openai"; @@ -1151,6 +1180,7 @@ function relabelAllRoutedHistoryToOpenai(db: Database): { rows: number; files: n `) .all(); + for (const row of rows) assertLegacyHistoryWritable(row.rollout_path); let files = 0; for (const row of rows) { try { @@ -1302,6 +1332,7 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP `) .all(); + for (const row of [...openaiRows, ...execRows]) assertLegacyHistoryWritable(row.rollout_path); const manifest = readBackup(backupPath, stateDbPath).manifest; for (const row of [...openaiRows, ...execRows]) rememberOriginal(manifest, row); writeBackup(backupPath, manifest, stateDbPath); @@ -1409,6 +1440,8 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C const manifest = backup.manifest; const entries = Object.values(manifest.entries); + for (const entry of entries) assertLegacyHistoryWritable(entry.rolloutPath); + const db = openStateDb(stateDbPath); try { if (entries.length === 0) return { rows: 0, files: 0 }; @@ -1547,6 +1580,7 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C export function restoreLegacyOpenaiHistory(stateDbPath = resolveCodexStateDbPath()): CodexHistorySyncResult { if (!existsSync(stateDbPath)) return { rows: 0, files: 0 }; + try { const retried = withHistoryRetryResult(() => { const db = openStateDb(stateDbPath); try { @@ -1556,6 +1590,10 @@ export function restoreLegacyOpenaiHistory(stateDbPath = resolveCodexStateDbPath } }); return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason }; + } catch (error) { + if (error instanceof CodexHistoryIntegrityError) return integrityFailureResult(error); + throw error; + } } /** diff --git a/structure/catalog.md b/structure/catalog.md index 9a0ca082966..97eca6cb81f 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -261,3 +261,7 @@ provider wire mapping; unpinned native requests retain their existing pass-throu > Decision record: [ADR-0025](decisions/ADR-0025-ultra-reasoning-level.md) > Decision record: [ADR-0026](decisions/ADR-0026-ultra-reasoning-level.md) + +## Paginated history writer boundary + +Catalog convergence can include a provider-history transition. Paginated history refusal is an explicit incomplete transition, not successful migration; see [Codex Home](codex-home.md#paginated-history-writer-boundary). diff --git a/structure/codex-home.md b/structure/codex-home.md index 9b5e1ded4ad..9c6274f8611 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -223,3 +223,7 @@ a deliberate user choice: - Project-level Codex config that bypasses managed routing (`src/codex/project-config-warnings.ts`), surfaced by `ocx doctor` as a warning rather than an override. + +## Paginated history writer boundary + +`src/codex/history-provider.ts` rejects provider-history changes with `history_paginated_requires_native_writer` when a target begins with an ordinal-bearing record or declares `history_mode=paginated`. Apply, manifest-backed restore, and explicit legacy recovery preflight all selected targets before changing database rows or manifests. The append boundary checks again. Codex owns ordinal allocation and the live projection cursor; reading the last ordinal and appending N+1 is not safe concurrent coordination. Legacy unnumbered rollouts retain their existing behavior. This guard prevents the observed stable-format corruption; it does not implement native-writer integration or guarantee a concurrent legacy-to-paginated conversion is excluded. diff --git a/structure/config.md b/structure/config.md index 224c5a350d1..f1ea1c6037a 100644 --- a/structure/config.md +++ b/structure/config.md @@ -192,3 +192,7 @@ the residual directory for manual review; there is no recursive-delete fallback. ## Remote client key files Client connection metadata stores a stable `apiKeyId` and a non-secret rotation `pendingOperation`. The current data secret remains only in `service-api-token`; a bounded rotation temporarily keeps the old secret in owner-only `service-api-token.prev`. Commit or recovery clears the marker before orphan cleanup. `ocx disconnect` is local-only and leaves remote revocation to the hub's **Integrations → API Keys** page. Hub and local usage stores are not mirrored. + +## Paginated history writer boundary + +Authless routing cannot relabel paginated history through the legacy file writer. A refused history transition must be reported; provider-definition retention during that refusal remains an integration concern. See [Codex Home](codex-home.md#paginated-history-writer-boundary). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index c6b35fbad87..456baf2acdd 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -504,3 +504,7 @@ converge the Codex catalog once and return its disposition. The Models UI owns a picker data resource so failure cannot erase the ordinary model inventory; Apply publishes through the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode survives availability drift, while complete/native custom orders await explicit replacement. + +## Paginated history writer boundary + +Settings convergence must surface paginated-history refusal rather than claim history was migrated. See [Codex Home](codex-home.md#paginated-history-writer-boundary). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 6d7988af785..ca04da85526 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -300,3 +300,7 @@ This keeps release runs short and makes release a deployment of a verified commi ## Remote Hub locale and release gate The Remote Hub guide and affected CLI, server-config, management-API, and dashboard references have eight sources: root English plus `fr`, `ko`, `zh-cn`, `zh-tw`, `ru`, `ja`, and `tr`. English is canonical; commands, defaults, endpoint auth, and warnings remain exact in translations. A release requires the remote-only focused/full gates, privacy scan, GUI/docs builds, protocol compatibility receipts, and the MAINTAINERS security review for the exact head. + +## Paginated history writer boundary + +History-provider regression coverage includes numbered rollout apply/restore refusal without changing source bytes or provenance. See [Codex Home](../codex-home.md#paginated-history-writer-boundary). diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 9f86870bae9..40b1131a214 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -368,3 +368,7 @@ model settings, and noncanonical `openai` rows never receive that recovery path. `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a successful main usage refresh clears the runtime mark. + +## Paginated history writer boundary + +Provider account selection does not authorize relabeling paginated Codex records; the [history writer boundary](../codex-home.md#paginated-history-writer-boundary) applies independently of account mode. diff --git a/structure/runtime.md b/structure/runtime.md index 3099c13bfd4..5144de50cb4 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -185,3 +185,7 @@ not an authentication or entitlement decision. ## Remote Hub hardening ownership `src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. + +## Paginated history writer boundary + +Provider-history transitions refuse paginated rollout writes before changing history state; see [Codex Home](codex-home.md#paginated-history-writer-boundary). diff --git a/structure/subagents.md b/structure/subagents.md index a3513a726a2..afaea173802 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -195,3 +195,7 @@ Claude ModelInfo ordering receives optional `{ modelPickerOrder, featured }` aft It orders routed output groups after alias deduplication, preserving the collision winner and base/1M/Fast siblings. Native groups and explicit Desktop profile ownership are unchanged. Native Codex advertisements still follow display priority; private guidance ranks do not freeze them. + +## Paginated history writer boundary + +History metadata for parent or child threads follows the same [paginated writer boundary](codex-home.md#paginated-history-writer-boundary); a child source does not authorize external ordinal allocation. diff --git a/tests/codex-integration/codex-history-provider.test.ts b/tests/codex-integration/codex-history-provider.test.ts index fbc97238ded..791fc57d4e1 100644 --- a/tests/codex-integration/codex-history-provider.test.ts +++ b/tests/codex-integration/codex-history-provider.test.ts @@ -101,6 +101,44 @@ function makeFixture({ includeExec = false, includeLegacy = false } = {}) { } describe("Codex history provider sync", () => { + for (const marker of ["ordinal", "history_mode"] as const) { + test(`refuses paginated ${marker} before routing any row or writing a manifest`, () => { + const fixture = makeFixture(); + noopSnapshotArtifacts.add(join(fixture.dbPath, "..")); + const records = readFileSync(fixture.rollout, "utf8").trim().split("\n").map(line => JSON.parse(line)); + if (marker === "ordinal") records.forEach((record, ordinal) => { record.ordinal = ordinal; }); + else records[0].payload.history_mode = "paginated"; + const before = records.map(record => JSON.stringify(record)).join("\n") + "\n"; + writeFileSync(fixture.rollout, before); + const result = syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath); + expect(result).toMatchObject({ rows: 0, files: 0, failed: true, integrityCode: "history_paginated_requires_native_writer" }); + expect(readFileSync(fixture.rollout, "utf8")).toBe(before); + expect(existsSync(fixture.backupPath)).toBe(false); + const db = new Database(fixture.dbPath, { readonly: true }); + expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()).toEqual({ model_provider: "openai" }); + db.close(); + }); + } + + test("preserves a routed paginated rollout and its restore manifest", () => { + const fixture = makeFixture(); + noopSnapshotArtifacts.add(join(fixture.dbPath, "..")); + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath).failed).toBeUndefined(); + const records = readFileSync(fixture.rollout, "utf8").trim().split("\n").map(line => JSON.parse(line)); + records.forEach((record, ordinal) => { record.ordinal = ordinal; }); + const before = records.map(record => JSON.stringify(record)).join("\n") + "\n"; + writeFileSync(fixture.rollout, before); + const manifest = readFileSync(fixture.backupPath, "utf8"); + for (const result of [syncCodexHistoryProvider("openai", fixture.dbPath, fixture.backupPath), restoreLegacyOpenaiHistory(fixture.dbPath)]) { + expect(result).toMatchObject({ rows: 0, files: 0, failed: true, integrityCode: "history_paginated_requires_native_writer" }); + } + expect(readFileSync(fixture.rollout, "utf8")).toBe(before); + expect(readFileSync(fixture.backupPath, "utf8")).toBe(manifest); + const db = new Database(fixture.dbPath, { readonly: true }); + expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()).toEqual({ model_provider: "opencodex" }); + db.close(); + }); + test("maps resumable Codex threads to opencodex via the latest session_meta", () => { const { dbPath, backupPath, rollout } = makeFixture(); From e154d649af0f1a223ea49b594ca09d53f7318532 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:19:14 +0900 Subject: [PATCH 2/6] fix(history): preserve provider config before paginated transition refusal --- src/codex/history-provider.ts | 36 +++++++++++++++++++ src/codex/inject.ts | 32 ++++++++++++++++- structure/catalog.md | 2 ++ structure/codex-home.md | 2 ++ structure/config.md | 2 ++ structure/gui-and-management-api.md | 2 ++ structure/ops/docs-and-release.md | 2 ++ structure/providers/openai-tiers.md | 2 ++ structure/runtime.md | 2 ++ structure/subagents.md | 2 ++ .../codex-history-provider.test.ts | 14 ++++++++ .../codex-inject-integration.test.ts | 23 ++++++++++++ 12 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index 95440ff615c..8c8cf4cbcb0 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -245,6 +245,42 @@ function assertLegacyHistoryWritable(path: string): void { } } +/** Read-only preflight before the injector changes provider definitions. + * Native paginated history cannot participate in the legacy relabel protocol. + * Returning a refusal preserves the existing config as well as the rollout. + */ +export function preflightCodexHistoryInjection( + providerTableMode: boolean, + resumeHistory: boolean, + stateDbPath = resolveCodexStateDbPath(), +): string | null { + if (!existsSync(stateDbPath)) return null; + let db: Database | undefined; + try { + db = new Database(stateDbPath, { readonly: true }); + const columns = db.query<{ name: string }, []>("PRAGMA table_info(threads)").all(); + const paginatedColumn = columns.some(column => column.name === "history_mode"); + const rows = db.query<{ rollout_path: string; history_mode: string | null }, []>(` + SELECT rollout_path, ${paginatedColumn ? "history_mode" : "NULL AS history_mode"} + FROM threads + WHERE ${providerTableMode + ? resumeHistory ? "model_provider IN ('openai', 'opencodex')" : "0" + : "model_provider = 'opencodex'"} + `).all(); + for (const row of rows) { + if (row.history_mode === "paginated") return "history_paginated_requires_native_writer"; + assertLegacyHistoryWritable(row.rollout_path); + } + return null; + } catch (error) { + return error instanceof CodexHistoryIntegrityError + ? error.message + : "history_injection_preflight_unavailable"; + } finally { + db?.close(); + } +} + function integrityFailureResult(error: CodexHistoryIntegrityError): CodexHistorySyncResult { return { rows: error.progress.rows, diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 12ae087ac4a..4bc8305b47a 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -46,7 +46,7 @@ import { } from "./journal"; import { withCatalogWriteSerialization } from "./catalog-write-serialization"; import { restoreCodexCatalogWithPermit } from "./catalog/sync"; -import { syncCodexHistoryProvider, type CodexHistoryFailureReason } from "./history-provider"; +import { preflightCodexHistoryInjection, syncCodexHistoryProvider, type CodexHistoryFailureReason } from "./history-provider"; import { describeHistoryJobFailure, deriveCodexHistoryOperation, @@ -75,6 +75,7 @@ import { parseTomlString, readRootTomlString, resolveCodexConfigPath, + resolveCodexStateDbPath, tomlString, } from "./paths"; import { resolveEffectiveProjectModelProvider } from "./project-config-warnings"; @@ -929,6 +930,10 @@ export async function injectCodexConfig( } const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); + const preflightTableMode = usesProviderTable(routingTarget); + const compactionOnly = routingTarget.clientCompaction === true + && routingTarget.desktopAuthless !== true + && routingTarget.requiresAdmissionToken !== true; const activeProvider = externalCodexModelProvider(rawContent); if (activeProvider) { // A launcher may have journaled before the provider manager took ownership. Never let shutdown @@ -1138,6 +1143,29 @@ export async function injectCodexConfig( ); content = applyEol(content, eol); + // Resolve storage from the normalized candidate. Owned duplicate catalog keys + // are repairable above and must not make this read-only preflight throw. + const historyPreflight = (): string | null => { + try { + return preflightCodexHistoryInjection( + preflightTableMode, + config?.syncResumeHistory !== false && !compactionOnly, + resolveCodexStateDbPath({ readConfig: () => content }), + ); + } catch { + return "history_injection_preflight_unavailable"; + } + }; + const historyPreflightError = historyPreflight(); + if (historyPreflightError) { + return { + success: false, + message: `Codex config injection refused: ${historyPreflightError}. ` + + "Existing provider definitions and conversation files were preserved. " + + "Paginated history requires native-writer coordination; do not run legacy recovery or retry this transition blindly.", + }; + } + /* * The witness, built from the FINAL bytes. Everything it hashes is either the * output about to be written or evidence that can be re-read under the lock; @@ -1228,6 +1256,8 @@ export async function injectCodexConfig( } const applyNativeArtifacts = (): void => { + const historyError = historyPreflight(); + if (historyError) throw new Error(historyError); writeJournal({ currentStateIsNative: journalBaselineIsNative(), configContent: baselineContent, diff --git a/structure/catalog.md b/structure/catalog.md index 97eca6cb81f..d14468232c4 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -265,3 +265,5 @@ provider wire mapping; unpinned native requests retain their existing pass-throu ## Paginated history writer boundary Catalog convergence can include a provider-history transition. Paginated history refusal is an explicit incomplete transition, not successful migration; see [Codex Home](codex-home.md#paginated-history-writer-boundary). + +Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. diff --git a/structure/codex-home.md b/structure/codex-home.md index 9c6274f8611..21ca81de7e0 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -227,3 +227,5 @@ a deliberate user choice: ## Paginated history writer boundary `src/codex/history-provider.ts` rejects provider-history changes with `history_paginated_requires_native_writer` when a target begins with an ordinal-bearing record or declares `history_mode=paginated`. Apply, manifest-backed restore, and explicit legacy recovery preflight all selected targets before changing database rows or manifests. The append boundary checks again. Codex owns ordinal allocation and the live projection cursor; reading the last ordinal and appending N+1 is not safe concurrent coordination. Legacy unnumbered rollouts retain their existing behavior. This guard prevents the observed stable-format corruption; it does not implement native-writer integration or guarantee a concurrent legacy-to-paginated conversion is excluded. + +Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. diff --git a/structure/config.md b/structure/config.md index f1ea1c6037a..323244cab35 100644 --- a/structure/config.md +++ b/structure/config.md @@ -196,3 +196,5 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation ## Paginated history writer boundary Authless routing cannot relabel paginated history through the legacy file writer. A refused history transition must be reported; provider-definition retention during that refusal remains an integration concern. See [Codex Home](codex-home.md#paginated-history-writer-boundary). + +Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 456baf2acdd..fc1eb0c6176 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -508,3 +508,5 @@ survives availability drift, while complete/native custom orders await explicit ## Paginated history writer boundary Settings convergence must surface paginated-history refusal rather than claim history was migrated. See [Codex Home](codex-home.md#paginated-history-writer-boundary). + +Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index ca04da85526..5d14e57663b 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -304,3 +304,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo ## Paginated history writer boundary History-provider regression coverage includes numbered rollout apply/restore refusal without changing source bytes or provenance. See [Codex Home](../codex-home.md#paginated-history-writer-boundary). + +Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 40b1131a214..29829d33bdb 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -372,3 +372,5 @@ successful main usage refresh clears the runtime mark. ## Paginated history writer boundary Provider account selection does not authorize relabeling paginated Codex records; the [history writer boundary](../codex-home.md#paginated-history-writer-boundary) applies independently of account mode. + +Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. diff --git a/structure/runtime.md b/structure/runtime.md index 5144de50cb4..8d121ee385c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -189,3 +189,5 @@ not an authentication or entitlement decision. ## Paginated history writer boundary Provider-history transitions refuse paginated rollout writes before changing history state; see [Codex Home](codex-home.md#paginated-history-writer-boundary). + +Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. diff --git a/structure/subagents.md b/structure/subagents.md index afaea173802..1bb45249d15 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -199,3 +199,5 @@ Native Codex advertisements still follow display priority; private guidance rank ## Paginated history writer boundary History metadata for parent or child threads follows the same [paginated writer boundary](codex-home.md#paginated-history-writer-boundary); a child source does not authorize external ordinal allocation. + +Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. diff --git a/tests/codex-integration/codex-history-provider.test.ts b/tests/codex-integration/codex-history-provider.test.ts index 791fc57d4e1..b523c2ed30f 100644 --- a/tests/codex-integration/codex-history-provider.test.ts +++ b/tests/codex-integration/codex-history-provider.test.ts @@ -5,6 +5,7 @@ import { Database } from "bun:sqlite"; import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { classifyRecoverableHistoryError, countPendingOpencodexHistory, historyBackupPathFor, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, restoredUserEventFor, setAfterNoopPendingCountForTests, setAfterStrictHistoryRolloutAppendForTests, setBeforeHistoryApplyTransactionForTests, setBeforeHistoryBackupConsumeForTests, setBeforeStrictHistoryRolloutAppendForTests, setHistoryDbBusyTimeoutForTests, snapshotCodexHistoryNoop, syncCodexHistoryProvider, withHistoryRetry } from "../../src/codex/history-provider"; import { INVALID_HISTORY_BACKUP_FIXTURES, validHistoryBackupFixture } from "../helpers/codex-history-manifest-fixtures"; +import { preflightCodexHistoryInjection } from "../../src/codex/history-provider"; // Windows CI: a transient file lock can consume the full production 5s busy timeout, tripping // bun's 5s default per-test timeout by itself. Fail fast into withHistoryRetry instead. @@ -101,6 +102,19 @@ function makeFixture({ includeExec = false, includeLegacy = false } = {}) { } describe("Codex history provider sync", () => { + test("injection preflight preserves provider definitions needed by paginated threads", () => { + const fixture = makeFixture({ includeLegacy: true }); + noopSnapshotArtifacts.add(join(fixture.dbPath, "..")); + const db = new Database(fixture.dbPath); + db.run("ALTER TABLE threads ADD COLUMN history_mode TEXT DEFAULT 'legacy'"); + db.run("UPDATE threads SET history_mode='paginated' WHERE id='thread-3'"); + db.close(); + expect(preflightCodexHistoryInjection(false, false, fixture.dbPath)).toBe("history_paginated_requires_native_writer"); + expect(preflightCodexHistoryInjection(true, true, fixture.dbPath)).toBe("history_paginated_requires_native_writer"); + expect(preflightCodexHistoryInjection(true, false, fixture.dbPath)).toBeNull(); + expect(existsSync(fixture.backupPath)).toBe(false); + }); + for (const marker of ["ordinal", "history_mode"] as const) { test(`refuses paginated ${marker} before routing any row or writing a manifest`, () => { const fixture = makeFixture(); diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 57e83650f1e..1303682c8cf 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -68,6 +68,29 @@ describe("injectCodexConfig integration (Design B)", () => { removeTreeWithRetry(ocxHome); }); + test.each([false, true])("paginated history preserves config and profile before provider transition (authless=%s)", (authless) => { + const original = 'model_provider = "opencodex"\n[model_providers.opencodex]\nname="OpenCodex"\nbase_url="http://127.0.0.1:10100/v1"\nwire_api="responses"\n'; + const configPath = join(codexHome, "config.toml"); + const profilePath = join(codexHome, "opencodex.config.toml"); + writeFileSync(configPath, original); + writeFileSync(profilePath, "# preserve profile\n"); + const rollout = join(codexHome, "fixture.jsonl"); + const bytes = JSON.stringify({ordinal:0,type:"session_meta",payload:{id:"fixture",history_mode:"paginated",model_provider:"opencodex"}}) + "\n"; + writeFileSync(rollout, bytes); + const db = new Database(join(codexHome, "state_5.sqlite")); + db.run("CREATE TABLE threads (id TEXT, rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); + db.run("INSERT INTO threads VALUES ('fixture', ?, 'opencodex', 'paginated')", rollout); + db.close(); + const result = runInject(codexHome, ocxHome, JSON.stringify({codexDesktopAuthless:authless})); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({success:false}); + expect(result.stdout).toContain("history_paginated_requires_native_writer"); + expect(readFileSync(configPath,"utf8")).toBe(original); + expect(readFileSync(profilePath,"utf8")).toBe("# preserve profile\n"); + expect(readFileSync(rollout,"utf8")).toBe(bytes); + expect(existsSync(join(codexHome,"opencodex-journal.json"))).toBe(false); + }); + test("remote target validate-only writes nothing; commit journals client ownership and restores exact preimage", () => { const original = '# remote baseline\nmodel_provider = "openai"\n'; writeFileSync(join(codexHome, "config.toml"), original, "utf8"); From 16b942958813280bcaafeb357793b2afcacbea9a Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:26:47 +0900 Subject: [PATCH 3/6] fix(history): protect restore and migration-capable stores --- src/codex/history-provider.ts | 22 +++++++++++++++---- src/codex/inject.ts | 8 +++++++ structure/catalog.md | 2 ++ structure/codex-home.md | 2 ++ structure/config.md | 2 ++ structure/gui-and-management-api.md | 2 ++ structure/ops/docs-and-release.md | 2 ++ structure/providers/openai-tiers.md | 2 ++ structure/runtime.md | 2 ++ structure/subagents.md | 2 ++ .../codex-history-provider.test.ts | 11 ++++++++++ .../codex-inject-integration.test.ts | 15 +++++++++++++ 12 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index 8c8cf4cbcb0..d17951868da 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -245,6 +245,16 @@ function assertLegacyHistoryWritable(path: string): void { } } +/** A store with history_mode can migrate legacy rows while Codex is running. + * Refuse the entire external mutation, not only rows already marked paginated. + */ +function assertLegacyHistoryStore(db: Database): void { + const columns = db.query<{ name: string }, []>("PRAGMA table_info(threads)").all(); + if (columns.some(column => column.name === "history_mode")) { + throw new CodexHistoryIntegrityError("history_paginated_requires_native_writer"); + } +} + /** Read-only preflight before the injector changes provider definitions. * Native paginated history cannot participate in the legacy relabel protocol. * Returning a refusal preserves the existing config as well as the rollout. @@ -252,12 +262,13 @@ function assertLegacyHistoryWritable(path: string): void { export function preflightCodexHistoryInjection( providerTableMode: boolean, resumeHistory: boolean, - stateDbPath = resolveCodexStateDbPath(), + stateDbPath?: string, ): string | null { - if (!existsSync(stateDbPath)) return null; let db: Database | undefined; try { - db = new Database(stateDbPath, { readonly: true }); + const resolvedPath = stateDbPath ?? resolveCodexStateDbPath(); + if (!existsSync(resolvedPath)) return null; + db = new Database(resolvedPath, { readonly: true }); const columns = db.query<{ name: string }, []>("PRAGMA table_info(threads)").all(); const paginatedColumn = columns.some(column => column.name === "history_mode"); const rows = db.query<{ rollout_path: string; history_mode: string | null }, []>(` @@ -268,7 +279,7 @@ export function preflightCodexHistoryInjection( : "model_provider = 'opencodex'"} `).all(); for (const row of rows) { - if (row.history_mode === "paginated") return "history_paginated_requires_native_writer"; + if (paginatedColumn || row.history_mode === "paginated") return "history_paginated_requires_native_writer"; assertLegacyHistoryWritable(row.rollout_path); } return null; @@ -1216,6 +1227,7 @@ function relabelAllRoutedHistoryToOpenai(db: Database): { rows: number; files: n `) .all(); + if (rows.length > 0) assertLegacyHistoryStore(db); for (const row of rows) assertLegacyHistoryWritable(row.rollout_path); let files = 0; for (const row of rows) { @@ -1368,6 +1380,7 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP `) .all(); + if (openaiRows.length + execRows.length > 0) assertLegacyHistoryStore(db); for (const row of [...openaiRows, ...execRows]) assertLegacyHistoryWritable(row.rollout_path); const manifest = readBackup(backupPath, stateDbPath).manifest; for (const row of [...openaiRows, ...execRows]) rememberOriginal(manifest, row); @@ -1481,6 +1494,7 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C const db = openStateDb(stateDbPath); try { if (entries.length === 0) return { rows: 0, files: 0 }; + assertLegacyHistoryStore(db); // Validate the whole manifest-to-database target set before touching a rollout. Only the // OpenCodex post-image (or an already-restored target from an interrupted retry) is owned by diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 4bc8305b47a..f55a689bbee 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1668,6 +1668,8 @@ function hasOpencodexRouting(content: string): boolean { export function removeCodexConfig( options: { preserveProfile?: boolean } = {}, ): { success: boolean; message: string } { + const historyError = preflightCodexHistoryInjection(false, false); + if (historyError) return { success: false, message: `Codex configuration preserved: ${historyError}. Native writer coordination is required.` }; if (!existsSync(CODEX_CONFIG_PATH)) { if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH); @@ -1870,6 +1872,8 @@ export function skippedRestoreEnvelope(success: boolean, message: string): Codex /** The config/profile half of a native restore, reported as one artifact. */ function restoreCodexConfigInline(): CodexRestoreConfigResult { try { + const historyError = preflightCodexHistoryInjection(false, false); + if (historyError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${historyError}.` }; const journal = restoreJournalState(); if (journal.unverified) { return { @@ -1943,6 +1947,8 @@ function restoreCodexCatalogArtifact( export async function restoreNativeCodexAsync( options: { revalidateDesiredState?: boolean } = {}, ): Promise { + const historyError = preflightCodexHistoryInjection(false, false); + if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { // External-provider courtesy: only the stale journal is removed. The @@ -2096,6 +2102,8 @@ export async function restoreNativeCodexAsync( } export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateDesiredState?: boolean } = {}): CodexNativeRestoreResult { + const historyError = preflightCodexHistoryInjection(false, false); + if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { removeJournal(); diff --git a/structure/catalog.md b/structure/catalog.md index d14468232c4..e96b75d3b2b 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -267,3 +267,5 @@ provider wire mapping; unpinned native requests retain their existing pass-throu Catalog convergence can include a provider-history transition. Paginated history refusal is an explicit incomplete transition, not successful migration; see [Codex Home](codex-home.md#paginated-history-writer-boundary). Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. + +The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/codex-home.md b/structure/codex-home.md index 21ca81de7e0..3fd224c02d9 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -229,3 +229,5 @@ a deliberate user choice: `src/codex/history-provider.ts` rejects provider-history changes with `history_paginated_requires_native_writer` when a target begins with an ordinal-bearing record or declares `history_mode=paginated`. Apply, manifest-backed restore, and explicit legacy recovery preflight all selected targets before changing database rows or manifests. The append boundary checks again. Codex owns ordinal allocation and the live projection cursor; reading the last ordinal and appending N+1 is not safe concurrent coordination. Legacy unnumbered rollouts retain their existing behavior. This guard prevents the observed stable-format corruption; it does not implement native-writer integration or guarantee a concurrent legacy-to-paginated conversion is excluded. Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. + +The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/config.md b/structure/config.md index 323244cab35..c425df7771c 100644 --- a/structure/config.md +++ b/structure/config.md @@ -198,3 +198,5 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Authless routing cannot relabel paginated history through the legacy file writer. A refused history transition must be reported; provider-definition retention during that refusal remains an integration concern. See [Codex Home](codex-home.md#paginated-history-writer-boundary). Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. + +The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index fc1eb0c6176..152947c2286 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -510,3 +510,5 @@ survives availability drift, while complete/native custom orders await explicit Settings convergence must surface paginated-history refusal rather than claim history was migrated. See [Codex Home](codex-home.md#paginated-history-writer-boundary). Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. + +The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 5d14e57663b..116cba1b9d8 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -306,3 +306,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo History-provider regression coverage includes numbered rollout apply/restore refusal without changing source bytes or provenance. See [Codex Home](../codex-home.md#paginated-history-writer-boundary). Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. + +The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 29829d33bdb..5abfdf3226d 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -374,3 +374,5 @@ successful main usage refresh clears the runtime mark. Provider account selection does not authorize relabeling paginated Codex records; the [history writer boundary](../codex-home.md#paginated-history-writer-boundary) applies independently of account mode. Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. + +The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/runtime.md b/structure/runtime.md index 8d121ee385c..0f3738971a0 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -191,3 +191,5 @@ not an authentication or entitlement decision. Provider-history transitions refuse paginated rollout writes before changing history state; see [Codex Home](codex-home.md#paginated-history-writer-boundary). Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. + +The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/subagents.md b/structure/subagents.md index 1bb45249d15..f76a4b2b52d 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -201,3 +201,5 @@ Native Codex advertisements still follow display priority; private guidance rank History metadata for parent or child threads follows the same [paginated writer boundary](codex-home.md#paginated-history-writer-boundary); a child source does not authorize external ordinal allocation. Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. + +The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/tests/codex-integration/codex-history-provider.test.ts b/tests/codex-integration/codex-history-provider.test.ts index b523c2ed30f..97415c70108 100644 --- a/tests/codex-integration/codex-history-provider.test.ts +++ b/tests/codex-integration/codex-history-provider.test.ts @@ -102,6 +102,17 @@ function makeFixture({ includeExec = false, includeLegacy = false } = {}) { } describe("Codex history provider sync", () => { + test("refuses legacy rows in a migration-capable store before external writes", () => { + const fixture = makeFixture(); + noopSnapshotArtifacts.add(join(fixture.dbPath, "..")); + const before = readFileSync(fixture.rollout, "utf8"); + const db = new Database(fixture.dbPath); + db.run("ALTER TABLE threads ADD COLUMN history_mode TEXT DEFAULT 'legacy'"); + db.close(); + expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath)).toMatchObject({failed:true,rows:0,files:0,integrityCode:"history_paginated_requires_native_writer"}); + expect(readFileSync(fixture.rollout,"utf8")).toBe(before); + expect(existsSync(fixture.backupPath)).toBe(false); + }); test("injection preflight preserves provider definitions needed by paginated threads", () => { const fixture = makeFixture({ includeLegacy: true }); noopSnapshotArtifacts.add(join(fixture.dbPath, "..")); diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 1303682c8cf..8a4e033c0c7 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -89,6 +89,21 @@ describe("injectCodexConfig integration (Design B)", () => { expect(readFileSync(profilePath,"utf8")).toBe("# preserve profile\n"); expect(readFileSync(rollout,"utf8")).toBe(bytes); expect(existsSync(join(codexHome,"opencodex-journal.json"))).toBe(false); + const restoreScript = ` + const { restoreNativeCodex, restoreNativeCodexAsync, removeCodexConfig } = require("./src/codex/inject"); + const results = [restoreNativeCodex(), await restoreNativeCodexAsync(), removeCodexConfig()]; + console.log(JSON.stringify(results)); + `; + const restored = spawnSync(process.execPath, ["--eval", restoreScript], { + cwd: repoRoot, env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + }); + expect(restored.status).toBe(0); + for (const outcome of JSON.parse(restored.stdout)) expect(outcome.success).toBe(false); + expect(readFileSync(configPath,"utf8")).toBe(original); + expect(readFileSync(profilePath,"utf8")).toBe("# preserve profile\n"); + expect(readFileSync(rollout,"utf8")).toBe(bytes); + expect(existsSync(join(codexHome,"opencodex-journal.json"))).toBe(false); }); test("remote target validate-only writes nothing; commit journals client ownership and restores exact preimage", () => { From be08fe0c053615e70322372b98f7cf2aeb1e51d3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:47:25 +0900 Subject: [PATCH 4/6] fix(history): address race handling and recovery review findings --- .../docs/fr/guides/codex-integration.md | 6 ++ .../content/docs/guides/codex-integration.md | 6 ++ .../docs/ja/guides/codex-integration.md | 6 ++ .../docs/ko/guides/codex-integration.md | 6 ++ .../docs/ru/guides/codex-integration.md | 6 ++ .../docs/tr/guides/codex-integration.md | 6 ++ .../docs/zh-cn/guides/codex-integration.md | 6 ++ .../docs/zh-tw/guides/codex-integration.md | 6 ++ src/codex/history-provider.ts | 67 +++++++++++++------ src/codex/inject.ts | 31 +++++++-- .../codex-history-provider.test.ts | 44 +++++++++++- .../codex-inject-integration.test.ts | 45 +++++++++++-- 12 files changed, 201 insertions(+), 34 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index e8a4deae4f5..68af97b7b48 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -416,3 +416,9 @@ ocx restore back # point plain Codex at the running proxy again Lorsque opencodex s'exécute comme [service d'arrière-plan géré](/fr/reference/cli/lifecycle/#ocx-service-installrepairstartstopstatusuninstallremove), il définit `OCX_SERVICE=1` afin qu'un redémarrage déclenché par le service ne modifie **pas** sans cesse la configuration Codex. Seule l'exécution explicite de `ocx stop` ou `ocx service stop` restaure Codex natif. + +## Refus de sécurité pour l’historique paginé + +Une transition de fournisseur peut renvoyer `history_paginated_requires_native_writer` si le stockage concerné prend en charge la pagination, même pour ses lignes legacy. OpenCodex conserve configuration, profil, catalogue, historique et preuves de restauration au lieu d’attribuer des numéros hors de Codex. Les sorties sans transition, comme la préservation d’un fournisseur externe, restent disponibles. + +Ne supprimez pas un fournisseur encore référencé, ne répétez pas `ocx sync` ou une restauration legacy et ne réécrivez pas un historique actif. Conservez les fichiers, fermez la conversation avant toute récupération et signalez l’erreur exacte et les versions sans publier de données privées. Utilisez un correctif vérifié coordonné avec le processus natif d’écriture. Une sauvegarde ou le succès d’un script ne prouve pas le rétablissement de l’affichage : vérifiez la conversation après réouverture de Codex. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index a99b880fc15..f36d7b0a701 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -772,3 +772,9 @@ injection. Explicit external-provider opt-out behavior is unchanged. In **Subagents → Delegation settings**, edit the ordered fallback chain and its availability polling interval (5000–600000 ms), then save it separately from the featured roster. A configured target that is no longer advertised remains in the chain until you remove it. The roster and fallback chain are separate settings; this editor does not make the roster replace the fallback policy. When a routed preferred model may receive V2 work from a native ChatGPT parent, the panel explains the upstream encrypted-task limitation. Readable tasks from routed parents are unaffected. The guidance uses `/api/v2` mode and native V1 pin state; the current API does not expose recovery activation or request-specific eligibility, so the panel reports those as unknown. V1/plaintext-compatible delegation remains an alternative. Experimental V2 recovery, where eligible and explicitly enabled, adds quota usage, latency, backend dependence and possible fidelity loss; it does not repair the upstream protocol. See [sub-agent surfaces](/guides/sub-agent-surface/) and [the upstream limitation](https://github.com/lidge-jun/opencodex/issues/92). + +## Paginated history safety refusal + +When an affected history store supports paginated records, a provider transition may return `history_paginated_requires_native_writer`. OpenCodex preserves the current configuration, profile, catalog, rollout and restore provenance instead of assigning ordinals outside Codex. This includes legacy rows in a migration-capable store. No-transition exits, such as preserving an external provider, remain available. + +Do not delete a provider definition still referenced by a conversation, repeatedly run `ocx sync` or legacy recovery, or rewrite an active rollout to work around this refusal. Keep the current files, close the affected conversation before any recovery, and report the exact error and versions without uploading private history. Use a verified fix with native-writer coordination; a backup or a successful script alone does not prove the conversation is visible again. Check the restored conversation in Codex after reopening. diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 4ce54074d3f..9fb892e763c 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -280,3 +280,9 @@ ocx restore back # point plain Codex at the running proxy again ``` opencodex が管理対象 [バックグラウンドサービス](/reference/cli/#ocx-service) として実行される場合、`OCX_SERVICE=1` が設定されるため、サービス主導の再起動によって Codex 設定がスラッシングされなくなります。明示的な `ocx stop` / `ocx service stop` のみがネイティブ Codex を復元します。 + +## ページ分割履歴の保護による拒否 + +対象の履歴ストアがページ分割をサポートする場合、プロバイダー変更は `history_paginated_requires_native_writer` で拒否されることがあります。Codex の外で番号を割り当てず、設定、プロファイル、カタログ、履歴ファイル、復元情報を保持します。移行可能なストアの legacy 行も対象です。外部プロバイダーを保持するだけの経路は利用できます。 + +会話が参照するプロバイダー定義を削除したり、`ocx sync` や旧式の復元を繰り返したり、使用中の履歴を書き換えたりしないでください。ファイルを保持し、復元前に対象の会話を閉じ、個人の履歴を公開せず正確なエラーとバージョンを報告してください。ネイティブの書き込み処理と連携する検証済みの修正が必要です。バックアップやスクリプトの成功だけでは表示の復元は証明されません。再度開いた Codex で確認してください。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 584e77adb03..7ae5a2aa09f 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -291,3 +291,9 @@ ocx restore back # point plain Codex at the running proxy again ``` opencodex가 managed [background service](/reference/cli/#ocx-service)로 실행될 때는 `OCX_SERVICE=1`을 설정하므로 service-driven restart가 Codex config를 흔들지 **않습니다**. 네이티브 Codex를 복원하는 것은 명시적인 `ocx stop` / `ocx service stop`뿐입니다. + +## 페이지 분할 기록 보호에 따른 거부 + +영향받는 기록 저장소가 페이지 분할을 지원하면 프로바이더 전환이 `history_paginated_requires_native_writer`로 거부될 수 있습니다. OpenCodex는 Codex 밖에서 순번을 지정하는 대신 현재 설정, 프로필, 카탈로그, 대화 원본과 복원 근거를 보존합니다. 변환 가능한 저장소의 `legacy` 행도 포함됩니다. 외부 프로바이더 보존처럼 전환을 하지 않는 경로는 계속 사용할 수 있습니다. + +대화가 참조하는 프로바이더 정의를 삭제하거나, `ocx sync`·레거시 복구를 반복하거나, 실행 중인 대화 원본을 고쳐 우회하지 마세요. 현재 파일을 보존하고 복구 전에 해당 대화를 닫은 뒤, 개인 대화 내용을 올리지 말고 정확한 오류와 버전을 보고하세요. 네이티브 기록 작성자와 조정하는 검증된 수정이 필요합니다. 백업이나 스크립트 성공만으로 표시 복구가 증명되지는 않으므로 Codex를 다시 열어 확인하세요. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index e6958303709..f23b7543a42 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -409,3 +409,9 @@ ocx restore back # point plain Codex at the running proxy again Когда opencodex работает как управляемая [фоновая служба](/reference/cli/#ocx-service), он устанавливает `OCX_SERVICE=1`, чтобы service-driven restart **не** дёргал конфигурацию Codex — только явный `ocx stop` / `ocx service stop` восстанавливает нативный Codex. + +## Защитный отказ для постраничной истории + +Если затронутое хранилище поддерживает постраничную историю, смена провайдера может вернуть `history_paginated_requires_native_writer`, в том числе для строк legacy. OpenCodex сохраняет конфигурацию, профиль, каталог, историю и данные восстановления, не назначая номера вне Codex. Пути без смены провайдера, например сохранение внешнего провайдера, остаются доступны. + +Не удаляйте используемое разговором определение провайдера, не повторяйте `ocx sync` или legacy-восстановление и не переписывайте активную историю. Сохраните файлы, закройте разговор перед восстановлением и сообщите точную ошибку и версии без публикации личной истории. Требуется проверенное исправление с согласованием с нативным процессом записи. Наличие резервной копии или успешный скрипт не доказывает восстановление отображения: проверьте разговор после повторного открытия Codex. diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index 23b9b87f2eb..501f05d8c9d 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -467,3 +467,9 @@ opencodex yönetilen bir [arka plan servisi](/tr/reference/cli/#ocx-service) olarak çalıştığında `OCX_SERVICE=1` ayarlar, böylece servis odaklı bir yeniden başlatma Codex yapılandırmasını **bozmaz** — yalnızca açık bir `ocx stop` / `ocx service stop` yerel Codex'i geri yükler. + +## Sayfalanmış geçmiş için güvenlik reddi + +Etkilenen geçmiş deposu sayfalamayı destekliyorsa sağlayıcı değişimi `history_paginated_requires_native_writer` döndürebilir; legacy satırlar da buna dahildir. OpenCodex, Codex dışında sıra numarası atamak yerine yapılandırmayı, profili, kataloğu, geçmişi ve geri yükleme kanıtlarını korur. Harici sağlayıcıyı korumak gibi değişim yapmayan yollar kullanılabilir. + +Konuşmanın kullandığı sağlayıcı tanımını silmeyin, `ocx sync` veya legacy kurtarmayı tekrarlamayın ve etkin geçmişi yeniden yazmayın. Dosyaları koruyun, kurtarmadan önce konuşmayı kapatın ve özel geçmişi yayımlamadan tam hatayı ve sürümleri bildirin. Yerel yazıcıyla koordineli, doğrulanmış bir düzeltme gerekir. Yedek veya başarılı betik görüntünün düzeldiğini kanıtlamaz; Codex’i yeniden açıp konuşmayı kontrol edin. diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 71d2b814731..37c12243f8c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -353,3 +353,9 @@ ocx restore back # point plain Codex at the running proxy again 当 opencodex 作为受管的 [background service](/reference/cli/#ocx-service) 运行时,它会设置 `OCX_SERVICE=1`,这样由服务驱动的重启**不会**反复改写 Codex config——只有显式的 `ocx stop` / `ocx service stop` 才会恢复原生 Codex。 + +## 分页历史记录安全拒绝 + +如果受影响的历史存储支持分页,提供商切换可能返回 `history_paginated_requires_native_writer`。OpenCodex 会保留当前配置、配置档、模型目录、历史文件及恢复依据,而不是在 Codex 之外分配序号;可迁移存储中的 legacy 记录也受保护。仅保留外部提供商而不执行切换的路径仍然可用。 + +不要删除会话仍在引用的提供商定义、反复运行 `ocx sync` 或旧版恢复,也不要改写正在使用的历史文件来绕过拒绝。保留文件,在恢复前关闭相关会话,并只报告准确的错误和版本,不要公开私人历史。需要与原生写入器协调的已验证修复。备份或脚本成功并不能证明显示已恢复;重新打开 Codex 后检查会话。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index c9f8090987a..c24cd53d090 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -360,3 +360,9 @@ ocx restore back # 讓普通 Codex 再次指向仍在執行的 proxy 當 opencodex 作為受管的 [背景服務](/zh-tw/reference/cli/#ocx-service) 執行時,會設定 `OCX_SERVICE=1`, 因此 service 驅動的 restart **不會**反覆改寫 Codex 設定;只有明確執行 `ocx stop` 或 `ocx service stop` 才會恢復原生 Codex。 + +## 分頁歷史記錄安全拒絕 + +如果受影響的歷史儲存區支援分頁,提供者切換可能傳回 `history_paginated_requires_native_writer`。OpenCodex 會保留目前設定、設定檔、模型目錄、歷史檔案及復原依據,而不在 Codex 之外分配序號;可遷移儲存區中的 legacy 記錄也受保護。不執行切換、僅保留外部提供者的路徑仍可使用。 + +請勿刪除對話仍參照的提供者定義、反覆執行 `ocx sync` 或舊版復原,也不要改寫使用中的歷史檔案來繞過拒絕。保留檔案,復原前關閉相關對話,只回報確切錯誤與版本,不要公開私人歷史。需要與原生寫入器協調的已驗證修正。備份或指令碼成功不能證明顯示已復原;重新開啟 Codex 後確認對話。 diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index d17951868da..8fa85e3f21d 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs"; +import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { zstdDecompressSync } from "node:zlib"; import { Database } from "bun:sqlite"; @@ -75,19 +75,21 @@ function openStateDb(stateDbPath: string): Database { * rollout's updated_at), and forcing it backwards could hide a real edit from list ordering. */ function appendRolloutLine(path: string, line: string): Buffer { - assertLegacyHistoryWritable(path); - const record = JSON.parse(line) as { ordinal?: unknown; payload?: { history_mode?: unknown } }; - if (Object.hasOwn(record, "ordinal") || record.payload?.history_mode === "paginated") { - throw new CodexHistoryIntegrityError("history_paginated_requires_native_writer"); - } - const fd = openSync(path, "a"); + assertLegacyHistoryRecord(line); + // No O_CREAT: a disappeared target is not an invitation to recreate history. + const fd = openSync(path, constants.O_RDWR | constants.O_APPEND); const buf = Buffer.from(line.endsWith("\n") ? line : `${line}\n`, "utf8"); try { + assertLegacyHistoryWritable(path, fd); + historyAppendHooks?.beforeWrite?.(path); + assertHistoryDescriptorIdentity(path, fd); let offset = 0; while (offset < buf.length) { offset += writeSync(fd, buf, offset, buf.length - offset, null); } try { fsyncSync(fd); } catch { /* best-effort durability */ } + historyAppendHooks?.afterWrite?.(path); + assertHistoryDescriptorIdentity(path, fd); } finally { closeSync(fd); } @@ -230,18 +232,41 @@ class CodexHistoryIntegrityError extends Error { * O_APPEND does not allocate an ordinal or update that writer's in-memory cursor. * Refuse before changing the DB, manifest, or first-line provider; never guess N+1. */ -function assertLegacyHistoryWritable(path: string): void { +function assertLegacyHistoryRecord(line: string): void { + let value: unknown; + try { value = JSON.parse(line); } catch { throw new CodexHistoryIntegrityError("history_rollout_record_invalid"); } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new CodexHistoryIntegrityError("history_rollout_record_invalid"); + } + const record = value as Record; + const payload = record.payload; + if (Object.hasOwn(record, "ordinal") || (payload !== null && typeof payload === "object" && (payload as Record).history_mode === "paginated")) { + throw new CodexHistoryIntegrityError("history_paginated_requires_native_writer"); + } +} + +function assertHistoryDescriptorIdentity(path: string, fd: number): void { + const held = fstatSync(fd); + let current: ReturnType; + try { current = lstatSync(path); } catch { throw new CodexHistoryIntegrityError("history_rollout_identity_changed"); } + if (!current.isFile() || current.isSymbolicLink() || current.dev !== held.dev || current.ino !== held.ino) { + throw new CodexHistoryIntegrityError("history_rollout_identity_changed"); + } +} + +let historyAppendHooks: { beforeWrite?: (path: string) => void; afterWrite?: (path: string) => void } | undefined; +export function setHistoryAppendHooksForTests(hooks: typeof historyAppendHooks): void { historyAppendHooks = hooks; } + +function assertLegacyHistoryWritable(path: string, heldFd?: number): void { if (!path || !existsSync(path)) return; - const fd = openSync(path, "r"); + const fd = heldFd ?? openSync(path, "r"); try { + assertHistoryDescriptorIdentity(path, fd); const first = readFirstRolloutLine(fd); - if (!first) return; - const record = JSON.parse(first) as { ordinal?: unknown; payload?: { history_mode?: unknown } }; - if (Object.hasOwn(record, "ordinal") || record.payload?.history_mode === "paginated") { - throw new CodexHistoryIntegrityError("history_paginated_requires_native_writer"); - } + if (!first) throw new CodexHistoryIntegrityError("history_rollout_record_invalid"); + assertLegacyHistoryRecord(first); } finally { - closeSync(fd); + if (heldFd === undefined) closeSync(fd); } } @@ -1236,8 +1261,8 @@ function relabelAllRoutedHistoryToOpenai(db: Database): { rows: number; files: n provider: "openai", source: row.source === "exec" ? "cli" : undefined, }).changed) files++; - } catch { - /* explicit legacy recovery still relabels the DB when an old rollout is missing */ + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; } } @@ -1447,15 +1472,15 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP for (const row of openaiRows) { try { if (updateSessionMeta(row.rollout_path, row.id, { provider: "opencodex" }).changed) files++; - } catch { - /* keep DB migration moving; the manifest still carries exact original metadata */ + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; } } for (const row of execRows) { try { if (updateSessionMeta(row.rollout_path, row.id, { source: "cli" }).changed) files++; - } catch { - /* keep DB migration moving; the manifest still carries exact original metadata */ + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; } } }); diff --git a/src/codex/inject.ts b/src/codex/inject.ts index f55a689bbee..bca6d1131d6 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -900,10 +900,28 @@ export interface CodexInjectResult { nativeSubagentDefaultsWarning?: string; } +class CodexHistoryPreflightRefusal extends Error {} +let beforeHistoryArtifactCommitForTests: ((kind: string) => void) | undefined; +export function setBeforeHistoryArtifactCommitForTests(hook: typeof beforeHistoryArtifactCommitForTests): void { + beforeHistoryArtifactCommitForTests = hook; +} + export async function injectCodexConfig( port: number, config?: OcxConfig, options: InjectCodexOptions = {}, +): Promise { + try { return await injectCodexConfigImpl(port, config, options); } + catch (error) { + if (error instanceof CodexHistoryPreflightRefusal) return { success: false, message: `Codex config injection refused: ${error.message}. Existing configuration and history were preserved.` }; + throw error; + } +} + +async function injectCodexConfigImpl( + port: number, + config?: OcxConfig, + options: InjectCodexOptions = {}, ): Promise { // Point Codex at the unauthenticated loopback listener when it is enabled (#1102). // @@ -1256,8 +1274,9 @@ export async function injectCodexConfig( } const applyNativeArtifacts = (): void => { + beforeHistoryArtifactCommitForTests?.(eligibility.kind); const historyError = historyPreflight(); - if (historyError) throw new Error(historyError); + if (historyError) throw new CodexHistoryPreflightRefusal(historyError); writeJournal({ currentStateIsNative: journalBaselineIsNative(), configContent: baselineContent, @@ -1947,8 +1966,6 @@ function restoreCodexCatalogArtifact( export async function restoreNativeCodexAsync( options: { revalidateDesiredState?: boolean } = {}, ): Promise { - const historyError = preflightCodexHistoryInjection(false, false); - if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { // External-provider courtesy: only the stale journal is removed. The @@ -1965,8 +1982,12 @@ export async function restoreNativeCodexAsync( if (options.revalidateDesiredState) { const ownership = inspectNativeCodexOwnership(); if (ownership.ownership === "foreign") return foreignOwnershipRestoreRefusal(ownership.reason); + if (shouldSyncCodexOnStart(loadConfig())) return desiredEnabledRestoreSkip(); } + const historyError = preflightCodexHistoryInjection(false, false); + if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); + const eligibility = codexWriteCoordinationEligibility({ coordinatorPath: () => resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), getCodexHome()), @@ -2102,8 +2123,6 @@ export async function restoreNativeCodexAsync( } export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateDesiredState?: boolean } = {}): CodexNativeRestoreResult { - const historyError = preflightCodexHistoryInjection(false, false); - if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { removeJournal(); @@ -2112,6 +2131,8 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { return desiredEnabledRestoreSkip(); } + const historyError = preflightCodexHistoryInjection(false, false); + if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); // Captured before the config half: a successful journal restore DELETES the journal, and // restoring the config can drop `model_catalog_json`. Either one would hide the routed // catalog we actually wrote (#1798). diff --git a/tests/codex-integration/codex-history-provider.test.ts b/tests/codex-integration/codex-history-provider.test.ts index 97415c70108..830c4bb1e6d 100644 --- a/tests/codex-integration/codex-history-provider.test.ts +++ b/tests/codex-integration/codex-history-provider.test.ts @@ -1,11 +1,11 @@ -import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { classifyRecoverableHistoryError, countPendingOpencodexHistory, historyBackupPathFor, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, restoredUserEventFor, setAfterNoopPendingCountForTests, setAfterStrictHistoryRolloutAppendForTests, setBeforeHistoryApplyTransactionForTests, setBeforeHistoryBackupConsumeForTests, setBeforeStrictHistoryRolloutAppendForTests, setHistoryDbBusyTimeoutForTests, snapshotCodexHistoryNoop, syncCodexHistoryProvider, withHistoryRetry } from "../../src/codex/history-provider"; import { INVALID_HISTORY_BACKUP_FIXTURES, validHistoryBackupFixture } from "../helpers/codex-history-manifest-fixtures"; -import { preflightCodexHistoryInjection } from "../../src/codex/history-provider"; +import { preflightCodexHistoryInjection, setHistoryAppendHooksForTests } from "../../src/codex/history-provider"; // Windows CI: a transient file lock can consume the full production 5s busy timeout, tripping // bun's 5s default per-test timeout by itself. Fail fast into withHistoryRetry instead. @@ -16,6 +16,7 @@ setDefaultTimeout(30_000); const noopSnapshotArtifacts = new Set(); afterEach(() => { + setHistoryAppendHooksForTests(undefined); setBeforeHistoryBackupConsumeForTests(undefined); setBeforeStrictHistoryRolloutAppendForTests(undefined); setAfterStrictHistoryRolloutAppendForTests(undefined); @@ -102,6 +103,45 @@ function makeFixture({ includeExec = false, includeLegacy = false } = {}) { } describe("Codex history provider sync", () => { + test.each(["{broken", "null", "[]", "42", '"text"'])("invalid first record %s is a structured integrity failure", (first) => { + const fixture = makeFixture(); + noopSnapshotArtifacts.add(join(fixture.dbPath,"..")); + writeFileSync(fixture.rollout, first + "\n"); + expect(syncCodexHistoryProvider("opencodex",fixture.dbPath,fixture.backupPath)).toMatchObject({failed:true,rows:0,files:0,integrityCode:"history_rollout_record_invalid"}); + const db = new Database(fixture.dbPath); + db.run("UPDATE threads SET model_provider='opencodex'"); + db.close(); + expect(restoreLegacyOpenaiHistory(fixture.dbPath)).toMatchObject({failed:true,rows:0,files:0,integrityCode:"history_rollout_record_invalid"}); + expect(readFileSync(fixture.rollout,"utf8")).toBe(first+"\n"); + }); + + test.each(["beforeWrite", "afterWrite"] as const)("descriptor-bound append refuses path replacement %s", (stage) => { + const fixture = makeFixture(); + noopSnapshotArtifacts.add(join(fixture.dbPath,"..")); + const replacement=JSON.stringify({ordinal:0,type:"session_meta",payload:{id:"thread-1",history_mode:"paginated",model_provider:"openai"}})+"\n"; + setHistoryAppendHooksForTests({[stage]:(path:string)=>{ + if(path!==fixture.rollout)return; + renameSync(path,path+".old"); + writeFileSync(path,replacement); + }}); + expect(syncCodexHistoryProvider("opencodex",fixture.dbPath,fixture.backupPath)).toMatchObject({failed:true,rows:0,integrityCode:"history_rollout_identity_changed"}); + expect(readFileSync(fixture.rollout,"utf8")).toBe(replacement); + const db=new Database(fixture.dbPath,{readonly:true}); + expect(db.query("SELECT model_provider FROM threads WHERE id='thread-1'").get()).toEqual({model_provider:"openai"}); + db.close(); + }); + + test("late paginated conversion rolls back routing instead of swallowing integrity failure",()=>{ + const fixture=makeFixture(); + noopSnapshotArtifacts.add(join(fixture.dbPath,"..")); + const replacement=JSON.stringify({ordinal:0,type:"session_meta",payload:{id:"thread-1",history_mode:"paginated",model_provider:"openai"}})+"\n"; + setBeforeHistoryApplyTransactionForTests(()=>writeFileSync(fixture.rollout,replacement)); + expect(syncCodexHistoryProvider("opencodex",fixture.dbPath,fixture.backupPath)).toMatchObject({failed:true,rows:0,integrityCode:"history_paginated_requires_native_writer"}); + expect(readFileSync(fixture.rollout,"utf8")).toBe(replacement); + const db=new Database(fixture.dbPath,{readonly:true}); + expect(db.query("SELECT model_provider FROM threads WHERE id='thread-1'").get()).toEqual({model_provider:"openai"}); + db.close(); + }); test("refuses legacy rows in a migration-capable store before external writes", () => { const fixture = makeFixture(); noopSnapshotArtifacts.add(join(fixture.dbPath, "..")); diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 8a4e033c0c7..f36b7632f8e 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -34,10 +34,10 @@ function runInject(codexHome: string, ocxHome: string, configJson = "{}"): { std return { stdout: result.stdout?.trim() ?? "", status: result.status ?? 1 }; } -function runRestore(codexHome: string, ocxHome: string): { stdout: string; status: number } { +function runRestore(codexHome: string, ocxHome: string, asyncRestore = false): { stdout: string; status: number } { const script = ` - const { restoreNativeCodex } = require("./src/codex/inject"); - console.log(JSON.stringify(restoreNativeCodex())); + const { restoreNativeCodex, restoreNativeCodexAsync } = require("./src/codex/inject"); + console.log(JSON.stringify(${asyncRestore ? "await restoreNativeCodexAsync()" : "restoreNativeCodex()"})); `; const result = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, @@ -68,6 +68,37 @@ describe("injectCodexConfig integration (Design B)", () => { removeTreeWithRetry(ocxHome); }); + test.each([false,true])("commit-boundary history refusal returns a result after rollback (legacy=%s)",(legacy)=>{ + const original=legacy ? DESIGN_B_BLOCK+"\n" : 'model="test"\n'; + writeFileSync(join(codexHome,"config.toml"),original); + if(legacy) writeFileSync(join(codexHome,"opencodex.config.toml"),"[invalid profile\n"); + const script=` + const {Database}=require("bun:sqlite"); + const {join}=require("node:path"); + const {injectCodexConfig,setBeforeHistoryArtifactCommitForTests}=require("./src/codex/inject"); + let kind; + setBeforeHistoryArtifactCommitForTests(value=>{ + kind=value; + const db=new Database(join(process.env.CODEX_HOME,"state_5.sqlite")); + db.run("CREATE TABLE threads (rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); + db.run("INSERT INTO threads VALUES ('fixture','opencodex','paginated')"); + db.close(); + }); + const result=await injectCodexConfig(10100,{}); + console.log(JSON.stringify({kind,result})); + `; + const child=spawnSync(process.execPath,["--eval",script],{cwd:repoRoot,env:{...process.env,CODEX_HOME:codexHome,OPENCODEX_HOME:ocxHome},encoding:"utf8",timeout:SPAWN_BUDGET_MS-5000}); + expect(child.status).toBe(0); + const value=JSON.parse(child.stdout); + expect(value.kind).toBe(legacy?"legacy-uncoordinated":"coordinated"); + expect(value.result).toMatchObject({success:false}); + expect(value.result.message).toContain("history_paginated_requires_native_writer"); + expect(readFileSync(join(codexHome,"config.toml"),"utf8")).toBe(original); + expect(existsSync(join(codexHome,"opencodex-journal.json"))).toBe(false); + if(legacy) expect(readFileSync(join(codexHome,"opencodex.config.toml"),"utf8")).toBe("[invalid profile\n"); + else expect(existsSync(join(codexHome,"opencodex.config.toml"))).toBe(false); + }); + test.each([false, true])("paginated history preserves config and profile before provider transition (authless=%s)", (authless) => { const original = 'model_provider = "opencodex"\n[model_providers.opencodex]\nname="OpenCodex"\nbase_url="http://127.0.0.1:10100/v1"\nwire_api="responses"\n'; const configPath = join(codexHome, "config.toml"); @@ -798,7 +829,7 @@ describe("injectCodexConfig integration (Design B)", () => { expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); }); - test("restoreNativeCodex removes a stale journal without changing external provider state", () => { + test.each([false,true])("restore removes a stale journal without changing external provider state (async=%s)", (asyncRestore) => { const configPath = join(codexHome, "config.toml"); const config = 'model_provider = "custom"\nmodel = "third-party-model"\n'; writeFileSync(configPath, config, "utf8"); @@ -820,7 +851,9 @@ describe("injectCodexConfig integration (Design B)", () => { id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, model_provider TEXT NOT NULL, source TEXT NOT NULL, first_user_message TEXT NOT NULL, has_user_event INTEGER NOT NULL )`); - db.run(`INSERT INTO threads VALUES ('thread-custom', ?, 'custom', 'cli', 'hello', 1)`, rolloutPath); + db.run(`INSERT INTO threads VALUES ('thread-custom', ?, 'custom', 'cli', 'hello', 1)`, rolloutPath); + db.run("ALTER TABLE threads ADD COLUMN history_mode TEXT DEFAULT 'legacy'"); + db.run("INSERT INTO threads VALUES ('old-routed', ?, 'opencodex', 'cli', 'older', 1, 'paginated')", rolloutPath); db.close(); const dbBefore = readFileSync(dbPath); @@ -833,7 +866,7 @@ describe("injectCodexConfig integration (Design B)", () => { timestamp: new Date().toISOString(), }), "utf8"); - const r = runRestore(codexHome, ocxHome); + const r = runRestore(codexHome, ocxHome, asyncRestore); expect(r.status).toBe(0); const result = JSON.parse(r.stdout); expect(result.success).toBe(true); From ad7c6874ba5ea676c18fa28fc57b59b0469eec3c Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:32:30 +0900 Subject: [PATCH 5/6] fix(codex): compensate history migration races and abort failed restore --- src/codex/history-provider.ts | 40 ++++++++++--- src/codex/inject.ts | 60 +++++++++++++++++-- structure/catalog.md | 2 +- structure/codex-home.md | 2 +- structure/config.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/docs-and-release.md | 2 +- structure/providers/openai-tiers.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- .../codex-history-provider.test.ts | 28 +++++++++ .../codex-inject-integration.test.ts | 58 ++++++++++++++++-- 12 files changed, 177 insertions(+), 25 deletions(-) diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index 8fa85e3f21d..e121d7007de 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -193,21 +193,38 @@ function readFirstLineProviderValue(path: string, expectedId: string): string | } } -function patchFirstLineProviderInPlace(path: string, expectedId: string, provider: string): FirstLineProviderResult { - if (!existsSync(path)) return "unsafe"; +function patchFirstLineProviderInPlace( + path: string, expectedId: string, provider: string, + expectedIdentity: { dev: number | bigint; ino: number | bigint }, +): FirstLineProviderResult { + historyAppendHooks?.beforeFirstLineOpen?.(path); const fd = openSync(path, "r+"); try { + const assertIdentity = (): void => { + assertHistoryDescriptorIdentity(path, fd); + const held = fstatSync(fd); + if (held.dev !== expectedIdentity.dev || held.ino !== expectedIdentity.ino) { + throw new CodexHistoryIntegrityError("history_rollout_identity_changed"); + } + }; + assertIdentity(); + assertLegacyHistoryWritable(path, fd); const firstLine = readFirstRolloutLine(fd); if (firstLine === null) return "unsafe"; const plan = planFirstLineProvider(firstLine, expectedId, provider); if (plan.state === "unsafe") return "unsafe"; if (plan.state === "current") return "current"; const out = Buffer.from(plan.patchedLine, "utf8"); + historyAppendHooks?.beforeFirstLineWrite?.(path); + assertIdentity(); + assertLegacyHistoryWritable(path, fd); let offset = 0; while (offset < out.length) { offset += writeSync(fd, out, offset, out.length - offset, offset); } try { fsyncSync(fd); } catch { /* best-effort durability */ } + historyAppendHooks?.afterFirstLineWrite?.(path); + assertIdentity(); return "patched"; } finally { closeSync(fd); @@ -254,7 +271,13 @@ function assertHistoryDescriptorIdentity(path: string, fd: number): void { } } -let historyAppendHooks: { beforeWrite?: (path: string) => void; afterWrite?: (path: string) => void } | undefined; +let historyAppendHooks: { + beforeWrite?: (path: string) => void; + afterWrite?: (path: string) => void; + beforeFirstLineOpen?: (path: string) => void; + beforeFirstLineWrite?: (path: string) => void; + afterFirstLineWrite?: (path: string) => void; +} | undefined; export function setHistoryAppendHooksForTests(hooks: typeof historyAppendHooks): void { historyAppendHooks = hooks; } function assertLegacyHistoryWritable(path: string, heldFd?: number): void { @@ -1122,6 +1145,7 @@ function updateSessionMeta( } = {}, ): SessionMetaUpdateResult { if (!path || !existsSync(path)) return { changed: false, durableProvider: false }; + const validatedIdentity = lstatSync(path); if (options.expectedFileIdentity !== undefined && historyFileIdentity(path) !== options.expectedFileIdentity) { return { changed: false, durableProvider: false, conflict: true }; @@ -1195,8 +1219,9 @@ function updateSessionMeta( let firstLine: FirstLineProviderResult = "current"; if (patch.provider !== undefined) { try { - firstLine = patchFirstLineProviderInPlace(path, expectedId, patch.provider); - } catch { + firstLine = patchFirstLineProviderInPlace(path, expectedId, patch.provider, validatedIdentity); + } catch (error) { + if (error instanceof CodexHistoryIntegrityError) throw error; firstLine = "unsafe"; } } @@ -1223,8 +1248,9 @@ function updateSessionMeta( let firstLine: FirstLineProviderResult = "current"; if (patch.provider !== undefined) { try { - firstLine = patchFirstLineProviderInPlace(path, expectedId, patch.provider); - } catch { + firstLine = patchFirstLineProviderInPlace(path, expectedId, patch.provider, validatedIdentity); + } catch (error) { + if (error instanceof CodexHistoryIntegrityError) throw error; firstLine = "unsafe"; } if (options.requireDurableProvider && firstLine === "unsafe") { diff --git a/src/codex/inject.ts b/src/codex/inject.ts index bca6d1131d6..65661963fb6 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -901,6 +901,15 @@ export interface CodexInjectResult { } class CodexHistoryPreflightRefusal extends Error {} +class CodexRestoreRefusal extends Error {} +let historyArtifactStageForTests: ((stage: string) => void) | undefined; +export function setHistoryArtifactStageForTests(hook: typeof historyArtifactStageForTests): void { + historyArtifactStageForTests = hook; +} +let beforeRestoreConfigForTests: ((kind: string) => void) | undefined; +export function setBeforeRestoreConfigForTests(hook: typeof beforeRestoreConfigForTests): void { + beforeRestoreConfigForTests = hook; +} let beforeHistoryArtifactCommitForTests: ((kind: string) => void) | undefined; export function setBeforeHistoryArtifactCommitForTests(hook: typeof beforeHistoryArtifactCommitForTests): void { beforeHistoryArtifactCommitForTests = hook; @@ -1277,6 +1286,9 @@ async function injectCodexConfigImpl( beforeHistoryArtifactCommitForTests?.(eligibility.kind); const historyError = historyPreflight(); if (historyError) throw new CodexHistoryPreflightRefusal(historyError); + const preImages = captureCodexPreImages(); + try { + historyArtifactStageForTests?.("after-preflight"); writeJournal({ currentStateIsNative: journalBaselineIsNative(), configContent: baselineContent, @@ -1286,6 +1298,7 @@ async function injectCodexConfigImpl( // must not gain the new injection's hash and later overwrite preserved user edits. if (hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage); atomicWriteFile(CODEX_CONFIG_PATH, content); + historyArtifactStageForTests?.("after-config"); atomicWriteFile(CODEX_PROFILE_PATH, profileContent); markJournalInjectedState(content, profileContent, { // A root override is ours whenever we wrote one and no user-owned value won. That is @@ -1306,6 +1319,16 @@ async function injectCodexConfigImpl( // already points at that path and therefore needs no textual rewrite. injectedCatalogPath: catalogPath, }); + historyArtifactStageForTests?.("after-artifacts"); + // Detect migration throughout the artifact transaction, not just at entry. + // This is compensation, not a native-writer lock or permission to append ordinals. + const finalHistoryError = historyPreflight(); + if (finalHistoryError) throw new CodexHistoryPreflightRefusal(finalHistoryError); + } catch (error) { + const compensated = restoreCodexPreImages(preImages); + if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); + throw error; + } }; /* @@ -1889,8 +1912,19 @@ export function skippedRestoreEnvelope(success: boolean, message: string): Codex } /** The config/profile half of a native restore, reported as one artifact. */ -function restoreCodexConfigInline(): CodexRestoreConfigResult { +function restoreCodexConfigInline(kind = "sync"): CodexRestoreConfigResult { + const preImages = captureCodexPreImages(); + const result = restoreCodexConfigInlineImpl(kind); + if (result.state === "failed") { + const compensated = restoreCodexPreImages(preImages); + if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); + } + return result; +} + +function restoreCodexConfigInlineImpl(kind: string): CodexRestoreConfigResult { try { + beforeRestoreConfigForTests?.(kind); const historyError = preflightCodexHistoryInjection(false, false); if (historyError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${historyError}.` }; const journal = restoreJournalState(); @@ -1965,6 +1999,17 @@ function restoreCodexCatalogArtifact( */ export async function restoreNativeCodexAsync( options: { revalidateDesiredState?: boolean } = {}, +): Promise { + try { + return await restoreNativeCodexAsyncImpl(options); + } catch (error) { + if (!(error instanceof CodexRestoreRefusal)) throw error; + return skippedRestoreEnvelope(false, error.message); + } +} + +async function restoreNativeCodexAsyncImpl( + options: { revalidateDesiredState?: boolean }, ): Promise { const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { @@ -2037,7 +2082,9 @@ export async function restoreNativeCodexAsync( const preImages = captureCodexPreImages(); let restored: CodexRestoreConfigResult; try { - restored = restoreCodexConfigInline(); + restored = restoreCodexConfigInline(eligibility.kind); + // Throw inside N so the published remove transition rolls back too. + if (restored.state === "failed") throw new CodexRestoreRefusal(restored.message); } catch (error) { const compensated = restoreCodexPreImages(preImages); if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); @@ -2078,9 +2125,10 @@ export async function restoreNativeCodexAsync( if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { return desiredEnabledRestoreSkip(); } - config = restoreCodexConfigInline(); + config = restoreCodexConfigInline(eligibility.kind); } + if (config.state === "failed") return skippedRestoreEnvelope(false, config.message); const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath); const outcome = await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), @@ -2112,8 +2160,7 @@ export async function restoreNativeCodexAsync( const base = catalog.removed > 0 ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` : config.message; - const success = config.state !== "failed" - && catalog.state !== "failed" + const success = catalog.state !== "failed" && history.state !== "failed"; return { success, @@ -2138,6 +2185,7 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD // catalog we actually wrote (#1798). const journaledCatalogPath = journaledInjectedCatalogPath(); const config = restoreCodexConfigInline(); + if (config.state === "failed") return skippedRestoreEnvelope(false, config.message); const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath); // Design B (loopback) steady state: threads are already tagged openai, so prove the // no-op with a readonly probe instead of write-opening a DB the Codex app may hold @@ -2174,7 +2222,7 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).` : config.message; return { - success: config.state !== "failed" && catalog.state !== "failed" && history.state !== "failed", + success: catalog.state !== "failed" && history.state !== "failed", message, artifacts: { config, catalog, history }, }; diff --git a/structure/catalog.md b/structure/catalog.md index e96b75d3b2b..86000706382 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -266,6 +266,6 @@ provider wire mapping; unpinned native requests retain their existing pass-throu Catalog convergence can include a provider-history transition. Paginated history refusal is an explicit incomplete transition, not successful migration; see [Codex Home](codex-home.md#paginated-history-writer-boundary). -Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. +Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Detected migration restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/codex-home.md b/structure/codex-home.md index 3fd224c02d9..e3389bdf332 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -228,6 +228,6 @@ a deliberate user choice: `src/codex/history-provider.ts` rejects provider-history changes with `history_paginated_requires_native_writer` when a target begins with an ordinal-bearing record or declares `history_mode=paginated`. Apply, manifest-backed restore, and explicit legacy recovery preflight all selected targets before changing database rows or manifests. The append boundary checks again. Codex owns ordinal allocation and the live projection cursor; reading the last ordinal and appending N+1 is not safe concurrent coordination. Legacy unnumbered rollouts retain their existing behavior. This guard prevents the observed stable-format corruption; it does not implement native-writer integration or guarantee a concurrent legacy-to-paginated conversion is excluded. -Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. +Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Detected migration restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/config.md b/structure/config.md index c425df7771c..d8384e82b2f 100644 --- a/structure/config.md +++ b/structure/config.md @@ -197,6 +197,6 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Authless routing cannot relabel paginated history through the legacy file writer. A refused history transition must be reported; provider-definition retention during that refusal remains an integration concern. See [Codex Home](codex-home.md#paginated-history-writer-boundary). -Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. +Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Detected migration restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 152947c2286..9d2ca448051 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -509,6 +509,6 @@ survives availability drift, while complete/native custom orders await explicit Settings convergence must surface paginated-history refusal rather than claim history was migrated. See [Codex Home](codex-home.md#paginated-history-writer-boundary). -Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. +Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Detected migration restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 116cba1b9d8..bcf89f8933b 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -305,6 +305,6 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo History-provider regression coverage includes numbered rollout apply/restore refusal without changing source bytes or provenance. See [Codex Home](../codex-home.md#paginated-history-writer-boundary). -Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. +Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Detected migration restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 5abfdf3226d..2e13055deaf 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -373,6 +373,6 @@ successful main usage refresh clears the runtime mark. Provider account selection does not authorize relabeling paginated Codex records; the [history writer boundary](../codex-home.md#paginated-history-writer-boundary) applies independently of account mode. -Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. +Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Detected migration restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/runtime.md b/structure/runtime.md index 0f3738971a0..edcb0c7712b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -190,6 +190,6 @@ not an authentication or entitlement decision. Provider-history transitions refuse paginated rollout writes before changing history state; see [Codex Home](codex-home.md#paginated-history-writer-boundary). -Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. +Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Detected migration restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/subagents.md b/structure/subagents.md index f76a4b2b52d..8b55aa1f039 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -200,6 +200,6 @@ Native Codex advertisements still follow display priority; private guidance rank History metadata for parent or child threads follows the same [paginated writer boundary](codex-home.md#paginated-history-writer-boundary); a child source does not authorize external ordinal allocation. -Injection now preflights affected paginated threads before writing config/profile/journal, using the normalized config candidate to resolve SQLite storage. It rechecks immediately before artifact writes; this preserves provider definitions on a stable-format refusal but does not replace native-writer coordination for concurrent format changes. +Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Detected migration restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/tests/codex-integration/codex-history-provider.test.ts b/tests/codex-integration/codex-history-provider.test.ts index 830c4bb1e6d..972245846f2 100644 --- a/tests/codex-integration/codex-history-provider.test.ts +++ b/tests/codex-integration/codex-history-provider.test.ts @@ -131,6 +131,34 @@ describe("Codex history provider sync", () => { db.close(); }); + for (const stage of ["beforeFirstLineOpen", "beforeFirstLineWrite", "afterFirstLineWrite"] as const) { + test.each([false, true])(`first-line identity guard preserves replacement (${stage}, strict=%s)`, (strict) => { + const fixture = makeFixture(); + noopSnapshotArtifacts.add(join(fixture.dbPath, "..")); + // Leave enough first-line padding for forward provider replacement in place. + const raw = readFileSync(fixture.rollout, "utf8"); + writeFileSync(fixture.rollout, raw.replace('"model_provider":"openai"', '"model_provider":"openai" ')); + if (strict) expect(syncCodexHistoryProvider("opencodex", fixture.dbPath, fixture.backupPath).rows).toBe(1); + const provider = strict ? "opencodex" : "openai"; + const replacement = JSON.stringify({ordinal:0,type:"session_meta",payload:{id:"thread-1",history_mode:"paginated",model_provider:provider}}) + "\n"; + let fired = false; + setHistoryAppendHooksForTests({[stage]:(path:string)=>{ + if(path!==fixture.rollout || fired)return; + fired=true; + renameSync(path,path+".old"); + writeFileSync(path,replacement); + }}); + const result=syncCodexHistoryProvider(strict?"openai":"opencodex",fixture.dbPath,fixture.backupPath); + expect(fired).toBe(true); + expect(result).toMatchObject({failed:true,rows:0,integrityCode:strict?"history_backup_partial_restore":"history_rollout_identity_changed"}); + expect(readFileSync(fixture.rollout,"utf8")).toBe(replacement); + const db=new Database(fixture.dbPath,{readonly:true}); + expect(db.query("SELECT model_provider FROM threads WHERE id='thread-1'").get()).toEqual({model_provider:provider}); + db.close(); + if(strict) expect(existsSync(fixture.backupPath)).toBe(true); + }); + } + test("late paginated conversion rolls back routing instead of swallowing integrity failure",()=>{ const fixture=makeFixture(); noopSnapshotArtifacts.add(join(fixture.dbPath,"..")); diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index f36b7632f8e..ee2c9fdbd9a 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -68,22 +68,27 @@ describe("injectCodexConfig integration (Design B)", () => { removeTreeWithRetry(ocxHome); }); - test.each([false,true])("commit-boundary history refusal returns a result after rollback (legacy=%s)",(legacy)=>{ + for (const stage of ["before-preflight", "after-preflight", "after-config", "after-artifacts"]) { + test.each([false,true])(`commit-boundary history refusal returns a result after rollback (${stage}, legacy=%s)`,(legacy)=>{ const original=legacy ? DESIGN_B_BLOCK+"\n" : 'model="test"\n'; writeFileSync(join(codexHome,"config.toml"),original); if(legacy) writeFileSync(join(codexHome,"opencodex.config.toml"),"[invalid profile\n"); const script=` const {Database}=require("bun:sqlite"); const {join}=require("node:path"); - const {injectCodexConfig,setBeforeHistoryArtifactCommitForTests}=require("./src/codex/inject"); + const {injectCodexConfig,setBeforeHistoryArtifactCommitForTests,setHistoryArtifactStageForTests}=require("./src/codex/inject"); let kind; - setBeforeHistoryArtifactCommitForTests(value=>{ - kind=value; + const migrate=()=>{ const db=new Database(join(process.env.CODEX_HOME,"state_5.sqlite")); db.run("CREATE TABLE threads (rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); db.run("INSERT INTO threads VALUES ('fixture','opencodex','paginated')"); db.close(); + }; + setBeforeHistoryArtifactCommitForTests(value=>{ + kind=value; + if (${JSON.stringify(stage)} === "before-preflight") migrate(); }); + setHistoryArtifactStageForTests(value=>{if(value===${JSON.stringify(stage)})migrate();}); const result=await injectCodexConfig(10100,{}); console.log(JSON.stringify({kind,result})); `; @@ -98,6 +103,51 @@ describe("injectCodexConfig integration (Design B)", () => { if(legacy) expect(readFileSync(join(codexHome,"opencodex.config.toml"),"utf8")).toBe("[invalid profile\n"); else expect(existsSync(join(codexHome,"opencodex.config.toml"))).toBe(false); }); + } + + test.each(["sync", "legacy-uncoordinated", "coordinated"])("config restore failure aborts every later artifact (%s)", (kind) => { + const original = kind === "coordinated" ? 'model="test"\n' : DESIGN_B_BLOCK + "\n"; + writeFileSync(join(codexHome, "config.toml"), original); + if (kind !== "coordinated") writeFileSync(join(codexHome, "opencodex.config.toml"), "[invalid profile\n"); + const catalog = '{"models":[],"sentinel":"preserve"}\n'; + writeFileSync(join(codexHome, "models_cache.json"), catalog); + const script = ` + const {Database}=require("bun:sqlite"); + const {join}=require("node:path"); + const {restoreNativeCodex,restoreNativeCodexAsync,setBeforeRestoreConfigForTests}=require("./src/codex/inject"); + const readState=${kind === "coordinated" ? 'require("./src/codex/transition-state").readCodexTransitionState' : "()=>null"}; + const before=readState(); + let observed; + setBeforeRestoreConfigForTests(value=>{ + observed=value; + const db=new Database(join(process.env.CODEX_HOME,"state_5.sqlite")); + db.run("CREATE TABLE threads (rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); + db.run("INSERT INTO threads VALUES ('fixture','opencodex','paginated')"); + db.close(); + }); + const result=${kind === "sync" ? "restoreNativeCodex()" : "await restoreNativeCodexAsync()"}; + console.log(JSON.stringify({observed,result,before,after:readState()})); + `; + const child=spawnSync(process.execPath,["--eval",script],{cwd:repoRoot,env:{...process.env,CODEX_HOME:codexHome,OPENCODEX_HOME:ocxHome},encoding:"utf8",timeout:SPAWN_BUDGET_MS-5000}); + expect(child.status).toBe(0); + const value=JSON.parse(child.stdout); + expect(value.observed).toBe(kind); + expect(value.result.success).toBe(false); + if(kind==="coordinated") { + // Also covered by the earlier fork PR luvs01/opencodex#118: never advance + // durable generation/tx state when config restoration reports failure. + expect(value.before.state).toMatchObject({nativeGeneration:0,currentTxId:null}); + expect(value.after.state).toEqual(value.before.state); + } + for (const artifact of Object.values(value.result.artifacts)) { + expect(artifact).toMatchObject({state:"skipped",changed:false}); + } + expect(readFileSync(join(codexHome,"config.toml"),"utf8")).toBe(original); + expect(readFileSync(join(codexHome,"models_cache.json"),"utf8")).toBe(catalog); + expect(existsSync(join(codexHome,"opencodex-journal.json"))).toBe(false); + if(kind!=="coordinated") expect(readFileSync(join(codexHome,"opencodex.config.toml"),"utf8")).toBe("[invalid profile\n"); + else expect(existsSync(join(codexHome,"opencodex.config.toml"))).toBe(false); + }); test.each([false, true])("paginated history preserves config and profile before provider transition (authless=%s)", (authless) => { const original = 'model_provider = "opencodex"\n[model_providers.opencodex]\nname="OpenCodex"\nbase_url="http://127.0.0.1:10100/v1"\nwire_api="responses"\n'; From 676b97ff1314b24f5da9949732bfad45809f2eae Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:36:54 +0900 Subject: [PATCH 6/6] test(codex): keep rollback assertion comment self-contained --- tests/codex-integration/codex-inject-integration.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index ee2c9fdbd9a..0b30d787039 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -134,8 +134,7 @@ describe("injectCodexConfig integration (Design B)", () => { expect(value.observed).toBe(kind); expect(value.result.success).toBe(false); if(kind==="coordinated") { - // Also covered by the earlier fork PR luvs01/opencodex#118: never advance - // durable generation/tx state when config restoration reports failure. + // Never advance durable generation/tx state when config restoration fails. expect(value.before.state).toMatchObject({nativeGeneration:0,currentTxId:null}); expect(value.after.state).toEqual(value.before.state); }