@bitkyc08/opencodex 2.64.0 → 2.65.0-preview.20260925

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +28 -25
  2. package/bin/ocx.mjs +16 -2
  3. package/gui/dist/assets/App-8NMiZxT0.css +1 -0
  4. package/gui/dist/assets/App-CsYvvpr3.js +50 -0
  5. package/gui/dist/assets/Tray-DUvc_Wul.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-SggB6t3z.js → index-BB0iHG8-.js} +12 -12
  8. package/gui/dist/assets/tray-data-f0lTZ4sF.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -4
  11. package/src/adapters/anthropic.ts +30 -1
  12. package/src/adapters/claude-cli/adapter.ts +218 -0
  13. package/src/adapters/claude-cli/profiles.ts +38 -0
  14. package/src/adapters/codebuddy/profiles.ts +2 -0
  15. package/src/adapters/coding-agent/profile.ts +12 -4
  16. package/src/adapters/coding-agent/turn.ts +16 -6
  17. package/src/adapters/command-code-tool-text.ts +133 -23
  18. package/src/adapters/cursor/catalog.ts +4 -8
  19. package/src/adapters/cursor/discovery.ts +10 -5
  20. package/src/adapters/cursor/effort-map.ts +2 -6
  21. package/src/adapters/cursor/envelope-echo.ts +10 -5
  22. package/src/adapters/cursor/request-builder.ts +9 -4
  23. package/src/adapters/cursor/thread-continuity.ts +53 -0
  24. package/src/adapters/cursor.ts +29 -13
  25. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  26. package/src/adapters/google-tool-schema.ts +26 -3
  27. package/src/adapters/identity.ts +214 -2
  28. package/src/adapters/openai-chat/passthrough.ts +1 -0
  29. package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
  30. package/src/adapters/openai-chat.ts +43 -15
  31. package/src/adapters/openai-responses/canonical-forward.ts +17 -10
  32. package/src/adapters/openai-responses/passthrough.ts +19 -4
  33. package/src/adapters/openai-responses/request-strips.ts +25 -0
  34. package/src/adapters/openai-responses/web-search.ts +22 -4
  35. package/src/adapters/qoder/profiles.ts +2 -0
  36. package/src/adapters/registry.ts +8 -0
  37. package/src/adapters/run-turn-queue.ts +7 -0
  38. package/src/bridge/response-json.ts +6 -4
  39. package/src/bridge/sse.ts +7 -5
  40. package/src/claude/alias.ts +73 -26
  41. package/src/claude/context-windows.ts +15 -6
  42. package/src/claude/desktop-3p-library.ts +3 -2
  43. package/src/claude/desktop-3p.ts +1 -1
  44. package/src/claude/desktop-first-party.ts +63 -22
  45. package/src/claude/desktop-picker-profile.ts +302 -0
  46. package/src/claude/desktop-picker.ts +365 -0
  47. package/src/claude/desktop-risk.ts +20 -0
  48. package/src/claude/gateway-cache.ts +13 -4
  49. package/src/claude/inbound.ts +5 -2
  50. package/src/claude/intercept/connect-proxy.ts +106 -29
  51. package/src/claude/intercept/local-ca.ts +76 -18
  52. package/src/claude/intercept/model-bindings.ts +145 -0
  53. package/src/claude/intercept/picker-bootstrap.ts +141 -0
  54. package/src/claude/intercept/picker-ca.ts +126 -0
  55. package/src/claude/intercept/picker-listener.ts +213 -0
  56. package/src/claude/intercept/picker-models.ts +101 -0
  57. package/src/claude/intercept/picker-runtime.ts +330 -0
  58. package/src/claude/intercept/picker-trust.ts +125 -0
  59. package/src/claude/intercept/runtime.ts +149 -2
  60. package/src/claude/model-info.ts +18 -4
  61. package/src/cli/agent.ts +16 -2
  62. package/src/cli/capabilities.ts +71 -2
  63. package/src/cli/claude-desktop.ts +319 -33
  64. package/src/cli/claude.ts +15 -7
  65. package/src/cli/codex-shim-autorestore.ts +1 -0
  66. package/src/cli/dispatch.ts +29 -23
  67. package/src/cli/effort.ts +16 -6
  68. package/src/cli/ensure-desired-integrations.ts +29 -4
  69. package/src/cli/ready.ts +1 -1
  70. package/src/cli/registry.ts +8 -0
  71. package/src/cli/restart-scope.ts +9 -1
  72. package/src/cli/runtime-api.ts +17 -0
  73. package/src/cli/system-command.ts +11 -12
  74. package/src/client/connect.ts +12 -2
  75. package/src/client/state.ts +39 -1
  76. package/src/clients/config-export.ts +57 -9
  77. package/src/codex/auth-context.ts +51 -11
  78. package/src/codex/catalog/derive-entry.ts +5 -5
  79. package/src/codex/catalog/gather-capture.ts +25 -6
  80. package/src/codex/catalog/metadata.ts +3 -3
  81. package/src/codex/catalog/parsing.ts +39 -1
  82. package/src/codex/catalog/provider-models.ts +8 -6
  83. package/src/codex/catalog/retained-sync.ts +17 -7
  84. package/src/codex/catalog/sync.ts +2 -2
  85. package/src/codex/desktop-app-restart.ts +8 -0
  86. package/src/codex/desktop-switches.ts +9 -1
  87. package/src/codex/home.ts +43 -4
  88. package/src/codex/inject/config-toml.ts +137 -0
  89. package/src/codex/inject/plan.ts +23 -0
  90. package/src/codex/inject/remove.ts +21 -1
  91. package/src/codex/inject.ts +63 -8
  92. package/src/codex/journal.ts +36 -0
  93. package/src/codex/main-account-hard-lock.ts +14 -1
  94. package/src/codex/main-account-policy-wait.ts +109 -0
  95. package/src/codex/native-profile-startup.ts +24 -6
  96. package/src/codex/native-residue.ts +28 -10
  97. package/src/codex/quota-types.ts +8 -1
  98. package/src/codex/quota.ts +49 -7
  99. package/src/codex/runtime.ts +31 -10
  100. package/src/combos/failover.ts +7 -6
  101. package/src/combos/resolve.ts +91 -14
  102. package/src/combos/types.ts +18 -1
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/schema/config-schema.ts +21 -2
  105. package/src/config/schema/leaf-validators.ts +6 -3
  106. package/src/config/subagent-models.ts +3 -1
  107. package/src/generated/compatibility-version.json +275 -163
  108. package/src/images/artifacts.ts +14 -0
  109. package/src/integrations/owned-refresh.ts +1 -1
  110. package/src/integrations/ownership-policy.ts +32 -1
  111. package/src/integrations/state.ts +3 -1
  112. package/src/integrations/writer.ts +9 -0
  113. package/src/lib/config-ownership.ts +3 -0
  114. package/src/lib/errors.ts +87 -0
  115. package/src/lib/package-tree-integrity.ts +1 -1
  116. package/src/lib/request-execution-budget.ts +42 -14
  117. package/src/lib/request-resend-gate.ts +4 -3
  118. package/src/lib/retry-delay.ts +7 -1
  119. package/src/lib/tool-envelope-echo-filter.ts +236 -0
  120. package/src/lib/upstream-retry.ts +20 -12
  121. package/src/oauth/index.ts +1 -0
  122. package/src/oauth/kiro-credentials.ts +14 -1
  123. package/src/oauth/login-cli.ts +1 -0
  124. package/src/providers/derive.ts +4 -0
  125. package/src/providers/fast-opt-in.ts +31 -0
  126. package/src/providers/model-rename-fields.ts +2 -0
  127. package/src/providers/provider-id-rewrite.ts +2 -0
  128. package/src/providers/quota/vendor-probes-key.ts +8 -2
  129. package/src/providers/registry/entries-core.ts +45 -2
  130. package/src/providers/registry/entries-extended.ts +99 -12
  131. package/src/providers/registry/model-ids.ts +2 -0
  132. package/src/providers/registry/types.ts +7 -1
  133. package/src/providers/resolved-model-policy.ts +11 -4
  134. package/src/providers/service-tier.ts +10 -3
  135. package/src/responses/code-mode-helper-compat.ts +3 -0
  136. package/src/responses/parser.ts +45 -3
  137. package/src/router.ts +6 -0
  138. package/src/server/auth-cors.ts +8 -0
  139. package/src/server/chat-completions.ts +3 -1
  140. package/src/server/claude-messages.ts +69 -14
  141. package/src/server/grok-upstream-envelope-echo.ts +120 -0
  142. package/src/server/index/claude-intercept-lifecycle.ts +24 -0
  143. package/src/server/index/serve-options.ts +2 -2
  144. package/src/server/index.ts +7 -0
  145. package/src/server/management/agent-settings-routes.ts +204 -112
  146. package/src/server/management/claude-desktop-picker-routes.ts +128 -0
  147. package/src/server/management/combo-routes.ts +59 -2
  148. package/src/server/management/config-routes.ts +81 -39
  149. package/src/server/management/context.ts +3 -0
  150. package/src/server/management/native-integration-routes.ts +169 -93
  151. package/src/server/management/provider-routes.ts +46 -4
  152. package/src/server/management/route-registry.ts +10 -0
  153. package/src/server/management/routing-profile-routes.ts +9 -0
  154. package/src/server/management/sidebar-routes.ts +52 -0
  155. package/src/server/management-api.ts +2 -0
  156. package/src/server/proxy-liveness.ts +38 -3
  157. package/src/server/request-log-account-rotation.ts +53 -0
  158. package/src/server/request-log.ts +3 -16
  159. package/src/server/responses/adapter-dispatch.ts +17 -1
  160. package/src/server/responses/agent-task-recovery.ts +49 -26
  161. package/src/server/responses/codex-ws-exchange.ts +18 -7
  162. package/src/server/responses/codex-ws-wire.ts +27 -4
  163. package/src/server/responses/core-combo.ts +21 -3
  164. package/src/server/responses/core-normalize.ts +7 -0
  165. package/src/server/responses/core-opaque-recovery.ts +3 -2
  166. package/src/server/responses/encrypted-payload.ts +15 -14
  167. package/src/server/responses/fetch-helpers.ts +1 -1
  168. package/src/server/responses/input-admission.ts +10 -6
  169. package/src/server/responses/native-response-control.ts +2 -2
  170. package/src/server/responses/passthrough-delivery.ts +182 -12
  171. package/src/server/responses/passthrough-dispatch.ts +102 -44
  172. package/src/server/responses/policy-fallback.ts +3 -0
  173. package/src/server/responses/policy-refusal.ts +67 -0
  174. package/src/server/responses/request-send-budget.ts +4 -0
  175. package/src/server/responses/run-turn-execution.ts +42 -11
  176. package/src/server/responses/ws-upstream.ts +2 -1
  177. package/src/server/system-env-shell.ts +0 -1
  178. package/src/server/system-env.ts +25 -4
  179. package/src/service/cli.ts +34 -3
  180. package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
  181. package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
  182. package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
  183. package/src/tray/windows-tray.ps1 +188 -7
  184. package/src/tray/windows.ts +23 -2
  185. package/src/types/config.ts +52 -12
  186. package/src/types/provider.ts +22 -5
  187. package/src/types/tools.ts +11 -5
  188. package/src/types.ts +1 -0
  189. package/src/update/async-check.ts +109 -0
  190. package/src/update/badge.ts +11 -17
  191. package/src/update/check-types.ts +9 -0
  192. package/src/update/desktop-badge.ts +102 -0
  193. package/src/update/index.ts +48 -8
  194. package/src/update/install-detection.d.mts +29 -2
  195. package/src/update/install-detection.mjs +213 -7
  196. package/src/update/job.ts +17 -12
  197. package/src/update/notify.ts +16 -11
  198. package/src/update/pnpm-owner-worker.ts +12 -0
  199. package/src/update/refresh-scheduler.ts +141 -0
  200. package/src/usage/cost.ts +31 -4
  201. package/src/usage/expected-prices.ts +57 -0
  202. package/assets/download-linux.svg +0 -10
  203. package/assets/download-macos.svg +0 -10
  204. package/assets/download-windows.svg +0 -10
  205. package/gui/dist/assets/App-CpuDF3ci.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
