diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7117b4679ea..81c34dee6c4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -518,6 +518,8 @@ "codex-home-wsl.test.ts": "codex-integration", "codex-inject-history-wording.test.ts": "codex-integration", "codex-inject-integration.test.ts": "codex-integration", + "codex-inject-retained-table.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/src/codex/inject.ts b/src/codex/inject.ts index f88edfe67b0..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 { 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; } } @@ -218,10 +214,6 @@ async function injectCodexConfigImpl( } const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); - const preflightTableMode = usesProviderTable(routingTarget); - const compactionOnly = routingTarget.clientCompaction === true - && routingTarget.desktopAuthless !== true - && routingTarget.requiresAdmissionToken !== true; const activeProvider = externalCodexModelProvider(rawContent); if (activeProvider) { // A launcher may have journaled before the provider manager took ownership. Never let shutdown @@ -253,253 +245,41 @@ async function injectCodexConfigImpl( }; } - // 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}. ` + - `No files were changed; inspect ${CODEX_CONFIG_PATH}.`, - }; - } - const baselineContent = nativeDefaultsBaseline.content; - /* - * 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 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. */ - // 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 v1Reconcile = await prepareInjectedV1SurfaceReconcile(config, options); - 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. + * 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. */ - 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; + const planContext: CodexInjectionPlanContext = { + config, + routingTarget, + 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 @@ -515,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, @@ -565,76 +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())) { - return { success: false, message: unverifiedJournalMessage }; + // 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: 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); }; /* @@ -645,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(); @@ -661,7 +500,22 @@ async function injectCodexConfigImpl( }; } 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. @@ -732,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. @@ -748,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 @@ -765,6 +627,7 @@ async function injectCodexConfigImpl( if (coordinated.status !== "acquired") { return codexInjectLockOutcome(coordinated); } + effectivePlan = coordinated.value.plan; recordCodexNativeTransactionProvenance( coordinated.value.preImages, coordinated.value.receipt.currentTxId, @@ -788,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: @@ -821,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` @@ -849,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}`, }; @@ -886,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 new file mode 100644 index 00000000000..7338c06a76d --- /dev/null +++ b/src/codex/inject/multi-agent-v2.ts @@ -0,0 +1,96 @@ +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; changed: boolean } + | { 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; +} + +/** + * 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 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 }, +): Promise { + if (options.validateOnly || config?.multiAgentMode !== "v1") { + return null; + } + const { isMultiAgentV2Enabled, transitionMultiAgentV2 } = await import("../features"); + const enabledAtPrepare = isMultiAgentV2Enabled(); + let toggle = toggleForTests; + if (enabledAtPrepare && !toggle) { + const { runCodexFeaturesCommand } = await import("../../cli/v2"); + toggle = enabled => runCodexFeaturesCommand(enabled ? "enable" : "disable"); + } + 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/src/codex/inject/remove.ts b/src/codex/inject/remove.ts index fab8e95fe0e..3eb578c4db2 100644 --- a/src/codex/inject/remove.ts +++ b/src/codex/inject/remove.ts @@ -133,7 +133,15 @@ export function extractOcxProviderTableBlock(content: string): string | null { * only do if the append is a transform rather than a second file operation. */ export function appendOcxProviderTableBlock(content: string, block: string): string { - if (hasOcxProviderTable(content)) return content; + if (hasOcxProviderTable(content)) { + const existing = extractOcxProviderTableBlock(content); + if (existing !== block.replace(/\n+$/, "") + "\n") { + throw new Error( + "Codex restore refused: the native config already defines a different [model_providers.opencodex] table.", + ); + } + return content; + } return `${content.replace(/\n+$/, "")}\n\n${block.replace(/\n+$/, "")}\n`; } diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index 444b7bb763f..f1972723f82 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -49,6 +49,9 @@ const LAYER_SECTION_TAGS: Record = { skills: "skills_instructions", apps: "apps_instructions", plugins: "plugins_instructions", + // Context-dependent: it is absent when the active collaboration mode adds no + // instructions, but Codex wraps it in this tag when it does render. + collaboration: "collaboration_mode", environment: "environment_context", permissions: "permissions instructions", // Synthetic: the project doc carries no tag of its own (see extractSections). @@ -71,7 +74,6 @@ const UNMAPPED_LAYER_IDS = [ // truth is that this extractor has no verified tag for them. "personality", "realtime", - "collaboration", // The Rust source names a marker pair, but a world-state section is // DIFF-rendered: it emits nothing on a turn where its state has not changed. Live // `codex debug prompt-input` (codex-cli 0.145.0, 32978 bytes) showed no such block and @@ -802,6 +804,22 @@ function extractSections(raw: string): Map { /** Test seam: the extraction is the part worth pinning, not the spawn. */ export const extractSectionsForTests = extractSections; +function mapSectionsToLayers(sections: Map): Record { + const layers: Record = {}; + for (const [layerId, tag] of Object.entries(LAYER_SECTION_TAGS)) { + const text = sections.get(tag) ?? null; + layers[layerId] = text === null + // Registered but not rendered on this turn, which is an ordinary state for + // a diff-rendered section rather than an error. + ? { text: null, reason: "not-rendered", bytes: 0 } + : { text, reason: "ok", bytes: Buffer.byteLength(text, "utf8") }; + } + return layers; +} + +/** Test seam: pin section-to-layer projection independently of the subprocess. */ +export const mapSectionsToLayersForTests = mapSectionsToLayers; + /** * Probe once and map every known layer to its rendered text. * @@ -915,15 +933,7 @@ export async function probePromptText( detail: "prompt output could not be parsed", }; } - const layers: Record = {}; - for (const [layerId, tag] of Object.entries(LAYER_SECTION_TAGS)) { - const text = sections.get(tag) ?? null; - layers[layerId] = text === null - // Registered but not rendered on this turn, which is an ordinary state for - // a diff-rendered section rather than an error. - ? { text: null, reason: "not-rendered", bytes: 0 } - : { text, reason: "ok", bytes: Buffer.byteLength(text, "utf8") }; - } + const layers = mapSectionsToLayers(sections); // A file that exists and is empty is not the same as a layer that chose to send // nothing. Reporting "sent nothing" for an empty AGENTS.md tells the user their diff --git a/src/codex/sync.ts b/src/codex/sync.ts index d9217cc0a15..47dc2a8b001 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -14,6 +14,7 @@ import { import { admitCodexWrite, type CodexAdmission } from "./admission"; import type { CodexCatalogSyncOptions } from "./catalog/sync"; import { resetCodexAppServerCatalogStateCache } from "./app-server-processes"; +import { providerUsesReasoningMetadata, refreshReasoningMetadata } from "../providers/reasoning-metadata"; export interface CodexSyncResult { /** @@ -67,13 +68,21 @@ interface CodexSyncDeps { admitCodexWrite?: () => CodexSyncAdmission; currentExternalCodexModelProvider?: typeof currentExternalCodexModelProvider; collectCodexHomeDiagnostic?: typeof collectOrcaCodexHomeDiagnostic; + refreshReasoningMetadata?: typeof refreshReasoningMetadata; } const defaultDeps: CodexSyncDeps = { refreshCodexModelCatalog, injectCodexConfig, + refreshReasoningMetadata, }; +async function refreshReasoningMetadataForSync(config: OcxConfig, deps: CodexSyncDeps): Promise { + if (Object.values(config.providers).some(providerUsesReasoningMetadata)) { + await deps.refreshReasoningMetadata?.(); + } +} + function reportCodexHomeTarget( log: Pick | null, collectDiagnostic: typeof collectOrcaCodexHomeDiagnostic, @@ -234,6 +243,9 @@ export async function syncModelsToCodex( } applyProxyEnv(config); // `ocx ensure`/`ocx sync` fetch provider models outside the server process + // Bootstrap the optional ladder snapshot before gathering the catalog. Keeping this in the + // sync plane prevents an unrelated models.dev fetch from interleaving with a routed turn. + await refreshReasoningMetadataForSync(config, deps); let added = 0; let catalogPath: string | null = null; let catalogPathForInjection: string | null | undefined; @@ -339,6 +351,7 @@ async function refreshCatalogForSync( let refreshOutcome: "committed" | "refused" | undefined; let comboOmissions: ComboCatalogOmission[] = []; try { + await refreshReasoningMetadataForSync(config, deps); const cat = await deps.refreshCodexModelCatalog(config, undefined, catalogOptions); refreshOutcome = cat.refreshOutcome; added = cat.added; diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index f22ef8363ea..4c486ca8781 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -137,6 +137,11 @@ function metadataProviderKey(provider: OcxProviderConfig): string | undefined { return undefined; } +/** Whether catalog synchronization should fetch models.dev metadata for this destination. */ +export function providerUsesReasoningMetadata(provider: OcxProviderConfig): boolean { + return metadataProviderKey(provider) !== undefined; +} + /** * Local mirror of `modelRecordValue()` from `src/reasoning-effort.ts`, which imports this * module and so cannot be imported back. Exact id, then the `family:` prefix, then a diff --git a/src/reasoning-effort.ts b/src/reasoning-effort.ts index a29ce1888e8..1f406632099 100644 --- a/src/reasoning-effort.ts +++ b/src/reasoning-effort.ts @@ -1,6 +1,6 @@ import type { OcxProviderConfig } from "./types"; import { modelInList } from "./types"; -import { dropLearnedUnsupportedReasoningEfforts, ensureReasoningMetadataSnapshot, reasoningEffortsFromMetadata } from "./providers/reasoning-metadata"; +import { dropLearnedUnsupportedReasoningEfforts, reasoningEffortsFromMetadata } from "./providers/reasoning-metadata"; // Descriptions mirror the upstream bundled models.json canonical wording (openai/codex PR #31684). export const CODEX_REASONING_LEVELS: { effort: string; description: string }[] = [ @@ -168,18 +168,10 @@ export function configuredReasoningEfforts(provider: OcxProviderConfig, modelId: } // models.dev publishes the per-model ladder that routed providers never expose on /models. // (OpenCode Zen Go answers ids only). Only consulted when nothing was configured for this - // model, so every hand-written contract stays authoritative. The snapshot refreshes itself in - // the background; no snapshot means the previous behaviour. - // The refresh is asked for only once a snapshot has already answered, which means it only ever - // refreshes a STALE snapshot. Review asked for the opposite — refresh when the snapshot is - // missing or corrupt, since that is the case this lookup cannot serve. That is declined here: - // a missing snapshot is the default state of every fresh install and every test process, so - // requesting the fetch here puts a models.dev request on the request path of the first routed - // turn to a gated destination. Refreshing a snapshot that does not exist is catalog-sync work, - // not request work. + // model, so every hand-written contract stays authoritative. Catalog sync owns snapshot + // refresh; no snapshot means the previous behaviour. const fromMetadata = reasoningEffortsFromMetadata(provider, modelId); if (fromMetadata !== undefined) { - ensureReasoningMetadataSnapshot(); return dropLearnedUnsupportedReasoningEfforts(provider, modelId, healMappedTiers(provider, modelId, fromMetadata)); } return undefined; diff --git a/structure/catalog.md b/structure/catalog.md index a3f8e24df18..8732010ccfb 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -175,6 +175,10 @@ credential only for a scoped entry, reads OAuth through the passive store observ unavailable or changed authority. Successful API-key selection commits clear the cache and revoke in-flight publication; unrelated unscoped rows require no credential lookup. +Synchronizing a supported routed provider first refreshes its models.dev effort snapshot through +`src/codex/sync.ts` and `src/providers/reasoning-metadata.ts`; this keeps missing-cache network work +out of request-time ladder resolution. + A Devin live row spreads its measured `inputModalities` before `catalogHintsFromProviderConfig`, so exact `modelCapabilities` declarations, the legacy `modelInputModalities` record and the vision-sidecar rewrite keep precedence and the live diff --git a/structure/codex-home.md b/structure/codex-home.md index 47177b946ae..e48954de7c9 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -313,6 +313,9 @@ management read degrades instead of returning an error page. `src/codex/history-provider.ts` rejects provider-history changes with `history_paginated_requires_native_writer` when a target begins with an ordinal-bearing record, later contains a paginated record after a legacy start (#4311), or declares `history_mode=paginated`. The first line alone is not sufficient: a rollout that started unnumbered and was later migrated is also refused. Apply, manifest-backed restore, and explicit legacy recovery preflight all selected targets before changing database rows or manifests. The append boundary checks again. Codex owns ordinal allocation and the live projection cursor; reading the last ordinal and appending N+1 is not safe concurrent coordination. Legacy unnumbered rollouts retain their existing behavior. History Worker targets resolve the canonical manifest first and an existing pre-normalization Windows filename second, so passing an explicit target cannot bypass upgrade recovery. This guard prevents the observed stable-format corruption; it does not implement native-writer integration. +`src/codex/sync.ts` owns startup and explicit-sync bootstrap of the optional +`src/providers/reasoning-metadata.ts` snapshot; request handling never owns that disk refresh. + Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Native restore also rechecks after successful journal restoration or fallback removal, while exact config/profile/journal preimages and any coordinated remove transaction remain available for compensation. The preflight opens the state store read-write-free and in that order deliberately. `{ readonly: true }` is the primary open and the only one that joins a live writer's WAL shared memory, so a thread another process just migrated to paginated history is visible and refuses here. A WAL store whose last writer closed cleanly has no `-shm` to join and a read-only connection may not create one, so that open fails `SQLITE_CANTOPEN` on a perfectly healthy store and the catch-all turned it into `history_injection_preflight_unavailable` on every attempt (#4943). The immutable fallback (`immutable=1` over a `file:` URI, the same idiom as the storage scanner and the log-guard inspector) is admitted only when neither `-wal` nor `-shm` is on disk, because that is the state in which the main database is the whole store and an immutable read is exact rather than stale. Either sidecar present, or any other open failure, keeps the original error and the refusal that follows: an immutable read is a snapshot, and a refusal this preflight fails to observe is a config transition over history Codex owns. The guard covers the whole primary attempt, not only the constructor. `sqlite3_open_v2` never reads page 1, so a WAL header is not inspected until the first prepare, and on macOS that is where the absent `-shm` is raised; the first read therefore happens inside the attempt, where the error can still be classified. Bun's bundled SQLite on Linux materializes both sidecars on that same read and never fails, which is why Linux and Windows evidence could not see this gap. @@ -323,7 +326,7 @@ On apply, that reason retires the relabel unit and the config/profile/journal wr `history_paginated_openai_requires_native_writer` is the one apply-side reason that is neither of those. It means a provider-table transition found an `openai`-tagged row already paginated, and the transition as planned would take the root `openai_base_url` out from under it. `applyPaginatedOpenaiCompat` in `src/codex/inject/paginated-openai-compat.ts` resolves it in the same window as the provider-table retention above, before the witness: it keeps the marker-owned root override beside the table and downgrades the reason to the stand-down constant, so the relabel unit never starts and the paginated row is neither read nor written. The retained line is journaled as OpenCodex's own, which is what lets restore remove it later; a line the user owns is left in place and journaled as theirs. Only an admission-token form still refuses, because Codex's built-in `openai` entry cannot carry `x-opencodex-api-key`, and that refusal names the configuration that resolves it rather than telling the operator not to retry (#5321). -On restore and removal, that same reason no longer refuses the config half. It selects a degraded restore: every OpenCodex root routing key comes out, `[model_providers.opencodex]` is retained verbatim including its ownership marker, and the history relabel is skipped rather than attempted. The retained table is captured from the pre-transform bytes and re-appended into the same buffer, so the write is one atomic transformation — a config carrying root `model_provider = "opencodex"` without a matching table fails the whole Codex config load, not one thread, which makes that intermediate state strictly worse than the routing it replaces. `resolveRestoreHistoryDisposition` in `src/codex/inject/restore.ts` is the single place that reads the preflight reason and answers the separate question of whether routing may come out. Every other reason keeps the hard refusal and compensates on every artifact, because retiring a provider definition its thread rows still name would orphan them. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. +On restore and removal, that same reason no longer refuses the config half. It selects a degraded restore: every OpenCodex root routing key comes out, `[model_providers.opencodex]` is retained verbatim including its ownership marker, and the history relabel is skipped rather than attempted. The retained table is captured from the pre-transform bytes and re-appended into the same buffer, so the write is one atomic transformation — a config carrying root `model_provider = "opencodex"` without a matching table fails the whole Codex config load, not one thread, which makes that intermediate state strictly worse than the routing it replaces. If an exact journal restore brings back a same-named provider table, retention accepts it only when the extracted table block matches the captured one — the extractor collapses blank-line runs and trims the block tail, so this is a normalized comparison, not a raw-byte one; a different table fails the restore and compensation reinstates the pre-restore files rather than rebinding tagged threads to another destination. `resolveRestoreHistoryDisposition` in `src/codex/inject/restore.ts` is the single place that reads the preflight reason and answers the separate question of whether routing may come out. Every other reason keeps the hard refusal and compensates on every artifact, because retiring a provider definition its thread rows still name would orphan them. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. It no longer costs the home its ability to be uninstalled: synchronous and asynchronous restore, inline journal restore, and direct config removal all take routing down on that reason while keeping the provider table, so an already-paginated home can be stopped and uninstalled and plain `codex` returns to the built-in provider. Rows naming `opencodex` still resolve through the retained table; their requests reach a proxy that is gone and fail with an ordinary connection error, which is a per-conversation failure rather than a broken config. `ocx restore --remove-codex-provider-table` removes the table for a user who accepts that those conversations stop opening; nothing selects it implicitly. diff --git a/structure/config.md b/structure/config.md index 12cf9acbd10..7488970b4e7 100644 --- a/structure/config.md +++ b/structure/config.md @@ -185,6 +185,14 @@ 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, 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 `src/codex/history-manifest.ts` is the pure schema-and-identity leaf for the versioned history @@ -474,6 +482,9 @@ management handler's own unknown-id answer, and it names the id and `ocx models Paginated and migration-capable history follows the [authoritative writer contract](codex-home.md#paginated-history-writer-boundary); this document adds no independent writer guarantee. +Catalog synchronization in `src/codex/sync.ts` also bootstraps reasoning metadata only when the +configured providers contain a destination supported by `src/providers/reasoning-metadata.ts`. + Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback, preserved affinity, strategy-specific threshold summaries, and shared short-observation freshness for switch warnings. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5be71c0bfbc..856d1ad11aa 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -766,7 +766,7 @@ its defaults and exclusions are owned by [Responses transport](transports/respon The provider editor field policy exposes `showThinkingSummary` as a boolean provider option; it controls Responses summary defaults without a dashboard rendering change. See [Google provider](providers/google.md). -Paginated and migration-capable history follows the [authoritative writer contract](codex-home.md#paginated-history-writer-boundary); this document adds no independent writer guarantee. +Paginated and migration-capable history follows the [authoritative writer contract](codex-home.md#paginated-history-writer-boundary); this document adds no independent writer guarantee. Management-triggered catalog synchronization uses `src/codex/sync.ts` to bootstrap the optional `src/providers/reasoning-metadata.ts` snapshot before catalog gathering for supported destinations. Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback, preserved affinity, strategy-specific threshold summaries, and shared short-observation freshness for switch warnings. Codex account DTOs and cards expose the routing-plan exclusion separately from credential health; the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions) also governs CLI projection. Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 9fc4dcfa50b..b8472204b31 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -508,6 +508,10 @@ Native steering generation overrides, explicit public-API eligibility and the co The public server configuration reference documents the optional [compaction routing override](../transports/responses.md#compaction-routing-overrides). Its regression file is registered in both test-layout inventories. +Startup and explicit catalog synchronization in `src/codex/sync.ts` bootstrap supported-provider +effort metadata through `src/providers/reasoning-metadata.ts`; routed requests do not trigger that +network refresh. + ## Bun updater ownership transaction `src/update/ownership-transaction.ts` holds one mutation lease across the Bun updater's awaited diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 0fde5683acd..27d8d14bee9 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -731,3 +731,7 @@ often that refusal fires and can never replace it. Upstream API-key usage follows the [physical-attempt account attribution contract](../gui-and-management-api.md#upstream-key-account-attribution), independently of subscription quota observations. `src/codex/auth-api/login-flow.ts` distinguishes HTTP 429 from an attempted warmup as `codex_warmup_rate_limited` and preserves that code in OAuth status. Failed attempted warmup does not persist replacement credentials; quota-confirmed deferred registration and HTTP 401/403 handling remain separate. `src/codex/warmup.ts` retains a known 429 when bounded error-body draining times out. + +Catalog synchronization in `src/codex/sync.ts` bootstraps models.dev effort metadata through +`src/providers/reasoning-metadata.ts` only for supported routed destinations; request-time effort +mapping does not initiate that network operation. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 4dfa27723f0..5dbfe175e27 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -128,6 +128,8 @@ Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows [Anthropic seed image metadata](../runtime.md#capability-aware-image-admission) is provider-scoped; xAI model metadata and transport behavior remain unchanged. Provider-scoped catalog hints remain isolated by provider in `src/providers/registry/entries-core.ts`. The +models.dev effort snapshot is likewise destination-gated by `src/providers/reasoning-metadata.ts` +and bootstrapped from `src/codex/sync.ts`, not from a routed request. OpenCode Go `deepseek-v4.1-flash` 1,048,576-token context hint does not change xAI model metadata or transport behavior. The first-party DeepSeek `deepseek-flash` native `text`/`image` declaration is likewise scoped to diff --git a/structure/runtime.md b/structure/runtime.md index 26f5b2a43f8..99fdfc2d082 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -481,6 +481,8 @@ Provider-scoped approval reviewer settings are projected by the [catalog owner]( Renamed fixed-key providers receive [missing reasoning metadata](catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. +Catalog sync refreshes supported-provider effort snapshots off the request path under the [catalog contract](catalog.md). + Translated audio/file admission follows the [final-adapter input contract](adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. ## Request-local target compatibility diff --git a/structure/subagents.md b/structure/subagents.md index cf52d556baf..a3c6ba4b42c 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -78,6 +78,11 @@ 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. 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 @@ -250,6 +255,9 @@ final catalog merge fences pending retained rows, including delete/re-add recove Raw management rows remain visible as pending/OFF. Config listener bindings are excluded from inventory identity because live and persisted bindings may differ. +Supported routed-provider reasoning snapshots are bootstrapped by `src/codex/sync.ts` through +`src/providers/reasoning-metadata.ts`, rather than by a model request. + Codex `spawn_agent` advertises only the highest-priority first five picker-visible catalog rows. Use at most five configured `subagentModels` ids; they may contain bare catalog ids, routed `provider/model` ids, or exact account-qualified `/` ids. The diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index bfd34825f7d..0e1d728a0da 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -46,6 +46,10 @@ surface is listed here so a maintainer can find the owner without grepping: | Alibaba regions | `src/providers/alibaba-region-backup.ts`, `src/providers/alibaba-region-migration.ts`, `src/providers/alibaba-region-startup.ts` | Region migration backs up before rewriting and is idempotent across restarts. | | Discovery and quota | `src/providers/model-discovery.ts`, `src/providers/quota.ts`, `src/providers/registry.ts` | Discovery rejects a response over 4 MiB or past 2,000 raw rows before caching it. Provider-scoped hints fill capabilities omitted by live rosters; OpenCode Go's `deepseek-v4.1-flash` keeps its 1,048,576-token context window. The fixed-key Opper preset uses the shared OpenAI Chat adapter at `https://api.opper.ai/v3/compat`, discovers models through its conventional authenticated `/models` path, preserves an older same-named custom destination, and falls back to bare pool ids while passing vendor-prefixed ids through unchanged. Codex quota DTOs suppress retired Spark evidence under the [OpenAI scope contract](../providers/openai-tiers.md#public-provider-contract), retaining ordinary custom windows. | +`src/codex/sync.ts` bootstraps models.dev effort ladders through +`src/providers/reasoning-metadata.ts` for supported destinations before catalog gathering, never +from the routed request transport. + The registry's first-party `deepseek-flash` row declares native `text` and `image` input, so image requests bypass the vision sidecar by default; explicit `noVisionModels` or text-only declarations remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 0615fb2d374..d5f43842292 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -1947,14 +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 = {"); - }); }); diff --git a/tests/codex-integration/codex-inject-retained-table.test.ts b/tests/codex-integration/codex-inject-retained-table.test.ts new file mode 100644 index 00000000000..0315a7e90ec --- /dev/null +++ b/tests/codex-integration/codex-inject-retained-table.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; +import { 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); + +// 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 retained provider restore", () => { + 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("a journaled same-named foreign table aborts restore and reinstates the pre-restore config", () => { + // The user's pre-injection config carries its own [model_providers.opencodex] table. + // Injection strips it (it is not ours) but journals it as the user's baseline, so the + // journal restore replays it verbatim. Retention then sees a table that differs from + // the captured block, refuses to rebind tagged threads, and the failed config artifact + // makes the caller roll every file back to its pre-restore bytes. + const configPath = join(codexHome, "config.toml"); + const journalPath = join(codexHome, "opencodex-journal.json"); + writeFileSync(configPath, [ + 'model="test"', + "", + "[model_providers.opencodex]", + 'name="Unrelated Provider"', + 'base_url="https://unrelated.invalid/v1"', + "", + ].join("\n")); + const seed = runInject(codexHome, ocxHome, JSON.stringify({ codexClientCompaction: true })); + expect(seed.status).toBe(0); + expect(JSON.parse(seed.stdout).success).toBe(true); + const configBefore = readFileSync(configPath, "utf8"); + expect(configBefore).not.toContain("unrelated.invalid"); + const script = ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const { Database } = require("bun:sqlite"); + const { restoreNativeCodex } = require("./src/codex/inject"); + const { syncCodexHistoryProvider, historyBackupPathFor } = require("./src/codex/history-provider"); + const dbPath = require("./src/codex/paths").resolveCodexStateDbPath(); + const rollout = join(process.env.CODEX_HOME, "manifest-fixture.jsonl"); + fs.writeFileSync(rollout, JSON.stringify({type:"session_meta",payload:{id:"fixture",model_provider:"openai",source:"cli"}})+String.fromCharCode(10)); + const db = new Database(dbPath); + db.run("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT, model_provider TEXT, source TEXT, first_user_message TEXT, has_user_event INTEGER)"); + db.run("INSERT INTO threads VALUES ('fixture', ?, 'openai', 'cli', 'hello', 1)", rollout); + const routed = syncCodexHistoryProvider("opencodex", dbPath); + if (routed.failed || routed.rows !== 1) throw new Error("fixture history route failed"); + db.run("ALTER TABLE threads ADD COLUMN history_mode TEXT DEFAULT 'legacy'"); + db.close(); + const historyPaths = [historyBackupPathFor(dbPath), rollout]; + const beforeHistory = historyPaths.map(p=>fs.readFileSync(p,"utf8")); + const result = restoreNativeCodex(); + const restoredDb = new Database(dbPath, { readonly: true }); + const provider = restoredDb.query("SELECT model_provider FROM threads WHERE id='fixture'").get().model_provider; + restoredDb.close(); + const configAfter = fs.readFileSync(join(process.env.CODEX_HOME,"config.toml"),"utf8"); + console.log(JSON.stringify({ + result, + provider, + configAfter, + journalExists: fs.existsSync(join(process.env.CODEX_HOME,"opencodex-journal.json")), + historyPreserved: historyPaths.every((p,i)=>fs.readFileSync(p,"utf8")===beforeHistory[i]), + })); + `; + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, env: { ...process.env, CODEX_HOME: codexHome, CODEX_SQLITE_HOME: "", OPENCODEX_HOME: ocxHome }, + encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + }); + expect(child.status, child.stderr).toBe(0); + const result = JSON.parse(child.stdout); + expect(result.result.success).toBe(false); + expect(result.result.artifacts.config).toMatchObject({ state: "failed", action: "failed" }); + expect(result.result.artifacts.config.message).toContain( + "native config already defines a different [model_providers.opencodex] table", + ); + expect(result.configAfter).toBe(configBefore); + expect(result.journalExists).toBe(true); + expect(result.provider).toBe("opencodex"); + expect(result.historyPreserved).toBe(true); + }); + +}); 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"'); + }); +}); diff --git a/tests/codex-integration/codex-inject.test.ts b/tests/codex-integration/codex-inject.test.ts index d1763153ab9..2b190513705 100644 --- a/tests/codex-integration/codex-inject.test.ts +++ b/tests/codex-integration/codex-inject.test.ts @@ -21,7 +21,10 @@ import { buildProviderTableBlockForTarget, resolveCodexProviderDisplayName, } from "../../src/codex/inject/config-toml"; -import { extractOcxProviderTableBlock } from "../../src/codex/inject/remove"; +import { + appendOcxProviderTableBlock, + extractOcxProviderTableBlock, +} from "../../src/codex/inject/remove"; import { OCX_ROUTING_MARKER_LINE, OCX_SECTION_MARKER, stripJournaledOpenaiBaseUrl } from "../../src/codex/injected-marker"; import { MANAGED_AGENTS_TABLE_MARKER, @@ -711,6 +714,52 @@ describe("Design B openai_base_url injection", () => { ].join("\n")); }); + test("provider-table retention refuses to rebind tagged threads to a different table", () => { + const captured = [ + "# Auto-injected by opencodex", + "[model_providers.opencodex]", + 'name = "OpenCodex Proxy"', + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + const restored = [ + "[model_providers.opencodex]", + 'name = "Unrelated Provider"', + 'base_url = "https://unrelated.invalid/v1"', + "", + ].join("\n"); + + expect(() => appendOcxProviderTableBlock(restored, captured)).toThrow( + "native config already defines a different [model_providers.opencodex] table", + ); + expect(appendOcxProviderTableBlock(captured, captured)).toBe(captured); + }); + + test("provider-table retention accepts a table that differs only in blank-line count", () => { + // The comparison is intentionally on the extracted table string, not raw bytes: + // extractOcxProviderTableBlock collapses blank-line runs and trims the block tail, + // so a cosmetic blank-line edit keeps the user's own bytes instead of refusing. + const captured = [ + "# Auto-injected by opencodex", + "[model_providers.opencodex]", + 'name = "OpenCodex Proxy"', + 'base_url = "http://127.0.0.1:10100/v1"', + "", + ].join("\n"); + const current = [ + "# Auto-injected by opencodex", + "[model_providers.opencodex]", + 'name = "OpenCodex Proxy"', + 'base_url = "http://127.0.0.1:10100/v1"', + "", + "", + "", + ].join("\n"); + + expect(extractOcxProviderTableBlock(current)).toBe(captured); + expect(appendOcxProviderTableBlock(current, captured)).toBe(current); + }); + test("legacy marker directly before the provider table survives the root strip order (removeOcxSection keeps its anchor)", () => { // No Design B form present — stripInjectedOpenaiBaseUrl must not eat the legacy EOF marker // in a way that leaves the [model_providers.opencodex] table behind. diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 3d7b756dfa5..f96e4aeea87 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { extractSectionsForTests, + mapSectionsToLayersForTests, probePromptText, promptTextProbeSpawnAttemptsForTests, resetPromptTextProbeForTests, @@ -183,6 +184,23 @@ describe("section extraction", () => { expect(sections.get("apps_instructions")).toBe("line one\nline two"); }); + test("context-dependent collaboration text maps to its prompt layer", () => { + const rendered = extractSectionsForTests( + message("Pair-programming instructions."), + ); + expect(mapSectionsToLayersForTests(rendered).collaboration).toEqual({ + text: "Pair-programming instructions.", + reason: "ok", + bytes: 30, + }); + + expect(mapSectionsToLayersForTests(new Map()).collaboration).toEqual({ + text: null, + reason: "not-rendered", + bytes: 0, + }); + }); + test("AGENTS.md is bounded by its own INSTRUCTIONS wrapper", () => { // Capturing to end-of-message swept up whatever untagged prose followed. The // body is delimited, so the delimiter is the boundary. diff --git a/tests/codex-integration/codex-sync-api.test.ts b/tests/codex-integration/codex-sync-api.test.ts index e3ebb44297b..f5fc0943cfb 100644 --- a/tests/codex-integration/codex-sync-api.test.ts +++ b/tests/codex-integration/codex-sync-api.test.ts @@ -164,6 +164,42 @@ describe("GUI/CLI Codex sync backend", () => { expect(errors).toEqual([]); }); + test("bootstraps reasoning metadata for gated providers before catalog gathering", async () => { + const calls: string[] = []; + const zenConfig = { + ...config, + providers: { + zen: { + ...config.providers.fixture, + baseUrl: "https://opencode.ai/zen/go/v1", + }, + }, + } as OcxConfig; + + await syncModelsToCodex(12345, zenConfig, null, { + admitCodexWrite: admittedSync, + refreshReasoningMetadata: async () => { + calls.push("reasoning"); + return { ok: true as const, reason: "refreshed", providers: 1, models: 1 }; + }, + refreshCodexModelCatalog: async () => { + calls.push("catalog"); + return { + added: 1, + path: "/tmp/opencodex-catalog.json", + catalogExists: true, + catalogWritten: true, + cacheSynced: true, + comboOmissions: [], + }; + }, + injectCodexConfig: async () => ({ success: true, message: "injected" }), + currentExternalCodexModelProvider: () => null, + }); + + expect(calls).toEqual(["reasoning", "catalog"]); + }); + test("refuses during injection preflight before catalog or cache mutation", async () => { let refreshCalls = 0; let injectCalls = 0; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1fd592857c0..9fd4a11a6ad 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -349,6 +349,8 @@ "codex-home-wsl.test.ts": "codex-integration", "codex-inject-history-wording.test.ts": "codex-integration", "codex-inject-integration.test.ts": "codex-integration", + "codex-inject-retained-table.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",