From 96dcf81e3c8eeb3fb2c0ec7b1ed36fa15dd24055 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:11:57 +0900 Subject: [PATCH 1/9] fix(codex): reconcile a v1 injection with the global multi_agent_v2 flag Codex resolves the global features.multi_agent_v2 override before catalog-level multi_agent_version pins. injectCodexConfig() never reconciled it, so a fresh OpenCodex install (multiAgentMode: v1) on a Codex home that had previously enabled v2 produced a catalog claiming v1 while new sessions actually ran v2 and spawned unreadable encrypted child tasks. The explicit mode selectors (ocx v2 mode, PUT /api/v2) already run the format-preserving transition; the injection path now does the same before taking the journal baseline. The reconcile lives in src/codex/inject/multi-agent-v2.ts so the inject.ts facade stays under its file-size ratchet cap. Validation-only injection and externally managed provider configs remain read-only. --- src/codex/inject.ts | 8 ++- src/codex/inject/multi-agent-v2.ts | 60 +++++++++++++++++++ structure/config.md | 5 ++ structure/subagents.md | 4 ++ .../codex-inject-integration.test.ts | 30 ++++++++++ 5 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/codex/inject/multi-agent-v2.ts diff --git a/src/codex/inject.ts b/src/codex/inject.ts index f88edfe67b0..8d09900e808 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -92,7 +92,7 @@ import { stripRootContextWindowOverrides, } from "./inject/config-toml"; import { hasOcxProviderTable, removeOcxSection } from "./inject/remove"; - +import { reconcileInjectedV1Surface } from "./inject/multi-agent-v2"; export { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target"; @@ -217,7 +217,7 @@ async function injectCodexConfigImpl( }; } - const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); + let rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); const preflightTableMode = usesProviderTable(routingTarget); const compactionOnly = routingTarget.clientCompaction === true && routingTarget.desktopAuthless !== true @@ -253,6 +253,10 @@ async function injectCodexConfigImpl( }; } + const v1Surface = await reconcileInjectedV1Surface(config, options, rawContent); + if (!v1Surface.ok) return { success: false, message: v1Surface.message }; + rawContent = v1Surface.content; + // Marker-owned native defaults are OpenCodex residue, never part of the // user's journal baseline. Clean them before either snapshotting or adding a // root routing key: inserting that key ahead of a marker-owned first table diff --git a/src/codex/inject/multi-agent-v2.ts b/src/codex/inject/multi-agent-v2.ts new file mode 100644 index 00000000000..47d102cf639 --- /dev/null +++ b/src/codex/inject/multi-agent-v2.ts @@ -0,0 +1,60 @@ +import { readFileSync } from "node:fs"; +import type { OcxConfig } from "../../types"; +import { CODEX_CONFIG_PATH } from "../paths"; + +/** + * Reconcile the native `features.multi_agent_v2` override when an injection carries an + * explicit v1 surface pin. + * + * Codex resolves the global v2 feature before catalog-level `multi_agent_version` pins, so a + * config.toml that still enables `multi_agent_v2` would run v2 sessions under a catalog the + * injection just stamped v1 — and the child tasks it then produces are undeliverable ciphertext + * to a v1 reader. Fresh OpenCodex configs write `multiAgentMode: "v1"`, which makes first + * injection on a previously-v2 Codex home the common trigger. The explicit mode selectors + * (`ocx v2 mode`, `PUT /api/v2`) already run the same format-preserving transition; this is + * the injection-side half of that contract. + */ +export type InjectedV1SurfaceReconcile = + | { ok: true; content: string } + | { ok: false; message: string }; + +let toggleForTests: ((enabled: boolean) => void) | undefined; + +/** Test seam: substitute the native `codex features` toggle so no Codex runtime is required. */ +export function setCodexMultiAgentV2ToggleForTests( + toggle: ((enabled: boolean) => void) | undefined, +): void { + toggleForTests = toggle; +} + +/** + * Disable a pre-existing global v2 override before the journal baseline when the injected + * OpenCodex config explicitly selects v1. Returns the config.toml bytes the caller should keep + * working from: unchanged input when no transition ran, re-read post-transition bytes when it + * did. Read-only preflight and non-v1 modes are pass-throughs, and externally owned provider + * configs never reach this point — the caller returns before invoking it. + */ +export async function reconcileInjectedV1Surface( + config: Pick | undefined, + options: { validateOnly?: boolean }, + rawContent: string, +): Promise { + if (options.validateOnly || config?.multiAgentMode !== "v1") { + return { ok: true, content: rawContent }; + } + const { isMultiAgentV2Enabled, transitionMultiAgentV2 } = await import("../features"); + if (!isMultiAgentV2Enabled()) return { ok: true, content: rawContent }; + let toggle = toggleForTests; + if (!toggle) { + const { runCodexFeaturesCommand } = await import("../../cli/v2"); + toggle = enabled => runCodexFeaturesCommand(enabled ? "enable" : "disable"); + } + const transition = transitionMultiAgentV2(false, toggle); + if (!transition.ok) { + return { + ok: false, + message: `Codex config injection refused: could not reconcile the v1 surface with the global multi_agent_v2 feature: ${transition.error}.`, + }; + } + return { ok: true, content: readFileSync(CODEX_CONFIG_PATH, "utf-8") }; +} diff --git a/structure/config.md b/structure/config.md index 12cf9acbd10..92496f1274c 100644 --- a/structure/config.md +++ b/structure/config.md @@ -185,6 +185,11 @@ Native Codex sub-agent defaults are a separate, explicit opt-in. When overwritten. Disabling the option and fallback restore remove only marker-owned values; journal restore must preserve later user edits while stripping those managed values. +An injection whose OpenCodex config explicitly selects the v1 multi-agent surface also +reconciles Codex's higher-precedence global `features.multi_agent_v2` override to disabled before +taking the journal baseline. It uses the same format-preserving feature transition as explicit +mode selection; validation-only injection and externally managed provider configs remain read-only. + ### History backup manifest contract `src/codex/history-manifest.ts` is the pure schema-and-identity leaf for the versioned history diff --git a/structure/subagents.md b/structure/subagents.md index cf52d556baf..45adcb105d5 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -78,6 +78,10 @@ with `multiAgentMode` field. The `multi_agent_v2` feature flag and the logical maximum thread count are separate from `multiAgentMode` (`src/codex/features.ts`): the mode decides which surface Codex advertises, while the flag and thread count decide what the native runtime allows. +Because the global feature has precedence over catalog pins, Codex config injection reconciles it +to disabled whenever the persisted OpenCodex mode explicitly selects v1. This includes a fresh +install on a Codex home that had previously enabled v2; external-provider ownership and read-only +injection preflight still prohibit that write. `keepNativeChatGptOnV1` makes mode `v2` a catalog-driven hybrid: OpenCodex disables the global `multi_agent_v2` override because codex-rs resolves that override before a model row's explicit diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 0615fb2d374..14b5fc90576 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -1957,4 +1957,34 @@ describe("injectCodexConfig integration (Design B)", () => { expect(config).not.toContain("multi_agent_v2 = true"); expect(config).not.toContain("multi_agent_v2 = {"); }); + + test("a v1 injection disables a pre-existing global v2 override", () => { + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5.5"\n\n[features]\nmulti_agent_v2 = true\n', "utf8"); + const script = ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const { injectCodexConfig } = require("./src/codex/inject"); + const { setCodexMultiAgentV2ToggleForTests } = require("./src/codex/inject/multi-agent-v2"); + const path = join(process.env.CODEX_HOME, "config.toml"); + setCodexMultiAgentV2ToggleForTests(enabled => { + const current = fs.readFileSync(path, "utf8"); + fs.writeFileSync(path, current.replace("multi_agent_v2 = true", "multi_agent_v2 = " + enabled)); + }); + const result = await injectCodexConfig(10100, { multiAgentMode: "v1" }); + console.log(JSON.stringify(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 - 5_000, + }); + + expect(child.status, child.stderr).toBe(0); + expect(JSON.parse(child.stdout)).toMatchObject({ success: true }); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("multi_agent_v2 = false"); + expect(config).toContain('openai_base_url = "http://127.0.0.1:10100/v1"'); + }); }); From ab917f3c97f1c9d18601fedc34a70900cd85f876 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:48:28 +0900 Subject: [PATCH 2/9] fix(codex): gate the v1-surface reconcile on the client-write checks --- src/codex/inject.ts | 58 +++++++++++++++++-- src/codex/inject/multi-agent-v2.ts | 8 +-- .../codex-inject-integration.test.ts | 33 +++++++++++ 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 8d09900e808..47e4186e5af 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -253,10 +253,44 @@ async function injectCodexConfigImpl( }; } + /* + * The v1-surface reconcile persists its own config.toml transition, so the + * write gates are evaluated before it may run: a skip or guard refusal after + * it would leave the file changed while the result reports that nothing + * changed. The under-lock re-checks below still apply for races after this + * pre-check; the reconcile-aware messages below keep those rare outcomes + * truthful as well. + */ + if (!options.validateOnly && config?.multiAgentMode === "v1") { + const gateSnapshot = loadConfig(); + if (!shouldSyncCodexOnStart(gateSnapshot)) { + return { + success: true, + status: "skipped", + skippedReason: localClientSkipReason(gateSnapshot), + message: localClientSkipMessage( + gateSnapshot, + "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", + "No Codex config, catalog, cache, or history was changed.", + ), + }; + } + runClientWriteGuard(options.beforeClientWrite); + } const v1Surface = await reconcileInjectedV1Surface(config, options, rawContent); if (!v1Surface.ok) return { success: false, message: v1Surface.message }; rawContent = v1Surface.content; + // A skip or refusal reached after the reconcile already persisted must not + // repeat the stock "nothing changed" clause. + const reconcileAware = (message: string): string => + v1Surface.changed + ? message.replace( + /no Codex config, catalog, cache, or history was changed./i, + "the v1-surface reconcile was applied to config.toml; no catalog, cache, or history was changed.", + ) + : message; + // Marker-owned native defaults are OpenCodex residue, never part of the // user's journal baseline. Clean them before either snapshotting or adding a // root routing key: inserting that key ahead of a marker-owned first table @@ -271,7 +305,9 @@ async function injectCodexConfigImpl( success: false, message: `Codex config injection refused: existing OpenCodex-managed native sub-agent defaults are ambiguous: ${nativeDefaultsBaseline.error}. ` + - `No files were changed; inspect ${CODEX_CONFIG_PATH}.`, + (v1Surface.changed + ? `The v1-surface reconcile was applied to config.toml before this refusal; inspect ${CODEX_CONFIG_PATH}.` + : `No files were changed; inspect ${CODEX_CONFIG_PATH}.`), }; } const baselineContent = nativeDefaultsBaseline.content; @@ -584,7 +620,15 @@ async function injectCodexConfigImpl( : null; const unverifiedJournalMessage = "Codex configuration was not written: the journal has no verified baseline for the current config/profile. Current files and the journal were preserved."; if (!journalBaselineIsNative() && hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) { - return { success: false, message: unverifiedJournalMessage }; + return { + success: false, + message: v1Surface.changed + ? unverifiedJournalMessage.replace( + "Current files and the journal were preserved.", + "The v1-surface reconcile was applied to config.toml; other current files and the journal were preserved.", + ) + : unverifiedJournalMessage, + }; } if (options.validateOnly) { @@ -657,11 +701,11 @@ async function injectCodexConfigImpl( success: true, status: "skipped", skippedReason: localClientSkipReason(legacyGateSnapshot), - message: localClientSkipMessage( + message: reconcileAware(localClientSkipMessage( legacyGateSnapshot, "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", "No Codex config, catalog, cache, or history was changed.", - ), + )), }; } runClientWriteGuard(options.beforeClientWrite); @@ -767,7 +811,11 @@ async function injectCodexConfigImpl( ); if (coordinated.status !== "acquired") { - return codexInjectLockOutcome(coordinated); + const outcome = codexInjectLockOutcome(coordinated); + if (v1Surface.changed && outcome.success) { + outcome.message = reconcileAware(outcome.message); + } + return outcome; } recordCodexNativeTransactionProvenance( coordinated.value.preImages, diff --git a/src/codex/inject/multi-agent-v2.ts b/src/codex/inject/multi-agent-v2.ts index 47d102cf639..9048d3a312e 100644 --- a/src/codex/inject/multi-agent-v2.ts +++ b/src/codex/inject/multi-agent-v2.ts @@ -15,7 +15,7 @@ import { CODEX_CONFIG_PATH } from "../paths"; * the injection-side half of that contract. */ export type InjectedV1SurfaceReconcile = - | { ok: true; content: string } + | { ok: true; content: string; changed: boolean } | { ok: false; message: string }; let toggleForTests: ((enabled: boolean) => void) | undefined; @@ -40,10 +40,10 @@ export async function reconcileInjectedV1Surface( rawContent: string, ): Promise { if (options.validateOnly || config?.multiAgentMode !== "v1") { - return { ok: true, content: rawContent }; + return { ok: true, content: rawContent, changed: false }; } const { isMultiAgentV2Enabled, transitionMultiAgentV2 } = await import("../features"); - if (!isMultiAgentV2Enabled()) return { ok: true, content: rawContent }; + if (!isMultiAgentV2Enabled()) return { ok: true, content: rawContent, changed: false }; let toggle = toggleForTests; if (!toggle) { const { runCodexFeaturesCommand } = await import("../../cli/v2"); @@ -56,5 +56,5 @@ export async function reconcileInjectedV1Surface( message: `Codex config injection refused: could not reconcile the v1 surface with the global multi_agent_v2 feature: ${transition.error}.`, }; } - return { ok: true, content: readFileSync(CODEX_CONFIG_PATH, "utf-8") }; + return { ok: true, content: readFileSync(CODEX_CONFIG_PATH, "utf-8"), changed: transition.changed }; } diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 14b5fc90576..0f375e7bb38 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -1987,4 +1987,37 @@ describe("injectCodexConfig integration (Design B)", () => { expect(config).toContain("multi_agent_v2 = false"); expect(config).toContain('openai_base_url = "http://127.0.0.1:10100/v1"'); }); + + test("a skipped v1 injection does not run the v2 reconcile or leave the file changed", () => { + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5.5"\n\n[features]\nmulti_agent_v2 = true\n', "utf8"); + // Integration OFF in the OCX config snapshot the write gate reads. + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ clientIntegrations: { codex: false } })); + const script = ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const { injectCodexConfig } = require("./src/codex/inject"); + const { setCodexMultiAgentV2ToggleForTests } = require("./src/codex/inject/multi-agent-v2"); + const path = join(process.env.CODEX_HOME, "config.toml"); + let toggles = 0; + setCodexMultiAgentV2ToggleForTests(() => { toggles += 1; }); + const result = await injectCodexConfig(10100, { multiAgentMode: "v1" }); + console.log(JSON.stringify({ result, toggles })); + `; + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); + + expect(child.status, child.stderr).toBe(0); + const out = JSON.parse(child.stdout); + expect(out.result).toMatchObject({ success: true, status: "skipped", skippedReason: "desired_disabled" }); + // The gate ran before the reconcile: no transition ran and nothing was written. + expect(out.toggles).toBe(0); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("multi_agent_v2 = true"); + expect(config).not.toContain("openai_base_url"); + }); }); From 487c1cf2299aea783dcb44cf73ac167084eb982d Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:57:23 +0900 Subject: [PATCH 3/9] fix(codex): move the v1-surface reconcile inside the write boundary The reconcile ran before withCodexWriteLock, so a later ambiguous-baseline, journal, or lock refusal left config.toml changed while the injection failed, and a competing writer could land between the transition and the commit. The feature transition now runs inside the coordinated write boundary: one preimage captured under the lock covers it and the artifact commit, and any later refusal restores the exact original bytes, flag included. The committed bytes are re-derived from the post-transition input so the injection cannot re-enable the flag it just turned off. The derivation pipeline moves to inject/plan.ts so inject.ts stays under its file-size ratchet cap, and the reconcile tests move to codex-inject-v1-reconcile.test.ts for the same reason. New regressions prove a post-reconcile failure restores byte-exact config and feature state, and that a competing writer is serialized out between the transition and the commit. --- src/codex/inject.ts | 579 ++++++------------ src/codex/inject/multi-agent-v2.ts | 74 ++- src/codex/inject/plan.ts | 365 +++++++++++ structure/config.md | 5 +- structure/subagents.md | 3 +- .../codex-inject-integration.test.ts | 73 --- .../codex-inject-v1-reconcile.test.ts | 224 +++++++ 7 files changed, 845 insertions(+), 478 deletions(-) create mode 100644 src/codex/inject/plan.ts create mode 100644 tests/codex-integration/codex-inject-v1-reconcile.test.ts diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 47e4186e5af..17188a20b8d 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -4,7 +4,6 @@ import { loadConfig, observeConfigGeneration, readConfigAdmissionSnapshot, - websocketsEnabled, withConfigMutationLockSync, } from "../config"; import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock"; @@ -40,8 +39,7 @@ import { removeJournal, writeJournal, } from "./journal"; -import { HISTORY_RELABEL_STANDS_DOWN, preflightCodexHistoryInjection } from "./history-provider"; -import { applyPaginatedOpenaiCompat } from "./inject/paginated-openai-compat"; +import { HISTORY_RELABEL_STANDS_DOWN } from "./history-provider"; import { describeHistoryJobFailure, deriveCodexHistoryOperation, @@ -54,45 +52,29 @@ import { hasInjectedCodexRouting, hasInjectedOpenaiBaseUrl, rootTomlString, - stripJournaledOpenaiBaseUrl, } from "./injected-marker"; import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, getCodexHome, - resolveCodexStateDbPath, tomlString, } from "./paths"; -import { transformManagedSubagentDefaults } from "./subagent-defaults"; import type { OcxConfig } from "../types"; import { configuredManagedSubagentDefaults, standaloneCodexRoutingTarget, - usesProviderTable, validateCodexRoutingTarget, type CodexRoutingTarget, } from "./inject/routing-target"; import { - applyEol, - buildProfileFileForTarget, - buildProviderTableBlockForTarget, - chooseCatalogPathForInjection, - dominantEol, - ensureFastModeFeature, externalCodexModelProvider, - normalizeServiceTier, - removeProfileSection, - setRootModelCatalogPath, - setRootModelProvider, - setRootOpenaiBaseUrlForTarget, - setRootRealtimeWsBaseUrl, - stripExistingModelProvider, - stripInjectedOpenaiBaseUrl, - stripOpencodexCatalogPath, - stripRootContextWindowOverrides, } from "./inject/config-toml"; -import { hasOcxProviderTable, removeOcxSection } from "./inject/remove"; -import { reconcileInjectedV1Surface } from "./inject/multi-agent-v2"; +import { prepareInjectedV1SurfaceReconcile } from "./inject/multi-agent-v2"; +import { + deriveCodexInjectionPlan, + type CodexInjectionPlanContext, + type CodexInjectionPlanOk, +} from "./inject/plan"; export { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target"; @@ -167,6 +149,19 @@ export interface CodexInjectResult { } class CodexHistoryPreflightRefusal extends Error {} + +/** + * A refusal raised inside the write boundary. The caller's catch has already + * restored the captured preimages — a landed v1-surface reconcile included — + * so the carried result is returned verbatim by the outer wrapper. + */ +class CodexInjectRefusal extends Error { + constructor(readonly result: CodexInjectResult) { + super(result.message); + this.name = "CodexInjectRefusal"; + } +} + let historyArtifactStageForTests: ((stage: string) => void) | undefined; export function setHistoryArtifactStageForTests(hook: typeof historyArtifactStageForTests): void { historyArtifactStageForTests = hook; @@ -184,6 +179,7 @@ export async function injectCodexConfig( try { return await injectCodexConfigImpl(port, config, options); } catch (error) { if (error instanceof CodexHistoryPreflightRefusal) return { success: false, historyPreflightFailureReason: error.message, message: `Codex config injection refused: ${error.message}. Existing configuration and history were preserved.` }; + if (error instanceof CodexInjectRefusal) return error.result; throw error; } } @@ -217,11 +213,7 @@ async function injectCodexConfigImpl( }; } - let rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); - const preflightTableMode = usesProviderTable(routingTarget); - const compactionOnly = routingTarget.clientCompaction === true - && routingTarget.desktopAuthless !== true - && routingTarget.requiresAdmissionToken !== true; + const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); const activeProvider = externalCodexModelProvider(rawContent); if (activeProvider) { // A launcher may have journaled before the provider manager took ownership. Never let shutdown @@ -254,292 +246,40 @@ async function injectCodexConfigImpl( } /* - * The v1-surface reconcile persists its own config.toml transition, so the - * write gates are evaluated before it may run: a skip or guard refusal after - * it would leave the file changed while the result reports that nothing - * changed. The under-lock re-checks below still apply for races after this - * pre-check; the reconcile-aware messages below keep those rare outcomes - * truthful as well. + * The v1-surface reconcile mutates config.toml through the native + * `codex features` transition, so it runs INSIDE the coordinated write + * boundary below — under the same lock and preimage as the artifact commit. + * Run here, a later ambiguous-baseline, journal, or lock refusal left + * config.toml changed while the rest of the injection failed, and a + * competing writer could land between the transition and the commit. + * Its dependencies are resolved now because the commit callback is + * synchronous and cannot await them there. */ - if (!options.validateOnly && config?.multiAgentMode === "v1") { - const gateSnapshot = loadConfig(); - if (!shouldSyncCodexOnStart(gateSnapshot)) { - return { - success: true, - status: "skipped", - skippedReason: localClientSkipReason(gateSnapshot), - message: localClientSkipMessage( - gateSnapshot, - "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", - "No Codex config, catalog, cache, or history was changed.", - ), - }; - } - runClientWriteGuard(options.beforeClientWrite); - } - const v1Surface = await reconcileInjectedV1Surface(config, options, rawContent); - if (!v1Surface.ok) return { success: false, message: v1Surface.message }; - rawContent = v1Surface.content; - - // A skip or refusal reached after the reconcile already persisted must not - // repeat the stock "nothing changed" clause. - const reconcileAware = (message: string): string => - v1Surface.changed - ? message.replace( - /no Codex config, catalog, cache, or history was changed./i, - "the v1-surface reconcile was applied to config.toml; no catalog, cache, or history was changed.", - ) - : message; - - // Marker-owned native defaults are OpenCodex residue, never part of the - // user's journal baseline. Clean them before either snapshotting or adding a - // root routing key: inserting that key ahead of a marker-owned first table - // would otherwise separate the table marker from its header. Ambiguous - // markers fail closed without writing config, profile, or journal state. - const nativeDefaultsBaseline = transformManagedSubagentDefaults( - rawContent, - null, - ); - if (!nativeDefaultsBaseline.ok) { - return { - success: false, - message: - `Codex config injection refused: existing OpenCodex-managed native sub-agent defaults are ambiguous: ${nativeDefaultsBaseline.error}. ` + - (v1Surface.changed - ? `The v1-surface reconcile was applied to config.toml before this refusal; inspect ${CODEX_CONFIG_PATH}.` - : `No files were changed; inspect ${CODEX_CONFIG_PATH}.`), - }; - } - const baselineContent = nativeDefaultsBaseline.content; + const v1Reconcile = await prepareInjectedV1SurfaceReconcile(config, options); /* - * The journal write used to happen HERE, before the transforms. It now happens - * inside the write lock further down, and the transforms were hoisted above it - * rather than the lock being narrowed to the three file writes. - * - * Why: the lock's witness hashes the CANDIDATE BYTES, and those are not final - * until `profileContent` and the EOL-applied `content` exist. Opening the lock - * before them would leave nothing to hash; keeping the journal outside the - * lock would leave the first artifact-creating write unserialized, which is - * the hole this edge exists to close. - * - * The move is safe because the region between here and the writes performs no - * filesystem mutation — its only touch is `existsSync` on the catalog paths - * (`chooseCatalogPathForInjection`) — and because `writeJournal` is called - * with `configContent`, so it snapshots the baseline it is handed rather than - * rereading `config.toml` underneath the transforms. + * The plan against the admitted input. When the reconcile transitions the + * file under the lock, the committed bytes are re-derived from the + * post-transition input by reconcileAndDerivePlan — the admitted candidate + * still fingerprints this operation because that re-derivation is a + * deterministic function of the admitted input. */ - // EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write. - const eol = dominantEol(rawContent); - let content = applyEol(baselineContent, "\n"); - - // Idempotent clean-up of any prior injection: drop the provider table (marker-based) and every - // stray/mis-nested model_provider line, so re-injecting can't duplicate keys or leave the buggy - // table-nested key behind. - // Design B form FIRST: removeOcxSection also keys on the marker line, so a root-level - // marker + openai_base_url pair must be gone before it scans or it would swallow root keys. - content = stripInjectedOpenaiBaseUrl(content); - // #1798: after a Codex app rewrite the markers are gone but the values we recorded writing - // are still ours. Consume them by value here, BEFORE the routing form is chosen, so a - // Design B -> provider-table transition (hostname change, authless opt-in) cannot leave our - // own root URLs behind as if they were the user's, and so re-inject never journals them as - // not-ours (which would make them unrestorable). - content = stripJournaledOpenaiBaseUrl( - content, - journaledInjectedOpenaiBaseUrl({ readOnly: !!options.beforeClientWrite }), - journaledInjectedRealtimeWsBaseUrl({ readOnly: !!options.beforeClientWrite }), - ); - // Whether this home already published the provider id that its thread rows may reference. - // Design B strips the table below; it may only stay stripped if those rows can be relabeled. - const hadOcxProviderTableOnDisk = hasOcxProviderTable(content); - if (hadOcxProviderTableOnDisk) { - content = removeOcxSection(content); - } - content = removeProfileSection(content); - content = stripExistingModelProvider(content); - content = stripRootContextWindowOverrides(content); - content = normalizeServiceTier(content); - content = ensureFastModeFeature(content, config?.fastMode); - - const catalogPath = chooseCatalogPathForInjection( - content, - options.catalogPath, - ); - content = catalogPath - ? setRootModelCatalogPath(content, catalogPath) - : stripOpencodexCatalogPath(content); - - // Provider-table form: non-loopback admission or an explicit Desktop policy. - const providerTableMode = usesProviderTable(routingTarget); - // Client compaction is the one table form that must not orphan existing threads. It changes - // the DEFAULT provider to `opencodex`, but a thread already tagged `openai` keeps resolving - // to Codex's built-in entry, and without the root override that entry is api.openai.com — - // the thread would resume outside this proxy and outside configured routing. Keeping the - // marker-owned root override alongside the table fixes that at the source: codex builds its - // provider map as merge_configured_model_providers(built_in_model_providers(openai_base_url), - // model_providers), so the override lands on the built-in `openai` entry when the map is - // built, independent of which id is the default, and the merge leaves that entry alone for - // every id except the two Amazon Bedrock ones. With the managed override in place both - // entries point at this proxy. That is a guarantee about the line we own: when the user owns - // the root line we inject nothing, and the built-in entry keeps whatever destination they - // chose, so an `openai`-tagged thread follows their configuration rather than this proxy. - // - // Re-tagging history was the alternative and it cannot be made durable: the length-preserving - // first-line repair cannot grow "openai" into "opencodex" without pre-existing padding, and - // codex re-appends that stale first line whenever it writes git or memory-mode metadata. - // - // Authless is excluded here on purpose: its whole point is a provider that carries - // requires_openai_auth = false, so it forward-tags resume history with originals backed up - // instead, and that includes the case where a user enables authless and client compaction - // together. Only the compaction-only form skips the history unit up front. When forward - // tagging turns out to be impossible because Codex already paginated those rows, the same - // retention is selected below from the preflight verdict rather than from the routing form. - let keepRootOverrideAlongsideTable = providerTableMode - && routingTarget.clientCompaction === true - && routingTarget.desktopAuthless !== true - && routingTarget.requiresAdmissionToken !== true; - let keptUserBaseUrl = false; - let keptUserRealtimeWsBaseUrl = false; - if (providerTableMode) { - // Legacy (non-loopback) injection: the built-in openai provider cannot carry the - // x-opencodex-api-key env header, so keep the opencodex provider table + root re-tag. - // The authless opt-in needs the same table because only a dedicated provider can carry - // requires_openai_auth = false. - // 1) Root key BEFORE the first table header (must be a global, not nested under a table). - content = setRootModelProvider(content); - // 2) Provider table appended at EOF (position-independent). - content = - content.trimEnd() + - "\n" + - buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName); - // 3) Keep existing `openai`-tagged threads reaching the proxy (see above). Ownership rules - // are the Design B ones: a user's own root line is never replaced. - if (keepRootOverrideAlongsideTable) { - content = stripInjectedOpenaiBaseUrl(content); - const rootFallback = setRootOpenaiBaseUrlForTarget(content, routingTarget); - content = rootFallback.content; - keptUserBaseUrl = rootFallback.keptUserBaseUrl; - } - } else { - // Design B (loopback): a single root override; codex keeps its native `openai` provider id - // so thread history is never remapped. Any legacy form was already stripped above. - content = stripInjectedOpenaiBaseUrl(content); // normalize before idempotent re-insert - const result = setRootOpenaiBaseUrlForTarget(content, routingTarget); - content = result.content; - keptUserBaseUrl = result.keptUserBaseUrl; - // Voice sideband override rides on the routing override: same value, same ownership rule, - // and never when the user owns the routing line (we inject nothing in that case). - if (!keptUserBaseUrl) { - const realtime = setRootRealtimeWsBaseUrl(content, routingTarget); - content = realtime.content; - keptUserRealtimeWsBaseUrl = realtime.keptUserRealtimeWsBaseUrl; - } - } - - const desiredSubagentDefaults = configuredManagedSubagentDefaults(config); - const routingOwnershipWarning = - keptUserBaseUrl && desiredSubagentDefaults - ? "Native Codex sub-agent defaults were not injected: a user-owned root openai_base_url prevents OpenCodex from managing active Codex routing." - : undefined; - const managedDefaults = transformManagedSubagentDefaults( - content, - keptUserBaseUrl ? null : desiredSubagentDefaults, - ); - let nativeSubagentDefaultsWarning = routingOwnershipWarning; - let managedDefaultsMessage = routingOwnershipWarning - ? ` ⚠️ ${routingOwnershipWarning}\n` - : ""; - if (managedDefaults.ok) { - content = managedDefaults.content; - if (desiredSubagentDefaults && managedDefaults.conflicts.length > 0) { - const keys = managedDefaults.conflicts - .map((conflict) => `agents.${conflict.key}`) - .join(", "); - nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults were not injected: user-owned ${keys} preserved.`; - managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; - } - } else { - const action = - desiredSubagentDefaults && !keptUserBaseUrl - ? "were not injected" - : "could not be safely removed"; - nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults ${action}: ${managedDefaults.error}.`; - managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; - } - - const profileContent = buildProfileFileForTarget( + const planContext: CodexInjectionPlanContext = { + config, routingTarget, - catalogPath, - websocketsEnabled(config ?? {}), - config?.fastMode, - config?.codexProviderDisplayName, - ); - 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"; - } - }; - /* - * ONE refusal stands the relabel unit down instead of vetoing the config transition, and - * only because it is permanent. Codex allocates paginated rollout ordinals in its own - * writer, so `assertLegacyHistoryRecord` refuses every rollout on a current install and no - * amount of retrying changes that. While it vetoed the write, `model_catalog_json` never - * reached config.toml, so the app and the CLI both fell back to their built-in model list - * while `ocx sync` still reported success. - * - * Every other reason — an unreadable state database, a rollout whose identity changed, a - * preflight that could not run — describes a store that may well be relabelable on the next - * attempt. Treating those as a stand-down would record the transition as converged and - * suppress the relabel permanently, so they keep the hard refusal and the rollback. - */ - /* - * Re-observed inside the artifact transaction. A store that migrates to paginated history - * mid-write can retire the relabel unit while its already-admitted candidate leaves - * existing provider references resolvable. Existing provider definitions are retained - * before the witness; no post-commit compensation may overwrite a newer native write. - */ - const observeHistoryRefusalOrThrow = (known: string | null): string | null => { - if (known) return known; - const observed = historyPreflight(); - if (observed && observed !== HISTORY_RELABEL_STANDS_DOWN) throw new CodexHistoryPreflightRefusal(observed); - return observed; + catalogPathOption: options.catalogPath, + journalReadOnly: !!options.beforeClientWrite, }; - const compat = applyPaginatedOpenaiCompat(historyPreflight(), routingTarget, content, eol); - content = compat.content; - keepRootOverrideAlongsideTable ||= compat.retainedRootOverride; - const observedHistoryRefusal = compat.refusal; - if (observedHistoryRefusal && observedHistoryRefusal !== HISTORY_RELABEL_STANDS_DOWN) { + const admittedPlan = deriveCodexInjectionPlan(rawContent, planContext); + if (admittedPlan.kind === "refused") { return { success: false, - historyPreflightFailureReason: observedHistoryRefusal, - message: compat.message, + ...(admittedPlan.historyPreflightFailureReason + ? { historyPreflightFailureReason: admittedPlan.historyPreflightFailureReason } + : {}), + message: admittedPlan.message, }; } - let historyRelabelRefusal = observedHistoryRefusal; - - /* - * Rows this home may have tagged `opencodex` resolve only through a provider table. Design B - * selects built-in `openai` for new work, but background relabel and native publication are - * not atomic. Codex can paginate after the final check or when the worker starts. Retain - * an existing definition BEFORE the witness regardless of preflight, so worker failure - * cannot orphan old references. Explicit restoration keeps its removal and history guards. - */ - if (hadOcxProviderTableOnDisk && !providerTableMode) { - content = applyEol( - content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName), - eol, - ); - } /* * The witness, built from the FINAL bytes. Everything it hashes is either the @@ -555,13 +295,8 @@ async function injectCodexConfigImpl( observedGeneration.kind === "ready" ? { present: true, value: observedGeneration.generation.value } : { present: false, value: 0 }; - const candidate = { - configBytes: content, - profileBytes: profileContent, - catalogPath, - }; const witness = buildInjectWitness( - candidate, + admittedPlan.candidate, rawContent, persistedIdentity, generation, @@ -605,84 +340,132 @@ async function injectCodexConfigImpl( }; } - const journalBaselineIsNative = (): boolean => { + const journalBaselineIsNative = (nativeInput: string): boolean => { // Value evidence survives an app rewrite that removes the ownership comments. const journaledBaseUrl = journaledInjectedOpenaiBaseUrl({ readOnly: true }); const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl({ readOnly: true }); const looksInjectedByValue = - (journaledBaseUrl !== null && rootTomlString(rawContent, "openai_base_url") === journaledBaseUrl) + (journaledBaseUrl !== null && rootTomlString(nativeInput, "openai_base_url") === journaledBaseUrl) || (journaledRealtimeWsBaseUrl !== null - && rootTomlString(rawContent, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl); - return !hasInjectedCodexRouting(rawContent) && !looksInjectedByValue; + && rootTomlString(nativeInput, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl); + return !hasInjectedCodexRouting(nativeInput) && !looksInjectedByValue; }; const readCurrentProfile = (): string | null => existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null; const unverifiedJournalMessage = "Codex configuration was not written: the journal has no verified baseline for the current config/profile. Current files and the journal were preserved."; - if (!journalBaselineIsNative() && hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) { + // When the reconcile will rewrite config.toml under the lock, the baseline it + // must be journaled against does not exist yet — this check runs inside the + // boundary on the post-transition plan instead. Otherwise the admitted bytes + // are final and the early refusal saves acquiring the lock just to say no. + if (v1Reconcile?.enabledAtPrepare !== true + && !journalBaselineIsNative(rawContent) + && hasUnverifiedJournalBaseline(admittedPlan.baselineContent, readCurrentProfile())) { return { success: false, - message: v1Surface.changed - ? unverifiedJournalMessage.replace( - "Current files and the journal were preserved.", - "The v1-surface reconcile was applied to config.toml; other current files and the journal were preserved.", - ) - : unverifiedJournalMessage, + message: unverifiedJournalMessage, }; } if (options.validateOnly) { return { success: true, - ...(historyRelabelRefusal ? { historyPreflightFailureReason: historyRelabelRefusal } : {}), + ...(admittedPlan.historyRelabelRefusal ? { historyPreflightFailureReason: admittedPlan.historyRelabelRefusal } : {}), message: "Codex config injection preflight passed; no files were changed.", }; } - const applyNativeArtifacts = (): void => { + /* + * Re-observed inside the artifact transaction. A store that migrates to + * paginated history mid-write can retire the relabel unit while its + * already-admitted candidate leaves existing provider references resolvable. + */ + const observeHistoryRefusalOrThrow = (plan: CodexInjectionPlanOk): string | null => { + if (plan.historyRelabelRefusal) return plan.historyRelabelRefusal; + const observed = plan.historyPreflight(); + if (observed && observed !== HISTORY_RELABEL_STANDS_DOWN) throw new CodexHistoryPreflightRefusal(observed); + return observed; + }; + + /* + * The half of the injection that only exists inside the write boundary: the + * v1-surface reconcile first, then the plan re-derived from whatever bytes + * the transition left so the committed file cannot re-enable the flag the + * reconcile just turned off. Every refusal here is thrown as + * CodexInjectRefusal so the caller's catch restores the preimage — the flag + * flip included — before the result is reported. + */ + const reconcileAndDerivePlan = (): { plan: CodexInjectionPlanOk; nativeInput: string } => { + let nativeInput = rawContent; + let plan = admittedPlan; + if (v1Reconcile) { + const reconciled = v1Reconcile.run(); + if (!reconciled.ok) { + throw new CodexInjectRefusal({ success: false, message: reconciled.message }); + } + nativeInput = reconciled.content; + if (reconciled.content !== rawContent) { + const rederived = deriveCodexInjectionPlan(reconciled.content, planContext); + if (rederived.kind === "refused") { + throw new CodexInjectRefusal({ + success: false, + ...(rederived.historyPreflightFailureReason + ? { historyPreflightFailureReason: rederived.historyPreflightFailureReason } + : {}), + message: rederived.message, + }); + } + plan = rederived; + } + // Seam for the mutual-exclusion regression: the feature transition has + // landed and the artifact commit has not — the window a competing writer + // must be unable to enter. + historyArtifactStageForTests?.("after-v1-reconcile"); + } + if (!journalBaselineIsNative(nativeInput) + && hasUnverifiedJournalBaseline(plan.baselineContent, readCurrentProfile())) { + throw new CodexInjectRefusal({ success: false, message: unverifiedJournalMessage }); + } + return { plan, nativeInput }; + }; + + const applyNativeArtifacts = (plan: CodexInjectionPlanOk, nativeInput: string): void => { beforeHistoryArtifactCommitForTests?.(eligibility.kind); - historyRelabelRefusal = observeHistoryRefusalOrThrow(historyRelabelRefusal); - const preImages = captureCodexPreImages(); - try { + plan.historyRelabelRefusal = observeHistoryRefusalOrThrow(plan); historyArtifactStageForTests?.("after-preflight"); writeJournal({ - currentStateIsNative: journalBaselineIsNative(), - configContent: baselineContent, + currentStateIsNative: journalBaselineIsNative(nativeInput), + configContent: plan.baselineContent, owner: options.journalOwner, }); // A native snapshot may have been refreshed above. An older hashless routed snapshot // 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); + if (hasUnverifiedJournalBaseline(plan.baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage); + atomicWriteFile(CODEX_CONFIG_PATH, plan.content); historyArtifactStageForTests?.("after-config"); - atomicWriteFile(CODEX_PROFILE_PATH, profileContent); - markJournalInjectedState(content, profileContent, { + atomicWriteFile(CODEX_PROFILE_PATH, plan.profileContent); + markJournalInjectedState(plan.content, plan.profileContent, { // A root override is ours whenever we wrote one and no user-owned value won. That is - // loopback Design B, the client-compaction form, and any table form that retained the - // root line for a paginated openai row, all of which keep the marker-owned line beside - // the table. Journaling it matters because the marker comment is not durable: the Codex - // app can reserialize config.toml and drop comments, and restore then has only the - // journaled value to tell our line from a user's (#1798). Other table forms record null. - injectedOpenaiBaseUrl: (providerTableMode && !keepRootOverrideAlongsideTable) || keptUserBaseUrl + // loopback Design B, and now also the client-compaction form, which keeps the same + // marker-owned root line beside its provider table. Journaling it matters because the + // marker comment is not durable: the Codex app can reserialize config.toml and drop + // comments, and restore then has only the journaled value to tell our line from a user's + // (#1798). The other table forms never write the key, so they still record null. + injectedOpenaiBaseUrl: (plan.providerTableMode && !plan.keepRootOverrideAlongsideTable) || plan.keptUserBaseUrl ? null - : rootTomlString(content, "openai_base_url"), + : rootTomlString(plan.content, "openai_base_url"), // The sideband override is ours only when we wrote it this pass (never in legacy mode, // never when the user owns either key). - injectedRealtimeWsBaseUrl: providerTableMode || keptUserBaseUrl || keptUserRealtimeWsBaseUrl + injectedRealtimeWsBaseUrl: plan.providerTableMode || plan.keptUserBaseUrl || plan.keptUserRealtimeWsBaseUrl ? null - : rootTomlString(content, REALTIME_WS_BASE_URL_KEY), + : rootTomlString(plan.content, REALTIME_WS_BASE_URL_KEY), // This is the catalog artifact selected for this injection, even when config.toml // already points at that path and therefore needs no textual rewrite. - injectedCatalogPath: catalogPath, + injectedCatalogPath: plan.catalogPath, }); historyArtifactStageForTests?.("after-artifacts"); // Detect migration throughout the artifact transaction, not just at entry. - historyRelabelRefusal = observeHistoryRefusalOrThrow(historyRelabelRefusal); - } catch (error) { - const compensated = restoreCodexPreImages(preImages); - if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); - throw error; - } + plan.historyRelabelRefusal = observeHistoryRefusalOrThrow(plan); }; /* @@ -693,6 +476,14 @@ async function injectCodexConfigImpl( */ let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined; + /* + * The plan the committed write actually used: the admitted plan, or the + * re-derivation from the post-reconcile bytes when the feature transition + * rewrote config.toml under the lock. Every reader below the boundary takes + * this plan so the report describes the bytes that were committed. + */ + let effectivePlan: CodexInjectionPlanOk = admittedPlan; + if (eligibility.kind === "legacy-uncoordinated") { const applyLegacy = (): CodexInjectResult | undefined => { const legacyGateSnapshot = loadConfig(); @@ -701,15 +492,30 @@ async function injectCodexConfigImpl( success: true, status: "skipped", skippedReason: localClientSkipReason(legacyGateSnapshot), - message: reconcileAware(localClientSkipMessage( + message: localClientSkipMessage( legacyGateSnapshot, "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.", "No Codex config, catalog, cache, or history was changed.", - )), + ), }; } runClientWriteGuard(options.beforeClientWrite); - applyNativeArtifacts(); + /* + * One preimage covers the reconcile and the artifact commit together: a + * refusal after the feature transition hands back the exact bytes the + * home started with, flag included. + */ + const preImages = captureCodexPreImages(); + try { + const resolved = reconcileAndDerivePlan(); + applyNativeArtifacts(resolved.plan, resolved.nativeInput); + effectivePlan = resolved.plan; + } catch (error) { + const restored = restoreCodexPreImages(preImages); + if (!restored.complete) throw new CodexPartialWriteError(restored.unrestored); + throw error; + } + return undefined; }; // Only connected guarded writes add C here. A concurrent disconnect claim // either follows this commit or is observed by the guard before any write. @@ -780,10 +586,17 @@ async function injectCodexConfigImpl( * failure partway leaves earlier replacements in place. `restoreJournalState` * cannot be the undo — it restores whichever journal occupies the path, * which need not be the one this operation wrote. + * + * The capture precedes the v1-surface reconcile on purpose: one verified + * preimage covers the feature transition and the artifact commit, so a + * later refusal restores the flag the transition flipped along with the + * files the commit replaced. */ const preImages = captureCodexPreImages(); + let resolved: { plan: CodexInjectionPlanOk; nativeInput: string }; try { - applyNativeArtifacts(); + resolved = reconcileAndDerivePlan(); + applyNativeArtifacts(resolved.plan, resolved.nativeInput); } catch (error) { // Compensate, then ALWAYS throw. Returning a partial result would let the // lock commit a row describing an apply that did not finish. @@ -796,6 +609,7 @@ async function injectCodexConfigImpl( return { kind: "applied" as const, preImages, + plan: resolved.plan, /* * The receipt the terminal update matches on. The transition commits * when the callback returns, so this pair is what the post-job @@ -811,12 +625,9 @@ async function injectCodexConfigImpl( ); if (coordinated.status !== "acquired") { - const outcome = codexInjectLockOutcome(coordinated); - if (v1Surface.changed && outcome.success) { - outcome.message = reconcileAware(outcome.message); - } - return outcome; + return codexInjectLockOutcome(coordinated); } + effectivePlan = coordinated.value.plan; recordCodexNativeTransactionProvenance( coordinated.value.preImages, coordinated.value.receipt.currentTxId, @@ -840,15 +651,15 @@ async function injectCodexConfigImpl( // A stood-down relabel unit spawns no Worker: the preflight it would run first has // already refused, and the config half is committed either way. historyArtifactStageForTests?.("before-history-worker"); - const historyOutcome: CodexHistoryJobOutcome = historyRelabelRefusal + const historyOutcome: CodexHistoryJobOutcome = effectivePlan.historyRelabelRefusal ? { kind: "skipped" } : await runCodexHistoryJob({ ...resolveCodexHistoryJobTarget(), expectedDesiredEnabled: true, operation: deriveCodexHistoryOperation({ direction: "apply", - resumeHistory: config?.syncResumeHistory !== false && !keepRootOverrideAlongsideTable, - legacyMode: providerTableMode, + resumeHistory: config?.syncResumeHistory !== false && !effectivePlan.keepRootOverrideAlongsideTable, + legacyMode: effectivePlan.providerTableMode, }), }); // A blocked or failed unit is reported, not silently counted as zero work: @@ -873,23 +684,23 @@ async function injectCodexConfigImpl( resolveCodexHistoryTransition(transitionReceipt, historyOutcome); } - const catalogMessage = catalogPath - ? ` Codex model catalog: ${catalogPath}\n` + const catalogMessage = effectivePlan.catalogPath + ? ` Codex model catalog: ${effectivePlan.catalogPath}\n` : ` Codex model catalog not injected because no opencodex catalog file exists yet.\n`; const ejected = (history as { ejectedRows?: number }).ejectedRows ?? 0; const migratedRows = (history.rows ?? 0) + ejected; const historyMessage = - keepRootOverrideAlongsideTable - ? (keptUserBaseUrl + effectivePlan.keepRootOverrideAlongsideTable + ? (effectivePlan.keptUserBaseUrl ? ` Codex resume history: left unchanged; threads already tagged openai follow your configured root openai_base_url.\n` : ` Codex resume history: left unchanged; existing threads keep reaching the proxy through the retained openai_base_url override.\n`) - : historyRelabelRefusal - ? ` ⚠️ Codex resume history: left to Codex's native writer (${historyRelabelRefusal}); existing threads keep the provider they are tagged with. Routing and the model catalog were still installed, so new threads reach the proxy.\n` + : effectivePlan.historyRelabelRefusal + ? ` ⚠️ Codex resume history: left to Codex's native writer (${effectivePlan.historyRelabelRefusal}); existing threads keep the provider they are tagged with. Routing and the model catalog were still installed, so new threads reach the proxy.\n` : config?.syncResumeHistory === false ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n` : history.failed - ? formatApplyHistoryFailure(historyOutcome, providerTableMode) - : providerTableMode + ? formatApplyHistoryFailure(historyOutcome, effectivePlan.providerTableMode) + : effectivePlan.providerTableMode ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n` : migratedRows > 0 ? ` Codex resume history: restored original provider metadata for ${migratedRows} manifest-backed thread(s) (one-time).\n` @@ -901,35 +712,35 @@ async function injectCodexConfigImpl( // misdescribe the file it just produced: new threads do use the injected table. Report that // mixed result on its own terms, and never tell the operator to delete a setting of theirs. // Ownership alone says nothing about destination: their line may already target this proxy. - if (keptUserBaseUrl && keepRootOverrideAlongsideTable) { + if (effectivePlan.keptUserBaseUrl && effectivePlan.keepRootOverrideAlongsideTable) { return { success: true, - ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), - ...(historyRelabelRefusal ? { historyPreflightFailureReason: historyRelabelRefusal } : {}), + ...(effectivePlan.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: effectivePlan.nativeSubagentDefaultsWarning } : {}), + ...(effectivePlan.historyRelabelRefusal ? { historyPreflightFailureReason: effectivePlan.historyRelabelRefusal } : {}), message: `Injected opencodex as default provider into Codex config (client-side compaction mode; ChatGPT auth remains required).\n` + ` Your root openai_base_url was left exactly as you set it, so opencodex did not add its own.\n` + catalogMessage + historyMessage + - managedDefaultsMessage + + effectivePlan.managedDefaultsMessage + ` New threads use the injected opencodex provider and route through the proxy.\n` + ` Threads already tagged openai resolve through Codex's built-in provider, which your root openai_base_url points at.\n` + ` No root URL change is required to enable client-side compaction for new threads.\n` + ` Fallback: codex --profile opencodex (same behavior)`, }; } - if (keptUserBaseUrl) { + if (effectivePlan.keptUserBaseUrl) { return { success: true, - ...(nativeSubagentDefaultsWarning - ? { nativeSubagentDefaultsWarning } + ...(effectivePlan.nativeSubagentDefaultsWarning + ? { nativeSubagentDefaultsWarning: effectivePlan.nativeSubagentDefaultsWarning } : {}), - ...(historyRelabelRefusal ? { historyPreflightFailureReason: historyRelabelRefusal } : {}), + ...(effectivePlan.historyRelabelRefusal ? { historyPreflightFailureReason: effectivePlan.historyRelabelRefusal } : {}), message: `⚠️ Codex routing NOT injected: your config already sets a root openai_base_url, and opencodex never overwrites a user-owned override.\n` + catalogMessage + historyMessage + - managedDefaultsMessage + + effectivePlan.managedDefaultsMessage + ` To route plain codex through the proxy, remove your openai_base_url line from ~/.codex/config.toml and rerun 'ocx start'.\n` + ` Reference config: ${CODEX_PROFILE_PATH}`, }; @@ -938,22 +749,22 @@ async function injectCodexConfigImpl( ? `Injected opencodex as default provider into Codex config (authless Desktop mode: requires_openai_auth = false).\n` : routingTarget.clientCompaction === true ? `Injected opencodex as default provider into Codex config (client-side compaction mode; ChatGPT auth remains required).\n` - : providerTableMode + : effectivePlan.providerTableMode ? `Injected opencodex as default provider into Codex config.\n` : `Pointed Codex's built-in openai provider at the opencodex proxy (openai_base_url + realtime sideband override).\n`; return { success: true, - ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), - ...(historyRelabelRefusal ? { historyPreflightFailureReason: historyRelabelRefusal } : {}), + ...(effectivePlan.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: effectivePlan.nativeSubagentDefaultsWarning } : {}), + ...(effectivePlan.historyRelabelRefusal ? { historyPreflightFailureReason: effectivePlan.historyRelabelRefusal } : {}), message: headline + catalogMessage + historyMessage + - managedDefaultsMessage + + effectivePlan.managedDefaultsMessage + ` All models now route through opencodex proxy (like OpenRouter).\n` + ` OpenAI models (gpt-5.5, etc.) are passed through to OpenAI.\n` + ` Custom models route to their configured providers.\n` + - (providerTableMode + (effectivePlan.providerTableMode ? ` Fallback: codex --profile opencodex (same behavior)` : ` Fallback reference: ${CODEX_PROFILE_PATH}`), }; diff --git a/src/codex/inject/multi-agent-v2.ts b/src/codex/inject/multi-agent-v2.ts index 9048d3a312e..7338c06a76d 100644 --- a/src/codex/inject/multi-agent-v2.ts +++ b/src/codex/inject/multi-agent-v2.ts @@ -28,33 +28,69 @@ export function setCodexMultiAgentV2ToggleForTests( } /** - * Disable a pre-existing global v2 override before the journal baseline when the injected - * OpenCodex config explicitly selects v1. Returns the config.toml bytes the caller should keep - * working from: unchanged input when no transition ran, re-read post-transition bytes when it - * did. Read-only preflight and non-v1 modes are pass-throughs, and externally owned provider - * configs never reach this point — the caller returns before invoking it. + * A reconcile whose dependencies are resolved ahead of the Codex write lock. + * + * The write-lock commit callback is synchronous, so the dynamic imports and the + * toggle seam are settled in `prepareInjectedV1SurfaceReconcile` before + * acquisition. `run()` is the synchronous half and must be called while the + * coordinated write boundary is held: the transition mutates config.toml and a + * caller that lets it escape the boundary leaves the file changed when a later + * step refuses. */ -export async function reconcileInjectedV1Surface( +export interface PreparedV1SurfaceReconcile { + /** + * Whether the on-disk flag was enabled when the prepare ran. A pre-lock hint + * only — `run()` re-checks the flag on the bytes present under the lock. + */ + readonly enabledAtPrepare: boolean; + run(): InjectedV1SurfaceReconcile; +} + +/** + * Resolve the reconcile for a v1 injection, or null when none can apply. + * + * Read-only preflight and non-v1 modes are pass-throughs, and externally owned + * provider configs never reach this point — the caller returns before + * preparing. The toggle import is resolved eagerly only when the flag is + * already on, so a clean-home v1 injection does not load the CLI module. + */ +export async function prepareInjectedV1SurfaceReconcile( config: Pick | undefined, options: { validateOnly?: boolean }, - rawContent: string, -): Promise { +): Promise { if (options.validateOnly || config?.multiAgentMode !== "v1") { - return { ok: true, content: rawContent, changed: false }; + return null; } const { isMultiAgentV2Enabled, transitionMultiAgentV2 } = await import("../features"); - if (!isMultiAgentV2Enabled()) return { ok: true, content: rawContent, changed: false }; + const enabledAtPrepare = isMultiAgentV2Enabled(); let toggle = toggleForTests; - if (!toggle) { + if (enabledAtPrepare && !toggle) { const { runCodexFeaturesCommand } = await import("../../cli/v2"); toggle = enabled => runCodexFeaturesCommand(enabled ? "enable" : "disable"); } - const transition = transitionMultiAgentV2(false, toggle); - if (!transition.ok) { - return { - ok: false, - message: `Codex config injection refused: could not reconcile the v1 surface with the global multi_agent_v2 feature: ${transition.error}.`, - }; - } - return { ok: true, content: readFileSync(CODEX_CONFIG_PATH, "utf-8"), changed: transition.changed }; + return { + enabledAtPrepare, + run() { + // Decide on the bytes present NOW, under the lock — the prepare-time + // answer is stale the moment another writer could have touched the file. + if (!isMultiAgentV2Enabled()) { + return { ok: true, content: readFileSync(CODEX_CONFIG_PATH, "utf-8"), changed: false }; + } + let active = toggle ?? toggleForTests; + if (!active) { + // The flag flipped on between prepare and the lock — reachable only on + // the uncoordinated legacy path, which has no admission re-check. + const { runCodexFeaturesCommand } = require("../../cli/v2") as typeof import("../../cli/v2"); + active = enabled => runCodexFeaturesCommand(enabled ? "enable" : "disable"); + } + const transition = transitionMultiAgentV2(false, active); + if (!transition.ok) { + return { + ok: false, + message: `Codex config injection refused: could not reconcile the v1 surface with the global multi_agent_v2 feature: ${transition.error}.`, + }; + } + return { ok: true, content: readFileSync(CODEX_CONFIG_PATH, "utf-8"), changed: transition.changed }; + }, + }; } diff --git a/src/codex/inject/plan.ts b/src/codex/inject/plan.ts new file mode 100644 index 00000000000..ab31fa6b1e7 --- /dev/null +++ b/src/codex/inject/plan.ts @@ -0,0 +1,365 @@ +/** + * The injection plan: every byte the artifact commit will write, plus the + * journal baseline, derived from one native config.toml text. + * + * This is pure transformation and read-only preflight — no filesystem + * mutation — so the caller derives once at admission time for the write + * witness and pre-lock checks, and then AGAIN under the write lock when the + * v1-surface reconcile changes config.toml there. Re-deriving from the + * post-transition bytes is what keeps the committed file from re-enabling the + * flag the reconcile just turned off. + */ +import { websocketsEnabled } from "../../config"; +import { + HISTORY_RELABEL_STANDS_DOWN, + preflightCodexHistoryInjection, +} from "../history-provider"; +import { + journaledInjectedOpenaiBaseUrl, + journaledInjectedRealtimeWsBaseUrl, +} from "../journal"; +import { stripJournaledOpenaiBaseUrl } from "../injected-marker"; +import { CODEX_CONFIG_PATH, resolveCodexStateDbPath } from "../paths"; +import { transformManagedSubagentDefaults } from "../subagent-defaults"; +import type { OcxConfig } from "../../types"; +import type { CodexWriteCandidate } from "../write-coordination"; +import { + applyEol, + buildProfileFileForTarget, + buildProviderTableBlockForTarget, + chooseCatalogPathForInjection, + dominantEol, + ensureFastModeFeature, + normalizeServiceTier, + removeProfileSection, + setRootModelCatalogPath, + setRootModelProvider, + setRootOpenaiBaseUrlForTarget, + setRootRealtimeWsBaseUrl, + stripExistingModelProvider, + stripInjectedOpenaiBaseUrl, + stripOpencodexCatalogPath, + stripRootContextWindowOverrides, +} from "./config-toml"; +import { hasOcxProviderTable, removeOcxSection } from "./remove"; +import { + configuredManagedSubagentDefaults, + usesProviderTable, + type CodexRoutingTarget, +} from "./routing-target"; +import { applyPaginatedOpenaiCompat } from "./paginated-openai-compat"; + +/** Everything the plan needs that is not the config.toml input text. */ +export interface CodexInjectionPlanContext { + readonly config: OcxConfig | undefined; + readonly routingTarget: CodexRoutingTarget; + /** The caller's catalog path option — the RESOLVED path lands on the plan. */ + readonly catalogPathOption: string | null | undefined; + /** Journal reads stay read-only while a client guard owns the write channel. */ + readonly journalReadOnly: boolean; +} + +/** The ok-variant of the plan: every derived artifact and reportable warning. */ +export interface CodexInjectionPlanOk { + kind: "ok"; + /** The input with marker-owned residue removed — what writeJournal snapshots. */ + baselineContent: string; + /** The exact string about to replace config.toml. */ + content: string; + /** The exact string about to replace the profile file. */ + profileContent: string; + /** The resolved catalog path, never the raw option. */ + catalogPath: string | null; + providerTableMode: boolean; + keepRootOverrideAlongsideTable: boolean; + keptUserBaseUrl: boolean; + keptUserRealtimeWsBaseUrl: boolean; + nativeSubagentDefaultsWarning: string | undefined; + managedDefaultsMessage: string; + /** + * Mutable: the artifact commit re-observes the history store mid-write and + * records the outcome here so the caller's message reflects it. + */ + historyRelabelRefusal: string | null; + /** Read-only history preflight bound to this plan's candidate bytes. */ + historyPreflight(): string | null; + /** The witness candidate: the bytes this plan commits. */ + candidate: CodexWriteCandidate; +} + +export type CodexInjectionPlan = + | { + kind: "refused"; + message: string; + historyPreflightFailureReason?: string; + } + | CodexInjectionPlanOk; + +export function deriveCodexInjectionPlan( + source: string, + ctx: CodexInjectionPlanContext, +): CodexInjectionPlan { + const { config, routingTarget } = ctx; + const preflightTableMode = usesProviderTable(routingTarget); + const compactionOnly = routingTarget.clientCompaction === true + && routingTarget.desktopAuthless !== true + && routingTarget.requiresAdmissionToken !== true; + + // Marker-owned native defaults are OpenCodex residue, never part of the + // user's journal baseline. Clean them before either snapshotting or adding a + // root routing key: inserting that key ahead of a marker-owned first table + // would otherwise separate the table marker from its header. Ambiguous + // markers fail closed without writing config, profile, or journal state. + const nativeDefaultsBaseline = transformManagedSubagentDefaults( + source, + null, + ); + if (!nativeDefaultsBaseline.ok) { + return { + kind: "refused", + message: + `Codex config injection refused: existing OpenCodex-managed native sub-agent defaults are ambiguous: ${nativeDefaultsBaseline.error}. ` + + `No files were changed; inspect ${CODEX_CONFIG_PATH}.`, + }; + } + const baselineContent = nativeDefaultsBaseline.content; + + /* + * The journal write happens inside the write lock, after this plan is + * derived. The lock's witness hashes the CANDIDATE BYTES, and those are not + * final until `profileContent` and the EOL-applied `content` exist. + * Opening the lock before them would leave nothing to hash; keeping the + * journal outside the lock would leave the first artifact-creating write + * unserialized. + * + * The split is safe because derivation performs no filesystem mutation — its + * only touch is `existsSync` on the catalog paths + * (`chooseCatalogPathForInjection`) — and because `writeJournal` is called + * with `configContent`, so it snapshots the baseline it is handed rather + * than rereading config.toml underneath the transforms. + */ + // EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write. + const eol = dominantEol(source); + let content = applyEol(baselineContent, "\n"); + + // Idempotent clean-up of any prior injection: drop the provider table (marker-based) and every + // stray/mis-nested model_provider line, so re-injecting can't duplicate keys or leave the buggy + // table-nested key behind. + // Design B form FIRST: removeOcxSection also keys on the marker line, so a root-level + // marker + openai_base_url pair must be gone before it scans or it would swallow root keys. + content = stripInjectedOpenaiBaseUrl(content); + // #1798: after a Codex app rewrite the markers are gone but the values we recorded writing + // are still ours. Consume them by value here, BEFORE the routing form is chosen, so a + // Design B -> provider-table transition (hostname change, authless opt-in) cannot leave our + // own root URLs behind as if they were the user's, and so re-inject never journals them as + // not-ours (which would make them unrestorable). + content = stripJournaledOpenaiBaseUrl( + content, + journaledInjectedOpenaiBaseUrl({ readOnly: ctx.journalReadOnly }), + journaledInjectedRealtimeWsBaseUrl({ readOnly: ctx.journalReadOnly }), + ); + // Whether this home already published the provider id that its thread rows may reference. + // Design B strips the table below; it may only stay stripped if those rows can be relabeled. + const hadOcxProviderTableOnDisk = hasOcxProviderTable(content); + if (hadOcxProviderTableOnDisk) { + content = removeOcxSection(content); + } + content = removeProfileSection(content); + content = stripExistingModelProvider(content); + content = stripRootContextWindowOverrides(content); + content = normalizeServiceTier(content); + content = ensureFastModeFeature(content, config?.fastMode); + + const catalogPath = chooseCatalogPathForInjection( + content, + ctx.catalogPathOption, + ); + content = catalogPath + ? setRootModelCatalogPath(content, catalogPath) + : stripOpencodexCatalogPath(content); + + // Provider-table form: non-loopback admission or an explicit Desktop policy. + const providerTableMode = usesProviderTable(routingTarget); + // Client compaction is the one table form that must not orphan existing threads. It changes + // the DEFAULT provider to `opencodex`, but a thread already tagged `openai` keeps resolving + // to Codex's built-in entry, and without the root override that entry is api.openai.com — + // the thread would resume outside this proxy and outside configured routing. Keeping the + // marker-owned root override alongside the table fixes that at the source: codex builds its + // provider map as merge_configured_model_providers(built_in_model_providers(openai_base_url), + // model_providers), so the override lands on the built-in `openai` entry when the map is + // built, independent of which id is the default, and the merge leaves that entry alone for + // every id except the two Amazon Bedrock ones. With the managed override in place both + // entries point at this proxy. That is a guarantee about the line we own: when the user owns + // the root line we inject nothing, and the built-in entry keeps whatever destination they + // chose, so an `openai`-tagged thread follows their configuration rather than this proxy. + // + // Re-tagging history was the alternative and it cannot be made durable: the length-preserving + // first-line repair cannot grow "openai" into "opencodex" without pre-existing padding, and + // codex re-appends that stale first line whenever it writes git or memory-mode metadata. + // + // Authless is excluded on purpose: its whole point is a provider that carries + // requires_openai_auth = false, and admission-token forms cannot use the root key at all. + // Those two forms therefore keep their existing behaviour, forward-tagging resume history with + // originals backed up, and that includes the case where a user enables authless and client + // compaction together. Only the compaction-only form skips the history unit. + let keepRootOverrideAlongsideTable = providerTableMode + && routingTarget.clientCompaction === true + && routingTarget.desktopAuthless !== true + && routingTarget.requiresAdmissionToken !== true; + let keptUserBaseUrl = false; + let keptUserRealtimeWsBaseUrl = false; + if (providerTableMode) { + // Legacy (non-loopback) injection: the built-in openai provider cannot carry the + // x-opencodex-api-key env header, so keep the opencodex provider table + root re-tag. + // The authless opt-in needs the same table because only a dedicated provider can carry + // requires_openai_auth = false. + // 1) Root key BEFORE the first table header (must be a global, not nested under a table). + content = setRootModelProvider(content); + // 2) Provider table appended at EOF (position-independent). + content = + content.trimEnd() + + "\n" + + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName); + // 3) Keep existing `openai`-tagged threads reaching the proxy (see above). Ownership rules + // are the Design B ones: a user's own root line is never replaced. + if (keepRootOverrideAlongsideTable) { + content = stripInjectedOpenaiBaseUrl(content); + const rootFallback = setRootOpenaiBaseUrlForTarget(content, routingTarget); + content = rootFallback.content; + keptUserBaseUrl = rootFallback.keptUserBaseUrl; + } + } else { + // Design B (loopback): a single root override; codex keeps its native `openai` provider id + // so thread history is never remapped. Any legacy form was already stripped above. + content = stripInjectedOpenaiBaseUrl(content); // normalize before idempotent re-insert + const result = setRootOpenaiBaseUrlForTarget(content, routingTarget); + content = result.content; + keptUserBaseUrl = result.keptUserBaseUrl; + // Voice sideband override rides on the routing override: same value, same ownership rule, + // and never when the user owns the routing line (we inject nothing in that case). + if (!keptUserBaseUrl) { + const realtime = setRootRealtimeWsBaseUrl(content, routingTarget); + content = realtime.content; + keptUserRealtimeWsBaseUrl = realtime.keptUserRealtimeWsBaseUrl; + } + } + + const desiredSubagentDefaults = configuredManagedSubagentDefaults(config); + const routingOwnershipWarning = + keptUserBaseUrl && desiredSubagentDefaults + ? "Native Codex sub-agent defaults were not injected: a user-owned root openai_base_url prevents OpenCodex from managing active Codex routing." + : undefined; + const managedDefaults = transformManagedSubagentDefaults( + content, + keptUserBaseUrl ? null : desiredSubagentDefaults, + ); + let nativeSubagentDefaultsWarning = routingOwnershipWarning; + let managedDefaultsMessage = routingOwnershipWarning + ? ` ⚠️ ${routingOwnershipWarning}\n` + : ""; + if (managedDefaults.ok) { + content = managedDefaults.content; + if (desiredSubagentDefaults && managedDefaults.conflicts.length > 0) { + const keys = managedDefaults.conflicts + .map((conflict) => `agents.${conflict.key}`) + .join(", "); + nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults were not injected: user-owned ${keys} preserved.`; + managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; + } + } else { + const action = + desiredSubagentDefaults && !keptUserBaseUrl + ? "were not injected" + : "could not be safely removed"; + nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults ${action}: ${managedDefaults.error}.`; + managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; + } + + const profileContent = buildProfileFileForTarget( + routingTarget, + catalogPath, + websocketsEnabled(config ?? {}), + config?.fastMode, + config?.codexProviderDisplayName, + ); + 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"; + } + }; + /* + * ONE refusal stands the relabel unit down instead of vetoing the config transition, and + * only because it is permanent. Codex allocates paginated rollout ordinals in its own + * writer, so `assertLegacyHistoryRecord` refuses every rollout on a current install and no + * amount of retrying changes that. While it vetoed the write, `model_catalog_json` never + * reached config.toml, so the app and the CLI both fell back to their built-in model list + * while `ocx sync` still reported success. + * + * Every other reason — an unreadable state database, a rollout whose identity changed, a + * preflight that could not run — describes a store that may well be relabelable on the next + * attempt. Treating those as a stand-down would record the transition as converged and + * suppress the relabel permanently, so they keep the hard refusal and the rollback. + */ + /* + * Re-observed inside the artifact transaction. A store that migrates to paginated history + * mid-write can retire the relabel unit while its already-admitted candidate leaves + * existing provider references resolvable. Existing provider definitions are retained + * before the witness; no post-commit compensation may overwrite a newer native write. + */ + const compat = applyPaginatedOpenaiCompat(historyPreflight(), routingTarget, content, eol); + content = compat.content; + keepRootOverrideAlongsideTable ||= compat.retainedRootOverride; + const observedHistoryRefusal = compat.refusal; + if (observedHistoryRefusal && observedHistoryRefusal !== HISTORY_RELABEL_STANDS_DOWN) { + return { + kind: "refused", + historyPreflightFailureReason: observedHistoryRefusal, + message: compat.message, + }; + } + + /* + * Rows this home may have tagged `opencodex` resolve only through a provider table. Design B + * selects built-in `openai` for new work, but background relabel and native publication are + * not atomic. Codex can paginate after the final check or when the worker starts. Retain + * an existing definition BEFORE the witness regardless of preflight, so worker failure + * cannot orphan old references. Explicit restoration keeps its removal and history guards. + */ + if (hadOcxProviderTableOnDisk && !providerTableMode) { + content = applyEol( + content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName), + eol, + ); + } + + return { + kind: "ok", + baselineContent, + content, + profileContent, + catalogPath, + providerTableMode, + keepRootOverrideAlongsideTable, + keptUserBaseUrl, + keptUserRealtimeWsBaseUrl, + nativeSubagentDefaultsWarning, + managedDefaultsMessage, + historyRelabelRefusal: observedHistoryRefusal, + historyPreflight, + candidate: { + configBytes: content, + profileBytes: profileContent, + catalogPath, + }, + }; +} diff --git a/structure/config.md b/structure/config.md index 92496f1274c..cde23210f00 100644 --- a/structure/config.md +++ b/structure/config.md @@ -188,7 +188,10 @@ restore must preserve later user edits while stripping those managed values. An injection whose OpenCodex config explicitly selects the v1 multi-agent surface also reconciles Codex's higher-precedence global `features.multi_agent_v2` override to disabled before taking the journal baseline. It uses the same format-preserving feature transition as explicit -mode selection; validation-only injection and externally managed provider configs remain read-only. +mode selection, and it runs inside the injection's coordinated write boundary: the transition and +the artifact commit share one preimage, so a later refusal restores the flag along with the files, +and no competing writer can land between them. Validation-only injection and externally managed +provider configs remain read-only. ### History backup manifest contract diff --git a/structure/subagents.md b/structure/subagents.md index 45adcb105d5..e2e7df9bce3 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -81,7 +81,8 @@ the flag and thread count decide what the native runtime allows. Because the global feature has precedence over catalog pins, Codex config injection reconciles it to disabled whenever the persisted OpenCodex mode explicitly selects v1. This includes a fresh install on a Codex home that had previously enabled v2; external-provider ownership and read-only -injection preflight still prohibit that write. +injection preflight still prohibit that write. The transition runs inside the same write lock and +preimage as the rest of the injection, so a later refusal rolls the flag back with the files. `keepNativeChatGptOnV1` makes mode `v2` a catalog-driven hybrid: OpenCodex disables the global `multi_agent_v2` override because codex-rs resolves that override before a model row's explicit diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 0f375e7bb38..d5f43842292 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -1947,77 +1947,4 @@ describe("injectCodexConfig integration (Design B)", () => { expect(config).not.toContain("\r"); }); - test("inject does not turn on multi_agent_v2; fresh installs stay on Codex's default v1 surface until the user opts in", () => { - writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); - - expect(runInject(codexHome, ocxHome).status).toBe(0); - const config = readFileSync(join(codexHome, "config.toml"), "utf8"); - - expect(config).not.toContain("[features.multi_agent_v2]"); - expect(config).not.toContain("multi_agent_v2 = true"); - expect(config).not.toContain("multi_agent_v2 = {"); - }); - - test("a v1 injection disables a pre-existing global v2 override", () => { - const configPath = join(codexHome, "config.toml"); - writeFileSync(configPath, 'model = "gpt-5.5"\n\n[features]\nmulti_agent_v2 = true\n', "utf8"); - const script = ` - const fs = require("node:fs"); - const { join } = require("node:path"); - const { injectCodexConfig } = require("./src/codex/inject"); - const { setCodexMultiAgentV2ToggleForTests } = require("./src/codex/inject/multi-agent-v2"); - const path = join(process.env.CODEX_HOME, "config.toml"); - setCodexMultiAgentV2ToggleForTests(enabled => { - const current = fs.readFileSync(path, "utf8"); - fs.writeFileSync(path, current.replace("multi_agent_v2 = true", "multi_agent_v2 = " + enabled)); - }); - const result = await injectCodexConfig(10100, { multiAgentMode: "v1" }); - console.log(JSON.stringify(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 - 5_000, - }); - - expect(child.status, child.stderr).toBe(0); - expect(JSON.parse(child.stdout)).toMatchObject({ success: true }); - const config = readFileSync(configPath, "utf8"); - expect(config).toContain("multi_agent_v2 = false"); - expect(config).toContain('openai_base_url = "http://127.0.0.1:10100/v1"'); - }); - - test("a skipped v1 injection does not run the v2 reconcile or leave the file changed", () => { - const configPath = join(codexHome, "config.toml"); - writeFileSync(configPath, 'model = "gpt-5.5"\n\n[features]\nmulti_agent_v2 = true\n', "utf8"); - // Integration OFF in the OCX config snapshot the write gate reads. - writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ clientIntegrations: { codex: false } })); - const script = ` - const fs = require("node:fs"); - const { join } = require("node:path"); - const { injectCodexConfig } = require("./src/codex/inject"); - const { setCodexMultiAgentV2ToggleForTests } = require("./src/codex/inject/multi-agent-v2"); - const path = join(process.env.CODEX_HOME, "config.toml"); - let toggles = 0; - setCodexMultiAgentV2ToggleForTests(() => { toggles += 1; }); - const result = await injectCodexConfig(10100, { multiAgentMode: "v1" }); - console.log(JSON.stringify({ result, toggles })); - `; - const child = spawnSync(process.execPath, ["--eval", script], { - cwd: repoRoot, - env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, - encoding: "utf8", - timeout: SPAWN_BUDGET_MS - 5_000, - }); - - expect(child.status, child.stderr).toBe(0); - const out = JSON.parse(child.stdout); - expect(out.result).toMatchObject({ success: true, status: "skipped", skippedReason: "desired_disabled" }); - // The gate ran before the reconcile: no transition ran and nothing was written. - expect(out.toggles).toBe(0); - const config = readFileSync(configPath, "utf8"); - expect(config).toContain("multi_agent_v2 = true"); - expect(config).not.toContain("openai_base_url"); - }); }); diff --git a/tests/codex-integration/codex-inject-v1-reconcile.test.ts b/tests/codex-integration/codex-inject-v1-reconcile.test.ts new file mode 100644 index 00000000000..d0b4505d1a6 --- /dev/null +++ b/tests/codex-integration/codex-inject-v1-reconcile.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; +import { existsSync, mkdtempSync, writeFileSync, readFileSync, realpathSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); + +setDefaultTimeout(SPAWN_BUDGET_MS); +console.error('ocx-startup-diagnostic:{"file":"codex-inject-v1-reconcile","phase":"file_imported"}'); + +// Full injectCodexConfig runs in a subprocess with isolated CODEX_HOME/OPENCODEX_HOME so +// module-level path constants bind to the temp dirs (same pattern as codex-journal.test.ts). +function runInject( + codexHome: string, + ocxHome: string, + configJson = "{}", +): { stdout: string; stderr: string; status: number } { + const script = ` + const { injectCodexConfig } = require("./src/codex/inject"); + injectCodexConfig(10100, JSON.parse(process.env.TEST_OCX_CONFIG)).then(r => { + console.log(JSON.stringify(r)); + }); + `; + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, TEST_OCX_CONFIG: configJson }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); + return { + stdout: result.stdout?.trim() ?? "", + stderr: result.stderr?.trim() ?? "", + status: result.status ?? 1, + }; +} + +describe("injectCodexConfig v1-surface reconcile", () => { + let codexHome: string; + let ocxHome: string; + + beforeEach(() => { + codexHome = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-inject-codex-"))); + ocxHome = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-inject-home-"))); + }); + + afterEach(() => { + removeTreeWithRetry(codexHome); + removeTreeWithRetry(ocxHome); + }); + + test("inject does not turn on multi_agent_v2; fresh installs stay on Codex's default v1 surface until the user opts in", () => { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); + + expect(runInject(codexHome, ocxHome).status).toBe(0); + const config = readFileSync(join(codexHome, "config.toml"), "utf8"); + + expect(config).not.toContain("[features.multi_agent_v2]"); + expect(config).not.toContain("multi_agent_v2 = true"); + expect(config).not.toContain("multi_agent_v2 = {"); + }); + + test("a v1 injection disables a pre-existing global v2 override", () => { + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5.5"\n\n[features]\nmulti_agent_v2 = true\n', "utf8"); + const script = ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const { injectCodexConfig } = require("./src/codex/inject"); + const { setCodexMultiAgentV2ToggleForTests } = require("./src/codex/inject/multi-agent-v2"); + const path = join(process.env.CODEX_HOME, "config.toml"); + setCodexMultiAgentV2ToggleForTests(enabled => { + const current = fs.readFileSync(path, "utf8"); + fs.writeFileSync(path, current.replace("multi_agent_v2 = true", "multi_agent_v2 = " + enabled)); + }); + const result = await injectCodexConfig(10100, { multiAgentMode: "v1" }); + console.log(JSON.stringify(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 - 5_000, + }); + + expect(child.status, child.stderr).toBe(0); + expect(JSON.parse(child.stdout)).toMatchObject({ success: true }); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("multi_agent_v2 = false"); + expect(config).toContain('openai_base_url = "http://127.0.0.1:10100/v1"'); + }); + + test("a skipped v1 injection does not run the v2 reconcile or leave the file changed", () => { + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5.5"\n\n[features]\nmulti_agent_v2 = true\n', "utf8"); + // Integration OFF in the OCX config snapshot the write gate reads. + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ clientIntegrations: { codex: false } })); + const script = ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const { injectCodexConfig } = require("./src/codex/inject"); + const { setCodexMultiAgentV2ToggleForTests } = require("./src/codex/inject/multi-agent-v2"); + const path = join(process.env.CODEX_HOME, "config.toml"); + let toggles = 0; + setCodexMultiAgentV2ToggleForTests(() => { toggles += 1; }); + const result = await injectCodexConfig(10100, { multiAgentMode: "v1" }); + console.log(JSON.stringify({ result, toggles })); + `; + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); + + expect(child.status, child.stderr).toBe(0); + const out = JSON.parse(child.stdout); + expect(out.result).toMatchObject({ success: true, status: "skipped", skippedReason: "desired_disabled" }); + // The gate ran before the reconcile: no transition ran and nothing was written. + expect(out.toggles).toBe(0); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("multi_agent_v2 = true"); + expect(config).not.toContain("openai_base_url"); + }); + + test("a post-reconcile failure restores the exact original config bytes and feature state", () => { + const configPath = join(codexHome, "config.toml"); + const original = 'model = "gpt-5.5"\n\n[features]\nmulti_agent_v2 = true\n'; + writeFileSync(configPath, original, "utf8"); + const script = ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const { injectCodexConfig, setHistoryArtifactStageForTests } = require("./src/codex/inject"); + const { setCodexMultiAgentV2ToggleForTests } = require("./src/codex/inject/multi-agent-v2"); + const path = join(process.env.CODEX_HOME, "config.toml"); + setCodexMultiAgentV2ToggleForTests(enabled => { + const current = fs.readFileSync(path, "utf8"); + fs.writeFileSync(path, current.replace("multi_agent_v2 = true", "multi_agent_v2 = " + enabled)); + }); + setHistoryArtifactStageForTests(stage => { + // The feature transition has landed; failing here must roll it back too. + if (stage === "after-v1-reconcile") throw new Error("injected post-reconcile failure"); + }); + try { + const result = await injectCodexConfig(10100, { multiAgentMode: "v1" }); + console.log(JSON.stringify({ threw: false, result })); + } catch (error) { + console.log(JSON.stringify({ threw: true, message: String(error) })); + } + `; + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); + + expect(child.status, child.stderr).toBe(0); + expect(JSON.parse(child.stdout)).toMatchObject({ threw: true }); + // Byte-exact restoration: the flag flip was rolled back with everything else. + expect(readFileSync(configPath, "utf8")).toBe(original); + expect(existsSync(join(codexHome, "opencodex.config.toml"))).toBe(false); + expect(existsSync(join(codexHome, "opencodex-journal.json"))).toBe(false); + }); + + test("a competing writer cannot land between the feature transition and the injection commit", () => { + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5.5"\n\n[features]\nmulti_agent_v2 = true\n', "utf8"); + const script = ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const { spawnSync } = require("node:child_process"); + const { injectCodexConfig, setHistoryArtifactStageForTests } = require("./src/codex/inject"); + const { setCodexMultiAgentV2ToggleForTests } = require("./src/codex/inject/multi-agent-v2"); + const path = join(process.env.CODEX_HOME, "config.toml"); + setCodexMultiAgentV2ToggleForTests(enabled => { + const current = fs.readFileSync(path, "utf8"); + fs.writeFileSync(path, current.replace("multi_agent_v2 = true", "multi_agent_v2 = " + enabled)); + }); + let competitor = null; + setHistoryArtifactStageForTests(stage => { + if (stage !== "after-v1-reconcile") return; + // The transition has landed and the commit has not: a second injection on + // the same home must be serialized by the write lock, never admitted. + const grandchild = spawnSync(process.execPath, ["--eval", \` + const { injectCodexConfig } = require("./src/codex/inject"); + injectCodexConfig(10100, { multiAgentMode: "v1" }, { lockTimeoutMs: 800 }).then(r => { + console.log(JSON.stringify(r)); + }); + \`], { + cwd: ${JSON.stringify(repoRoot)}, + env: { ...process.env }, + encoding: "utf8", + timeout: 30000, + }); + competitor = { + status: grandchild.status, + stdout: (grandchild.stdout || "").trim(), + stderr: (grandchild.stderr || "").trim(), + }; + }); + const result = await injectCodexConfig(10100, { multiAgentMode: "v1" }); + console.log(JSON.stringify({ result, competitor })); + `; + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); + + expect(child.status, child.stderr).toBe(0); + const out = JSON.parse(child.stdout); + expect(out.result).toMatchObject({ success: true }); + expect(out.competitor.status).toBe(0); + expect(JSON.parse(out.competitor.stdout).success).toBe(false); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("multi_agent_v2 = false"); + expect(config).toContain('openai_base_url = "http://127.0.0.1:10100/v1"'); + }); +}); From 39fb323ec5243641dfc48b8bceff4896d963ef4e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:11:51 +0000 Subject: [PATCH 4/9] test(layout): register codex-inject-v1-reconcile in both layout maps Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0cbb0fc7b38..7cff7c37575 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -518,6 +518,7 @@ "codex-home-wsl.test.ts": "codex-integration", "codex-inject-history-wording.test.ts": "codex-integration", "codex-inject-integration.test.ts": "codex-integration", + "codex-inject-v1-reconcile.test.ts": "codex-integration", "codex-inject-write-lock.test.ts": "codex-integration", "codex-inject.test.ts": "codex-integration", "codex-injected-marker.test.ts": "codex-integration", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c69955e4208..9662536ceeb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -349,6 +349,7 @@ "codex-home-wsl.test.ts": "codex-integration", "codex-inject-history-wording.test.ts": "codex-integration", "codex-inject-integration.test.ts": "codex-integration", + "codex-inject-v1-reconcile.test.ts": "codex-integration", "codex-inject-write-lock.test.ts": "codex-integration", "codex-inject.test.ts": "codex-integration", "codex-injected-marker.test.ts": "codex-integration", From 9a0ba20bf0029f5d473229a1157f4abcc37b52c4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:16:19 +0000 Subject: [PATCH 5/9] fix(ci): build unsigned macOS bundle without updater artifacts The widget job runs 'tauri build' without TAURI_SIGNING_PRIVATE_KEY, but bundle.createUpdaterArtifacts is enabled so the bundler tries to sign the .app.tar.gz updater archive and the job fails after a green build. Pass --config to disable updater artifacts in the unsigned CI build only; release.yml still produces signed updater artifacts. Also set mainBinaryName so the in-bundle binary is Contents/MacOS/OpenCodex (Tauri v2 defaults to the Cargo package name), which the verify step asserts. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- desktop/src-tauri/tauri.conf.json | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 4fe732e0dc7..ac92d3f46bf 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,6 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", + "mainBinaryName": "OpenCodex", "version": "2.61.0", "identifier": "com.opencodex.desktop", "build": { From 9aeac79309e650d3d8a6745a7cc0c6e7aaa36020 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:21:04 +0000 Subject: [PATCH 6/9] test(codex-integration): stub the ACL lane around startServer's spend-ledger lease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe-hardening file fakes the trusted System32 directory for its schtasks and sc.exe fixtures. Since #5157, startServer first acquires the shared spend-ledger lease, which hardens the state directory through the trusted icacls.exe/powershell.exe resolution — the fake directory shadows both, so the principal lookup dies EACLIDENTITY before the ownership probe runs and the `one startup keeps two targeted queries` case fails on the Windows leg. Pin the icacls and principal runners for the file (the #3258 hermetic-ACL convention); the seams are inert for the tests that never start a server. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...ex-service-manager-probe-hardening.test.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-service-manager-probe-hardening.test.ts b/tests/codex-integration/codex-service-manager-probe-hardening.test.ts index 2f9e3b6bfab..0643a4bec8e 100644 --- a/tests/codex-integration/codex-service-manager-probe-hardening.test.ts +++ b/tests/codex-integration/codex-service-manager-probe-hardening.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -11,6 +11,11 @@ import { } from "../../src/service-manager-probe"; import { inspectNativeCodexOwnership } from "../../src/integrations/native/ownership-preflight"; import { setTrustedWindowsSystemDirectoryResolverForTests } from "../../src/lib/windows-elevation"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { + setAsyncWindowsPrincipalRunnerForTests, + setWindowsPrincipalRunnerForTests, +} from "../../src/lib/windows-user-principal"; import { getDefaultConfig } from "../../src/config"; import { startServer } from "../../src/server"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -19,6 +24,24 @@ let home = ""; let configDir = ""; let trustedSystem32 = ""; +// This file fakes the trusted System32 directory, which also shadows the icacls.exe / +// powershell.exe resolution startServer's spend-ledger acquisition hardens through — a +// real lookup resolves inside the fake directory and dies EACLIDENTITY before the probe +// runs. Stub both runners so the harden stays hermetic; the seams are inert for the tests +// that never start a server. +const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const TEST_IDENTITY = { success: true, exitCode: 0, timedOut: false, stdout: "S-1-5-21-1-2-3-1001\nocx-test\n" }; +setIcaclsRunnerForTests(() => ICACLS_OK); +setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); +setWindowsPrincipalRunnerForTests(() => TEST_IDENTITY); +setAsyncWindowsPrincipalRunnerForTests(async () => TEST_IDENTITY); +afterAll(() => { + setWindowsPrincipalRunnerForTests(null); + setAsyncWindowsPrincipalRunnerForTests(null); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); +}); + beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-probe-hardening-")); configDir = join(home, "custom-opencodex"); From ab9f2bca7166ed4e13a1190e9cd8e25203736ca2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:24:34 +0000 Subject: [PATCH 7/9] test(ci): warm the reset-credit redeemer's child module graph before it is timed Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../codex-reset-credit-auto-redeem.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/codex-integration/codex-reset-credit-auto-redeem.test.ts b/tests/codex-integration/codex-reset-credit-auto-redeem.test.ts index eb7e213420a..ff427824887 100644 --- a/tests/codex-integration/codex-reset-credit-auto-redeem.test.ts +++ b/tests/codex-integration/codex-reset-credit-auto-redeem.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { Database } from "bun:sqlite"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -10,6 +10,7 @@ import { resolveResetCreditAutoRedeemSettings, type ResetCredit, } from "../../src/codex/reset-credit-auto-redeem"; +import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; import { readConfigGeneration } from "../../src/config"; @@ -87,6 +88,15 @@ describe("reset-credit auto-redeem settings + plan (#822)", () => { }); describe("reset-credit auto-redeemer runtime (#822)", () => { + // The two-process reservation test bounds its children with a 25s deadline, which includes + // their cold load of this module's graph through src/config. Pay that once here (#4956). + beforeAll(async () => { + await warmModuleGraph({ + graph: "codex/reset-credit-auto-redeem", + entry: repoPath("src/codex/reset-credit-auto-redeem.ts"), + }); + }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + test("a disabled tick creates neither a journal nor a mutation coordinator", async () => { const journalFile = join(dir, "reset-credit-auto-redeem.json"); expect(readdirSync(dir)).toEqual([]); From fefd1755b8f0dd505b64e1393515b267f42bb4ac Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:29:12 +0000 Subject: [PATCH 8/9] test(claude-integration): settle the native-main startup gate before discovery fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four tests that write a main auth.json race startServer's asynchronous startup-gate convergence: while the snapshot is still recovery-pending, withNativeMainCredentialAdmission excludes __main__ by design, so the roster fetch never runs and askedVersions stays empty / the selector row never appears. The window is usually won locally; on the loaded Windows shard (run 35534280700, windows 6/9) all three entitlement-dependent assertions lost it. Wait on waitForNativeMainStartupGate — the same seam codex-envkey-admission-substitution uses for this flake class — before the first request. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../claude-models-discovery.test.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/claude-integration/claude-models-discovery.test.ts b/tests/claude-integration/claude-models-discovery.test.ts index e0cdc22d0e7..071178f2d8e 100644 --- a/tests/claude-integration/claude-models-discovery.test.ts +++ b/tests/claude-integration/claude-models-discovery.test.ts @@ -6,6 +6,7 @@ import { saveConfig } from "../../src/config"; import { resetCodexModelEntitlementCacheForTests, } from "../../src/codex/model-entitlements"; +import { waitForNativeMainStartupGate } from "../../src/codex/native-profile-startup"; import { handleManagementAPI } from "../../src/server/management-api"; import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; @@ -37,6 +38,20 @@ afterEach(() => { if (testDir) removeTreeWithRetry(testDir); }); +/** + * `startServer` returns while native-main convergence still holds its + * `recovery-pending` fence; a request that lands inside it gets the fail-closed + * answer (no main credential, no upstream roster fetch). These tests assert on + * main-admitted entitlement rows, so they wait for the gate — the same seam + * codex-envkey-admission-substitution uses — rather than racing it on a loaded + * Windows shard. + */ +async function startSettledServer(): Promise> { + const server = startServer(0); + await waitForNativeMainStartupGate(); + return server; +} + function configWithStaticModels(claudeCode?: OcxConfig["claudeCode"]): OcxConfig { return { port: 0, @@ -219,7 +234,7 @@ test("Codex discovery applies the OpenAI context cap to native rows (#1430)", as }; config.providerContextCaps = { openai: 272_000 }; saveConfig(config); - const server = startServer(0); + const server = await startSettledServer(); try { const response = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)); expect(response.status).toBe(200); @@ -451,7 +466,7 @@ test("Codex discovery exposes the observed native as a selector row plus one glo } return originalFetch(input, init); }) as typeof fetch; - const server = startServer(0); + const server = await startSettledServer(); try { const plain = await fetch(new URL("/v1/models", server.url)) .then(response => response.json()) as { data: Array<{ id: string }> }; @@ -589,7 +604,7 @@ test("the request's client_version reaches entitlement discovery (#2886)", async // restored, or every later test in this file inherits it. let server: ReturnType | null = null; try { - server = startServer(0); + server = await startSettledServer(); await fetch(new URL("/v1/models?client_version=0.151.7", server.url)) .then(response => response.json()); expect(askedVersions.length).toBeGreaterThan(0); @@ -648,7 +663,7 @@ test("with no inbound or runtime version, /v1/models still exposes the gated row let server: ReturnType | null = null; try { - server = startServer(0); + server = await startSettledServer(); // No client_version on the request, and no persisted runtime in this isolated home. const catalog = await fetch(new URL("/v1/models", server.url)) .then(response => response.json()) as { data: Array<{ id: string }> }; From ce6b978d3e6a8fc5cc1bbe5bb388b76ace5c8d8b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:47:57 +0000 Subject: [PATCH 9/9] test(server): await server.stop and settle ACL/startup teardown before removing the test home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows shard 7/9 timed out: once the first fire-and-forget stop left the spend-ledger lease and config-dir icacls flight pending, removeTreeWithRetry's synchronous sleeps starved the teardown that closes them, so every later hook re-failed EBUSY on the same fixed TEST_DIR. Await each stop, give the file a per-test mkdtemp home, and drain the harden flights, icacls reaps, and native-main startup releases in afterEach — the removal-barrier sequence server-management-auth.test.ts already documents for this Windows contract. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/server/server-request-body-size.test.ts | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/tests/server/server-request-body-size.test.ts b/tests/server/server-request-body-size.test.ts index 1fb06b1e7be..0aae77e07b6 100644 --- a/tests/server/server-request-body-size.test.ts +++ b/tests/server/server-request-body-size.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync} from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; import assert from "node:assert/strict"; import { gzipSync } from "node:zlib"; import { join } from "node:path"; import { getDefaultConfig, saveConfig } from "../../src/config"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import { startServer } from "../../src/server"; import { MAX_DECOMPRESSED_BODY_BYTES, MAX_CONFIGURABLE_INBOUND_BODY_BYTES, readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../../src/server/request-decompress"; import { @@ -14,21 +16,37 @@ import { } from "../../src/server/inbound-body-admission"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { flushNativeMainStartupReleases } from "../../src/codex/native-profile-startup"; +import { flushWindowsSecretAclReapsBeforeRemoval } from "../../src/lib/windows-secret-acl"; -const TEST_DIR = join(import.meta.dir, ".tmp-server-request-body-size-test"); +let testHome = ""; let isolatedCodexHome: IsolatedCodexHome | null = null; +const previousHome = process.env.OPENCODEX_HOME; +// server.stop(true) is async: its teardown closes the spend-ledger lease and drains the +// icacls.exe config-dir flight, both of which hold OPENCODEX_HOME open on Windows. A test +// that discards the promise lets removeTreeWithRetry's synchronous sleeps starve that +// teardown entirely, so every later hook re-fails EBUSY on the same fixed directory. beforeEach(() => { - if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); - mkdirSync(TEST_DIR, { recursive: true }); - process.env.OPENCODEX_HOME = TEST_DIR; + testHome = mkdtempSync(join(tmpdir(), "ocx-server-body-size-")); + process.env.OPENCODEX_HOME = testHome; isolatedCodexHome = installIsolatedCodexHome("ocx-server-body-size-codex-"); }); -afterEach(() => { +afterEach(async () => { + // Awaited server.stop() drains the config-dir ACL flight it started, but the failed-start + // rollback and timed-out icacls reaps release those handles through fire-and-forget paths — + // the hook that removes the tree has to settle all three itself (mandatory Windows locking + // turns any still-open handle under the state directory into EBUSY/EPERM on rm). + await flushConfigDirHardeningForTests(); + await flushWindowsSecretAclReapsBeforeRemoval(testHome); + if (isolatedCodexHome) await flushWindowsSecretAclReapsBeforeRemoval(isolatedCodexHome.path); + await flushNativeMainStartupReleases(); isolatedCodexHome?.restore(); isolatedCodexHome = null; - if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testHome) removeTreeWithRetry(testHome); }); describe("server maxRequestBodySize (Issue #1601)", () => { @@ -56,7 +74,7 @@ describe("server maxRequestBodySize (Issue #1601)", () => { // Drain the response so the connection closes cleanly. await res.text(); } finally { - void server.stop(true); + await server.stop(true); } }); }); @@ -92,7 +110,7 @@ describe("configurable listener body size (Issue #3573)", () => { expect(result.refused).toBe(false); expect(result.status).not.toBeNull(); } finally { - void server.stop(true); + await server.stop(true); } }); @@ -105,7 +123,7 @@ describe("configurable listener body size (Issue #3573)", () => { try { expect((await postFixedBody(server.port)).refused).toBe(true); } finally { - void server.stop(true); + await server.stop(true); } }); });