@@ -28,6 +28,7 @@ import {
28
28
  } from "./main-account";
29
29
  import { isMainAccountPolicyBindingPending, isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
30
30
  import type { NativeMainStartupBlockReason } from "./native-profile-startup";
31
+ import { waitForMainAccountPolicyBinding } from "./main-account-policy-wait";
31
32
  import {
32
33
  codexQuotaScopeForModel,
33
34
  computeCodexUsageScore,
@@ -67,7 +68,12 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
67
68
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
68
69
  import { captureConfigGeneration } from "../lib/state-store-sweeper";
69
70
  import { extractAccountId, extractEmail } from "../oauth/chatgpt";
70
- import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock";
71
+ import {
72
+ MAIN_ACCOUNT_HARD_LOCK_PERCENT,
73
+ getMainAccountHardLockStatus,
74
+ isMainAccountHardLockEnabled,
75
+ isMainAccountHardLocked,
76
+ } from "./main-account-hard-lock";
71
77
  import {
72
78
  captureMainAccountIdentityGeneration,
73
79
  captureMainQuotaWriter,
@@ -508,7 +514,7 @@ export class CodexMainAccountHardLockError extends CodexAccountCooldownError {
508
514
  super(MAIN_CODEX_ACCOUNT_ID, resetAt ?? 0);
509
515
  this.name = "CodexMainAccountHardLockError";
510
516
  this.resetAt = resetAt;
511
- this.message = "Codex main account is blocked by the 99% main-account quota policy."
517
+ this.message = `Codex main account is blocked by the ${MAIN_ACCOUNT_HARD_LOCK_PERCENT}% main-account quota policy.`
512
518
  + " Choose another account, wait for quota to reset, or disable codexMainAccountHardLock in Settings.";
513
519
  }
514
520
  }
@@ -898,6 +904,8 @@ export interface ResolveCodexAuthContextOptions {
898
904
  getValidMainAccountToken?: typeof getValidMainAccountToken;
899
905
  nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
900
906
  signal?: AbortSignal;
907
+ /** Test seam: overrides the bounded startup policy-binding wait before the hard-lock fence. */
908
+ mainAccountPolicyBindingWaitMs?: number;
901
909
  primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void>;
902
910
  /** Test seam for account-gated native model discovery. */
903
911
  resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements;
@@ -920,6 +928,32 @@ export interface CodexAccountSelectionAdmission {
920
928
  release(): void;
921
929
  }
922
930
 
931
+ /**
932
+ * Wait, bounded, for an owned startup's main-policy binding, then fail closed while it is still
933
+ * in flight.
934
+ *
935
+ * #5694 turned `codexMainAccountHardLock` on by default, so this fence stopped being an opt-in
936
+ * rarity and started intercepting ordinary requests: for the seconds a Windows startup spends
937
+ * recovering, sweeping stages, and binding the pinned home, every caller-owned direct request
938
+ * was answered with a 503 "native-main profile maintenance is active; retry". Waiting is the
939
+ * right answer for a window that closes on its own -- the request resumes and the hard lock
940
+ * below still decides on identity and quota, exactly as it does after startup. A gate still
941
+ * pending at the deadline (retained recovery, a manual-recovery requirement) is a real refusal,
942
+ * and that is the one case that keeps the draining error.
943
+ *
944
+ * Both reads are memory-only: neither this fence nor the wait probes a foreign home.
945
+ */
946
+ async function awaitMainAccountPolicyBindingSettled(
947
+ options: ResolveCodexAuthContextOptions,
948
+ ): Promise<void> {
949
+ if (!isMainAccountPolicyBindingPending()) return;
950
+ const settled = await waitForMainAccountPolicyBinding({
951
+ signal: options.signal,
952
+ timeoutMs: options.mainAccountPolicyBindingWaitMs,
953
+ });
954
+ if (!settled) throw new CodexMainProfileDrainingError();
955
+ }
956
+
923
957
  export async function resolveCodexAuthContext(
924
958
  headers: Headers,
925
959
  config: OcxConfig,
@@ -956,9 +990,11 @@ export async function resolveCodexAuthContext(
956
990
  );
957
991
  const requestOwnedMainPinCandidate = mainPinState().candidate;
958
992
  // During an owned startup, equality cannot be established until recovery and the
959
- // memory-only policy binding finish. This read-only fence never probes a foreign home.
960
- if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) {
961
- throw new CodexMainProfileDrainingError();
993
+ // memory-only policy binding finish. This read-only fence never probes a foreign home, and it
994
+ // waits out a window that closes on its own instead of refusing a request that arrived inside
995
+ // it. Only a binding still pending at the deadline fails closed.
996
+ if (isMainAccountHardLockEnabled(policy) && requestOwnedMainPinCandidate) {
997
+ await awaitMainAccountPolicyBindingSettled(options);
962
998
  }
963
999
  const preserveRequestOwnedMainPin = () => mainPinState().preserve;
964
1000
  if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
@@ -970,9 +1006,7 @@ export async function resolveCodexAuthContext(
970
1006
  // Trusted substitution still has to claim and validate stored main below.
971
1007
  if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
972
1008
  if (!substituteStoredMain) {
973
- if (policy.codexMainAccountHardLock === true && isMainAccountPolicyBindingPending()) {
974
- throw new CodexMainProfileDrainingError();
975
- }
1009
+ if (isMainAccountHardLockEnabled(policy)) await awaitMainAccountPolicyBindingSettled(options);
976
1010
  if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
977
1011
  assertCallerOwnedMainPoolNotCooled();
978
1012
  if (reserve) {
@@ -1014,7 +1048,7 @@ export async function resolveCodexAuthContext(
1014
1048
  ) {
1015
1049
  throw new CodexMainProfileDrainingError();
1016
1050
  }
1017
- if (policy.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState();
1051
+ if (isMainAccountHardLockEnabled(policy)) reconcileMainCodexAccountRuntimeState();
1018
1052
  assertMainAccountPolicy(policy);
1019
1053
  if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) {
1020
1054
  const entitled = entitledCodexAccountIdsForModel(
@@ -1542,7 +1576,11 @@ export function materializeCodexUpstreamAuth(
1542
1576
  if (accountId) selected.set("chatgpt-account-id", accountId);
1543
1577
  }
1544
1578
  if (ctx.kind === "main" && options.substituteMainCredential === true) {
1545
- if (options.config?.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState();
1579
+ // No config object is "no policy input", not the persisted opt-out: only a config that
1580
+ // actually says `false` disables the default-on lock (#5694).
1581
+ if (options.config !== undefined && isMainAccountHardLockEnabled(options.config)) {
1582
+ reconcileMainCodexAccountRuntimeState();
1583
+ }
1546
1584
  const writer = captureObservedMainWriter();
1547
1585
  const stored = getMainAccountToken();
1548
1586
  // Fail BEFORE any upstream I/O. Falling through here would send the admission secret.
@@ -1620,7 +1658,9 @@ export async function materializeCodexUpstreamAuthAsync(
1620
1658
  const value = headers.get(name);
1621
1659
  if (value) selected.set(name, value);
1622
1660
  }
1623
- if (options.config?.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState();
1661
+ if (options.config !== undefined && isMainAccountHardLockEnabled(options.config)) {
1662
+ reconcileMainCodexAccountRuntimeState();
1663
+ }
1624
1664
  const writer = captureObservedMainWriter();
1625
1665
  const stored = await getValidMainAccountToken({
1626
1666
  signal: options.signal,
@@ -1,6 +1,6 @@
1
1
  import type { OcxConfig } from "../../types";
2
2
  import { effectiveProviderAlias } from "../../providers/default-aliases";
3
- import { identifyRoutedModel } from "../../adapters/identity";
3
+ import { neutralizeIdentity } from "../../adapters/identity";
4
4
  import { COMBO_NAMESPACE } from "../../combos";
5
5
  import {
6
6
  CODEX_CUSTOM_MODEL_CATALOG_KIND,
@@ -145,13 +145,13 @@ export function deriveEntry(
145
145
  delete e.max_context_window;
146
146
  delete e.auto_compact_token_limit;
147
147
  }
148
- // Native id for identity text + metadata lookups — the slug may be an encoded
149
- // alias (`provider/vendor-model`); the model object carries the native id.
150
- const modelName = model?.id ?? slug.slice(slug.indexOf("/") + 1);
151
148
  if (typeof e.base_instructions === "string") {
152
149
  // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy
153
150
  // (leaking that into base_instructions is a non-first-party signature → ToS risk).
154
- e.base_instructions = identifyRoutedModel(e.base_instructions, modelName);
151
+ // Model-neutral on disk (#5217): Codex stores this block as the session's instructions and
152
+ // replays it verbatim into a sub-agent spawned on a DIFFERENT model, so a baked-in model id
153
+ // follows the worker and misnames it. The destination id is written at request time instead.
154
+ e.base_instructions = neutralizeIdentity(e.base_instructions);
155
155
  }
156
156
  applyReasoningLevels(
157
157
  e,
@@ -25,11 +25,13 @@ import {
25
25
  } from "../model-cache";
26
26
  import {
27
27
  buildModelsRequest,
28
+ getOAuthCredentialApiBaseUrl,
28
29
  getValidAccessTokenSnapshot,
29
30
  observeActiveOAuthAccessToken,
30
31
  resolveModelsAuthToken,
31
32
  type OAuthActiveTokenObservation,
32
33
  } from "../../oauth";
34
+ import { getAccountSet } from "../../oauth/store";
33
35
  import type { OcxConfig, OcxProviderConfig } from "../../types";
34
36
  import { modelInList } from "../../types";
35
37
  import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
@@ -150,6 +152,12 @@ export interface CapturedProviderGather {
150
152
  readonly metadataModelIdCaseFold: boolean;
151
153
  readonly effectiveAlias?: string | null;
152
154
  readonly observedAuth?: ModelsAuthResolution;
155
+ /**
156
+ * The active OAuth account a refreshing capture was taken under. It is part of the
157
+ * flight's auth identity, so a caller on another account never joins a pending
158
+ * discovery started for this one, even when both accounts share an API host.
159
+ */
160
+ readonly refreshingOAuthAccountId?: string;
153
161
  /**
154
162
  * Configured model ids this provider must keep even when live discovery omits
155
163
  * them — combo targets that are also listed in providers.*.models (OCX-111).
@@ -315,14 +323,12 @@ export function captureTrustedOpenAiApiPolicy(
315
323
  });
316
324
  }
317
325
 
318
- function captureModelsRequest(
326
+ export function captureModelsRequest(
319
327
  name: string,
320
328
  provider: OcxProviderConfig,
321
- observedAuth: ModelsAuthResolution | undefined,
329
+ oauthApiBaseUrl: string | undefined,
322
330
  ): CapturedModelsRequest {
323
- const observed = observedAuth
324
- ? { oauthApiBaseUrl: observedAuth.oauthApiBaseUrl }
325
- : undefined;
331
+ const observed = { oauthApiBaseUrl };
326
332
  const withoutCredential = buildModelsRequest(provider, undefined, name, observed);
327
333
  const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed);
328
334
  const method = withoutCredential.method ?? "GET";
@@ -378,7 +384,18 @@ export function captureProviderGather(
378
384
  && provider.liveModels !== false
379
385
  ? authResolver.resolve(name, provider)
380
386
  : undefined;
381
- const request = captureModelsRequest(name, provider, observedAuth);
387
+ // A refreshing capture carries the stored origin so accounts on different hosts keep separate
388
+ // flights. The send is rebuilt from the auth the gather resolves, and the observed path never
389
+ // reads the live store.
390
+ const oauthApiBaseUrl = observedAuth
391
+ ? observedAuth.oauthApiBaseUrl
392
+ : authResolver.kind === "refreshing" && provider.authMode === "oauth"
393
+ ? getOAuthCredentialApiBaseUrl(name)
394
+ : undefined;
395
+ const request = captureModelsRequest(name, provider, oauthApiBaseUrl);
396
+ const refreshingOAuthAccountId = !observedAuth && authResolver.kind === "refreshing" && provider.authMode === "oauth"
397
+ ? getAccountSet(name)?.activeAccountId
398
+ : undefined;
382
399
  const resolved = resolveProviderModelDiscovery(name, provider);
383
400
  const discovery = detachedFrozen({
384
401
  ...(resolved.spec ? { spec: resolved.spec } : {}),
@@ -413,6 +430,7 @@ export function captureProviderGather(
413
430
  metadataModelIdCaseFold,
414
431
  effectiveAlias,
415
432
  ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}),
433
+ ...(refreshingOAuthAccountId ? { refreshingOAuthAccountId } : {}),
416
434
  ...(retainConfiguredModelIds && retainConfiguredModelIds.size > 0
417
435
  ? { retainConfiguredModelIds }
418
436
  : {}),
@@ -447,6 +465,7 @@ export function captureGatherFlight(
447
465
  liveModels: provider.provider.liveModels ?? null,
448
466
  credential: provider.provider.apiKey ?? null,
449
467
  observedAuth: provider.observedAuth ?? null,
468
+ oauthAccount: provider.refreshingOAuthAccountId ?? null,
450
469
  headers: provider.request.headersWithCredential,
451
470
  url: provider.request.url,
452
471
  }))),
@@ -16,7 +16,7 @@ import { getProviderRegistryEntry, providerCodexAccountMode } from "../../provid
16
16
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
17
17
  import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
18
18
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
19
- import { identifyRoutedModel } from "../../adapters/identity";
19
+ import { neutralizeIdentity } from "../../adapters/identity";
20
20
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
21
21
  import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
22
22
  import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
@@ -584,14 +584,14 @@ function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined {
584
584
  alias.display_name = presentation.displayName;
585
585
  alias.description = presentation.description;
586
586
  if (typeof alias.base_instructions === "string") {
587
- alias.base_instructions = identifyRoutedModel(alias.base_instructions, slug);
587
+ alias.base_instructions = neutralizeIdentity(alias.base_instructions);
588
588
  }
589
589
  if (alias.model_messages && typeof alias.model_messages === "object" && !Array.isArray(alias.model_messages)) {
590
590
  const modelMessages = alias.model_messages as Record<string, unknown>;
591
591
  if (typeof modelMessages.instructions_template === "string") {
592
592
  alias.model_messages = {
593
593
  ...modelMessages,
594
- instructions_template: identifyRoutedModel(modelMessages.instructions_template, slug),
594
+ instructions_template: neutralizeIdentity(modelMessages.instructions_template),
595
595
  };
596
596
  }
597
597
  }
@@ -731,6 +731,7 @@ export function applyMultiAgentMode(
731
731
  ): RawEntry[] {
732
732
  if (mode === "v2" && options.keepNativeChatGptOnV1 === true) {
733
733
  for (const entry of entries) {
734
+ recordMultiAgentOrigin(entry);
734
735
  entry.multi_agent_version = catalogEntryIsNativeChatGpt(entry) ? "v1" : "v2";
735
736
  }
736
737
  return entries;
@@ -739,6 +740,9 @@ export function applyMultiAgentMode(
739
740
  // Restore upstream defaults: clear any stale forced multi_agent_version and
740
741
  // re-apply upstream pins from the snapshot for native entries that have one.
741
742
  for (const entry of entries) {
743
+ // A forced mode recorded what this row carried before it was overwritten; returning
744
+ // to default consumes that record whichever branch below decides the row.
745
+ const origin = takeMultiAgentOrigin(entry);
742
746
  if (options.preserveDefaultMultiAgentVersion?.(entry)) continue;
743
747
  const slug = typeof entry.slug === "string" ? entry.slug : "";
744
748
  const nativeAlias = entry.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND;
@@ -777,7 +781,17 @@ export function applyMultiAgentMode(
777
781
  && isNativeCatalogEntry
778
782
  && !hasNativeDefault
779
783
  && typeof entry.multi_agent_version === "string") {
780
- continue;
784
+ // The baseline predates this native row, so it cannot say whether the live pin
785
+ // is genuine. With a recorded origin it no longer has to guess: restore what the
786
+ // row carried before the first forced mode (issue 5636). A row without a record
787
+ // (older catalogs) keeps the non-destructive read.
788
+ if (origin === undefined) continue;
789
+ if (typeof origin === "string") {
790
+ entry.multi_agent_version = origin;
791
+ continue;
792
+ }
793
+ if (v2FeatureEnabled) entry.multi_agent_version = "v2";
794
+ else delete entry.multi_agent_version;
781
795
  } else if (v2FeatureEnabled) {
782
796
  entry.multi_agent_version = "v2";
783
797
  } else {
@@ -787,11 +801,35 @@ export function applyMultiAgentMode(
787
801
  return entries;
788
802
  }
789
803
  for (const entry of entries) {
804
+ recordMultiAgentOrigin(entry);
790
805
  entry.multi_agent_version = mode;
791
806
  }
792
807
  return entries;
793
808
  }
794
809
 
810
+ /**
811
+ * Provenance for a forced multi-agent stamp: the value the row carried before the first
812
+ * forced v1/v2 pass, or null when it carried none. Repeated forced passes never replace it,
813
+ * so a v1 -> v2 -> default round trip still restores the original. Codex ignores unknown
814
+ * catalog fields, as it does opencodex_catalog_kind.
815
+ */
816
+ export const MULTI_AGENT_ORIGIN_FIELD = "opencodex_multi_agent_version_origin";
817
+
818
+ function recordMultiAgentOrigin(entry: RawEntry): void {
819
+ if (Object.hasOwn(entry, MULTI_AGENT_ORIGIN_FIELD)) return;
820
+ (entry as Record<string, unknown>)[MULTI_AGENT_ORIGIN_FIELD] = typeof entry.multi_agent_version === "string"
821
+ ? entry.multi_agent_version
822
+ : null;
823
+ }
824
+
825
+ /** Remove and return the recorded origin: a string pin, null for "no pin", undefined when unrecorded. */
826
+ function takeMultiAgentOrigin(entry: RawEntry): string | null | undefined {
827
+ if (!Object.hasOwn(entry, MULTI_AGENT_ORIGIN_FIELD)) return undefined;
828
+ const raw = (entry as Record<string, unknown>)[MULTI_AGENT_ORIGIN_FIELD];
829
+ delete (entry as Record<string, unknown>)[MULTI_AGENT_ORIGIN_FIELD];
830
+ return typeof raw === "string" ? raw : raw === null ? null : undefined;
831
+ }
832
+
795
833
  export function normalizeRoutedCatalogEntry(
796
834
  entry: RawEntry,
797
835
  parallelToolCalls = false,
@@ -105,7 +105,7 @@ import type {
105
105
  import type { CapturedProviderGather, CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, ModelsAuthResolution, ModelsAuthResolver } from "./gather-capture";
106
106
  import { QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, applyConfigHintsToCachedModels, applyProviderConfigHints, boundedOwnedBy, catalogHintsFromModelsApiItem, catalogHintsFromProviderConfig } from "./model-hints";
107
107
  import { mergeConfiguredModelsIntoLiveCatalog, shouldExposeProviderModel, warnDroppedConfiguredIdsOnce } from "./model-visibility";
108
- import { captureProviderGather, materializeCapturedHeaders } from "./gather-capture";
108
+ import { captureModelsRequest, captureProviderGather, materializeCapturedHeaders } from "./gather-capture";
109
109
 
110
110
  export interface ProviderModelsResult {
111
111
  readonly models: CatalogModel[];
@@ -151,7 +151,7 @@ export async function fetchProviderModelsWithAuth(
151
151
  contextCap: number | undefined,
152
152
  resolveAuth: ModelsAuthResolver,
153
153
  ): Promise<ProviderModelsResult> {
154
- const { name, provider: prov, discovery, request, metadataModelIdCaseFold } = captured;
154
+ const { name, provider: prov, discovery, metadataModelIdCaseFold } = captured;
155
155
  const observed = (
156
156
  models: CatalogModel[],
157
157
  state: CatalogGatherProviderModelOutcome["state"],
@@ -217,10 +217,7 @@ export async function fetchProviderModelsWithAuth(
217
217
  return observed(configured, "authoritative");
218
218
  }
219
219
  const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing"
220
- ? prov.authMode === "oauth" && (
221
- effectiveGoogleMode(name, prov) === "cloud-code-assist"
222
- || prov.adapter === "devin"
223
- )
220
+ ? prov.authMode === "oauth"
224
221
  ? await getValidAccessTokenSnapshot(name)
225
222
  .then(snapshot => ({
226
223
  apiKey: snapshot.accessToken,
@@ -475,6 +472,11 @@ export async function fetchProviderModelsWithAuth(
475
472
  "degraded",
476
473
  );
477
474
  }
475
+ // The captured request predates any refresh, so a refreshing gather rebuilds it
476
+ // from the auth it resolved: the token and its origin, together.
477
+ const request = resolveAuth.kind === "refreshing"
478
+ ? captureModelsRequest(name, prov, auth.oauthApiBaseUrl)
479
+ : captured.request;
478
480
  const url = request.url;
479
481
  let headers = materializeCapturedHeaders(request, apiKey);
480
482
  // One Ollama authority contract: for canonical ollama-cloud/ollama-native rows, discovery
@@ -649,11 +649,13 @@ export async function syncCatalogModels(
649
649
  };
650
650
  }
651
651
 
652
- export function invalidateCodexModelsCacheWithPermit(
652
+ export type CodexCacheInvalidationOutcome = "written" | "unchanged" | "missing_catalog" | "desired_disabled" | "failed";
653
+
654
+ export function invalidateCodexModelsCacheWithPermitOutcome(
653
655
  permit: CatalogWritePermit,
654
656
  owningCodexHome: string,
655
657
  options?: CodexCatalogSyncOptions,
656
- ): boolean {
658
+ ): CodexCacheInvalidationOutcome {
657
659
  try {
658
660
  // This permit is a REACQUISITION: refreshCodexModelCatalog's commit released
659
661
  // K before this rewrite runs, so the commit-path desired-state check cannot
@@ -661,9 +663,9 @@ export function invalidateCodexModelsCacheWithPermit(
661
663
  // routed cache write — re-read intent under this permit, same as the commit.
662
664
  // The catalog-only sync override applies here too so an explicit refresh
663
665
  // keeps the cache consistent with the catalog it just wrote.
664
- if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false;
666
+ if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return "desired_disabled";
665
667
  const catalogPath = readCodexCatalogPathForHome(owningCodexHome);
666
- if (!existsSync(catalogPath)) return false;
668
+ if (!existsSync(catalogPath)) return "missing_catalog";
667
669
  const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
668
670
  const models = catalog.models ?? catalog;
669
671
  const cachePath = join(owningCodexHome, "models_cache.json");
@@ -711,14 +713,22 @@ export function invalidateCodexModelsCacheWithPermit(
711
713
  // `cacheSynced` mean what its name and its consumers already assume, and what
712
714
  // `pullRemoteCatalog` and the early returns in `refreshCodexModelCatalog`
713
715
  // already assert: a write happened.
714
- if (!preparedBytesDifferFromDisk(preparedCache)) return false;
716
+ if (!preparedBytesDifferFromDisk(preparedCache)) return "unchanged";
715
717
  replaceCodexModelsCache(permit, owningCodexHome, preparedCache);
716
- return true;
718
+ return "written";
717
719
  } catch {
718
- return false;
720
+ return "failed";
719
721
  }
720
722
  }
721
723
 
724
+ export function invalidateCodexModelsCacheWithPermit(
725
+ permit: CatalogWritePermit,
726
+ owningCodexHome: string,
727
+ options?: CodexCatalogSyncOptions,
728
+ ): boolean {
729
+ return invalidateCodexModelsCacheWithPermitOutcome(permit, owningCodexHome, options) === "written";
730
+ }
731
+
722
732
  export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): boolean {
723
733
  const owningCodexHome = getCodexHome();
724
734
  const outcome = withCatalogWriteSerialization(
@@ -46,7 +46,7 @@ export {
46
46
  export {
47
47
  syncCatalogModels,
48
48
  invalidateCodexModelsCache,
49
- invalidateCodexModelsCacheWithPermit,
49
+ invalidateCodexModelsCacheWithPermit, invalidateCodexModelsCacheWithPermitOutcome,
50
50
  } from "./retained-sync";
51
- export type { CodexCatalogSyncOptions } from "./retained-sync";
51
+ export type { CodexCacheInvalidationOutcome, CodexCatalogSyncOptions } from "./retained-sync";
52
52
  export { restoreCodexCatalog, restoreCodexCatalogWithPermit } from "./restore";
@@ -40,6 +40,7 @@ import { rootShells, type DesktopAppAdapter, type DesktopExec, type DesktopProce
40
40
  import { darwinDesktopAppAdapter, darwinDefaultExec } from "./desktop-app/darwin";
41
41
  import { linuxDesktopAppAdapter, linuxDefaultExec } from "./desktop-app/linux";
42
42
  import { windowsDesktopAppAdapter, windowsDefaultExec } from "./desktop-app/windows";
43
+ import { isTestHomeGuardArmed } from "../lib/test-home-guard";
43
44
 
44
45
  export type { DesktopAppExecOptions } from "./desktop-app/types";
45
46
 
@@ -81,6 +82,7 @@ export interface DesktopAppRestartIo {
81
82
 
82
83
  export type DesktopAppRestartReason =
83
84
  | "unsupported_platform"
85
+ | "test_environment"
84
86
  | "package_discovery_failed"
85
87
  | "process_probe_failed"
86
88
  | "no_targets"
@@ -213,6 +215,12 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp
213
215
  const adapter = io.adapter ?? selected?.adapter;
214
216
  const exec = io.execFile ?? selected?.exec;
215
217
  if (!adapter || !exec) return skipped("unsupported_platform");
218
+ // In an armed test process a call without an injected exec would reach the real OS (an injected
219
+ // adapter still execs through the platform default): on a developer Mac, `performCodexRestart`
220
+ // tests quit the user's ChatGPT (Codex) app and relaunched it through `/usr/bin/open` with the
221
+ // runner's sandbox HOME, logged out. Armed means the test preload's flag, not NODE_ENV, for the
222
+ // reason test-home-guard gives: a real `NODE_ENV=test ocx ...` must still restart the app.
223
+ if (!io.execFile && isTestHomeGuardArmed()) return skipped("test_environment");
216
224
 
217
225
  // Step 0. Two restarts at once are destructive rather than merely wasteful: the
218
226
  // first quits and relaunches, the second sees the freshly started shell as a target
@@ -88,7 +88,15 @@ export function describeCodexDesktopSwitches(
88
88
  };
89
89
  }
90
90
 
91
- export async function applyCodexDesktopSwitches(
91
+ /**
92
+ * Re-run the Codex config injection so a setting that lives in `~/.codex/config.toml` follows the
93
+ * stored config NOW rather than at the next `ocx sync`.
94
+ *
95
+ * Shared by the Desktop switches (`codexDesktopAuthless`, `codexClientCompaction`) and the
96
+ * web-search sidecar's Codex-side key. Both write through the same artifact transaction, so the
97
+ * failure vocabulary — and the "run 'ocx sync' to retry" advice that reads it — has to be one thing.
98
+ */
99
+ export async function applyCodexConfigInjection(
92
100
  config: OcxConfig,
93
101
  ): Promise<CodexDesktopSwitchApply> {
94
102
  if (!shouldSyncCodexOnStart(config)) {
package/src/codex/home.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, posix, resolve, win32 } from "node:path";
4
- import { expandUserPath } from "../config";
4
+ import { expandUserPath } from "../config/paths";
5
5
  import { redactUserPath } from "../lib/redact";
6
6
 
7
7
  export type CodexHomeDeps = {
@@ -135,9 +135,48 @@ export function findWslWindowsCodexHome(deps: CodexHomeDeps = {}): string | null
135
135
  export function defaultCodexHome(deps: CodexHomeDeps = {}): string {
136
136
  const home = (deps.homedir ?? homedir)();
137
137
  const defaultHome = join(home, ".codex");
138
- const exists = deps.existsSync ?? existsSync;
139
- const detected = !exists(join(defaultHome, "config.toml")) ? findWslWindowsCodexHome(deps) : null;
140
- return detected ?? defaultHome;
138
+ // A local ~/.codex that Codex is already using is the user's Codex home even before
139
+ // config.toml exists (a fresh install: login writes auth.json, first use writes
140
+ // sessions/ and history.jsonl). A local directory with none of that state is not
141
+ // evidence of a local Codex: before #5441 such a home let WSL discovery pick the
142
+ // Windows home, and existing WSL users who run against that Windows home must not
143
+ // be moved to an empty local one on upgrade.
144
+ if (localCodexHomeIsDirectory(defaultHome, deps) && localCodexHomeInUse(defaultHome, deps)) return defaultHome;
145
+ return findWslWindowsCodexHome(deps) ?? defaultHome;
146
+ }
147
+
148
+ function localCodexHomeInUse(home: string, deps: CodexHomeDeps): boolean {
149
+ // Files and directories Codex itself writes into a home it is using. Kept local: defaultCodexHome
150
+ // runs during other modules' initialisation (the storage workers reach it through an import
151
+ // cycle), and a module-level const declared below it is still in its temporal dead zone then.
152
+ return ["config.toml", "auth.json", "sessions", "history.jsonl"].some(entry => pathPresent(join(home, entry), deps));
153
+ }
154
+
155
+ /** stat-based presence: an unexpected stat error counts as present, never as a reason to switch homes. */
156
+ function pathPresent(path: string, deps: CodexHomeDeps): boolean {
157
+ const stat = deps.statSync ?? statSync;
158
+ try {
159
+ stat(path);
160
+ return true;
161
+ } catch (error) {
162
+ const code = (error as NodeJS.ErrnoException | null)?.code;
163
+ return !(code === "ENOENT" || code === "ENOTDIR");
164
+ }
165
+ }
166
+
167
+ function localCodexHomeIsDirectory(path: string, deps: CodexHomeDeps): boolean {
168
+ const stat = deps.statSync ?? statSync;
169
+ // stat, not existsSync: existsSync reports false for an access error too, and that
170
+ // must not read as "absent" and hand the user's state to a different home.
171
+ try {
172
+ return stat(path).isDirectory();
173
+ } catch (error) {
174
+ const code = (error as NodeJS.ErrnoException | null)?.code;
175
+ if (code === "ENOENT" || code === "ENOTDIR") return false;
176
+ // An unreadable local home is still the local home; never switch to a
177
+ // different Codex home because a stat failed for an unexpected reason.
178
+ return true;
179
+ }
141
180
  }
142
181
 
143
182
  export function resolveCodexHomeDir(deps: CodexHomeDeps = {}): string {