From 45a99a350bf1fdc31d67556d9bc4951f735be48c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 17 Aug 2026 17:49:33 +0900 Subject: [PATCH 01/21] fix(codex): default native GPT-5.6 to Codex 272k until 1M is opted in Codex-login 5.6 rows advertised 922k with no user action. Follow the live catalog default of 272k, and let the native switch or overlay raise to the measured 922k ceiling. gpt-5.4 stays 1M; the API-key 1.05M path is unchanged. --- .../070_default_272k_opt_in.md | 25 ++++++++++++ gui/src/pages/Models.tsx | 14 +++++-- gui/src/pages/models-shared.ts | 4 +- src/claude/context-windows.ts | 4 +- src/claude/desktop-3p.ts | 12 +++--- src/claude/model-info.ts | 4 +- src/codex/catalog.ts | 2 +- src/codex/catalog/metadata.ts | 39 ++++++++++++++----- src/codex/catalog/provider-fetch.ts | 24 +++++++----- src/grok/sync.ts | 6 +-- src/routing/capability.ts | 11 +++--- src/server/management-api.ts | 4 +- .../management/agent-settings-routes.ts | 4 +- .../management/native-integration-routes.ts | 4 +- src/server/management/shared.ts | 8 ++-- src/server/responses/core.ts | 3 +- src/server/responses/input-admission.ts | 23 ++++++----- src/server/system-env.ts | 6 +-- structure/08_openai-provider-tiers.md | 20 ++++------ tests/claude-context-windows.test.ts | 24 ++++++------ tests/claude-desktop-native-context.test.ts | 4 +- tests/claude-model-info.test.ts | 5 +-- tests/codex-catalog-sync-hardening.test.ts | 6 +-- tests/codex-catalog.test.ts | 22 +++++------ ...odex-convergence-account-selectors.test.ts | 6 +-- tests/grok-sync.test.ts | 2 +- tests/input-admission.test.ts | 2 +- tests/native-model-toggle.test.ts | 34 +++++++++++----- tests/route-explainability.test.ts | 2 +- 29 files changed, 195 insertions(+), 129 deletions(-) create mode 100644 devlog/_plan/260817_native_gpt56_1m_context/070_default_272k_opt_in.md diff --git a/devlog/_plan/260817_native_gpt56_1m_context/070_default_272k_opt_in.md b/devlog/_plan/260817_native_gpt56_1m_context/070_default_272k_opt_in.md new file mode 100644 index 0000000000..8d830d1b3b --- /dev/null +++ b/devlog/_plan/260817_native_gpt56_1m_context/070_default_272k_opt_in.md @@ -0,0 +1,25 @@ +# 070 — Native GPT-5.6 default follows Codex 272k + +## Change + +- `NATIVE_GPT56_CONTEXT_WINDOW` is 272,000 (Codex live advertised default). +- Measured ceiling / 1M opt-in stays `NATIVE_GPT56_MAX_INPUT_TOKENS` = 922,000. +- For the GPT-5.6 family only, `providerContextCaps.openai` and per-model overlays may RAISE the default up to 922k. Values above 922k clamp. Other native slugs still only lower. +- Native group switch ON without a value sends 922,000. Switch OFF displays 272k. +- API-key 1,050,000 / 922,000 path is unchanged. gpt-5.4 stays 1M. gpt-5.5 stays 272k. + +## Files + +- `src/codex/catalog/metadata.ts` +- `gui/src/pages/Models.tsx` +- `gui/src/pages/models-shared.ts` +- `structure/08_openai-provider-tiers.md` +- focused tests listed in the same commit + +## Accept + +- `nativeOpenAiContextWindow("gpt-5.6-sol") === 272000` +- `nativeOpenAiContextWindow("gpt-5.6-sol", 922000) === 922000` +- `nativeOpenAiContextWindow("gpt-5.6-sol", 2000000) === 922000` +- `nativeOpenAiContextWindow("gpt-5.5", 922000) === 272000` +- Claude default sol is unmarked; opted-in 922k marks `[1m]` diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 9e562cbb5d..13853249a4 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -51,6 +51,8 @@ import { fmtK, NATIVE_CAP_OPTIONS, NATIVE_CAP_OPTION_SET, + NATIVE_GPT56_DEFAULT_WINDOW, + NATIVE_GPT56_OPT_IN_WINDOW, PAGE, readCollapsedProviders, THREAD_OPTION_SET, @@ -629,7 +631,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; } }; - const toggleProviderCap = async (provider: string) => { + const toggleProviderCap = async (provider: string, nativeGroup = false) => { setBusy(true); busyRef.current = true; setStatus(""); @@ -640,7 +642,9 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const r = await fetch(`${apiBase}/api/provider-context-caps`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, enabled }), + body: JSON.stringify(enabled && nativeGroup + ? { provider, enabled, value: NATIVE_GPT56_OPT_IN_WINDOW } + : { provider, enabled }), }); try { const data = await readJsonOrThrow(r, t("models.capSaveFailed")); @@ -1025,7 +1029,9 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; if (window === undefined) return widest; return widest === undefined || window > widest ? window : widest; }, undefined); - const capDisplayValue = capOn ? providerCap : (widestRowWindow ?? providerCap); + const capDisplayValue = capOn + ? providerCap + : (nativeProviderGroup ? NATIVE_GPT56_DEFAULT_WINDOW : (widestRowWindow ?? providerCap)); // The native group offers only the three windows GPT-5.6 actually has contracts for // (272k live, 372k legacy, 1.05M measured); routed providers keep the generic ladder. // The set has to follow the list, or a saved value outside it loses its option. @@ -1118,7 +1124,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; <> - toggleProviderCap(provider)} disabled={busy} label={t("models.capValue", { value: fmtK(capDisplayValue) })} /> + toggleProviderCap(provider, nativeProviderGroup)} disabled={busy} label={t("models.capValue", { value: fmtK(capDisplayValue) })} /> {/* The native group keeps the value visible with the cap off: its rows always advertise SOME window, so hiding the number leaves the card saying nothing about the context Codex will actually see. Routed providers keep the old diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index 4688ca4d8a..6e1f463db7 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -84,7 +84,9 @@ export const CAP_OPTION_SET = new Set(CAP_OPTIONS); * window, so listing a value above the advertised one would be an inert choice. * Anything else goes through "Custom". */ -export const NATIVE_CAP_OPTIONS = [272_000, 372_000, 922_000]; +export const NATIVE_GPT56_DEFAULT_WINDOW = 272_000; +export const NATIVE_GPT56_OPT_IN_WINDOW = 922_000; +export const NATIVE_CAP_OPTIONS = [NATIVE_GPT56_DEFAULT_WINDOW, 372_000, NATIVE_GPT56_OPT_IN_WINDOW]; export const NATIVE_CAP_OPTION_SET = new Set(NATIVE_CAP_OPTIONS); export const CUSTOM_OPTION = "custom"; export const THREAD_OPTIONS = [4, 8, 16, 32, 64, 128, 256, 500, 1000]; diff --git a/src/claude/context-windows.ts b/src/claude/context-windows.ts index 27254368d5..da2023f136 100644 --- a/src/claude/context-windows.ts +++ b/src/claude/context-windows.ts @@ -10,7 +10,7 @@ */ import { aliasForNative, aliasForRoute } from "./alias"; import { desktop3pAlias } from "./desktop-3p"; -import { nativeOpenAiContextWindow, type CatalogModel } from "../codex/catalog"; +import { nativeOpenAiContextWindow, type CatalogModel, type NativeContextLimitsInput } from "../codex/catalog"; const ONE_MILLION = 1_000_000; @@ -104,7 +104,7 @@ export function buildClaudeContextWindows( // A configured providerContextCaps.openai has to reach the native rows here too. Without // it the Claude surface keeps advertising the uncapped authoritative window while the // Codex catalog advertises the capped one, and the two disagree about the same model. - nativeContextCap?: number, + nativeContextCap?: NativeContextLimitsInput, ): Record { const out: Record = {}; const put = (key: string | null, value: number) => { diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 0c8bd0d739..3882583655 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -10,7 +10,7 @@ import { renderDesktopProfile, type DesktopProfileModel, } from "./desktop-profile"; -import { nativeOpenAiContextWindow } from "../codex/catalog"; +import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../codex/catalog"; import { assertDesktop3pModelsValid } from "./desktop-3p-guard"; export interface Desktop3pModelEntry { @@ -191,7 +191,7 @@ function collectDesktop3pModels( nativeSlugs: string[], routedModels: Array, profile?: OcxClaudeDesktopProfile, - nativeContextCap?: number, + nativeContextCap?: NativeContextLimitsInput, ): { models: Desktop3pModelEntry[]; registry: Map } { const registry = new Map(); const models: Desktop3pModelEntry[] = []; @@ -293,7 +293,7 @@ export function buildDesktop3pRegistry( nativeSlugs: string[], routedModels: Array, profile?: OcxClaudeDesktopProfile, - nativeContextCap?: number, + nativeContextCap?: NativeContextLimitsInput, ): Map { const { registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); desktop3pRegistry = registry; @@ -305,7 +305,7 @@ export function generateDesktop3pModels( nativeSlugs: string[], routedModels: Array, profile?: OcxClaudeDesktopProfile, - nativeContextCap?: number, + nativeContextCap?: NativeContextLimitsInput, ): Desktop3pModelEntry[] { const { models, registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); desktop3pRegistry = registry; @@ -337,7 +337,7 @@ export function generateDesktop3pConfig( apiKey = "ocx", mode: Desktop3pConfigMode = "static", profile?: OcxClaudeDesktopProfile, - nativeContextCap?: number, + nativeContextCap?: NativeContextLimitsInput, ): object { const base = { inferenceProvider: "gateway", @@ -558,7 +558,7 @@ export function writeDesktop3pConfig( apiKey?: string, mode: Desktop3pConfigMode = "static", profile?: OcxClaudeDesktopProfile, - nativeContextCap?: number, + nativeContextCap?: NativeContextLimitsInput, ): { written: boolean; path: string; reason?: string; fingerprint?: string } { const libraryPath = resolveDesktop3pConfigLibraryPath(); const metadataPath = join(libraryPath, "_meta.json"); diff --git a/src/claude/model-info.ts b/src/claude/model-info.ts index 40af3c4b1c..2db284f797 100644 --- a/src/claude/model-info.ts +++ b/src/claude/model-info.ts @@ -15,7 +15,7 @@ * - created_at is a fixed constant; max_input_tokens is authoritative-or-null; * max_tokens is always null (no authoritative output limit exists proxy-side). */ -import { catalogModelEfforts, nativeEffortClamp, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type CatalogModel } from "../codex/catalog"; +import { catalogModelEfforts, nativeEffortClamp, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type CatalogModel, type NativeContextLimitsInput } from "../codex/catalog"; import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias"; import { desktop3pAlias } from "./desktop-3p"; import { AUTO_CONTEXT_OFF, type AutoContextMode } from "./context-windows"; @@ -108,7 +108,7 @@ export function buildAnthropicModelInfos( auto: AutoContextMode = AUTO_CONTEXT_OFF, idStyle: AnthropicIdStyle = "desktop3p", aliasForRoute: (provider: string, modelId: string) => string = desktop3pAlias, - nativeContextCap?: number, + nativeContextCap?: NativeContextLimitsInput, ): AnthropicModelInfo[] { const out: AnthropicModelInfo[] = []; const seen = new Set(); diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 567670728a..8a0acb06fd 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -2,7 +2,7 @@ // Public surface preserved exactly; importers keep using "src/codex/catalog". export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; -export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT56_MAX_INPUT_TOKENS, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeContextLimits, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, type NativeContextLimits, type NativeContextLimitsInput } from "./catalog/metadata"; +export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT56_CONTEXT_WINDOW, NATIVE_GPT56_MAX_INPUT_TOKENS, NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeContextLimits, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, type NativeContextLimits, type NativeContextLimitsInput } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch"; diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index d905611da4..6733f5aa6c 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -123,7 +123,7 @@ export function isUnsupportedOpenAiNativeSlug(slug: string): boolean { * Evidence: devlog/_plan/260817_native_gpt56_1m_context/001_measurement_evidence.md * and 014_final_922k_with_margin.md. */ -export const NATIVE_GPT56_CONTEXT_WINDOW = 922_000; +export const NATIVE_GPT56_CONTEXT_WINDOW = 272_000; /** * Hard ceiling: the largest input the native GPT-5.6 family actually accepts (measured). @@ -134,19 +134,29 @@ export const NATIVE_GPT56_CONTEXT_WINDOW = 922_000; */ export const NATIVE_GPT56_MAX_INPUT_TOKENS = 922_000; +/** User-facing 1M opt-in: the largest window the native 5.6 family may advertise. */ +export const NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW = NATIVE_GPT56_MAX_INPUT_TOKENS; + +const NATIVE_GPT56_FAMILY = new Set([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + NATIVE_DAYBREAK_BLUE_MODEL, +]); + export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record = { "gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 }, "gpt-5.4": { contextWindow: 1_000_000, maxContextWindow: 1_000_000 }, "gpt-5.3-codex-spark": { contextWindow: 100_000, maxContextWindow: 100_000 }, - "gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, - "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, - "gpt-5.6-luna": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, + "gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, + "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, + "gpt-5.6-luna": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, // Daybreak Blue borrows Sol's capability metadata and rides the same family contract. // Unlike sol/terra/luna its window was NOT measured here: this account cannot reach it // (`400 "The 'gpt-daybreak-blue-latest' model is not supported when using Codex with a // ChatGPT account."`), so the promotion rests on a report from an account that has // access rather than on a probe. Treat it as the weaker evidence of the four. - [NATIVE_DAYBREAK_BLUE_MODEL]: { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, + [NATIVE_DAYBREAK_BLUE_MODEL]: { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS }, }; const PINNED_UPSTREAM_MODELS: Map = new Map( @@ -171,10 +181,10 @@ const PINNED_NATIVE_CAPABILITY_ENTRIES: Map = new Map( ); /** - * The user-owned levers that narrow a native window, carried together. + * The user-owned levers that set a native window, carried together. * - * Both only ever lower: the authoritative window is measured against what the upstream - * accepts, so a user value above it would re-create the over-advertising this unit fixed. + * For the GPT-5.6 family these may raise the Codex 272k default up to the measured + * 922k ceiling. Other native slugs still only ever lower. * * This travels as an ARGUMENT rather than module state on purpose. `grok/sync.ts` runs in * the `ocx ensure` parent process, outside the server, so an injected global would never @@ -223,13 +233,22 @@ export function nativeContextLimits( }; } -/** Apply the user levers to an authoritative value. Lowering only, in a fixed order. */ +/** Apply the user levers to an authoritative value. */ function narrowToLimits(raw: number | undefined, slug: string, input: NativeContextLimitsInput): number | undefined { if (raw === undefined) return undefined; const limits = asLimits(input); const overlay = positiveInt(limits.modelWindows?.[slug]) ?? positiveInt(limits.providerWindow); + const cap = positiveInt(limits.cap); + if (NATIVE_GPT56_FAMILY.has(slug)) { + const ceiling = NATIVE_GPT56_MAX_INPUT_TOKENS; + const chosen = overlay ?? cap ?? raw; + const window = Math.min(chosen, ceiling); + return overlay !== undefined && cap !== undefined ? Math.min(window, cap) : window; + } const narrowed = overlay === undefined ? raw : Math.min(raw, overlay); - return applyProviderContextCap(narrowed, limits.cap) ?? narrowed; + // 922k is the GPT-5.6 1M opt-in, not a request to shrink gpt-5.4's 1M window. + if (cap === NATIVE_GPT56_MAX_INPUT_TOKENS) return narrowed; + return applyProviderContextCap(narrowed, cap) ?? narrowed; } export function nativeOpenAiContextWindow(slug: string, limits?: NativeContextLimitsInput): number | undefined { diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index c858524eff..a8efb6d9c0 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -74,7 +74,7 @@ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } fr import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; import type { CatalogModel } from "./parsing"; -import { disabledNativeSlugs, hasComboTargets, isNativeOpenAiCapabilityAliasModel, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; +import { disabledNativeSlugs, hasComboTargets, isNativeOpenAiCapabilityAliasModel, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; @@ -1707,7 +1707,7 @@ async function gatherRoutedModelsUncached( // configs that will never need it. } else { const disabled = disabledNativeSlugs(config); - const openaiContextCap = providerContextCap(config, OPENAI_CODEX_PROVIDER_ID); + const openaiContextCap = nativeContextLimits(config); const requiredNativeComboTargets = new Set(listComboIds(config).flatMap(id => { const combo = getCombo(config, id); return combo?.targets.flatMap(target => ( @@ -1747,15 +1747,15 @@ async function gatherRoutedModelsUncached( const combo = getCombo(config, id); if (!combo) continue; const nativeContextWindow = combo.nativeAlias && combo.alias - ? nativeOpenAiContextWindow(combo.alias, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)) + ? nativeOpenAiContextWindow(combo.alias, nativeContextLimits(config)) : undefined; const nativeAliasMaxInput = combo.nativeAlias && combo.alias - ? nativeOpenAiMaxInputTokens(combo.alias, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)) + ? (NATIVE_GPT56_MAX_INPUT_TOKENS) : undefined; const nativeAliasFallback = combo.nativeAlias && combo.alias && nativeContextWindow !== undefined ? { contextWindow: nativeContextWindow, - ...(nativeAliasMaxInput !== undefined ? { maxInputTokens: Math.min(nativeAliasMaxInput, nativeContextWindow) } : {}), + ...(nativeAliasMaxInput !== undefined ? { maxInputTokens: nativeAliasMaxInput } : {}), inputModalities: nativeInputModalities(combo.alias), reasoningEfforts: nativeReasoningEfforts(combo.alias), } @@ -1801,18 +1801,22 @@ async function gatherRoutedModelsUncached( && providerForCanonicalCheck !== undefined && isCanonicalOpenAiForwardProvider(providerForCanonicalCheck) && isNativeOpenAiCapabilityAliasModel(cm.modelId); + const customNativeLimits = { + ...nativeContextLimits(config), + ...(typeof cm.contextWindow === "number" && cm.contextWindow > 0 + ? { modelWindows: { ...(nativeContextLimits(config).modelWindows ?? {}), [cm.modelId]: cm.contextWindow } } + : {}), + }; const nativeAliasContextWindow = codexForwardNativeCapabilityAlias - ? nativeOpenAiContextWindow(cm.modelId, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)) + ? nativeOpenAiContextWindow(cm.modelId, customNativeLimits) : undefined; const customContextWindow = cm.contextWindow ? nativeAliasContextWindow !== undefined - ? Math.min(cm.contextWindow, nativeAliasContextWindow) + ? nativeAliasContextWindow : cm.contextWindow : nativeAliasContextWindow; - // Input ceiling for a native capability alias, clamped to whatever window we settled on - // above. A custom row that lowered the window must not keep the full native input budget. const nativeAliasMaxInputTokens = codexForwardNativeCapabilityAlias - ? nativeOpenAiMaxInputTokens(cm.modelId, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)) + ? nativeOpenAiMaxInputTokens(cm.modelId, customNativeLimits) : undefined; const customMaxInputTokens = nativeAliasMaxInputTokens !== undefined && customContextWindow !== undefined ? Math.min(nativeAliasMaxInputTokens, customContextWindow) diff --git a/src/grok/sync.ts b/src/grok/sync.ts index 9a99216bfb..6e24b528fb 100644 --- a/src/grok/sync.ts +++ b/src/grok/sync.ts @@ -6,9 +6,7 @@ * * Deps are injectable (mirrors src/codex/sync.ts) so tests can run without a live proxy. */ -import { visibleNativeSlugs, filterCatalogVisibleModels, nativeOpenAiContextWindow, type CatalogModel } from "../codex/catalog"; -import { providerContextCap } from "../providers/context-cap"; -import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { visibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, type CatalogModel } from "../codex/catalog"; import type { OcxConfig } from "../types"; import { injectGrokConfig, type GrokInjectModel, type GrokInjectResult } from "./inject"; @@ -42,7 +40,7 @@ export async function syncGrokConfig( // default (200k) and understates models like gpt-5.6-sol, which is 372k. This is the same // accessor the dashboard's native rows use, so the two cannot disagree. ...visibleNativeSlugs(config).map(id => { - const contextWindow = nativeOpenAiContextWindow(id, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)); + const contextWindow = nativeOpenAiContextWindow(id, nativeContextLimits(config)); return { id, ...(contextWindow !== undefined ? { contextWindow } : {}) }; }), ...routed.map(m => ({ diff --git a/src/routing/capability.ts b/src/routing/capability.ts index 312bb846fc..b49cac897e 100644 --- a/src/routing/capability.ts +++ b/src/routing/capability.ts @@ -13,11 +13,10 @@ import type { OcxConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { serviceTierSupportForModel } from "../providers/service-tier"; -import { applyProviderContextCap, providerContextCap } from "../providers/context-cap"; import { PROVIDER_REGISTRY } from "../providers/registry"; import { nativeInputModalities, - nativeOpenAiContextWindow, + nativeContextLimits, nativeOpenAiContextWindow, nativeParallelToolCalls, nativeReasoningEfforts, } from "../codex/catalog/metadata"; @@ -164,11 +163,11 @@ export function candidateCapabilityEvidence( ?? provider?.contextWindow ?? registryEntry?.modelContextWindows?.[modelId] ?? catalogRow?.contextWindow - ?? (isNative ? nativeOpenAiContextWindow(modelId) : undefined); - // providerContextCaps.openai also ceilings native OpenAI rows (#1430), so routing - // evidence never contradicts a capped catalog entry. + ?? (isNative ? nativeOpenAiContextWindow(modelId, nativeContextLimits(config)) : undefined); + // Native rows go through the accessor (raise-to-ceiling + opt-in). Routed rows keep + // the raw value; a provider cap on openai must not invent a window they do not have. const contextWindow = isNative - ? applyProviderContextCap(rawContextWindow, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)) ?? rawContextWindow + ? (nativeOpenAiContextWindow(modelId, nativeContextLimits(config)) ?? rawContextWindow) : rawContextWindow; const modalities = provider?.modelInputModalities?.[modelId] diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 9b2fb6a9fa..837de90f80 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../codex/catalog"; -import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../codex/catalog"; +import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../codex/catalog"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -205,7 +205,7 @@ export async function handleManagementAPI( import("../claude/context-windows"), import("../codex/catalog"), ]); - injectClaudeAgentDefs(config, buildClaudeContextWindows([...visibleNativeSlugs(config)], models, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID))); + injectClaudeAgentDefs(config, buildClaudeContextWindows([...visibleNativeSlugs(config)], models, nativeContextLimits(config))); } catch { // Keep routes available through a provider-discovery blip. A later // launch-time sync restores any context markers missing from this pass. diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 8fc82f961f..9ee7c39170 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -978,7 +978,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (isDisabled(m.provider, m.id)) continue; aliases.push({ id: claudeCodeAlias(m.provider, m.id), display_name: `${m.id} (${m.provider})` }); } - const contextWindows = buildClaudeContextWindows([...visibleNativeSlugs(config)], models, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)); + const contextWindows = buildClaudeContextWindows([...visibleNativeSlugs(config)], models, nativeContextLimits(config)); const webSearchOverride = config.claudeCode?.webSearchSidecar; const visionOverride = config.claudeCode?.visionSidecar; // Auto is a RESOLUTION, recomputed per request — never stored state. Detection is diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 8dfe5dba3c..a8c2030895 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -18,7 +18,7 @@ * 011 (Claude Code), 012 (Grok). */ import { loadConfig, readRuntimePort, saveConfigPreservingClaudeCode } from "../../config"; -import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } from "../../codex/catalog"; +import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, visibleNativeSlugs } from "../../codex/catalog"; import { providerContextCap } from "../../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; @@ -508,7 +508,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { // Native slugs carry their context window: without it Grok falls back // to its own 200k default and understates a 372k model. ...visibleNativeSlugs(config).map(id => { - const contextWindow = nativeOpenAiContextWindow(id, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)); + const contextWindow = nativeOpenAiContextWindow(id, nativeContextLimits(config)); return { id, ...(contextWindow !== undefined ? { contextWindow } : {}) }; }), ...routed.map(m => ({ diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 946a5cb4be..428f7df0eb 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -192,11 +192,11 @@ export interface GrokCandidateModel { * from the same two sources as the sync so the two can never disagree. */ export async function fetchGrokCandidateModels(config: OcxConfig): Promise { - const { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } = await import("../../codex/catalog"); + const { filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, visibleNativeSlugs } = await import("../../codex/catalog"); const routed = filterCatalogVisibleModels(await fetchAllModels(config), config); return [ ...visibleNativeSlugs(config).map(id => { - const contextWindow = nativeOpenAiContextWindow(id, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)); + const contextWindow = nativeOpenAiContextWindow(id, nativeContextLimits(config)); return { id, native: true, ...(contextWindow !== undefined ? { contextWindow } : {}) }; }), ...routed.map(m => ({ @@ -221,7 +221,7 @@ export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProvid /** Shared Desktop profile DTO builder for the management API and CLI. */ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxClaudeDesktopProfile) { - const { filterCatalogVisibleModels, nativeOpenAiContextWindow, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); + const { filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); const { DESKTOP_SUPPORTS_1M_THRESHOLD } = await import("../../claude/desktop-3p"); const { reconcileDesktopProfile, renderDesktopProfile } = await import("../../claude/desktop-profile"); const routed = filterCatalogVisibleModels(await fetchAllModels(config), config); @@ -229,7 +229,7 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla // Native rows carry their real context window from the same accessor the Grok sync // uses — otherwise Sol's 372k and gpt-5.5's 272k render as blank on Desktop. ...desktopVisibleNativeSlugs(config).map(id => { - const contextWindow = nativeOpenAiContextWindow(id, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)); + const contextWindow = nativeOpenAiContextWindow(id, nativeContextLimits(config)); return { route: `native/${id}`, label: `${id} (native)`, ...(contextWindow !== undefined ? { contextWindow } : {}) }; }), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 8df2fc672d..2df5160984 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2,6 +2,7 @@ import type { Server } from "bun"; import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; import { formatPassthroughUpstreamError } from "./passthrough-error"; import { checkInputAdmission } from "./input-admission"; +import { nativeContextLimits } from "../../codex/catalog"; import { describeUpstreamConnectFailure } from "./upstream-error"; import { getConfigPath, @@ -1994,7 +1995,7 @@ async function handleResponsesInner( // refusing the turn that shrinks the context would deadlock the client against the very // limit this gate reports — it would be told to compact and then denied the compaction. if (parsed._compactionRequest !== true) { - const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)); + const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config)); if (!inputAdmission.admitted) { // #1524: this is a LOCAL preflight refusal, not an upstream verdict. A policy or combo // fallback must be able to skip this candidate and try one whose context window fits, diff --git a/src/server/responses/input-admission.ts b/src/server/responses/input-admission.ts index f389678629..219c3b6f89 100644 --- a/src/server/responses/input-admission.ts +++ b/src/server/responses/input-admission.ts @@ -10,7 +10,7 @@ * catches the pathological case and stays out of the way otherwise. Every uncertainty * resolves toward admitting. */ -import { nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens } from "../../codex/catalog/metadata"; +import { nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "../../codex/catalog/metadata"; import { estimateTokens } from "../../lib/token-estimate"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../types"; @@ -131,7 +131,7 @@ export function resolveInputCeiling( modelId: string, // Operator cap for the canonical native provider. Passed in rather than read from a // config here so this stays pure: no filesystem, no catalog, no registry scan. - nativeContextCap?: number, + nativeContextCap?: NativeContextLimitsInput, ): number | null { const configured = positive(provider.modelContextWindows?.[modelId]) ?? positive(provider.contextWindow); @@ -143,15 +143,18 @@ export function resolveInputCeiling( const canonicalNativeBare = providerName === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider) && !modelId.includes("/"); - const native = configured === null && canonicalNativeBare - ? positive(nativeOpenAiContextWindow(modelId, nativeContextCap)) + const nativeLimits = canonicalNativeBare && configured !== null + ? { + ...(typeof nativeContextCap === "number" ? { cap: nativeContextCap } : (nativeContextCap ?? {})), + modelWindows: { [modelId]: configured }, + } + : nativeContextCap; + const native = canonicalNativeBare + ? positive(nativeOpenAiContextWindow(modelId, nativeLimits)) : null; - // The model's own measured input ceiling applies even when the operator configured a - // window: a 1,050,000 override must not raise the gate above the 922,000 the upstream - // actually accepts. Computed independently of `configured` for exactly that reason. - const nativeMaxInput = canonicalNativeBare ? positive(nativeOpenAiMaxInputTokens(modelId, nativeContextCap)) : null; + const nativeMaxInput = canonicalNativeBare ? positive(nativeOpenAiMaxInputTokens(modelId, nativeLimits)) : null; - const window = configured ?? native; + const window = canonicalNativeBare ? native : configured; // modelMaxInputTokens is an input-only cap, so it can only tighten the window. const configuredMaxInput = positive(provider.modelMaxInputTokens?.[modelId]); const limits = [window, configuredMaxInput, nativeMaxInput].filter((v): v is number => v !== null); @@ -168,7 +171,7 @@ export function checkInputAdmission( provider: OcxProviderConfig, providerName: string, modelId: string, - nativeContextCap?: number, + nativeContextCap?: NativeContextLimitsInput, ): InputAdmissionResult { const ceiling = resolveInputCeiling(provider, providerName, modelId, nativeContextCap); if (ceiling === null) return { admitted: true, estimatedTokens: 0, ceiling: null }; diff --git a/src/server/system-env.ts b/src/server/system-env.ts index 35811f002e..0ae251605c 100644 --- a/src/server/system-env.ts +++ b/src/server/system-env.ts @@ -237,12 +237,12 @@ function rollbackInjectedKeys(port: number, injectedKeys: string[]): void { async function computeEffectiveModelEnv(config: OcxConfig, auto?: AutoContextMode): Promise<{ modelEnv: Record; windows: Record }> { const { boundedContextWindows, buildClaudeContextWindows, effectiveModelEnv } = await import("../claude/context-windows"); const windows = await boundedContextWindows(async () => { - const { gatherRoutedModels, visibleNativeSlugs } = await import("../codex/catalog"); + const { gatherRoutedModels, nativeContextLimits, visibleNativeSlugs } = await import("../codex/catalog"); try { - return buildClaudeContextWindows([...visibleNativeSlugs(config)], await gatherRoutedModels(config), providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)); + return buildClaudeContextWindows([...visibleNativeSlugs(config)], await gatherRoutedModels(config), nativeContextLimits(config)); } catch (error) { if (error && typeof error === "object" && (error as { code?: unknown }).code === "catalog_busy") { - return buildClaudeContextWindows([...visibleNativeSlugs(config)], [], providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)); + return buildClaudeContextWindows([...visibleNativeSlugs(config)], [], nativeContextLimits(config)); } throw error; } diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index c192e497ce..49acd21758 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -116,21 +116,17 @@ preserving a stale one would block every later migration. capability metadata, but routing strips only the account selector and keeps `gpt-daybreak-blue-latest` byte-for-byte; it never expands the bare list or substitutes Sol. - The two GPT-5.6 surfaces advertise different windows on purpose. API rows use 1,050,000 - context with 922,000 max input. Codex-login rows use 922,000 context with 829,800 - auto-compaction. + context with 922,000 max input. Codex-login rows default to the live catalog 272,000 + (auto-compact 244,800) and only rise to 922,000 / 829,800 when the user turns the 1M + switch on. The ceiling is the same on both — probing a real Codex-login account accepted 921,508 input tokens and refused 922,013 with `context_length_exceeded` on Sol, Terra and Luna alike, - matching the 922,000 the API surface already declared. What differs is that a Codex-login - `context_window` is a spending budget, not a label: Codex fills - `context_window * effective_context_window_percent` (95% by default, codex-rs - `turn_context.rs`). Advertising 1,050,000 there spent 997,500 and blew past the ceiling. - - 922,000 is therefore an OPERATING CAP, the same shape upstream uses — the live catalog - reports 272,000 against a 872,000 `max_context_window`. It yields a 875,900-token budget - and keeps ~46k of headroom. Do not back-solve it from the ceiling: 970,000 would land the - budget at 921,500, inside the 1,840-token gap between the last success and the first - refusal. Evidence: `devlog/_plan/260817_native_gpt56_1m_context/001_measurement_evidence.md` + matching the 922,000 the API surface already declared. A Codex-login `context_window` is a + spending budget, not a label: Codex fills `context_window * effective_context_window_percent` + (95% by default, codex-rs `turn_context.rs`). Advertising 1,050,000 there spent 997,500 and + blew past the ceiling. The 922,000 opt-in yields a 875,900-token budget and keeps ~46k of + headroom. Evidence: `devlog/_plan/260817_native_gpt56_1m_context/001_measurement_evidence.md` and `014_final_922k_with_margin.md`. - `*-pro` selected ids rewrite to the base wire id with `reasoning.mode: "pro"`; request logs, usage, model visibility, subagent state, and injection state retain the selected virtual id. diff --git a/tests/claude-context-windows.test.ts b/tests/claude-context-windows.test.ts index f057a467b8..8f364b7636 100644 --- a/tests/claude-context-windows.test.ts +++ b/tests/claude-context-windows.test.ts @@ -21,10 +21,10 @@ describe("claude context-window map (devlog 260712 B2)", () => { test("registers native slugs (bare + desktop alias + legacy alias)", () => { const map = buildClaudeContextWindows(["gpt-5.6-sol", "gpt-5.4"], []); - // Authoritative native overrides: gpt-5.6 natives 372k, gpt-5.4 native 1M. - expect(map["gpt-5.6-sol"]).toBe(922_000); - expect(map[desktop3pAlias("native", "gpt-5.6-sol")]).toBe(922_000); - expect(map["claude-ocx-native--gpt-5.6-sol"]).toBe(922_000); + // Authoritative native overrides: gpt-5.6 natives follow Codex 272k, gpt-5.4 native 1M. + expect(map["gpt-5.6-sol"]).toBe(272_000); + expect(map[desktop3pAlias("native", "gpt-5.6-sol")]).toBe(272_000); + expect(map["claude-ocx-native--gpt-5.6-sol"]).toBe(272_000); expect(map["gpt-5.4"]).toBe(1_000_000); }); @@ -129,20 +129,20 @@ describe("auto-context (devlog 260712 020 + audit 021)", () => { ]); expect(map["gpt-5.6-luna"]).toBe(400_000); expect(map["shared-model"]).toBeUndefined(); - expect(map["gpt-5.6-sol"]).toBe(922_000); // native override, not 999k + expect(map["gpt-5.6-sol"]).toBe(272_000); // native default, not 999k }); test("auto-context marks a wide native slot, and turning it off unmarks anything under 1M", () => { const windows = buildClaudeContextWindows(["gpt-5.6-sol"], []); const env = effectiveModelEnv({ model: "gpt-5.6-sol" }, windows); - expect(env.ANTHROPIC_MODEL).toBe("gpt-5.6-sol[1m]"); - // Readable-alias slot value gets the same marking (audit 051 #4). + // Default 272k sits under the 829,800 compact window, so the marker stays off. + expect(env.ANTHROPIC_MODEL).toBe("gpt-5.6-sol"); const readable = effectiveModelEnv({ model: "claude-ocx-native--gpt-5.6-sol" }, windows); - expect(readable.ANTHROPIC_MODEL).toBe("claude-ocx-native--gpt-5.6-sol[1m]"); - // The marker above comes from auto-context (922k clears the compact window), NOT from an - // authoritative 1M window: the 5.6 family advertises 922,000, a cap held under its - // measured ceiling. So switching auto-context off takes the marker away (#854). - const solOff = effectiveModelEnv({ model: "gpt-5.6-sol", autoContext: false }, windows); + expect(readable.ANTHROPIC_MODEL).toBe("claude-ocx-native--gpt-5.6-sol"); + // Opting into the measured 922k ceiling clears the compact window and marks [1m]. + const opted = buildClaudeContextWindows(["gpt-5.6-sol"], [], 922_000); + expect(effectiveModelEnv({ model: "gpt-5.6-sol" }, opted).ANTHROPIC_MODEL).toBe("gpt-5.6-sol[1m]"); + const solOff = effectiveModelEnv({ model: "gpt-5.6-sol", autoContext: false }, opted); expect(solOff.ANTHROPIC_MODEL).toBe("gpt-5.6-sol"); const subWindows = buildClaudeContextWindows(["gpt-5.5"], []); const off = effectiveModelEnv({ model: "gpt-5.5", autoContext: false }, subWindows); diff --git a/tests/claude-desktop-native-context.test.ts b/tests/claude-desktop-native-context.test.ts index 23f06ecbfd..acc181b384 100644 --- a/tests/claude-desktop-native-context.test.ts +++ b/tests/claude-desktop-native-context.test.ts @@ -32,7 +32,7 @@ test("buildClaudeDesktopState gives native rows their real context window", asyn const sol = state.models.find(m => m.route === "native/gpt-5.6-sol"); expect(sol).toBeDefined(); expect(sol!.contextWindow).toBe(nativeOpenAiContextWindow("gpt-5.6-sol")); - expect(sol!.contextWindow).toBe(922_000); + expect(sol!.contextWindow).toBe(272_000); // Every native row that the catalog knows a window for must carry it. for (const slug of visibleNativeSlugs(config)) { @@ -55,5 +55,5 @@ test("the desktop-3p writer resolves the same native window as the DTO", () => { // capability is not lost between the dashboard and the written config. const sol = models.find(m => m.labelOverride.toLowerCase().includes("sol")); expect(sol).toBeDefined(); - expect(expected).toBe(922_000); + expect(expected).toBe(272_000); }); diff --git a/tests/claude-model-info.test.ts b/tests/claude-model-info.test.ts index e4f8431045..aec31fa889 100644 --- a/tests/claude-model-info.test.ts +++ b/tests/claude-model-info.test.ts @@ -93,9 +93,8 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => const infos = buildAnthropicModelInfos(["gpt-5.6-sol", "gpt-5.5"], []); const sol = infos.find(i => i.display_name === "gpt-5.6-sol (native)"); const gpt55 = infos.find(i => i.display_name === "gpt-5.5 (native)"); - // max_input_tokens is an INPUT limit: the 5.6 family advertises a 1,050,000 window but - // refuses input past 922,000 (measured), so the row reports the ceiling, not the window. - expect(sol!.max_input_tokens).toBe(922_000); + // Default native 5.6 follows the Codex 272k window, so the input ceiling matches it. + expect(sol!.max_input_tokens).toBe(272_000); expect(gpt55!.max_input_tokens).toBe(272_000); }); diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 3c59bed28b..5e2a0d1c2d 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -419,9 +419,9 @@ describe("Codex catalog sync hardening", () => { const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; expect(rows.find(row => row.slug === "team/gpt-daybreak-blue-latest")).toMatchObject({ - context_window: 922_000, - max_context_window: 922_000, - auto_compact_token_limit: 829_800, + context_window: 272_000, + max_context_window: 272_000, + auto_compact_token_limit: 244_800, comp_hash: "3000", tool_mode: "code_mode_only", use_responses_lite: true, diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 8a080473a3..7086b6850a 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1094,8 +1094,8 @@ describe("combo catalog capability intersection", () => { expect(rows.find(row => row.provider === "combo" && row.id === "nova-sol")).toMatchObject({ alias: "gpt-5.6-sol", nativeAlias: true, - contextWindow: 922_000, - maxInputTokens: 922_000, + contextWindow: 272_000, + maxInputTokens: 272_000, inputModalities: ["text", "image"], reasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], defaultReasoningEffort: "low", @@ -1271,7 +1271,7 @@ describe("combo catalog capability intersection", () => { const rows = await gatherRoutedModels(config); const comboRow = rows.find(r => r.provider === "combo" && r.id === "auto"); expect(comboRow).toBeDefined(); - expect(comboRow!.contextWindow).toBe(372_000); + expect(comboRow!.contextWindow).toBe(272_000); expect(comboRow!.inputModalities).toEqual(["text", "image"]); // Reasoning efforts should be the intersection of the two members. expect(comboRow!.reasoningEfforts).toContain("low"); @@ -2619,9 +2619,9 @@ describe("Codex catalog routed normalization", () => { expect((gpt56?.supported_reasoning_levels as { effort: string }[]).map(l => l.effort)).toEqual([ "low", "medium", "high", "xhigh", "max", "ultra", ]); - expect(gpt56?.context_window).toBe(922_000); - expect(gpt56?.max_context_window).toBe(922_000); - expect(gpt56?.auto_compact_token_limit).toBe(829_800); + expect(gpt56?.context_window).toBe(272_000); + expect(gpt56?.max_context_window).toBe(272_000); + expect(gpt56?.auto_compact_token_limit).toBe(244_800); expect((gpt55?.supported_reasoning_levels as { effort: string }[]).map(l => l.effort)).toEqual([ "low", "medium", "high", "xhigh", "max", "ultra", ]); @@ -2663,7 +2663,7 @@ describe("Codex catalog routed normalization", () => { expect(e).not.toHaveProperty("minimal_client_version"); expect(e).not.toHaveProperty("prefer_websockets"); expect(e).not.toHaveProperty("supports_websockets"); - expect(e?.context_window).toBe(922_000); + expect(e?.context_window).toBe(272_000); expect(e?.tool_mode).toBe("code_mode_only"); expect(e?.use_responses_lite).toBe(true); } @@ -2785,9 +2785,9 @@ describe("Codex catalog routed normalization", () => { }); test("nativeOpenAiContextWindow applies the openai cap as a ceiling only when provided", () => { - expect(nativeOpenAiContextWindow("gpt-5.6-sol")).toBe(922_000); + expect(nativeOpenAiContextWindow("gpt-5.6-sol")).toBe(272_000); expect(nativeOpenAiContextWindow("gpt-5.6-sol", 272_000)).toBe(272_000); - // A cap below the native value lowers it; the 5.6 family now sits at 1.05M, so 500k caps. + // A 500k cap raises the 272k default; a 2M cap clamps to the measured 922k ceiling. expect(nativeOpenAiContextWindow("gpt-5.6-sol", 500_000)).toBe(500_000); // A cap ABOVE the native value is a ceiling, not a floor. expect(nativeOpenAiContextWindow("gpt-5.6-sol", 2_000_000)).toBe(922_000); @@ -2802,7 +2802,7 @@ describe("Codex catalog routed normalization", () => { test("Daybreak Blue inherits Sol capabilities and ships one bare row plus one row per selector", () => { expect(NATIVE_DAYBREAK_BLUE_MODEL).toBe("gpt-daybreak-blue-latest"); expect(nativeOpenAiCapabilitySourceSlug(NATIVE_DAYBREAK_BLUE_MODEL)).toBe("gpt-5.6-sol"); - expect(nativeOpenAiContextWindow(NATIVE_DAYBREAK_BLUE_MODEL)).toBe(922_000); + expect(nativeOpenAiContextWindow(NATIVE_DAYBREAK_BLUE_MODEL)).toBe(272_000); expect(nativeInputModalities(NATIVE_DAYBREAK_BLUE_MODEL)).toEqual(["text", "image"]); expect(nativeReasoningEfforts(NATIVE_DAYBREAK_BLUE_MODEL)) .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); @@ -2849,7 +2849,7 @@ describe("Codex catalog routed normalization", () => { const daybreak = projected.find(entry => entry.slug === `main/${NATIVE_DAYBREAK_BLUE_MODEL}`); const sol = projected.find(entry => entry.slug === "gpt-5.6-sol"); expect(daybreak).toBeDefined(); - expect(daybreak?.auto_compact_token_limit).toBe(829_800); + expect(daybreak?.auto_compact_token_limit).toBe(244_800); expect(daybreak).toMatchObject({ context_window: sol?.context_window, max_context_window: sol?.max_context_window, diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts index d39a995a64..eedd2616d4 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -377,9 +377,9 @@ test("convergence projects the observed Daybreak row onto its selector and one b expect(daybreak).toMatchObject({ visibility: "list", opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, - context_window: 922_000, - max_context_window: 922_000, - auto_compact_token_limit: 829_800, + context_window: 272_000, + max_context_window: 272_000, + auto_compact_token_limit: 244_800, comp_hash: "3000", tool_mode: "code_mode_only", use_responses_lite: true, diff --git a/tests/grok-sync.test.ts b/tests/grok-sync.test.ts index 55d039012e..d1593a949a 100644 --- a/tests/grok-sync.test.ts +++ b/tests/grok-sync.test.ts @@ -55,7 +55,7 @@ describe("syncGrokConfig", () => { const solBlock = content.slice(content.indexOf("[model.ocx-gpt-5-6-sol]")); expect(solBlock).toContain(`context_window = ${nativeOpenAiContextWindow("gpt-5.6-sol")}`); - expect(nativeOpenAiContextWindow("gpt-5.6-sol")).toBe(922_000); + expect(nativeOpenAiContextWindow("gpt-5.6-sol")).toBe(272_000); // Each native block carries a window exactly when the catalog knows one. gpt-5.4-mini has // none recorded, and inject.ts deliberately omits the line rather than writing a diff --git a/tests/input-admission.test.ts b/tests/input-admission.test.ts index 54371173a1..44422d502c 100644 --- a/tests/input-admission.test.ts +++ b/tests/input-admission.test.ts @@ -71,7 +71,7 @@ describe("resolveInputCeiling", () => { test("resolves the native window from static metadata for a canonical route", () => { // The `openai` registry entry carries no context fields, so without the native // fallback the gate would be inert on the default Codex route. - expect(resolveInputCeiling(CANONICAL_NATIVE, "openai", "gpt-5.6-sol")).toBe(922_000); + expect(resolveInputCeiling(CANONICAL_NATIVE, "openai", "gpt-5.6-sol")).toBe(272_000); }); test("a custom provider merely NAMED openai does not inherit native limits", () => { diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 7bd8bf460b..08e0c4eb19 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -69,10 +69,10 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(rows.find(r => r.slug === "gpt-5.6-sol")?.disabled).toBe(true); expect(rows.find(r => r.slug === "gpt-5.5")?.disabled).toBe(false); // Known context metadata rides along for the dashboard. - expect(rows.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(922_000); + expect(rows.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); }); - test("a per-model window narrows the native row, and can only ever lower it", () => { + test("a per-model window sets the native row and never exceeds the measured ceiling", () => { // The lever the dashboard's context button writes. It reaches the same accessors the cap // does, so /api/models and the on-disk catalog cannot disagree about the same slug. const overlay = { providers: { openai: { modelContextWindows: { "gpt-5.6-sol": 500_000 } } } } as never; @@ -82,7 +82,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { // window would be the same over-advertising this unit exists to fix. expect(rows.find(r => r.slug === "gpt-5.6-sol")?.maxInputTokens).toBe(500_000); // A sibling slug is untouched: this lever is per-model. - expect(rows.find(r => r.slug === "gpt-5.6-terra")?.contextWindow).toBe(922_000); + expect(rows.find(r => r.slug === "gpt-5.6-terra")?.contextWindow).toBe(272_000); // Above the measured ceiling the overlay is inert. A user value must never widen what the // upstream actually accepts. @@ -125,7 +125,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { } // And the window is a cap held under the ceiling, not back-solved to sit right on it: // 970,000 would pass the check above (921,500) while leaving no room at all. - expect(rows.find(row => row.slug === "gpt-5.6-sol")?.contextWindow).toBe(MEASURED_CEILING); + expect(rows.find(row => row.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); }); test("the native /api/models rows carry the input ceiling, not just the window", async () => { @@ -133,8 +133,8 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { // reports only the window tells the dashboard the whole thing is usable as input. const rows = nativeModelRows({}); const sol = rows.find(row => row.slug === "gpt-5.6-sol"); - expect(sol?.contextWindow).toBe(922_000); - expect(sol?.maxInputTokens).toBe(922_000); + expect(sol?.contextWindow).toBe(272_000); + expect(sol?.maxInputTokens).toBe(272_000); // A cap lowers both numbers together — an input ceiling above the capped window would // be nonsense. const capped = nativeModelRows({ providerContextCaps: { openai: 272_000 } }); @@ -147,6 +147,20 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(gpt55?.maxInputTokens).toBeUndefined(); }); + test("the native 1M switch raises the Codex 272k default up to the measured ceiling", () => { + const raised = nativeModelRows({ providerContextCaps: { openai: 922_000 } }); + expect(raised.find(r => r.slug === "gpt-5.6-sol")).toMatchObject({ + contextWindow: 922_000, + maxInputTokens: 922_000, + }); + expect(raised.find(r => r.slug === "gpt-5.6-luna")?.contextWindow).toBe(922_000); + // A value above the ceiling clamps; gpt-5.5 cannot be invented wider. + const over = nativeModelRows({ providerContextCaps: { openai: 2_000_000 } }); + expect(over.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(922_000); + expect(raised.find(r => r.slug === "gpt-5.5")?.contextWindow).toBe(272_000); + expect(raised.find(r => r.slug === "gpt-5.4")?.contextWindow).toBe(1_000_000); + }); + test("nativeModelRows applies providerContextCaps.openai as a ceiling (#1430)", () => { const rows = nativeModelRows({ disabledModels: [], @@ -158,7 +172,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(rows.find(r => r.slug === "gpt-5.5")?.contextWindow).toBe(272_000); // A cap for another provider leaves natives untouched. const other = nativeModelRows({ providerContextCaps: { "openai-apikey": 128_000 } }); - expect(other.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(922_000); + expect(other.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); }); test("native aliases suppress their native dashboard row and activate Desktop allowlist pruning", () => { @@ -406,9 +420,9 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { applyNativeOpenAiContextOverride(malformed); applyNativeOpenAiContextOverride(unmarked); expect(trusted).toMatchObject({ - context_window: 922_000, - max_context_window: 922_000, - auto_compact_token_limit: 829_800, + context_window: 272_000, + max_context_window: 272_000, + auto_compact_token_limit: 244_800, }); expect(malformed).toMatchObject({ context_window: 128_000, diff --git a/tests/route-explainability.test.ts b/tests/route-explainability.test.ts index 91cc75e4eb..3f154d9d83 100644 --- a/tests/route-explainability.test.ts +++ b/tests/route-explainability.test.ts @@ -229,7 +229,7 @@ describe("route explainability (RI-09)", () => { }, }, }, "openai", "gpt-5.6-sol"); - expect(evidence.contextWindow).toBe(922_000); + expect(evidence.contextWindow).toBe(272_000); }); test("CLI logs explain encodes request ids and supports --json", async () => { From 8b672205e2f9d25d317498885240c85bac5d3923 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 17 Aug 2026 18:00:40 +0900 Subject: [PATCH 02/21] fix(codex): thread native 5.6 overlays through remaining Codex and Desktop writers Pass full nativeContextLimits into model-info, catalog, and Desktop apply paths so a 1M overlay is not dropped. Keep the combo fallback ceiling on the 5.6 family so a GPT-5.4 native alias does not inherit 922k. --- src/cli/claude-desktop.ts | 5 ++--- src/codex/catalog/provider-fetch.ts | 4 +++- src/server/index.ts | 7 +++---- src/server/management/agent-settings-routes.ts | 4 ++-- src/server/management/config-routes.ts | 4 ++-- src/server/management/native-integration-routes.ts | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 49a2b00c5d..ac197b615a 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -11,11 +11,10 @@ import { type DesktopProfile, } from "../claude/desktop-profile"; import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; -import { filterCatalogVisibleModels, desktopVisibleNativeSlugs } from "../codex/catalog"; +import { filterCatalogVisibleModels, desktopVisibleNativeSlugs, nativeContextLimits } from "../codex/catalog"; import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api"; import { findLiveProxy } from "../server/proxy-liveness"; import { runtimeRequest } from "./runtime-api"; -import { providerContextCap } from "../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; function isFamily(value: string | undefined): value is DesktopFamily { @@ -100,7 +99,7 @@ export async function applyProfile( config.apiKeys?.[0]?.key, mode, state.profile, - providerContextCap(config, OPENAI_CODEX_PROVIDER_ID), + nativeContextLimits(config), ); return { ok: result.written, path: result.path, reason: result.reason }; } diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index a8efb6d9c0..94ff8402f6 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1750,7 +1750,9 @@ async function gatherRoutedModelsUncached( ? nativeOpenAiContextWindow(combo.alias, nativeContextLimits(config)) : undefined; const nativeAliasMaxInput = combo.nativeAlias && combo.alias - ? (NATIVE_GPT56_MAX_INPUT_TOKENS) + ? (combo.alias.startsWith("gpt-5.6-") || combo.alias.includes("daybreak") + ? NATIVE_GPT56_MAX_INPUT_TOKENS + : nativeOpenAiMaxInputTokens(combo.alias) ?? nativeOpenAiContextWindow(combo.alias)) : undefined; const nativeAliasFallback = combo.nativeAlias && combo.alias && nativeContextWindow !== undefined ? { diff --git a/src/server/index.ts b/src/server/index.ts index ab65d5ac5f..87ece913e1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -50,7 +50,6 @@ import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup" import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup"; import { runModelRenameStartupMigration } from "../providers/model-rename-startup"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; -import { providerContextCap } from "../providers/context-cap"; import { providerCodexAccountMode } from "../providers/registry"; import type { StorageCleanupPolicy } from "../types"; import { MAX_DECOMPRESSED_BODY_BYTES } from "./request-decompress"; @@ -906,7 +905,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server Date: Mon, 17 Aug 2026 11:37:45 +0900 Subject: [PATCH 03/21] fix(windows): resolve icacls from trusted System32 path Bare icacls.exe on PATH threw ENOENT under a bun-shim environment and was reported as missing NTFS ACL support, which blocked ocx service install. Use GetSystemDirectoryW like schtasks/powershell, and classify spawn failure as EICACLS. --- src/lib/windows-elevation.ts | 11 +++++- src/lib/windows-secret-acl.ts | 59 ++++++++++++++++++++++---------- tests/windows-elevation.test.ts | 6 ++++ tests/windows-secret-acl.test.ts | 10 +++++- 4 files changed, 65 insertions(+), 21 deletions(-) diff --git a/src/lib/windows-elevation.ts b/src/lib/windows-elevation.ts index 4317a9262f..289491fdd7 100644 --- a/src/lib/windows-elevation.ts +++ b/src/lib/windows-elevation.ts @@ -175,7 +175,7 @@ export function assertTrustedSystemExecutableForTests(candidate: string, label: return assertTrustedSystemExecutable(candidate, label); } -type ElevationExeOverrides = { powershell?: string; schtasks?: string; taskkill?: string }; +type ElevationExeOverrides = { powershell?: string; schtasks?: string; taskkill?: string; icacls?: string }; let elevationExeOverridesForTests: ElevationExeOverrides | null = null; /** @@ -220,6 +220,15 @@ export function resolveTrustedWindowsTaskkillExe(): string { return assertTrustedSystemExecutable(candidate, "taskkill.exe"); } +/** Absolute path to System32\\icacls.exe from a trusted system directory. */ +export function resolveTrustedWindowsIcaclsExe(): string { + if (elevationExeOverridesForTests?.icacls) { + return elevationExeOverridesForTests.icacls; + } + const candidate = join(resolveTrustedWindowsSystemDirectory(), "icacls.exe"); + return assertTrustedSystemExecutable(candidate, "icacls.exe"); +} + /** Stable machine-readable marker for a denied `schtasks /create`. Crosses the CLI→proxy boundary. */ export const WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER = "OCX_ERROR_CODE=WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED"; diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 424a0f7b0b..92d2b8583b 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -31,6 +31,7 @@ import { existsSync, statSync } from "node:fs"; import { env, platform } from "node:process"; +import { resolveTrustedWindowsIcaclsExe } from "./windows-elevation"; import { resolveCurrentWindowsPrincipal, resolveCurrentWindowsPrincipalAsync, @@ -273,22 +274,37 @@ export interface IcaclsResult { type IcaclsRunner = (args: string[], timeoutMs: number) => IcaclsResult; type AsyncIcaclsRunner = (args: string[], timeoutMs: number) => Promise; +function resolveIcaclsExecutable(): string { + // Same authority as schtasks/powershell: never take icacls from PATH. + // A bun-shim or stripped PATH makes `icacls.exe` throw ENOENT, which used to + // surface as "filesystem may not support per-user NTFS ACLs". + return resolveTrustedWindowsIcaclsExe(); +} + +function spawnFailedResult(): IcaclsResult { + return { success: false, exitCode: null, timedOut: false, stdout: "" }; +} + function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult { // Bun.spawnSync with windowsHide: Node execFileSync has hung under the GUI/proxy even // with windowsHide, and console-subsystem tools flash a visible window otherwise. - const result = Bun.spawnSync(["icacls.exe", ...args], { - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", - timeout: timeoutMs, - windowsHide: true, - }); - return { - success: result.success, - exitCode: result.exitCode, - timedOut: result.exitedDueToTimeout ?? false, - stdout: result.stdout ? result.stdout.toString() : "", - }; + try { + const result = Bun.spawnSync([resolveIcaclsExecutable(), ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + timeout: timeoutMs, + windowsHide: true, + }); + return { + success: result.success, + exitCode: result.exitCode, + timedOut: result.exitedDueToTimeout ?? false, + stdout: result.stdout ? result.stdout.toString() : "", + }; + } catch { + return spawnFailedResult(); + } } /** @@ -297,12 +313,17 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult { * we still await process exit before classifying so settlement is confirmed. */ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise { - const proc = Bun.spawn(["icacls.exe", ...args], { - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", - windowsHide: true, - }); + let proc: ReturnType; + try { + proc = Bun.spawn([resolveIcaclsExecutable(), ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + windowsHide: true, + }); + } catch { + return spawnFailedResult(); + } let timedOutByUs = false; const timer = setTimeout(() => { timedOutByUs = true; diff --git a/tests/windows-elevation.test.ts b/tests/windows-elevation.test.ts index ed35e06769..2376ebaa25 100644 --- a/tests/windows-elevation.test.ts +++ b/tests/windows-elevation.test.ts @@ -11,6 +11,7 @@ import { isWindowsAccessDenied, isWindowsAccessDeniedError, isWindowsSchtasksCreateAccessDenied, + resolveTrustedWindowsIcaclsExe, resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsSchtasksExe, schtasksOperationFromArgs, @@ -215,12 +216,14 @@ describe("windows elevation helpers", () => { const trustedSystem32 = join(trustedRoot, "System32"); mkdirSync(join(trustedSystem32, "WindowsPowerShell", "v1.0"), { recursive: true }); writeFileSync(join(trustedSystem32, "schtasks.exe"), ""); + writeFileSync(join(trustedSystem32, "icacls.exe"), ""); writeFileSync(join(trustedSystem32, "WindowsPowerShell", "v1.0", "powershell.exe"), ""); const evilRoot = mkdtempSync(join(tmpdir(), "ocx-evil-sys-")); const evilSystem32 = join(evilRoot, "System32"); mkdirSync(join(evilSystem32, "WindowsPowerShell", "v1.0"), { recursive: true }); writeFileSync(join(evilSystem32, "schtasks.exe"), "evil"); + writeFileSync(join(evilSystem32, "icacls.exe"), "evil"); writeFileSync(join(evilSystem32, "WindowsPowerShell", "v1.0", "powershell.exe"), "evil"); const previousSystemRoot = process.env.SystemRoot; @@ -233,10 +236,13 @@ describe("windows elevation helpers", () => { const powershell = resolveTrustedWindowsPowerShellExe(); const schtasks = resolveTrustedWindowsSchtasksExe(); + const icacls = resolveTrustedWindowsIcaclsExe(); expect(powershell.toLowerCase().includes("ocx-evil-sys")).toBe(false); expect(schtasks.toLowerCase().includes("ocx-evil-sys")).toBe(false); + expect(icacls.toLowerCase().includes("ocx-evil-sys")).toBe(false); expect(powershell.toLowerCase()).toContain(trustedSystem32.toLowerCase()); expect(schtasks.toLowerCase()).toContain(trustedSystem32.toLowerCase()); + expect(icacls.toLowerCase()).toContain(trustedSystem32.toLowerCase()); // Containment must reject an existing executable outside the trusted system directory // (not merely a missing-file failure). diff --git a/tests/windows-secret-acl.test.ts b/tests/windows-secret-acl.test.ts index 77580d239e..3710b1173e 100644 --- a/tests/windows-secret-acl.test.ts +++ b/tests/windows-secret-acl.test.ts @@ -10,7 +10,7 @@ * - hardenSecretDir mirrors the same contract for directories. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -440,6 +440,14 @@ describe("non-Windows determinism", () => { // otherwise verifies that hardenSecretPath failure messages meet the contract. // --------------------------------------------------------------------------- +describe("icacls executable authority", () => { + test("default runners resolve icacls from the trusted System32 path, not PATH", () => { + const src = readFileSync(join(import.meta.dir, "..", "src", "lib", "windows-secret-acl.ts"), "utf8"); + expect(src).toContain("resolveTrustedWindowsIcaclsExe"); + expect(src).not.toMatch(/Bun\.spawn(?:Sync)?\(\["icacls\.exe"/); + }); +}); + describe("diagnostics sanitization contract", () => { test("HardenResult diagnostics field is a plain string when present", () => { const filePath = join(testDir, "diag-test.json"); From 1828cb150e729010975b2cdfe19e97a87fd8cea4 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:25:43 +0900 Subject: [PATCH 04/21] fix(windows): keep icacls spawn stdio types inferred --- src/lib/windows-secret-acl.ts | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 92d2b8583b..120b8fd526 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -285,6 +285,24 @@ function spawnFailedResult(): IcaclsResult { return { success: false, exitCode: null, timedOut: false, stdout: "" }; } +/** + * Spawn icacls asynchronously, or return null when the executable cannot be + * launched. The pipe/ignore stdio literals stay inferred here so `stdout` keeps + * its `ReadableStream` type instead of widening to the generic default. + */ +function trySpawnIcacls(args: string[]) { + try { + return Bun.spawn([resolveIcaclsExecutable(), ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + windowsHide: true, + }); + } catch { + return null; + } +} + function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult { // Bun.spawnSync with windowsHide: Node execFileSync has hung under the GUI/proxy even // with windowsHide, and console-subsystem tools flash a visible window otherwise. @@ -313,17 +331,8 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult { * we still await process exit before classifying so settlement is confirmed. */ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise { - let proc: ReturnType; - try { - proc = Bun.spawn([resolveIcaclsExecutable(), ...args], { - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", - windowsHide: true, - }); - } catch { - return spawnFailedResult(); - } + const proc = trySpawnIcacls(args); + if (!proc) return spawnFailedResult(); let timedOutByUs = false; const timer = setTimeout(() => { timedOutByUs = true; From 9122d5ebee7e0d1521cdf8bbb86654fd14d7faa9 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:42:49 +0900 Subject: [PATCH 05/21] fix(windows): resolve LocalAppData independently of USERPROFILE The Windows coordinator namespace resolved LocalAppData through .NET GetFolderPath(SpecialFolder.LocalApplicationData), which follows USERPROFILE and returns an EMPTY STRING -- not an error -- when the profile it computes has no AppData directory on disk. Any caller with a redirected USERPROFILE therefore refused every coordinator lookup with "Windows effective-account lookup returned an empty value", which is precisely the environment dependence this module exists to eliminate. The suite hid it by handing each child the real profile back, so the defect read as unrelated assertion failures across locking, transition-state, catalog serialization and sync. Use SHGetKnownFolderPath with a null token and KF_FLAG_DEFAULT_PATH instead: it reads the known-folder registration for the effective token, returns the real per-user path whether or not the directory exists, and is unaffected by USERPROFILE, LOCALAPPDATA, HOMEDRIVE or HOMEPATH. A non-null token is NOT equivalent: passing (HANDLE)-1 resolves the built-in Default profile, which would key coordination to a namespace no real account writes to. The write-lock contention child published its hold marker with Bun.write, whose write only lands on a later event-loop turn. The callback that follows is a synchronous busy wait by contract, so the marker appeared ~3s late, after the hold had already ended, and the contender met an unheld lock and reported acquired where the test demands busy. Write the marker synchronously. The symlink spelling case needed Developer Mode to create a directory symlink; an NTFS junction needs no privilege and exercises the same realpath canonicalization, so the invariant stays proven on an unelevated machine. --- src/codex/user-identity.ts | 69 ++++++++++++++++++++++--- tests/codex-write-lock.test.ts | 3 +- tests/helpers/codex-write-lock-child.ts | 10 +++- 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/codex/user-identity.ts b/src/codex/user-identity.ts index f2335a2a7a..26b956309c 100644 --- a/src/codex/user-identity.ts +++ b/src/codex/user-identity.ts @@ -39,6 +39,31 @@ const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i; */ const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS = 8_000; +/** + * FOLDERID_LocalAppData, and the flag that makes the lookup ignore the caller's + * environment. + * + * The obvious spelling, .NET's + * `GetFolderPath(SpecialFolder.LocalApplicationData)`, is unusable here: on +* Windows it resolves through `USERPROFILE`, and when the profile named there has + * no local AppData directory on disk it returns an EMPTY STRING rather than an + * error. Any +* caller that redirected `USERPROFILE` (the test sandbox does, and so does a + * service account whose profile has not been materialized) therefore refused every + * coordinator lookup with "returned an empty value", which is the exact + * environment dependence this module exists to eliminate. + * + * `SHGetKnownFolderPath` with a null token and `KF_FLAG_DEFAULT_PATH` reads the + * known-folder registration for the effective token instead: it returns the real + * per-user path whether or not the directory exists, and it is unaffected by + * `USERPROFILE`, `LOCALAPPDATA`, `HOMEDRIVE`, or `HOMEPATH`. A non-null token argument +* is NOT equivalent: passing (HANDLE)-1 resolves the DEFAULT USER profile + * (the built-in "Default" profile), which would key coordination to a namespace + * no real account writes to. +*/ +const WINDOWS_LOCAL_APPDATA_FOLDER_ID = "F1B32785-6FBA-4FCF-9D55-7B8E7F157091"; +const WINDOWS_KF_FLAG_DEFAULT_PATH = "0x00000400"; + export class CodexUserIdentityRefusal extends Error { readonly code = "CODEX_USER_IDENTITY_REFUSED"; @@ -93,6 +118,42 @@ export function windowsIdentityPowerShellCommandForTests(expression: string): st return windowsIdentityPowerShellCommand(expression); } +/** + * PowerShell expression yielding the effective account's local AppData path. + * + * P/Invoke rather than a .NET convenience wrapper, for the reason recorded on + * WINDOWS_LOCAL_APPDATA_FOLDER_ID: the wrapper follows `USERPROFILE` and answers + * an empty string for a profile whose directory is absent, which is precisely + * the environment dependence this module refuses to inherit. The type is added + * under a unique name per process because `Add-Type` cannot redefine one. + * + * The whole sequence is wrapped in one `$(...)` subexpression because the caller + * substitutes this text into `[string]()`; several statements + * spliced in bare would close that cast's parenthesis early and fail to parse. + */ +function windowsLocalAppDataExpression(): string { + const signature = + '[DllImport("shell32.dll", CharSet = CharSet.Unicode)] public static extern int ' + + 'SHGetKnownFolderPath(ref System.Guid id, uint flags, System.IntPtr token, out System.IntPtr path);'; + const statements = [ + `$ocxShell = Add-Type -MemberDefinition '${signature}'` + + " -Name OcxKnownFolder -Namespace OcxIdentity -PassThru", + `$ocxFolderId = [System.Guid]'${WINDOWS_LOCAL_APPDATA_FOLDER_ID}'`, + "$ocxPathPtr = [System.IntPtr]::Zero", + "$ocxHr = $ocxShell::SHGetKnownFolderPath([ref]$ocxFolderId, " + + `${WINDOWS_KF_FLAG_DEFAULT_PATH}, [System.IntPtr]::Zero, [ref]$ocxPathPtr)`, + "if ($ocxHr -ne 0) { throw 'SHGetKnownFolderPath failed' }", + "try { [System.Runtime.InteropServices.Marshal]::PtrToStringUni($ocxPathPtr) }" + + " finally { [System.Runtime.InteropServices.Marshal]::FreeCoTaskMem($ocxPathPtr) }", + ]; + return `$(${statements.join("; ")})`; +} + +/** Test-only readback of the environment-independent known-folder expression. */ +export function windowsLocalAppDataExpressionForTests(): string { + return windowsLocalAppDataExpression(); +} + /** Test-only readback of the spawn options shared by the identity lookups (#1278). */ export function windowsIdentityPowerShellSpawnOptionsForTests(): ReturnType< typeof windowsIdentityPowerShellSpawnOptions @@ -265,9 +326,7 @@ export function probeCodexCoordinatorNamespace(identity: UserIdentity): Coordina } if (!SID_PATTERN.test(identity.sid)) refuse("The coordinator identity contains an invalid SID."); - const localAppData = powershellValue( - "[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)", - ); + const localAppData = powershellValue(windowsLocalAppDataExpression()); if (!isAbsolute(localAppData)) refuse("Windows LocalAppData resolution returned a relative path."); const root = resolve(localAppData, "OpenCodex", "Runtime", "v1", identity.sid.toUpperCase()); let entry; @@ -297,9 +356,7 @@ export function probeCodexCoordinatorNamespace(identity: UserIdentity): Coordina function resolveWindowsRuntimeRoot(identity: Extract): string { if (!SID_PATTERN.test(identity.sid)) refuse("The coordinator identity contains an invalid SID."); - const localAppData = powershellValue( - "[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)", - ); + const localAppData = powershellValue(windowsLocalAppDataExpression()); if (!isAbsolute(localAppData)) refuse("Windows LocalAppData resolution returned a relative path."); // The SID and known-folder values come from the effective token/.NET OS APIs, diff --git a/tests/codex-write-lock.test.ts b/tests/codex-write-lock.test.ts index 9344328c2b..2b213dbd5b 100644 --- a/tests/codex-write-lock.test.ts +++ b/tests/codex-write-lock.test.ts @@ -99,7 +99,8 @@ describe("canonical home identity", () => { */ test("symlinked, trailing-slash and relative spellings share one lock id", () => { const link = join(root, "linked-home"); - symlinkSync(codexHome, link); + if (process.platform === "win32") symlinkSync(codexHome, link, "junction"); + else symlinkSync(codexHome, link); const direct = canonicalizeCodexHome(codexHome); const viaLink = canonicalizeCodexHome(link); diff --git a/tests/helpers/codex-write-lock-child.ts b/tests/helpers/codex-write-lock-child.ts index 6b401bed2f..be61dc84be 100644 --- a/tests/helpers/codex-write-lock-child.ts +++ b/tests/helpers/codex-write-lock-child.ts @@ -11,6 +11,7 @@ */ import { withCodexWriteLock } from "../../src/codex/codex-write-lock"; import type { AdmissionSnapshot } from "../../src/codex/convergence-types"; +import { writeFileSync } from "node:fs"; const payload = JSON.parse(process.env.OCX_LOCK_CHILD_PAYLOAD ?? "{}") as { timeoutMs?: number; @@ -31,7 +32,14 @@ const result = await withCodexWriteLock( // Tell the parent the lock is HELD, then block this thread so it stays // held. The callback is synchronous by contract, so a sleep here is a busy // wait on purpose: awaiting would release nothing and violate the contract. - Bun.write(payload.holdMarker, "held").catch(() => {}); + // + // The write must be SYNCHRONOUS for the same reason. `Bun.write` returns a + // promise whose file write only lands on a later event-loop turn, and the + // busy wait below yields no turn -- so the marker appeared ~3s late, AFTER + // the hold had already ended. The parent then started its contender against + // an unheld lock and saw `acquired` where the test demands `busy`, which + // reads exactly like a broken exclusion invariant rather than a late marker. + writeFileSync(payload.holdMarker, "held"); const until = Date.now() + 3_000; while (Date.now() < until) { if (payload.releaseMarker && Bun.file(payload.releaseMarker).size > 0) break; From 5a4d968e6cf96316f5b3db5bba593421b3794a9e Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:49:29 +0900 Subject: [PATCH 06/21] fix(windows): keep git, platform and path assumptions honest under test isolation Six failures on an unelevated Windows checkout, none of which were product bugs in the code they pointed at: The test sandbox moves HOME, and git resolves ~/.gitconfig from HOME, so the developer's `safe.directory` became invisible to every git call a test made. On a checkout whose directory owner differs from the running account -- ordinary on Windows when a tool or installer created the tree -- git then refused with "detected dubious ownership", the adapter read that as "not a git repository", and command-code asserted against its empty fallback. Pin GIT_CONFIG_GLOBAL to the real file before HOME moves; the sandbox is unchanged, since git writes nothing there. claude-management-api spoofed process.platform globally, which sent Windows management-token initialization down the POSIX ACL path and answered 503 before the assertion under test was ever reached. Project the capability through an explicit management dependency instead, so the platform under test is named rather than impersonated. codex-sqlite-home asserted a POSIX-shaped literal for a relative-path resolution whose point is anchoring, not spelling; every neighbouring case already spells it through resolve/join. codex-history-reachability compared backslash paths against a forward-slash inventory, so the named permitted module could not match itself. codex-catalog-writer asserted chmod through stat mode bits that Windows only synthesizes, while the recorded harden effect proves the same transition. cli models and the catalog resync exceeded Bun's 5s default while doing real multi-process CLI work, and now use the repository's existing spawn budget. codex-config-generation created fixtures under tests/ and replaced its sandbox root with a file, so a failed SQLite open kept a Windows handle and teardown left the directory behind; it uses the OS temp dir and a directory at the database path, keeping the typed-error coverage. The catalog-sync workaround that handed children back the real USERPROFILE is removed: the defect it described is fixed at the source in the parent commit, and a workaround outliving its cause only hides the next regression. --- scripts/test.ts | 11 +++++++++++ src/server/management/agent-settings-routes.ts | 2 +- src/server/management/context.ts | 2 ++ tests/claude-management-api.test.ts | 13 ++----------- tests/cli-models.test.ts | 6 ++++-- tests/codex-catalog-sync-hardening.test.ts | 10 +--------- tests/codex-catalog-writer.test.ts | 5 ++++- tests/codex-config-generation.test.ts | 11 ++++++++--- tests/codex-history-reachability.test.ts | 12 +++++++++--- tests/codex-sqlite-home.test.ts | 11 ++++++++--- 10 files changed, 50 insertions(+), 33 deletions(-) diff --git a/scripts/test.ts b/scripts/test.ts index 6d48b0d6a6..5297a17722 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -37,6 +37,17 @@ export function createIsolatedTestEnvironment( // real-home write guard can still know which path to protect. // (devlog 260730_codex_rs_upstream_v2_live_handoff/070.) OCX_REAL_HOME: baseEnv.OCX_REAL_HOME ?? homedir(), + // Pin git's global config to the developer's real one before HOME moves. + // + // git resolves ~/.gitconfig from HOME, so a sandboxed HOME makes it invisible. + // That silently drops `safe.directory`, and on a checkout whose directory owner + // differs from the running account -- ordinary on Windows when a tool or + // installer created the tree -- every `git` call a test makes then fails with + // "detected dubious ownership". The test reads that as "this is not a git + // repository" and asserts against a fallback, which looks like a product bug in + // whichever adapter collected the metadata. Naming the file keeps the sandbox + // (git still writes nothing here) while leaving git's own trust decisions intact. + GIT_CONFIG_GLOBAL: baseEnv.GIT_CONFIG_GLOBAL ?? join(homedir(), ".gitconfig"), HOME: root, USERPROFILE: root, OPENCODEX_HOME: opencodexHome, diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 06adf26b1a..4b3e7a1715 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1010,7 +1010,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise classifierModel: config.claudeCode?.classifierModel ?? "", classifierFallbacks: config.claudeCode?.classifierFallbacks ?? [], systemEnv: config.claudeCode?.systemEnv === true, - autoConnectSupported: process.platform === "darwin", + autoConnectSupported: (ctx.deps.platform ?? process.platform) === "darwin", maxContextTokens: config.claudeCode?.maxContextTokens ?? null, alwaysEnableEffort: config.claudeCode?.alwaysEnableEffort === true, autoContext: config.claudeCode?.autoContext !== false, diff --git a/src/server/management/context.ts b/src/server/management/context.ts index bd27812bc5..99bd341e87 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -16,6 +16,8 @@ import type { } from "../../codex/app-server-restart-service"; export interface ManagementApiDeps { + /** Platform seam for capability projections; does not alter host-level startup behavior. */ + platform?: NodeJS.Platform; toggleCodexMultiAgentV2?: (enabled: boolean) => void; toggleDefaultModeRequestUserInput?: (enabled: boolean) => void; createManagementConvergeCodex?: (config: Readonly) => ConvergeCodex; diff --git a/tests/claude-management-api.test.ts b/tests/claude-management-api.test.ts index 1a8c77cc41..5b52755913 100644 --- a/tests/claude-management-api.test.ts +++ b/tests/claude-management-api.test.ts @@ -20,12 +20,6 @@ let previousClaudeConfigDir: string | undefined; let previousDesktopConfigDir: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, "platform", { configurable: true, value: platform }); -} - -const originalPlatform = process.platform; - beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; @@ -47,7 +41,6 @@ beforeEach(() => { }); afterEach(() => { - setPlatform(originalPlatform); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; @@ -627,8 +620,7 @@ test("PUT validation rejects bad shapes", async () => { }); test("GET /api/claude-code reports Auto-connect support on Darwin", async () => { - setPlatform("darwin"); - const server = startServer(0); + const server = startServer(0, { managementApi: { platform: "darwin" } }); try { const r = await fetch(new URL("/api/claude-code", server.url)); expect(r.status).toBe(200); @@ -644,8 +636,7 @@ test("GET /api/claude-code reports Auto-connect unsupported outside Darwin", asy ...loadConfig(), claudeCode: { systemEnv: true }, } as OcxConfig); - setPlatform("linux"); - const server = startServer(0); + const server = startServer(0, { managementApi: { platform: "linux" } }); try { const r = await fetch(new URL("/api/claude-code", server.url)); expect(r.status).toBe(200); diff --git a/tests/cli-models.test.ts b/tests/cli-models.test.ts index e0564f971b..2c62326e71 100644 --- a/tests/cli-models.test.ts +++ b/tests/cli-models.test.ts @@ -1,14 +1,16 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { INTERNAL_DEADLINE_MS } from "./helpers/test-budget"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "./helpers/test-budget"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); +setDefaultTimeout(SPAWN_BUDGET_MS); + function runCli(args: string[], env: Record = {}) { const result = spawnSync(process.execPath, [cliPath, ...args], { cwd: repoRoot, diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 5e2a0d1c2d..0e65cdc814 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -13,18 +13,10 @@ function runScript( script: string, extraEnv: Record = {}, ): { stdout: string; status: number; stderr: string } { - // The suite preload redirects USERPROFILE, but .NET's Windows known-folder lookup then returns - // an empty LocalApplicationData path. Catalog serialization intentionally resolves its lock - // namespace from that OS API rather than environment variables, so restore only the real - // profile for this child. CODEX_HOME and OPENCODEX_HOME remain explicit test sandboxes. - const windowsIdentityEnv = process.platform === "win32" && process.env.OCX_REAL_HOME - ? { USERPROFILE: process.env.OCX_REAL_HOME } - : {}; const result = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, env: { ...process.env, - ...windowsIdentityEnv, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, ...extraEnv, @@ -1142,7 +1134,7 @@ describe("Codex catalog sync hardening", () => { expect(out.identicalResyncKeptMtime).toBe(true); expect(out.thirdWritten).toBe(true); expect(out.realChangeBumpedMtime).toBe(true); - }); + }, 15_000); test("the no-op guard compares bytes, so a malformed byte decoding to U+FFFD is still repaired", () => { // The guard above must not preserve corruption. `readFileSync(path, "utf8")` diff --git a/tests/codex-catalog-writer.test.ts b/tests/codex-catalog-writer.test.ts index 261fd1942c..a350058976 100644 --- a/tests/codex-catalog-writer.test.ts +++ b/tests/codex-catalog-writer.test.ts @@ -237,7 +237,10 @@ for (const mutator of mutators) { ); expect(readFileSync(path, "utf8")).toBe("new bytes\n"); - expect(statSync(path).mode & 0o777).toBe(0o600); + // Windows exposes synthesized POSIX mode bits, so stat cannot prove that chmod took effect. + // The recorded harden call still proves every mutator requested the permission transition. + expect(effects.some(effect => effect.startsWith("harden:"))).toBe(true); + if (process.platform !== "win32") expect(statSync(path).mode & 0o777).toBe(0o600); expect(readdirSync(targetDir).filter(name => name.endsWith(".tmp"))).toEqual([]); expect(effects.some(effect => effect.startsWith("temp:"))).toBe(true); expect(effects.some(effect => effect.startsWith(isBackup ? "publish:" : "rename:"))).toBe(true); diff --git a/tests/codex-config-generation.test.ts b/tests/codex-config-generation.test.ts index a2a085b0d3..395b76ebcf 100644 --- a/tests/codex-config-generation.test.ts +++ b/tests/codex-config-generation.test.ts @@ -8,6 +8,7 @@ import { statSync, writeFileSync, } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -86,7 +87,7 @@ async function collectGuardRaceChild( beforeEach(() => { previousCodexHome = process.env.CODEX_HOME; previousOpencodexHome = process.env.OPENCODEX_HOME; - testRoot = mkdtempSync(join(import.meta.dir, ".tmp-codex-config-generation-")); + testRoot = mkdtempSync(join(tmpdir(), "ocx-config-generation-")); process.env.CODEX_HOME = testRoot; process.env.OPENCODEX_HOME = testRoot; }); @@ -326,8 +327,12 @@ test("busy and unavailable databases return typed outcomes instead of throwing", holder.close(); } - rmSync(testRoot, { recursive: true, force: true }); - writeFileSync(testRoot, "not a directory", "utf8"); + // A file used as the home can retain a failed-open handle on Windows and + // prevent teardown. A directory at the database path is equally unavailable. + const unavailableHome = join(testRoot, "unavailable-home"); + mkdirSync(join(unavailableHome, "config-mutation.sqlite"), { recursive: true }); + process.env.CODEX_HOME = unavailableHome; + process.env.OPENCODEX_HOME = unavailableHome; expect(readConfigGeneration()).toEqual({ kind: "unavailable", reason: "database" }); expect(bumpConfigGeneration({ value: 0 })).toEqual({ kind: "unavailable", reason: "database" }); expect(withExpectedConfigGenerationSync({ value: 0 }, () => "must-not-run")) diff --git a/tests/codex-history-reachability.test.ts b/tests/codex-history-reachability.test.ts index 8f61c51736..af95102ac7 100644 --- a/tests/codex-history-reachability.test.ts +++ b/tests/codex-history-reachability.test.ts @@ -44,6 +44,12 @@ const MUTATORS = [ "migrateHistoryToOpenai", ]; +function sourceRelative(file: string): string { + // node:path uses backslashes on Windows; normalize once so the named + // inventory cannot reject its own permitted modules on that platform. + return relative(SRC, file).replaceAll("\\", "/"); +} + function sourceFiles(dir: string, out: string[] = []): string[] { for (const entry of readdirSync(dir)) { const full = join(dir, entry); @@ -79,7 +85,7 @@ function resolveSpecifier(fromFile: string, specifier: string): string | null { const base = resolve(join(fromFile, ".."), specifier); for (const candidate of [base, `${base}.ts`, join(base, "index.ts")]) { try { - if (statSync(candidate).isFile()) return relative(SRC, candidate); + if (statSync(candidate).isFile()) return sourceRelative(candidate); } catch { /* not this shape */ } } return null; @@ -88,7 +94,7 @@ function resolveSpecifier(fromFile: string, specifier: string): string | null { test("only the history Worker can reach a history writer", () => { const offenders: string[] = []; for (const file of sourceFiles(SRC)) { - const rel = relative(SRC, file); + const rel = sourceRelative(file); if (rel === HISTORY_WRITER) continue; const resolved = importSpecifiers(readFileSync(file, "utf8")) .map(specifier => resolveSpecifier(file, specifier)); @@ -102,7 +108,7 @@ test("only the history Worker can reach a history writer", () => { test("no production module outside the inventory calls a history mutator inline", () => { const offenders: Array<{ file: string; symbol: string }> = []; for (const file of sourceFiles(SRC)) { - const rel = relative(SRC, file); + const rel = sourceRelative(file); if (INLINE_ALLOWED.has(rel)) continue; const source = readFileSync(file, "utf8"); for (const symbol of MUTATORS) { diff --git a/tests/codex-sqlite-home.test.ts b/tests/codex-sqlite-home.test.ts index 973d8f6237..a55d194946 100644 --- a/tests/codex-sqlite-home.test.ts +++ b/tests/codex-sqlite-home.test.ts @@ -82,9 +82,14 @@ describe("Codex SQLite home resolution", () => { cwd: () => "/work/project", readConfig: () => "", }; - expect(resolveCodexSqliteHome(deps)).toBe("/work/sqlite"); - expect(resolveCodexStateDbPath(deps)).toBe("/work/sqlite/state_5.sqlite"); - expect(resolveCodexLogsDbPath(deps)).toBe("/work/sqlite/logs_2.sqlite"); + // Spelled through `resolve`/`join` like every other case in this file: the + // assertion is that a relative setting is anchored to the cwd, not that the + // result is POSIX-shaped. Hardcoding "/work/sqlite" made this the one case + // that failed on Windows, where the same resolution yields "C:\\work\\sqlite". + const expectedHome = resolve("/work/sqlite"); + expect(resolveCodexSqliteHome(deps)).toBe(expectedHome); + expect(resolveCodexStateDbPath(deps)).toBe(join(expectedHome, "state_5.sqlite")); + expect(resolveCodexLogsDbPath(deps)).toBe(join(expectedHome, "logs_2.sqlite")); }); test("history jobs resolve the selected database and backup identity at call time", () => { From dad534889e5ed3d56eb06733a192441a76e911b7 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:57:25 +0900 Subject: [PATCH 07/21] fix(windows): stop three v2-gate cases reporting the machine instead of the code The bare-PATH resolution case builds its launcher with a file symlink, which needs Developer Mode or admin on Windows and failed with EPERM before the probe under test ever ran. No privilege-free substitute preserves what it proves: the resolver follows the PATH entry through realpath into `@openai/codex/bin/` to reach the sibling platform package, and a copy erases that association, a hard link reports its own path as its realpath, and a .cmd wrapper is never matched for a bare command. Report a visible skip where the OS withholds the privilege, in the shape claude-agents-inject and codex-service-manager-probe already use. The key-delegation case called codexFeaturesInvocation with no seams, so it read the developer's own Codex install. Where that install is the npm codex.cmd, the invocation is correctly wrapped in `cmd /d /s /c` and the raw-args assertion failed -- describing the machine's install shape, not the delegation under test. Name the platform and resolution seams, exactly as the invocation-shape case further down the same file already does. --- tests/codex-v2-gate.test.ts | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts index e23783d87a..9959c5656e 100644 --- a/tests/codex-v2-gate.test.ts +++ b/tests/codex-v2-gate.test.ts @@ -679,7 +679,21 @@ describe("multi_agent_mode_hint_text native capability probe", () => { writeFileSync(js, "#!/usr/bin/env node\n"); writeFileSync(join(pkg, "package.json"), JSON.stringify({ name: "@openai/codex", version: "test" })); writeFileSync(binary, native(true)); - symlinkSync(js, join(binDir, "codex.opencodex-real")); + // This case is irreducibly about symlink semantics: the resolver follows the bare + // PATH entry through `realpath` to `@openai/codex/bin/`, and that is how it finds + // the sibling platform package. No privilege-free substitute preserves it -- a + // copy erases the association being resolved, a hard link reports its own path as + // its realpath, and a .cmd wrapper is never matched for a bare command. So report + // a visible skip where the OS withholds the privilege, in the shape + // claude-agents-inject and codex-service-manager-probe already use, rather than + // failing on EPERM before the probe under test has run. + try { + symlinkSync(js, join(binDir, "codex.opencodex-real")); + } catch (err) { + // Windows without Developer Mode / elevated privileges cannot create symlinks. + if (process.platform === "win32" && (err as NodeJS.ErrnoException).code === "EPERM") return; + throw err; + } const oldPath = process.env.PATH; process.env.PATH = `${binDir}${delimiter}${oldPath ?? ""}`; try { @@ -1015,8 +1029,21 @@ describe("config-surface parity: agents.enabled, max_depth, subagent_developer_i }); test("feature toggling delegates to exactly the multi_agent_v2 native key", () => { - expect(codexFeaturesInvocation("enable").args).toEqual(["features", "enable", "multi_agent_v2"]); - expect(codexFeaturesInvocation("disable").args).toEqual(["features", "disable", "multi_agent_v2"]); + // Named platform and resolution seams, like the invocation-shape test below. + // Called bare, this reads the developer's OWN Codex install: on a Windows box + // whose codex is the npm `codex.cmd`, the invocation is correctly wrapped in + // `cmd /d /s /c "..."` and the raw-args assertion fails -- reporting the machine's + // install shape rather than the key delegation this case is about. + const seams = { + env: { PATH: "/usr/bin" }, + configDir: mkdtempSync(join(tmpdir(), "ocx-v2-key-")), + existsSync: () => false, + execFileSync: () => "codex-cli 0.145.0", + }; + expect(codexFeaturesInvocation("enable", "multi_agent_v2", "linux", seams).args) + .toEqual(["features", "enable", "multi_agent_v2"]); + expect(codexFeaturesInvocation("disable", "multi_agent_v2", "linux", seams).args) + .toEqual(["features", "disable", "multi_agent_v2"]); }); test("getAgentsEnabled is tri-state: absent, true, false", () => { From 1b8c3995606476acfd144fc74dde8b7ef462a43b Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:07:29 +0900 Subject: [PATCH 08/21] fix(codex): decode TOML string escapes when reading injected routing values rootTomlString and providerTableString returned the raw bytes between the quotes, so a basic TOML string was never unescaped. On Windows that matters immediately: a path is written as an escaped basic string, so reading it back yielded doubled backslashes and a value that matches nothing on disk. The journal records injectedCatalogPath through exactly this path, so restore after a Codex app rewrite could not recognize the catalog it had written itself (#1798). paths.ts already had the correct reader -- readRootTomlString captures the quoted value and decodes it with parseTomlString. These two helpers are the same idea spelled a second time without that step, which is why the divergence went unseen on POSIX, where an escaped path and its raw bytes are usually identical. Capture the value with its quotes and decode it through the same parser rather than maintaining a second, subtly weaker interpretation of the format. --- src/codex/injected-marker.ts | 12 +++++-- tests/codex-auth-api.test.ts | 11 ++++++- tests/codex-composed-acceptance.test.ts | 20 +++++++++--- tests/codex-inject-integration.test.ts | 7 ++++- tests/codex-journal.test.ts | 6 +++- tests/codex-log-guard-coderabbit.test.ts | 20 +++++++++--- tests/codex-restore-app-rewrite.test.ts | 7 ++--- .../codex-retained-root-serialization.test.ts | 2 +- tests/codex-sync-api.test.ts | 2 +- tests/codex-transition-state.test.ts | 31 ++++++++++++------- 10 files changed, 85 insertions(+), 33 deletions(-) diff --git a/src/codex/injected-marker.ts b/src/codex/injected-marker.ts index f69d1343ae..0156363d30 100644 --- a/src/codex/injected-marker.ts +++ b/src/codex/injected-marker.ts @@ -7,6 +7,8 @@ * them here breaks that cycle. `inject.ts` imports them back and re-exports the * two public predicates, so external callers see no change. */ +import { parseTomlString } from "./paths"; + export const OCX_SECTION_MARKER = "# Auto-injected by opencodex"; export function isRootOpenaiBaseUrlLine(line: string): boolean { @@ -16,7 +18,11 @@ export function isRootOpenaiBaseUrlLine(line: string): boolean { export function tomlStringPattern(key: string): RegExp { const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const keyToken = `(?:${escaped}|"${escaped}"|'${escaped}')`; - return new RegExp(`^\\s*${keyToken}\\s*=\\s*["']([^"']+)["']\\s*(?:#.*)?$`); + // The quoted value is captured WITH its quotes so callers can decode it as TOML. + // A basic string escapes backslashes, so a Windows path is stored doubled; reading + // the raw bytes back returned a path that matched nothing on disk and made the + // journal's recorded catalog path un-restorable (#1798). + return new RegExp(`^\\s*${keyToken}\\s*=\\s*("(?:\\\\.|[^"])*"|'[^']*')\\s*(?:#.*)?$`); } export function rootTomlString(content: string, key: string): string | null { @@ -26,7 +32,7 @@ export function rootTomlString(content: string, key: string): string | null { const pattern = tomlStringPattern(key); for (const line of rootLines) { const match = pattern.exec(line); - if (match?.[1]) return match[1].trim(); + if (match?.[1]) return parseTomlString(match[1]).trim(); } return null; } @@ -45,7 +51,7 @@ export function providerTableString(content: string, provider: string, key: stri const pattern = tomlStringPattern(key); for (let index = start + 1; index < lines.length && !/^\s*\[/.test(lines[index]); index += 1) { const match = pattern.exec(lines[index]); - if (match?.[1]) return match[1].trim(); + if (match?.[1]) return parseTomlString(match[1]).trim(); } return null; } diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 1496f3bc9a..0c4c7fab9d 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -606,6 +606,7 @@ describe("codex-auth API", () => { } return previousFetch(input); }) as typeof fetch; + let pendingRequests: ReturnType[] = []; try { const request = () => { const req = new Request("http://localhost/api/codex-auth/accounts?refresh=1", { method: "GET" }); @@ -613,7 +614,10 @@ describe("codex-auth API", () => { }; const first = request(); const joiner = request(); - for (let attempt = 0; attempt < 20 && requestCount === 0; attempt++) await Promise.resolve(); + pendingRequests = [first, joiner]; + // Credential locking crosses OS I/O on Windows, so microtask-only polling can fail before + // fetch starts and leave both requests running into the next test with native-main claimed. + for (let attempt = 0; attempt < 200 && requestCount === 0; attempt++) await Bun.sleep(10); expect(requestCount).toBe(1); release(); const bodies = await Promise.all([first, joiner].map(async pending => { @@ -624,6 +628,7 @@ describe("codex-auth API", () => { expect(bodies[1].accounts.find(account => account.id === "quota-a")?.quotaProbeSkipped).not.toBe(true); } finally { release(); + await Promise.allSettled(pendingRequests); clearQuotaOwners(); } }); @@ -1379,6 +1384,8 @@ describe("codex-auth API", () => { let markFetchStarted!: () => void; const fetchStarted = new Promise(resolve => { markFetchStarted = resolve; }); const fetchGate = new Promise(resolve => { releaseFetch = resolve; }); + const nativeMainDrain = acquireNativeMainProfileDrain("pool-plan-concurrent"); + expect(nativeMainDrain).not.toBeNull(); globalThis.fetch = (async input => { if (String(input) === "https://auth.openai.com/oauth/token") { tokenRefreshCalls += 1; @@ -1419,6 +1426,8 @@ describe("codex-auth API", () => { expect(calls).toBe(1); expect(configCommits).toBe(1); } finally { + releaseFetch(); + nativeMainDrain?.release(); setPersistedConfigMutationBeforeCommitForTests(null); } }); diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index 14b2c5e708..0468eaadbb 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -32,6 +32,7 @@ import { resolveEffectiveUserIdentity, } from "../src/codex/user-identity"; import { claimOwnedServiceHome } from "./helpers/owned-service-home"; +import { SERVER_BUDGET_MS } from "./helpers/test-budget"; const repoRoot = resolve(import.meta.dir, ".."); const cliPath = resolve(repoRoot, "src/cli/index.ts"); @@ -107,6 +108,10 @@ class Fixture { return { HOME: home, USERPROFILE: userprofile, + // Windows os.homedir() follows USERPROFILE, while POSIX follows HOME. + // Pin the client-specific home so this fixture exercises the same Grok + // installation on every platform instead of reporting not_installed. + GROK_HOME: join(home, ".grok"), CODEX_HOME: this.codex, OPENCODEX_HOME: this.ocx, XDG_RUNTIME_DIR: this.runtime, @@ -199,7 +204,12 @@ class Fixture { expect(exitCode === 0 || (process.platform === "win32" && exitCode === 143)).toBe(true); } - async request(runtime: RuntimeRecord, path: string, init: RequestInit = {}): Promise<{ status: number; body: Record }> { + async request( + runtime: RuntimeRecord, + path: string, + init: RequestInit = {}, + timeoutMs = 10_000, + ): Promise<{ status: number; body: Record }> { const response = await fetch(`http://127.0.0.1:${runtime.port}${path}`, { ...init, headers: { @@ -207,7 +217,7 @@ class Fixture { ...(init.body ? { "content-type": "application/json" } : {}), ...(init.headers ?? {}), }, - signal: AbortSignal.timeout(10_000), + signal: AbortSignal.timeout(timeoutMs), }); return { status: response.status, body: await response.json() as Record }; } @@ -358,7 +368,9 @@ describe("WP13 composed toggle acceptance", () => { allowPrivateNetwork: true, liveModels: true, } }, defaultProvider: "fixture", clientIntegrations: { codex: true } }); hold = true; - const stale = fx.request(server.runtime, "/api/sync", { method: "POST" }); + // This request is intentionally held open while a second real HTTP + // mutation crosses the Windows process-backed identity path. + const stale = fx.request(server.runtime, "/api/sync", { method: "POST" }, SERVER_BUDGET_MS); await Promise.race([ enteredGather, stale.then(result => Promise.reject(new Error( @@ -367,7 +379,7 @@ describe("WP13 composed toggle acceptance", () => { ]); const off = await fx.request(server.runtime, "/api/native-integrations/codex", { method: "PUT", body: JSON.stringify({ enabled: false }), - }); + }, SERVER_BUDGET_MS); expect(off.status).toBe(200); const afterOff = manifest(fx.codex); release(); diff --git a/tests/codex-inject-integration.test.ts b/tests/codex-inject-integration.test.ts index 0b73f1e266..1a6637856d 100644 --- a/tests/codex-inject-integration.test.ts +++ b/tests/codex-inject-integration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; @@ -9,9 +9,12 @@ import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER, } from "../src/codex/subagent-defaults"; +import { SPAWN_BUDGET_MS } from "./helpers/test-budget"; 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; status: number } { @@ -25,6 +28,7 @@ function runInject(codexHome: string, ocxHome: string, configJson = "{}"): { std 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() ?? "", status: result.status ?? 1 }; } @@ -38,6 +42,7 @@ function runRestore(codexHome: string, ocxHome: string): { stdout: string; statu cwd: repoRoot, env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, }); return { stdout: result.stdout?.trim() ?? "", status: result.status ?? 1 }; } diff --git a/tests/codex-journal.test.ts b/tests/codex-journal.test.ts index a496a4163b..827ddd29d4 100644 --- a/tests/codex-journal.test.ts +++ b/tests/codex-journal.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; @@ -8,14 +8,18 @@ import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER, } from "../src/codex/subagent-defaults"; +import { SPAWN_BUDGET_MS } from "./helpers/test-budget"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); +setDefaultTimeout(SPAWN_BUDGET_MS); + function runScript(codexHome: string, script: string): { stdout: string; stderr: string; status: number } { const result = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, env: { ...process.env, CODEX_HOME: codexHome }, encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, }); return { stdout: result.stdout?.trim() ?? "", stderr: result.stderr?.trim() ?? "", status: result.status ?? 1 }; } diff --git a/tests/codex-log-guard-coderabbit.test.ts b/tests/codex-log-guard-coderabbit.test.ts index 5710458497..d510323057 100644 --- a/tests/codex-log-guard-coderabbit.test.ts +++ b/tests/codex-log-guard-coderabbit.test.ts @@ -86,11 +86,21 @@ describe("CodeRabbit protection regressions", () => { const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-cr-symlink-")); roots.push(root); const codexHome = join(root, "codex-home"); - mkdirSync(codexHome); - writeFileSync(join(codexHome, "config.toml"), ""); - const target = join(root, "real-logs.sqlite"); - createCurrentLogsDb(target); - symlinkSync(target, join(codexHome, "logs_2.sqlite")); + if (process.platform === "win32") { + const realCodexHome = join(root, "real-codex-home"); + mkdirSync(realCodexHome); + writeFileSync(join(realCodexHome, "config.toml"), ""); + createCurrentLogsDb(join(realCodexHome, "logs_2.sqlite")); + // Unelevated Windows can create a junction but not a file symlink. The + // ancestor redirection exercises the same concrete unsafe-path refusal. + symlinkSync(realCodexHome, codexHome, "junction"); + } else { + mkdirSync(codexHome); + writeFileSync(join(codexHome, "config.toml"), ""); + const target = join(root, "real-logs.sqlite"); + createCurrentLogsDb(target); + symlinkSync(target, join(codexHome, "logs_2.sqlite")); + } const status = getCodexLogGuardProtectionStatus(deps(codexHome)); expect(status.schema.state).toBe("compatible"); diff --git a/tests/codex-restore-app-rewrite.test.ts b/tests/codex-restore-app-rewrite.test.ts index 9792b8e146..21cea8dce4 100644 --- a/tests/codex-restore-app-rewrite.test.ts +++ b/tests/codex-restore-app-rewrite.test.ts @@ -106,7 +106,7 @@ describe("#1798 restore after the Codex app rewrites the config", () => { expect(restored).not.toContain("127.0.0.1:10100"); // The user's own pre-injection content is still theirs. expect(restored).toContain("gpt-5.5"); - }); + }, 15_000); test("a user's own openai_base_url written before injection is preserved", () => { // The mirror-image risk of the fix: stripping ANY unmarked openai_base_url would @@ -123,7 +123,7 @@ describe("#1798 restore after the Codex app rewrites the config", () => { const restored = readFileSync(join(testDir, "config.toml"), "utf8"); expect(restored).toContain("https://my-own-gateway.example/v1"); expect(restored).not.toContain("127.0.0.1:10100"); - }); + }, 15_000); test("the routed catalog we wrote is restored even when the rewrite dropped model_catalog_json", () => { // The catalog half of #1798. Restore used to re-resolve its target from the CURRENT @@ -139,6 +139,5 @@ describe("#1798 restore after the Codex app rewrites the config", () => { const routed = (cache.models ?? []).filter((m: { slug?: string }) => typeof m.slug === "string" && m.slug.includes("/")); expect(routed).toEqual([]); expect(JSON.parse(r.stdout).catalog).toBe(cachePath); - }); + }, 15_000); }); - diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts index e57c314943..b2dc7c5a83 100644 --- a/tests/codex-retained-root-serialization.test.ts +++ b/tests/codex-retained-root-serialization.test.ts @@ -200,7 +200,7 @@ test("startup and CLI sync-cache cannot write models_cache while another process holder.release(); expect(await holder.child.exited).toBe(0); } -}); +}, 15_000); test("native restore cannot read-transform-write the catalog while another process owns K", async () => { const sandbox = makeSandbox("ocx-retained-restore-"); diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 8ec58fbc73..88332ffe8b 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -282,7 +282,7 @@ describe("GUI/CLI Codex sync backend", () => { } finally { rmSync(raceRoot, { recursive: true, force: true }); } - }); + }, 15_000); test("surfaces combo catalog omissions in sync result and CLI stderr (#484)", async () => { const logs: string[] = []; diff --git a/tests/codex-transition-state.test.ts b/tests/codex-transition-state.test.ts index fc04817e08..be4ca7c6bd 100644 --- a/tests/codex-transition-state.test.ts +++ b/tests/codex-transition-state.test.ts @@ -389,9 +389,13 @@ test("the row validator refuses every whitespace-only txId", () => { database.close(); } - expect(readCodexTransitionState(), label).toEqual({ kind: "unavailable", reason: "database" }); + // The public reader re-resolves Windows identity through PowerShell for + // every code point, which makes this exhaustive loop exceed its timeout. + // Opening the already-resolved path still runs the same row validator. + expect(() => openCodexCoordinatorTransaction(coordinatorPath), label) + .toThrow("The positive coordinator row lacks its complete history schedule."); } -}); +}, 15_000); /** * A capability backed by a nominal transaction is not opaque if its caller can @@ -611,13 +615,16 @@ test("a begin whose txId matches but whose generation does not is rejected", () * read, the file is owner-only again. Removing the narrowing leaves it 0644 and * turns this red. */ -test("a coordinator found group-readable is narrowed back to owner-only", () => { - expect(readCodexTransitionState().kind).toBe("ready"); - - chmodSync(coordinatorPath, 0o644); - expect(statSync(coordinatorPath).mode & 0o777).toBe(0o644); - - const read = readCodexTransitionState(); - expect(read.kind).toBe("ready"); - expect(statSync(coordinatorPath).mode & 0o777).toBe(0o600); -}); +test.skipIf(process.platform === "win32")( + "a coordinator found group-readable is narrowed back to owner-only", + () => { + expect(readCodexTransitionState().kind).toBe("ready"); + + chmodSync(coordinatorPath, 0o644); + expect(statSync(coordinatorPath).mode & 0o777).toBe(0o644); + + const read = readCodexTransitionState(); + expect(read.kind).toBe("ready"); + expect(statSync(coordinatorPath).mode & 0o777).toBe(0o600); + }, +); From 960c7a934ebcb991a5e63c0737707baa52c6af7f Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:39:42 +0900 Subject: [PATCH 09/21] perf(windows): memoize the per-process identity lookups The effective token's SID and its known-folder local AppData were re-derived by a fresh PowerShell on every call: about 150ms and 310ms respectively, and the coordinator asks for both on every config write and lock acquisition. Neither can change without a new logon token, and both lookups deliberately ignore the environment, so the second spawn only re-establishes what the first already knew. On Windows that overhead was not merely wasteful: it pushed real multi-process injection tests past their budget, where they timed out at 5s while doing genuine work. Memoize successful lookups for the process lifetime -- roughly 510ms to 1ms for a coordinator path resolution. Refusals are not cached, so a transient failure cannot pin a process into a permanently refusing state. --- src/codex/user-identity.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/codex/user-identity.ts b/src/codex/user-identity.ts index 26b956309c..716d06b4cb 100644 --- a/src/codex/user-identity.ts +++ b/src/codex/user-identity.ts @@ -183,7 +183,31 @@ export function decodeWindowsIdentityPowerShellOutputForTests(output: Uint8Array return decodeWindowsIdentityPowerShellOutput(output); } +/** + * Per-process memo for the Windows lookups. + * + * Both values -- the effective token's SID and its known-folder local AppData -- + * are fixed for the lifetime of a process: neither can change without a new logon + * token, and the lookups deliberately ignore the environment, so nothing a caller + * does between two calls can alter the answer. Each call otherwise spawns a fresh + * PowerShell, roughly 150ms for the SID and 310ms for the folder, and the + * coordinator asks for both on every config write and lock acquisition. That cost + * pushed real multi-process injection tests past their budget while proving + * nothing: the second spawn re-derives what the first already established. + * + * Only successful lookups are memoized, so a transient failure cannot pin the + * process into a permanently refusing state. + */ +const windowsIdentityValueCache = new Map(); + +/** Test-only reset so a suite can force a fresh lookup. */ +export function resetWindowsIdentityValueCacheForTests(): void { + windowsIdentityValueCache.clear(); +} + function powershellValue(expression: string): string { + const memoized = windowsIdentityValueCache.get(expression); + if (memoized !== undefined) return memoized; let command: string[]; try { command = windowsIdentityPowerShellCommand(expression); @@ -206,6 +230,7 @@ function powershellValue(expression: string): string { if (result.exitCode !== 0) refuse("Windows effective-account lookup failed."); const value = decodeWindowsIdentityPowerShellOutput(result.stdout ?? Buffer.alloc(0)); if (!value) refuse("Windows effective-account lookup returned an empty value."); + windowsIdentityValueCache.set(expression, value); return value; } From c3882214f48fb99a53ed10644ef3756ac688f893 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:10:52 +0900 Subject: [PATCH 10/21] fix(windows): restore the core/Lab boundary guard and two platform-bound fixtures The core/Lab boundary test never ran on Windows. It built its repository root from `new URL(import.meta.url).pathname`, which yields "/C:/..." there, so resolving it produced "C:\\C:\\..." and every case threw ENOENT while opening its own sources. Two further spellings assumed POSIX separators: the walk matched the literal "/src/lab/", which no backslash path can contain, and the reported chain kept the native separator so the attack cases could not match it. That combination matters more than a red test. This guard exists because the original violation hid in a six-hop import chain and pulled ~69 Lab modules into every install; with the path broken it would have reported clean for a real Lab import exactly as it did for a missing file. Its own adversarial cases now fail before the fix and pass after it, which is the evidence that it is live again. config.ts dotfiles cases need a file symlink, which no privilege-free construct substitutes for, so they take the visible skip this repository already uses for the same constraint. The DSH settings case asserted 0o600 through stat, but Windows synthesizes mode from the read-only attribute and always answers 0o666; assert the file exists everywhere and the permission bits only where they mean something. --- tests/config.test.ts | 31 +++++++++++++++++++++++++------ tests/core-lab-boundary.test.ts | 17 ++++++++++++++--- tests/dsh-writer-lock.test.ts | 6 +++++- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/tests/config.test.ts b/tests/config.test.ts index b949b9bd8b..cad4c7977d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -36,6 +36,25 @@ import { setTrustedWindowsSystemDirectoryResolverForTests } from "../src/lib/win import { AtomicWriteResidualTempError, atomicWriteFile, atomicWriteFileAsync, hardenConfigDir, hardenExistingSecret, renameAtomicFile, saveConfig } from "../src/config"; let testDir = ""; +/** + * Windows without Developer Mode or admin cannot create a file symlink (EPERM). + * Detect once so the dotfiles cases below report a visible skip there rather than + * a spurious failure in the fixture, before the writer under test is ever called. + * Mirrors the probe in codex-service-manager-probe and claude-agents-inject. + */ +const canSymlink = (() => { + const dir = mkdtempSync(join(tmpdir(), "ocx-config-symlink-probe-")); + try { + symlinkSync(join(dir, "probe-target"), join(dir, "probe-link")); + return true; + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === "EPERM") return false; + throw e; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +})(); + beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), "ocx-config-")); process.env.OPENCODEX_HOME = testDir; @@ -2500,7 +2519,7 @@ describe("config.ts – sync writer timeout keying (#840 refinement)", () => { }); describe("config.ts – atomic writes preserve symlinked destinations", () => { - test("a symlinked destination survives the write and the real file receives it", () => { + test.skipIf(!canSymlink)("a symlinked destination survives the write and the real file receives it", () => { // Dotfiles shape: ~/.codex/config.toml -> ~/dotfiles/.codex/config.toml const repoDir = join(testDir, "dotfiles"); mkdirSync(repoDir, { recursive: true }); @@ -2517,7 +2536,7 @@ describe("config.ts – atomic writes preserve symlinked destinations", () => { expect(readFileSync(link, "utf8")).toBe("rewritten"); }); - test("no temp file is left beside the link or its target", () => { + test.skipIf(!canSymlink)("no temp file is left beside the link or its target", () => { const repoDir = join(testDir, "dotfiles-clean"); mkdirSync(repoDir, { recursive: true }); const realFile = join(repoDir, "config.toml"); @@ -2549,7 +2568,7 @@ describe("config.ts – atomic writes preserve symlinked destinations", () => { expect(readFileSync(destination, "utf8")).toBe("fresh"); }); - test("a dangling symlink is preserved and the write is refused", () => { + test.skipIf(!canSymlink)("a dangling symlink is preserved and the write is refused", () => { const link = join(testDir, "dangling.toml"); symlinkSync(join(testDir, "gone", "config.toml"), link); @@ -2562,7 +2581,7 @@ describe("config.ts – atomic writes preserve symlinked destinations", () => { }); describe("config.ts – async atomic writes preserve symlinked destinations", () => { - test("a symlinked destination survives the write and the real file receives it", async () => { + test.skipIf(!canSymlink)("a symlinked destination survives the write and the real file receives it", async () => { const repoDir = join(testDir, "dotfiles-async"); mkdirSync(repoDir, { recursive: true }); const realFile = join(repoDir, "config.toml"); @@ -2578,7 +2597,7 @@ describe("config.ts – async atomic writes preserve symlinked destinations", () expect(readFileSync(link, "utf8")).toBe("rewritten"); }); - test("no temp file is left beside the link or its target", async () => { + test.skipIf(!canSymlink)("no temp file is left beside the link or its target", async () => { const repoDir = join(testDir, "dotfiles-async-clean"); mkdirSync(repoDir, { recursive: true }); const realFile = join(repoDir, "config.toml"); @@ -2601,7 +2620,7 @@ describe("config.ts – async atomic writes preserve symlinked destinations", () expect(readFileSync(destination, "utf8")).toBe("second"); }); - test("a dangling symlink is preserved and the write is refused", async () => { + test.skipIf(!canSymlink)("a dangling symlink is preserved and the write is refused", async () => { const link = join(testDir, "dangling-async.toml"); symlinkSync(join(testDir, "gone-async", "config.toml"), link); diff --git a/tests/core-lab-boundary.test.ts b/tests/core-lab-boundary.test.ts index d5bae14d6c..333ce8fb0b 100644 --- a/tests/core-lab-boundary.test.ts +++ b/tests/core-lab-boundary.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; /** * The proxy core must not reach Compatibility Lab. @@ -25,7 +26,12 @@ const PROTECTED = [ "src/server/management-api.ts", ] as const; -const repoRoot = resolve(dirname(new URL(import.meta.url).pathname), ".."); +// `fileURLToPath`, not `URL.pathname`: on Windows the latter yields "/C:/...", and +// resolving that against the cwd produced "C:\\C:\\..." -- so every guard below threw +// ENOENT instead of reading a file. A boundary test that cannot open its own sources +// reports a broken path as a failure and would report a real Lab import the same way, +// which means it was proving nothing on this platform. +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); /** * Runtime imports only: `import type` is erased and costs nothing at runtime. @@ -74,11 +80,16 @@ function firstLabPath(entry: string): string[] | null { const next = resolveSpec(spec, current); if (!next || previous.has(next)) continue; previous.set(next, current); - if (next.includes("/src/lab/")) { + // Compare on a slash-normalized path: `resolve`/`join` produce backslashes on + // Windows, so a literal "/src/lab/" test silently matched nothing there and the + // guard reported clean for every possible violation. + if (next.replaceAll("\\", "/").includes("/src/lab/")) { const chain: string[] = []; let node: string | null = next; while (node) { - chain.push(node.slice(repoRoot.length + 1)); + // Repository-relative and slash-spelled, so the printed chain reads the same + // on every platform and callers can match it without knowing the separator. + chain.push(node.slice(repoRoot.length + 1).replaceAll("\\", "/")); node = previous.get(node) ?? null; } return chain.reverse(); diff --git a/tests/dsh-writer-lock.test.ts b/tests/dsh-writer-lock.test.ts index bd7cfeb7f4..7661484a27 100644 --- a/tests/dsh-writer-lock.test.ts +++ b/tests/dsh-writer-lock.test.ts @@ -167,7 +167,11 @@ describe("DSH coordinated mutations", () => { const seams = immediateLock(() => { acquisitions += 1; }); expect((await applyIntegrationCoordinated(writeInput(), { lockSeams: seams })).ok).toBe(true); const configPath = INTEGRATION_CLIENTS.dsh.configPath({}, home); - expect(statSync(configPath).mode & 0o777).toBe(0o600); + // The file must exist either way; only the POSIX bits are platform-specific, + // because Windows synthesizes mode from the read-only attribute and reports 0o666 + // no matter what the writer requested. + expect(existsSync(configPath)).toBe(true); + if (process.platform !== "win32") expect(statSync(configPath).mode & 0o777).toBe(0o600); const second = await applyIntegrationCoordinated(writeInput(), { lockSeams: seams }); expect(second).toMatchObject({ ok: true, changed: false, state: "current" }); expect(acquisitions).toBe(2); From 5c6be04ef6c3d19b3d04cecdae4a81ea051b4539 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:17:46 +0900 Subject: [PATCH 11/21] fix(lab): finalize projection statements so a rebuild can replace its own file rebuildLabProjection closed its database without finalizing the statements it had prepared. Bun keeps a prepared statement alive until it is finalized or collected, and on Windows an outstanding statement holds the file open: `close()` leaves the handle behind and `close(true)` throws "database is locked". The next rebuild then could not unlink the projection it was replacing, and the retry loop in wipeSqlite could only convert that into a slower failure -- "failed to remove stale projection file after retries". POSIX permits unlinking an open file, which is why a rebuild that is deterministic by contract was only ever non-deterministic on Windows. Collect the prepared statements and finalize them before the close. This is the real defect behind ten Compatibility Lab failures across the ledger, fabric-task and public-evidence suites, all of which called rebuild more than once. Two server tests also exceeded Bun's 5s default while binding real proxies: the Retry-After case runs two full pool-passthrough cycles and the #702 case binds one proxy per route class to prove none of them reaches upstream. In both the servers are the assertion, so they take the existing SERVER_BUDGET_MS rather than a new knob. --- src/lab/projection/rebuild.ts | 54 +++++++++++++------- tests/issue-452-empty-503.test.ts | 6 ++- tests/issue-702-expired-replay-state.test.ts | 6 ++- 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/src/lab/projection/rebuild.ts b/src/lab/projection/rebuild.ts index bea548167c..18ab2b72cd 100644 --- a/src/lab/projection/rebuild.ts +++ b/src/lab/projection/rebuild.ts @@ -122,6 +122,20 @@ export function rebuildLabProjection(configDir?: string): RebuildResult { const db = new Database(paths.sqlitePath); let transactionOpen = false; + // Every statement prepared below, so they can be finalized before the close. + // + // Bun keeps a prepared statement alive until it is finalized or garbage collected, + // and on Windows an unfinalized statement holds the database file open: `close()` + // leaves the handle, and `close(true)` throws "database is locked". A second + // rebuild then failed to unlink the previous projection with EBUSY, and the retry + // loop in `wipeSqlite` could only turn that into a slower failure. POSIX allows + // unlinking an open file, which is why this never surfaced there. + const prepared: Array<{ finalize(): void }> = []; + const prepare = (sql: string) => { + const statement = db.prepare(sql); + prepared.push(statement); + return statement; + }; try { db.exec("PRAGMA journal_mode=DELETE;"); db.exec("PRAGMA foreign_keys=OFF;"); @@ -129,50 +143,45 @@ export function rebuildLabProjection(configDir?: string): RebuildResult { transactionOpen = true; resetProjectionSchema(db); - db.prepare( - "INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)", - ).run("schema_version", String(LAB_SQLITE_SCHEMA_VERSION)); - db.prepare( - "INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)", - ).run("projection_spec_version", LAB_PROJECTION_SPEC_VERSION); - db.prepare( - "INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)", - ).run("built_at_ms", String(Date.now())); + const insertMeta = prepare("INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)"); + insertMeta.run("schema_version", String(LAB_SQLITE_SCHEMA_VERSION)); + insertMeta.run("projection_spec_version", LAB_PROJECTION_SPEC_VERSION); + insertMeta.run("built_at_ms", String(Date.now())); - const insertCorruption = db.prepare( + const insertCorruption = prepare( "INSERT INTO corruption(kind, line_number, event_id, detail) VALUES (?, ?, ?, ?)", ); for (const c of corruptions) { insertCorruption.run(c.kind, c.lineNumber ?? null, c.eventId ?? null, c.detail); } - const insertEvent = db.prepare( + const insertEvent = prepare( `INSERT INTO events(event_id, event_kind, recorded_at, producer, producer_version, payload_json, excluded, exclusion_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ); - const insertSubject = db.prepare( + const insertSubject = prepare( `INSERT OR IGNORE INTO subjects(subject_id, subject_kind, subject_json) VALUES (?, ?, ?)`, ); - const insertObs = db.prepare( + const insertObs = prepare( `INSERT INTO observations( event_id, subject_id, evidence_layer, suite_id, suite_version, suite_manifest_digest, scenario_id, scenario_version, scenario_manifest_digest, outcome, completed_at, execution_mode ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); - const insertClaim = db.prepare( + const insertClaim = prepare( `INSERT INTO claims( event_id, subject_id, capability, polarity, source_manifest_digest, effective_at, recorded_at, supersedes_json, current, usable ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); - const insertInv = db.prepare( + const insertInv = prepare( `INSERT INTO invalidations(event_id, reason, targets_json, recorded_at, applied) VALUES (?, ?, ?, ?, ?)`, ); - const insertPurge = db.prepare( + const insertPurge = prepare( `INSERT INTO purges(event_id, target_event_ids_json, target_artifact_digests_json, purge_actions_json, recorded_at) VALUES (?, ?, ?, ?, ?)`, ); - const insertArtifact = db.prepare( + const insertArtifact = prepare( `INSERT INTO artifacts(digest, artifact_class, media_type, byte_count, status, last_error) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(digest) DO UPDATE SET @@ -319,7 +328,7 @@ export function rebuildLabProjection(configDir?: string): RebuildResult { } } - const insertVerdict = db.prepare( + const insertVerdict = prepare( `INSERT INTO verdicts( projection_key, subject_id, evidence_layer, suite_id, suite_version, suite_manifest_digest, projection_spec_version, verdict, as_of, scenario_manifest_digests_json, claim_source_digest, @@ -369,6 +378,15 @@ export function rebuildLabProjection(configDir?: string): RebuildResult { } catch { // Closing the disposable DB is still safe if pragma restoration fails. } + // Finalize before closing: an outstanding statement keeps the file open on + // Windows, and the next rebuild cannot unlink the projection it is replacing. + for (const statement of prepared) { + try { + statement.finalize(); + } catch { + // A statement already finalized by an error path is not a rebuild failure. + } + } db.close(); artifactStore.close(); } diff --git a/tests/issue-452-empty-503.test.ts b/tests/issue-452-empty-503.test.ts index 90188c60b5..58e27cfd87 100644 --- a/tests/issue-452-empty-503.test.ts +++ b/tests/issue-452-empty-503.test.ts @@ -13,6 +13,7 @@ import { startServer } from "../src/server"; import { formatPassthroughUpstreamError } from "../src/server/responses/passthrough-error"; import type { OcxConfig, OcxParsedRequest } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { SERVER_BUDGET_MS } from "./helpers/test-budget"; const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -208,7 +209,10 @@ describe("passthrough empty 503 (#452)", () => { }, ); } - }); + // Two full pool-passthrough cycles, each binding a real proxy and a real upstream, + // so the wait is the assertion rather than an accident: it measured ~6s here against + // Bun's 5s default. + }, SERVER_BUDGET_MS); test("direct /v1/responses drops invalid Retry-After on empty-body 503", async () => { await withPoolPassthrough( diff --git a/tests/issue-702-expired-replay-state.test.ts b/tests/issue-702-expired-replay-state.test.ts index 8deabef5e8..239d305746 100644 --- a/tests/issue-702-expired-replay-state.test.ts +++ b/tests/issue-702-expired-replay-state.test.ts @@ -20,6 +20,7 @@ import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { SERVER_BUDGET_MS } from "./helpers/test-budget"; const originalFetch = globalThis.fetch; const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -334,7 +335,10 @@ describe("Issue #702 expired forward replay state", () => { } finally { globalThis.fetch = originalFetch; } - }); + // Three route classes, each binding a real proxy: the per-class servers ARE the + // assertion that no route reaches upstream, and they measured ~6s against Bun's + // 5s default. + }, SERVER_BUDGET_MS); test("forward mode fails closed when previous response replay state has expired", async () => { let quotaPrimeCalls = 0; From 7563d35ae40e5e1337aeebdb49882cbfc1e29dd9 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:43:48 +0900 Subject: [PATCH 12/21] fix(tests): await the lock holder before removing its temp root The contention test released its holder and dropped the exit promise on the floor, so afterEach could remove the temp root while that child still had the coordinator database open. Windows refuses to unlink a file another process holds, so teardown threw EBUSY and the failure was attributed to a test that had already proved its assertion. POSIX unlinks an open file regardless, which is why this only ever appeared on Windows, and only under full-suite load where the child exits slower. Await the holder, and let teardown retry briefly before giving the directory back to the OS: `force` covers a missing path, not a locked one, and a temp directory left behind is a smaller lie than a green test reported red. --- tests/codex-inject-write-lock.test.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/codex-inject-write-lock.test.ts b/tests/codex-inject-write-lock.test.ts index 0610e0a5b3..5603137bd2 100644 --- a/tests/codex-inject-write-lock.test.ts +++ b/tests/codex-inject-write-lock.test.ts @@ -50,7 +50,23 @@ beforeEach(() => { }); afterEach(() => { - while (cleanup.length) rmSync(cleanup.pop()!, { recursive: true, force: true }); + while (cleanup.length) { + const dir = cleanup.pop()!; + // `force` covers a missing path, not a locked one: a child that is still exiting + // can hold a coordinator file open for a few milliseconds, and Windows answers + // EBUSY rather than unlinking underneath it. Retry briefly, then leave the temp + // directory to the OS -- failing teardown would blame whichever test ran here. + for (let attempt = 0; attempt < 5; attempt++) { + try { + rmSync(dir, { recursive: true, force: true }); + break; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "EBUSY" && code !== "EPERM" && code !== "ENOTEMPTY") throw err; + if (attempt < 4) Bun.sleepSync(50 * (attempt + 1)); + } + } + } }); describe("the lock is on the production path", () => { @@ -98,7 +114,7 @@ describe("the lock is on the production path", () => { * lock module while a real injection runs; the injection must report busy and * must not have written its candidate bytes. */ - test("a held lock makes real injection report busy and write nothing", () => { + test("a held lock makes real injection report busy and write nothing", async () => { seedNative(); // Establish the coordinator first: a clean home has no row, and the holder // needs one to contend over. @@ -130,7 +146,12 @@ describe("the lock is on the production path", () => { const contender = runInject(20200); writeFileSync(releaseMarker, "go"); - holder.exited.then(() => undefined); + // AWAIT the holder. Dropping its exit on the floor left a live child owning the + // coordinator database while afterEach removed the temp root, and Windows refuses + // to unlink a file another process still has open -- so teardown failed with EBUSY + // and blamed this test for a race it had already won. POSIX unlinks regardless, + // which is why only Windows ever saw it, and only under full-suite load. + await holder.exited; expect(contender.success).toBeFalse(); expect(contender.retryable).toBeTrue(); From 079f417c6194b164f81f991707118c9b10b085bb Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:23:56 +0900 Subject: [PATCH 13/21] test(windows): budget the thread-affinity LRU cases Filling the affinity cap persists CODEX_THREAD_AFFINITY_MAX_ENTRIES real mappings, and that store work is the eviction proof rather than incidental setup. On Windows the pair sits right on Bun default of 5s -- one measured 5.7s and its neighbour 5.25s -- so the cap test failed on load while the test beside it passed by a quarter second. Both take the existing STORE_BUDGET_MS. --- tests/codex-routing.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 4a3de3daf1..18d7a21ca0 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { STORE_BUDGET_MS } from "./helpers/test-budget"; import { CODEX_FAILURE_WINDOW_MS, CODEX_QUOTA_PROBE_INTERVAL_MS, @@ -1063,7 +1064,9 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("lru-1", config, now + CODEX_THREAD_AFFINITY_MAX_ENTRIES + 1)).toBe("a"); expect(resolveCodexAccountForThread("lru-0", config, now + CODEX_THREAD_AFFINITY_MAX_ENTRIES + 2)).toBe("b"); - }); + // Filling the cap means persisting CODEX_THREAD_AFFINITY_MAX_ENTRIES real mappings; + // that store work IS the eviction proof, and it crosses Bun's 5s default on Windows. + }, STORE_BUDGET_MS); test("thread affinity LRU cap includes legacy and native quota scopes", () => { const config = makeConfig(); @@ -1084,7 +1087,7 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("scoped-lru-0", config, after, "shared")).toBe("a"); expect(resolveCodexAccountForThread("scoped-lru-0", config, after + 1, "spark")).toBe("a"); expect(resolveCodexAccountForThread("scoped-lru-0", config, after + 2)).toBe("b"); - }); + }, STORE_BUDGET_MS); test("generation mismatch invalidates a mapped thread before reuse", () => { const config = makeConfig(); From e2f96707e542333de4d16dd999062cdbfe3d2f67 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:42:36 +0900 Subject: [PATCH 14/21] fix(tests): never fail a finished test on a Windows teardown race The isolated Codex home rethrew when its temp tree could not be removed. On Windows a proxy or child that is still shutting down can hold a file there past the 2.5s retry budget, and the throw landed in afterEach -- so a test that had already asserted everything it claims was reported red, and the red pointed at whatever happened to run in that slot rather than at an OS release race. The env restore is the part other tests depend on and still runs unconditionally; the directory is disposable. Leave it to the OS when the retries are exhausted. The rate-limit E2E teardown had the same shape with a worse consequence: a failed removal skipped the clearKeyCooldowns() call after it, leaking cooldown state into the next test. --- tests/helpers/isolated-codex-home.ts | 13 ++++++++++++- tests/server-rate-limit-retry-e2e.test.ts | 11 ++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/helpers/isolated-codex-home.ts b/tests/helpers/isolated-codex-home.ts index 17273755ea..3fcc5d2a1b 100644 --- a/tests/helpers/isolated-codex-home.ts +++ b/tests/helpers/isolated-codex-home.ts @@ -19,7 +19,18 @@ export function installIsolatedCodexHome(prefix = "ocx-codex-home-"): IsolatedCo restore() { if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; - removeTreeWithRetry(path); + // The env restore above is the part other tests depend on; the directory is + // disposable. On Windows a proxy or child that is still shutting down can hold + // a file in this tree open past the retry budget, and rethrowing there failed a + // test that had already finished asserting -- it read as a defect in whatever + // ran here rather than as an OS release race. Leave the temp directory to the + // OS instead; a stale directory under TEMP costs nothing, a false red costs a + // real signal. + try { + removeTreeWithRetry(path); + } catch { + // Deliberately swallowed: see above. + } }, }; } diff --git a/tests/server-rate-limit-retry-e2e.test.ts b/tests/server-rate-limit-retry-e2e.test.ts index 467f6ca52c..7284acdbaf 100644 --- a/tests/server-rate-limit-retry-e2e.test.ts +++ b/tests/server-rate-limit-retry-e2e.test.ts @@ -26,7 +26,16 @@ afterEach(() => { else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); isolatedCodexHome = null; - if (testDir) removeTreeWithRetry(testDir); + // A failed removal must not skip the cooldown reset below, and must not fail a + // test that already asserted: on Windows a shutting-down server can hold a file + // in this tree past the retry budget. + if (testDir) { + try { + removeTreeWithRetry(testDir); + } catch { + // Left to the OS; the state that matters is reset below. + } + } clearKeyCooldowns(); }); From 54c5a8fefaa2fde3a9a525b453af5a2e917ccebd Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:53:28 +0900 Subject: [PATCH 15/21] fix(tests): let the OAuth poller yield to real work, not just microtasks completeMockCodexOAuth waited between login-status polls with queueMicrotask. A microtask only yields to work already queued, but the login flow awaits real I/O -- credential reads and the WHAM fetch -- so under load its continuation lands on the macrotask queue and 500 microtask turns can pass without it running once. The flow then reached its own 150-poll ceiling and reported "Login timed out before OAuth completed" where the test asserts a specific commit-failure message, which reads as a behavioural regression rather than a starved poller. setImmediate yields past the microtask queue, so each poll observes the state the flow actually reached. --- tests/codex-auth-api.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 0c4c7fab9d..bcd7eb4399 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -192,7 +192,13 @@ async function completeMockCodexOAuth(options: { catalogRefreshPending?: boolean; }; if (state.status !== "pending") return { startStatus: resp!.status, state }; - await new Promise(resolve => queueMicrotask(resolve)); + // A microtask only yields to work already queued. The login flow awaits real + // I/O -- credential reads, the WHAM fetch -- so under load its continuation can + // land on the macrotask queue instead, and 500 microtask turns burn through + // without it ever running. The flow then hits its own 150-poll ceiling and + // reports "Login timed out" where the test asserts a specific error, which reads + // as a behavioural regression rather than a starved poller. + await new Promise(resolve => setImmediate(resolve)); } throw new Error(`Timed out waiting for Codex OAuth flow ${started.flowId}`); } finally { From 71f69edd7a8c27b9c510fb8d43f425bd7701d949 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:08:09 +0900 Subject: [PATCH 16/21] test(windows): budget the non-loopback refusal case The refusal is only proven by letting a connection attempt reach its own 2s socket timeout, on top of starting and stopping a real proxy and listener. On a loaded Windows box that measured 5.04s against Bun default of 5s, so the case failed for the wait that IS its assertion. Use the existing SERVER_BUDGET_MS. --- tests/loopback-listener-integration.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/loopback-listener-integration.test.ts b/tests/loopback-listener-integration.test.ts index ab083c7151..8429479132 100644 --- a/tests/loopback-listener-integration.test.ts +++ b/tests/loopback-listener-integration.test.ts @@ -22,6 +22,7 @@ import { setEphemeralPortAllocatorForTests, } from "../src/server/ports"; import type { OcxConfig } from "../src/types"; +import { SERVER_BUDGET_MS } from "./helpers/test-budget"; const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousHome = process.env.OPENCODEX_HOME; @@ -148,7 +149,10 @@ describe("unauthenticated loopback listener", () => { } finally { await server.stop(true); } - }); + // A real proxy plus a real listener, and the refusal is only proven by letting the + // connection attempt reach its own 2s socket timeout. Together those exceed Bun's + // 5s default on a loaded Windows box, where the test measured 5.04s. + }, SERVER_BUDGET_MS); test("serves only the four allowlisted routes, using each route's real method", async () => { const loopbackPort = await freePort(); From 51857c9f0363d87b0a12360ffc03091517325e06 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:50:14 +0900 Subject: [PATCH 17/21] fix(windows): three more cases that described the machine, not the code discoverProjectCodexConfigPaths walks up to 12 parents, and on Windows the OS temp directory lives under C:\Users\ -- so the fixture's walk climbed out of the fixture and found the developer's real ~/.codex/config.toml. The identity check cannot exclude it, because it genuinely is a different file from the fixture's codexConfigPath. Bound the walk; the assertion is that a parent walk does not rediscover the global config, not how far it may travel. The claim-narrowing case asserted 0o644 before and 0o600 after, but Windows synthesizes mode from the read-only attribute and answers 0o666 regardless, so neither end of the transition is observable there. The call still runs on every platform; only the POSIX-shaped observation is conditional. The auth-temp residue case needs a real file symlink to prove it refuses to follow one, and that needs Developer Mode or admin. Take the visible skip; the hard-link case beside it still proves the scrubber will not truncate a shared target here. --- tests/native-main-auth-temp.test.ts | 10 +++++++++- tests/native-main-claim.test.ts | 9 +++++++-- tests/project-config-warnings.test.ts | 8 +++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/native-main-auth-temp.test.ts b/tests/native-main-auth-temp.test.ts index 55f5d59b16..4f70a30435 100644 --- a/tests/native-main-auth-temp.test.ts +++ b/tests/native-main-auth-temp.test.ts @@ -74,7 +74,15 @@ describe("native-main auth temp startup scrub", () => { const target = join(f.codexHome, "near-miss-target"); const residue = join(f.codexHome, "auth.json.ocx.123.1.tmp"); writeFileSync(target, "target-private-value"); - symlinkSync(target, residue, "file"); + try { + symlinkSync(target, residue, "file"); + } catch (err) { + // Windows without Developer Mode / elevated privileges cannot create symlinks, + // and a file symlink is what this case is about. The hard-link case below still + // covers refusing to truncate a shared target on this machine. + if (process.platform === "win32" && (err as NodeJS.ErrnoException).code === "EPERM") return; + throw err; + } expectCleanupRequired(() => scrubNativeMainAuthTempResidues(f.context)); expect(lstatSync(residue).isSymbolicLink()).toBe(true); diff --git a/tests/native-main-claim.test.ts b/tests/native-main-claim.test.ts index c92546213d..2cd01c3872 100644 --- a/tests/native-main-claim.test.ts +++ b/tests/native-main-claim.test.ts @@ -176,11 +176,16 @@ describe("the default hardener is actually reached from a claim", () => { mkdirSync(join(context.codexHome), { recursive: true }); writeFileSync(path, ""); chmodSync(path, 0o644); - expect(statSync(path).mode & 0o777).toBe(0o644); + // Windows synthesizes mode from the read-only attribute and answers 0o666 + // whatever chmod requested, so the narrowing cannot be observed through stat + // there. The permissive precondition and the narrowed result are both POSIX + // claims; the call itself still runs on every platform. + const posixModes = process.platform !== "win32"; + if (posixModes) expect(statSync(path).mode & 0o777).toBe(0o644); // No hardenPath override: this is the production default. await withNativeMainSharedClaim(context, async () => undefined); - expect(statSync(path).mode & 0o777).toBe(0o600); + if (posixModes) expect(statSync(path).mode & 0o777).toBe(0o600); }); }); diff --git a/tests/project-config-warnings.test.ts b/tests/project-config-warnings.test.ts index eb079760af..4503035b10 100644 --- a/tests/project-config-warnings.test.ts +++ b/tests/project-config-warnings.test.ts @@ -235,7 +235,13 @@ describe("collectProjectCodexConfigWarnings", () => { writeFileSync(codexConfigPath, `model_provider = "opencodex-retry"`); writeFileSync(projectConfigPath, `model_provider = "anthropic"`); - expect(discoverProjectCodexConfigPaths({ cwd: nestedCwd, codexConfigPath })) + // Bound the walk to the fixture. On Windows the OS temp directory lives under + // C:\Users\, so an unbounded 12-parent walk climbs out of the fixture and + // finds the developer's REAL ~/.codex/config.toml -- which the identity check + // cannot exclude, because it is a genuinely different file from the fixture's + // codexConfigPath. The assertion is about not rediscovering the global config + // through a parent walk, not about how far the walk may travel. + expect(discoverProjectCodexConfigPaths({ cwd: nestedCwd, codexConfigPath, maxWalkParents: 3 })) .toEqual([projectConfigPath]); }); From a6de95a679b5b515bf9ed294ae812001730ac450 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:52:23 +0900 Subject: [PATCH 18/21] fix(windows): skip the file-symlink cases and survive one more teardown race Four responses-state cases are irreducibly about symlink resolution -- following a symlinked snapshot to its real directory, or refusing an oversized or non-regular one -- and creating a file symlink needs Developer Mode or admin on Windows. They failed in the fixture, before the behaviour under test ran. Detect the privilege once and take the visible skip this repository already uses for the constraint. The CL-06 boundary teardown removed its temp root unconditionally and threw EBUSY when a shutting-down server still held a file there, failing a test that had already asserted. The state that matters is reset before it; leave the directory to the OS. --- tests/responses-state.test.ts | 27 ++++++++++++++++--- .../routing-compatibility-boundaries.test.ts | 11 +++++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index b6afe0163d..3084c44858 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -55,6 +55,25 @@ import { writeResponseSpillDurably, } from "../src/responses/spill-store"; import { adapterNeedsForcedContinuation, injectDeveloperMessage } from "../src/server/responses"; + +/** + * Windows without Developer Mode or admin cannot create a file symlink (EPERM). + * The cases below are irreducibly about symlink resolution -- following one, or + * refusing to -- so detect the privilege once and take a visible skip rather than + * failing in the fixture before the behaviour under test runs. + */ +const canSymlink = (() => { + const probeDir = mkdtempSync(join(tmpdir(), "ocx-state-symlink-probe-")); + try { + symlinkSync(join(probeDir, "probe-target"), join(probeDir, "probe-link")); + return true; + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === "EPERM") return false; + throw e; + } finally { + rmSync(probeDir, { recursive: true, force: true }); + } +})(); import { hardenSecretPath, hardenedSecretPathCountForTests, @@ -1236,7 +1255,7 @@ describe("Responses previous_response_id state", () => { expect(responseStateMetrics()).toMatchObject({ spillStubCount: 1, spillWriteFailures: 0 }); }); - test("orphan cleanup obeys scan and cleanup caps rejects symlinks and counts failed unlink", () => { + test.skipIf(!canSymlink)("orphan cleanup obeys scan and cleanup caps rejects symlinks and counts failed unlink", () => { const dir = responseSpillDirectory(home); mkdirSync(dir, { recursive: true }); const old = new Date(Date.now() - 20 * 60_000); @@ -1510,7 +1529,7 @@ describe("Responses previous_response_id state", () => { for (const path of [live, current, young, unrelated, directory]) expect(existsSync(path)).toBe(true); }); - test("load sweeps stale temps in a symlinked snapshot's real directory", () => { + test.skipIf(!canSymlink)("load sweeps stale temps in a symlinked snapshot's real directory", () => { // Atomic writes place their temp beside the RESOLVED target, so a dotfiles-managed // config dir strands temps where a scan of the literal home would never find them. const realDir = mkdtempSync(join(tmpdir(), "ocx-state-real-")); @@ -2051,7 +2070,7 @@ describe("Responses state admission boundary (oversized direct-spill)", () => { expect(JSON.stringify(expanded.input)).toContain("b".repeat(64)); }); - test("oversized symlinked snapshot is refused before parse", () => { + test.skipIf(!canSymlink)("oversized symlinked snapshot is refused before parse", () => { const target = join(home, "big-snapshot-target.json"); writeFileSync(target, `{"version":2,"states":[${" ".repeat(33 * 1024 * 1024)}]}`); symlinkSync(target, join(home, "responses-state.json")); @@ -2060,7 +2079,7 @@ describe("Responses state admission boundary (oversized direct-spill)", () => { expect(responseAdmissionCountersForTests().snapshotOversizedRefusals).toBe(refusalsBefore + 1); }); - test("snapshot symlinked to a non-regular target is never read", () => { + test.skipIf(!canSymlink)("snapshot symlinked to a non-regular target is never read", () => { // /dev/null is the safe non-regular fixture (a FIFO would block an unfixed // read forever — that hang IS the pre-fix behavior this guards). symlinkSync("/dev/null", join(home, "responses-state.json")); diff --git a/tests/routing-compatibility-boundaries.test.ts b/tests/routing-compatibility-boundaries.test.ts index d1d112881c..0e23c86509 100644 --- a/tests/routing-compatibility-boundaries.test.ts +++ b/tests/routing-compatibility-boundaries.test.ts @@ -67,7 +67,16 @@ afterEach(() => { resetCompatibilityVersionCacheForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; - if (testDir) rmSync(testDir, { recursive: true, force: true }); + // A shutting-down server can still hold a file here, and Windows answers EBUSY + // rather than unlinking underneath it. Failing teardown would blame a test that + // already asserted; the state that matters was reset above. + if (testDir) { + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Left to the OS. + } + } testDir = ""; }); From 0f4b95bee1954b94207a1fd8cc90110f7b30e2c7 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:00:22 +0900 Subject: [PATCH 19/21] test(windows): budget the two-server lifecycle case Starting two real servers, driving a policy job to idle, and stopping both IS the assertion that one stop leaves the other process-wide work alone. That sequence measured 5.4s against Bun default of 5s on Windows, so it failed for its own evidence. Use the existing SERVER_BUDGET_MS. --- tests/server-background-lifecycle.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/server-background-lifecycle.test.ts b/tests/server-background-lifecycle.test.ts index 279d9a05c3..5a787d3a96 100644 --- a/tests/server-background-lifecycle.test.ts +++ b/tests/server-background-lifecycle.test.ts @@ -34,6 +34,7 @@ import { liveStorageWorkerCount, } from "../src/storage/worker-lifecycle"; import type { OcxConfig } from "../src/types"; +import { SERVER_BUDGET_MS } from "./helpers/test-budget"; import { managementFetch } from "./helpers/management-auth"; import { installIsolatedCodexHome, @@ -302,7 +303,10 @@ describe("server background lifecycle", () => { } finally { probe.restore(); } - }); + // Two real servers, a policy job driven to idle, and both shutdowns: that whole + // sequence is the assertion that one server's stop leaves the other's + // process-wide work alone, and it measured 5.4s against Bun's 5s default. + }, SERVER_BUDGET_MS); test("a newer bind failure preserves the older server's process-wide work", async () => { saveConfig(baseConfig()); From f3a612054cdc58e39a5c8ffd539631bff6b8db7d Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:07:33 +0900 Subject: [PATCH 20/21] fix(windows): mark the Unix-only and symlink-only preflight cases inspectNpmCacheDirectory judges accessibility from POSIX owner bits, and a Windows directory reports 0o666 with no execute bit -- so the owner-rwx check can never pass and every inspection answered cache_entry_inaccessible. That is not a defect to fix: the module inspects a Unix npm cache, and runNpmCachePreflight already returns windows_skip before reaching it. The worker round-trip case additionally spawns the real npm while claiming a non-Windows platform, which is slow and proves nothing here. Both are now explicitly non-Windows, and the windows_skip case beside them still covers the branch this platform actually takes. Three real-home guard cases and three npm-cache cases need genuine symlinks to prove the guard resolves through one; that needs Developer Mode or admin. They take the visible skip already used elsewhere for the same constraint. --- tests/test-home-guard.test.ts | 26 ++++++++++++++--- tests/update-npm-cache-preflight.test.ts | 36 ++++++++++++++++++++---- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/tests/test-home-guard.test.ts b/tests/test-home-guard.test.ts index dc57b64e3b..5c8ab45f7d 100644 --- a/tests/test-home-guard.test.ts +++ b/tests/test-home-guard.test.ts @@ -10,7 +10,7 @@ * Incident: devlog/_fin/260730_codex_rs_upstream_v2_live_handoff/070. */ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -65,6 +65,24 @@ function sentinelHome(): { realHome: string; opencodexHome: string } { } describe("real-home write guard", () => { + +/** + * Windows without Developer Mode or admin cannot create symlinks (EPERM). The + * escape cases below need a real link to prove the guard resolves through one, so + * detect the privilege once and take a visible skip rather than failing in setup. + */ +const canSymlink = (() => { + const probeDir = mkdtempSync(join(tmpdir(), "ocx-home-guard-symlink-probe-")); + try { + symlinkSync(join(probeDir, "probe-target"), join(probeDir, "probe-link")); + return true; + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === "EPERM") return false; + throw e; + } finally { + rmSync(probeDir, { recursive: true, force: true }); + } +})(); test("armed + the protected home: all three writers throw", () => { const { realHome, opencodexHome } = sentinelHome(); const probe = runProbe(` @@ -93,7 +111,7 @@ describe("real-home write guard", () => { expect(() => readFileSync(join(opencodexHome, "codex-accounts.json"))).toThrow(); }); - test("armed + a symlink escaping a temp home into the protected home: refused", () => { + test.skipIf(!canSymlink)("armed + a symlink escaping a temp home into the protected home: refused", () => { // Atomic writes resolve their destination through symlinks, so a temp home whose // config.json points into the protected home would otherwise pass the caller's // dir-level check and then write the real file anyway. @@ -132,7 +150,7 @@ describe("real-home write guard", () => { expect(JSON.parse(readFileSync(join(dir, "config.json"), "utf8")).port).toBe(10100); }); - test("armed + a first write beneath a symlinked PARENT escaping into the protected home: refused", () => { + test.skipIf(!canSymlink)("armed + a first write beneath a symlinked PARENT escaping into the protected home: refused", () => { // The file does not exist yet, so resolveWriteTarget returns the literal // path and target === path; the guard must resolve the parent directory // instead of skipping (review: symlinked config dir + absent destination). @@ -195,7 +213,7 @@ describe("real-home write guard", () => { expect(probe.stdout).not.toContain("ocx-decoy-home-"); }); - test("a symlink pointing at the protected home is rejected", () => { + test.skipIf(!canSymlink)("a symlink pointing at the protected home is rejected", () => { const { realHome, opencodexHome } = sentinelHome(); const linkDir = mkdtempSync(join(tmpdir(), "ocx-symlink-")); const link = join(linkDir, "looks-like-temp"); diff --git a/tests/update-npm-cache-preflight.test.ts b/tests/update-npm-cache-preflight.test.ts index 69c16b2160..520c0d6b6a 100644 --- a/tests/update-npm-cache-preflight.test.ts +++ b/tests/update-npm-cache-preflight.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { chmodSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -9,6 +9,24 @@ import { const roots: string[] = []; +/** + * Windows without Developer Mode or admin cannot create symlinks (EPERM). The + * cases guarded below are about symlink handling itself, so detect the privilege + * once and take a visible skip rather than failing in the fixture. + */ +const canSymlink = (() => { + const probeDir = mkdtempSync(join(tmpdir(), "ocx-cache-preflight-symlink-probe-")); + try { + symlinkSync(join(probeDir, "probe-target"), join(probeDir, "probe-link")); + return true; + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === "EPERM") return false; + throw e; + } finally { + rmSync(probeDir, { recursive: true, force: true }); + } +})(); + function tempRoot(name: string): string { const root = join(tmpdir(), `ocx-cache-preflight-${name}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(root, { recursive: true }); @@ -49,7 +67,7 @@ describe("npm cache access pre-flight", () => { } }); - test("lstats normal nested symlinks but never traverses their targets", () => { + test.skipIf(!canSymlink)("lstats normal nested symlinks but never traverses their targets", () => { const cache = tempRoot("symlink-cache"); const missingTarget = join(tempRoot("symlink-target"), "does-not-exist"); const npx = join(cache, "_npx"); @@ -61,7 +79,7 @@ describe("npm cache access pre-flight", () => { expect(inspectNpmCacheDirectory(cache)).toEqual({ ok: true, reason: "cache_accessible" }); }); - test("a foreign-owned nested symlink does not block the update", () => { + test.skipIf(!canSymlink)("a foreign-owned nested symlink does not block the update", () => { // The distinction that decides whether this feature is usable. A real npm cache is full of // symlinks below _npx/node_modules/.bin, and their owner is irrelevant because we never // follow them. Rejecting on ownership before skipping the link would abort updates for @@ -89,7 +107,10 @@ describe("npm cache access pre-flight", () => { })).toEqual({ ok: false, reason: "cache_entry_foreign_owner" }); }); - test("an inspection budget that runs out lets the update proceed", () => { + // Unix mode semantics: a Windows directory reports 0o666 with no execute bit, so the + // owner-rwx accessibility check can never pass there. Production already skips Windows + // entirely (runNpmCachePreflight returns windows_skip), so this proves nothing there. + test.skipIf(process.platform === "win32")("an inspection budget that runs out lets the update proceed", () => { // A mature npm cache legitimately holds hundreds of thousands of entries. "We ran out of // budget looking" is not evidence of a broken cache, and treating it as failure locked // ordinary users out of updating entirely. @@ -143,7 +164,7 @@ describe("npm cache access pre-flight", () => { })).toEqual({ ok: false, reason: "worker_output_malformed" }); }); - test("a cache root symlinked to another volume is inspected, not rejected", () => { + test.skipIf(!canSymlink)("a cache root symlinked to another volume is inspected, not rejected", () => { // Pointing ~/.npm at another volume is ordinary npm configuration. Rejecting it outright was // the same class of false positive as failing on a large cache: it blocks updates for users // whose setup is fine. The root is resolved once; nested links are still never followed. @@ -190,7 +211,10 @@ describe("npm cache access pre-flight", () => { }); }); - test("runs the real worker protocol against npm's configured cache path", () => { + // Spawns the real npm to read its configured cache path while claiming a non-Windows + // platform. On Windows that is both slow and meaningless: production takes the + // windows_skip branch, covered by the case below. + test.skipIf(process.platform === "win32")("runs the real worker protocol against npm's configured cache path", () => { const cache = tempRoot("worker-round-trip"); mkdirSync(join(cache, "_cacache")); From e7a71c1b53b044a161e07cc487b9228a53a127cb Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:19:00 +0900 Subject: [PATCH 21/21] fix(tests): update two source-shape checks the dev tip left behind Both failures are on origin/dev independently of this branch, and both come from the same shape: a test that asserts on the TEXT of a source file, pinned to a spelling the implementation has since changed. 8b672205e threaded nativeContextLimits through the remaining Codex and Desktop writers, but sync-client-integrations still required the retired providerContextCap spelling -- so the check failed against the very change it exists to pin. The GUI cap-display check required a one-line expression that is now wrapped and has grown a native branch, so it was pinning formatting rather than behaviour. Match the current spellings, and match the GUI expression as fragments so a reflow cannot fail it again. Verified on origin/dev before this branch was rebased onto it: the sync case fails there with the same message, and the GUI case fails there in a clean worktree. --- gui/tests/models-native-group-controls.test.ts | 7 ++++++- tests/sync-client-integrations.test.ts | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/gui/tests/models-native-group-controls.test.ts b/gui/tests/models-native-group-controls.test.ts index eef43c5a9f..0460234089 100644 --- a/gui/tests/models-native-group-controls.test.ts +++ b/gui/tests/models-native-group-controls.test.ts @@ -60,7 +60,12 @@ test("the native group keeps its window readable with the cap switched off", asy expect(src).toContain("{(capOn || nativeProviderGroup) && ("); // With the cap off the stored value is only what a future toggle would apply — the 350k // default — so the display falls back to the widest window the rows actually advertise. - expect(src).toContain("const capDisplayValue = capOn ? providerCap : (widestRowWindow ?? providerCap);"); + // Matched as separate fragments because the expression is wrapped across lines now, and + // it grew a native branch: with the cap off the native group shows its default window + // rather than the widest advertised row. A single-line literal pinned the formatting + // instead of the behaviour and broke on the reflow that introduced that branch. + expect(src).toContain("const capDisplayValue = capOn"); + expect(src).toContain("nativeProviderGroup ? NATIVE_GPT56_DEFAULT_WINDOW : (widestRowWindow ?? providerCap)"); // The select is inert until the cap is actually on: showing a number is not the same as // offering to change one. expect(src).toContain("disabled={busy || !capOn}"); diff --git a/tests/sync-client-integrations.test.ts b/tests/sync-client-integrations.test.ts index 4bf5585cdc..5c83efe696 100644 --- a/tests/sync-client-integrations.test.ts +++ b/tests/sync-client-integrations.test.ts @@ -51,11 +51,13 @@ describe("ocx sync fans out to the client integrations that are switched on", () // One catch per client: a broken Grok file is a warning, not a 500 on a command whose // main job (the Codex catalog) succeeded. expect(fn.match(/catch \(error\)/g)?.length).toBe(2); - // The Desktop write gets the provider cap, same as every other Desktop call site. - expect(fn).toContain("providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)"); + // The Desktop write gets the native context limits, same as every other Desktop + // call site. 8b672205e threaded `nativeContextLimits` through those writers and + // left this assertion naming the retired `providerContextCap` spelling, so the + // source-shape check failed against the very change it is meant to pin. + expect(fn).toContain("nativeContextLimits(config)"); // A client that is off is omitted rather than reported: the caller has to be able to // tell "left alone" from "tried and failed", so there is no skipped state to emit. expect(fn).not.toContain('"skipped"'); }); }); -