@bitkyc08/opencodex 2.89.0 → 2.91.0

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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -5,7 +5,14 @@ import { selfLaunchArgv } from "../lib/self-launch-argv";
5
5
  import { windowsEnvIndirectBatchValue } from "../lib/win-paths";
6
6
 
7
7
  const SHIM_MARKER = "opencodex codex autostart shim";
8
- const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 3";
8
+ // Revision 4: OCX_SHIM_BYPASS skips loading the service API token. Bumping the marker
9
+ // makes installed revision-3 wrappers obsolete so the existing refresh path rewrites them.
10
+ const UNIX_SHIM_REVISION_MARKER = "opencodex unix codex shim revision 4";
11
+ // Windows CMD and PowerShell wrappers carry their own revision. Revision 1 is the first one that
12
+ // skips the service token under OCX_SHIM_BYPASS; a marked wrapper without it is obsolete and
13
+ // install/auto-restore rewrite it in place. (The extensionless Git-Bash wrapper is the Unix
14
+ // template and carries the Unix revision.)
15
+ const WINDOWS_SHIM_REVISION_MARKER = "opencodex windows codex shim revision 1";
9
16
 
10
17
  const CODEX_SHIM_REENTRY_EXIT_CODE = 126;
11
18
  const CODEX_SHIM_REENTRY_DIAGNOSTIC = "opencodex: saved Codex launcher resolved back to the autostart shim; run ocx codex-shim uninstall and reinstall Codex before enabling codexAutoStart.";
@@ -122,7 +129,9 @@ esac
122
129
  # redispatch reaches depth 2 and is rejected before it can form an infinite chain.
123
130
  OCX_SHIM_ACTIVE_PID=$$
124
131
  export OCX_SHIM_ACTIVE_PID OCX_SHIM_ACTIVE_DEPTH
125
- if [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then
132
+ # A bypassed launch is native passthrough: it never talks to this proxy, so it must not
133
+ # hand the proxy's data-plane token to the real Codex and everything Codex spawns.
134
+ if [ -z "$OCX_SHIM_BYPASS" ] && [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then
126
135
  OPENCODEX_API_AUTH_TOKEN="$(cat ${shQuote(tokenFile)})"
127
136
  export OPENCODEX_API_AUTH_TOKEN
128
137
  fi
@@ -190,13 +199,14 @@ export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cl
190
199
  const cliArgs = selfLaunchArgv([], { sourceEntrypoint: cliPath, isStandaloneExecutable: bunRuntimeSource === "standalone" });
191
200
  return `@echo off\r
192
201
  rem ${SHIM_MARKER}\r
202
+ rem ${WINDOWS_SHIM_REVISION_MARKER}\r
193
203
  setlocal\r
194
204
  ${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r
195
205
  ${windowsBatchSet("OCX_BUN", bunPath)}\r
196
206
  ${cliArgs.length ? windowsBatchSet("OCX_CLI", cliArgs[0]!) : ""}\r
197
207
  ${windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath())}\r
198
- if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r
199
208
  if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r
209
+ if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r
200
210
  goto scan_codex_args\r
201
211
  :scan_codex_args\r
202
212
  if "%~1"=="" goto ensure_ocx\r
@@ -240,10 +250,11 @@ export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath:
240
250
  .map(arg => `${psString(arg)} `).join("");
241
251
  return `#!/usr/bin/env pwsh
242
252
  # ${SHIM_MARKER}
253
+ # ${WINDOWS_SHIM_REVISION_MARKER}
243
254
  $hadApiAuthToken = Test-Path Env:\\OPENCODEX_API_AUTH_TOKEN
244
255
  $priorApiAuthToken = $env:OPENCODEX_API_AUTH_TOKEN
245
256
  try {
246
- if (-not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(tokenFile)})) {
257
+ if (-not $env:OCX_SHIM_BYPASS -and -not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(tokenFile)})) {
247
258
  $env:OPENCODEX_API_AUTH_TOKEN = (Get-Content -Raw -LiteralPath ${psString(tokenFile)}).Trim()
248
259
  }
249
260
  $internalCommands = @(${internalCommands})
@@ -294,4 +305,4 @@ function gitBashPath(path: string): string {
294
305
  return path.replace(/\\/g, "/");
295
306
  }
296
307
 
297
- export { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, CODEX_SHIM_REENTRY_EXIT_CODE, CODEX_SHIM_REENTRY_DIAGNOSTIC, shQuote, windowsBatchSet, psString, gitBashPath };
308
+ export { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER, WINDOWS_SHIM_REVISION_MARKER, CODEX_SHIM_REENTRY_EXIT_CODE, CODEX_SHIM_REENTRY_DIAGNOSTIC, shQuote, windowsBatchSet, psString, gitBashPath };
@@ -0,0 +1,224 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, openSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, extname, join } from "node:path";
4
+ import { sameFingerprintAfterRename, sameStableShimPathProbe, shimPathFingerprint, stableShimPathProbe, type StableShimPathProbe } from "./shim-fingerprint";
5
+
6
+ /**
7
+ * Identity of a directory entry: device, inode and whether the entry is itself a symbolic link.
8
+ *
9
+ * dev/ino alone is not an identity on Linux: ext4 and tmpfs hand a freed inode number straight to the
10
+ * next file or link created, so a stage unlinked and replaced by a link (or a new file) can carry the
11
+ * very numbers this refresh recorded. Every identity this module compares therefore belongs to a file
12
+ * it still holds open (an open inode cannot be freed, so its number cannot be reused), and the link
13
+ * flag is compared too, so a symlink never matches a regular file whatever the platform's link(2)
14
+ * does with a symlink source.
15
+ */
16
+ interface EntryIdentity { dev: bigint; ino: bigint; link: boolean }
17
+
18
+ /** Identity of the directory entry itself (a link is its own entry); null when absent. */
19
+ function entryIdentity(path: string): EntryIdentity | null {
20
+ try {
21
+ const entry = lstatSync(path, { bigint: true });
22
+ return { dev: entry.dev, ino: entry.ino, link: entry.isSymbolicLink() };
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /** Identity of what a path resolves to; null when absent or dangling. */
29
+ function targetIdentity(path: string): EntryIdentity | null {
30
+ try {
31
+ const target = statSync(path, { bigint: true });
32
+ return { dev: target.dev, ino: target.ino, link: false };
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ const same = (left: EntryIdentity | null, right: EntryIdentity | null): boolean =>
39
+ !!left && !!right && left.dev === right.dev && left.ino === right.ino && left.link === right.link;
40
+
41
+ /** The entry at `path` is exactly `identity` (same inode, same link-ness). */
42
+ function entryIs(path: string, identity: EntryIdentity): boolean {
43
+ return same(entryIdentity(path), identity);
44
+ }
45
+
46
+ /** An open regular file and its identity; the descriptor pins the inode for as long as it is open. */
47
+ interface HeldFile { fd: number; identity: EntryIdentity }
48
+
49
+ function release(held: HeldFile | null): void {
50
+ if (held) { try { closeSync(held.fd); } catch { /* already closed */ } }
51
+ }
52
+
53
+ /** Create `path` exclusively (never through an existing entry or link), write it, and keep it open. */
54
+ function createExclusive(path: string, content: string): HeldFile {
55
+ const flags = constants.O_RDWR | constants.O_CREAT | constants.O_EXCL
56
+ | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW);
57
+ const fd = openSync(path, flags, 0o644);
58
+ try {
59
+ writeFileSync(fd, content, "utf8");
60
+ const stat = fstatSync(fd, { bigint: true });
61
+ return { fd, identity: { dev: stat.dev, ino: stat.ino, link: false } };
62
+ } catch (error) {
63
+ try { closeSync(fd); } catch { /* already closed */ }
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ /** Open the regular file at `path` without following a link and confirm the entry is the opened file. */
69
+ function holdExisting(path: string): HeldFile | null {
70
+ const entry = entryIdentity(path);
71
+ if (!entry || entry.link) return null;
72
+ const flags = process.platform === "win32"
73
+ ? constants.O_RDONLY
74
+ : constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
75
+ let fd: number;
76
+ try { fd = openSync(path, flags); } catch { return null; }
77
+ try {
78
+ const stat = fstatSync(fd, { bigint: true });
79
+ const identity: EntryIdentity = { dev: stat.dev, ino: stat.ino, link: false };
80
+ if (stat.isFile() && same(identity, entry) && entryIs(path, identity)) return { fd, identity };
81
+ } catch { /* unreadable descriptor: treated like a mismatch */ }
82
+ // Every unsuccessful path releases the descriptor; the caller then hands the moved file back.
83
+ try { closeSync(fd); } catch { /* already closed */ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Hard-link `source` onto the empty `destination` and keep the result only if the published entry
89
+ * is exactly `expected`. If `source` was swapped (for another file or a link) between the caller's
90
+ * check and the link, the entry this call created is withdrawn; an entry that matches neither the
91
+ * source nor its target belongs to an independent later publisher and stays.
92
+ */
93
+ function linkExpected(source: string, destination: string, expected: EntryIdentity): "published" | "occupied" | "refused" {
94
+ try {
95
+ linkSync(source, destination);
96
+ } catch (error) {
97
+ return (error as NodeJS.ErrnoException).code === "EEXIST" ? "occupied" : "refused";
98
+ }
99
+ const published = entryIdentity(destination);
100
+ if (same(published, expected)) return "published";
101
+ if (published && (same(published, entryIdentity(source)) || same(published, targetIdentity(source)))) {
102
+ try { unlinkSync(destination); } catch { /* nothing to withdraw */ }
103
+ }
104
+ return "refused";
105
+ }
106
+
107
+ /** Whether `directory` supports hard links, proven with a throwaway pair before anything is displaced. */
108
+ function hardLinksWork(directory: string): boolean {
109
+ const probe = join(directory, `.ocx-link-probe-${randomUUID()}`);
110
+ const linked = `${probe}-linked`;
111
+ let held: HeldFile;
112
+ try { held = createExclusive(probe, ""); } catch { return false; }
113
+ try {
114
+ linkSync(probe, linked);
115
+ return entryIs(linked, held.identity);
116
+ } catch {
117
+ return false;
118
+ } finally {
119
+ for (const path of [linked, probe]) if (entryIs(path, held.identity)) { try { unlinkSync(path); } catch { /* best effort */ } }
120
+ release(held);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Replace an owned, obsolete Windows wrapper without ever truncating, replacing or writing through
126
+ * a file someone else published (an npm or version-manager install racing this refresh, or an entry
127
+ * planted beside the wrapper).
128
+ *
129
+ * 1. Move the observed wrapper aside, open it, and prove the moved file is the one that was probed;
130
+ * a different file means a concurrent replacement, which is put back untouched.
131
+ * 2. Create the stage exclusively under an unpredictable name and keep it open.
132
+ * 3. Publish by hard link onto the empty path, keeping the result only if it is that stage file.
133
+ *
134
+ * Every restore, withdrawal and cleanup is bound to the identity of a file this refresh holds open:
135
+ * only the stage it created and only the moved copy of the observed wrapper are ever published or
136
+ * unlinked. Returns true only when the current wrapper was published; a filesystem that refuses hard
137
+ * links leaves the obsolete wrapper restored and the refresh deferred.
138
+ */
139
+ export function refreshObsoleteWindowsShim(
140
+ wrapperPath: string,
141
+ observed: StableShimPathProbe,
142
+ render: () => string,
143
+ /** Test seam only: a fixed stage path, so a planted entry can be placed where the stage goes. */
144
+ stagedPathForTests?: string,
145
+ ): boolean {
146
+ const extension = extname(wrapperPath);
147
+ const aside = `${wrapperPath}.ocx-obsolete-${randomUUID()}${extension}`;
148
+ const staged = stagedPathForTests ?? `${wrapperPath}.ocx-refresh-${randomUUID()}${extension}`;
149
+ let moved: HeldFile | null = null;
150
+ let stage: HeldFile | null = null;
151
+ const discard = (path: string, held: HeldFile | null): void => {
152
+ if (held && entryIs(path, held.identity)) { try { unlinkSync(path); } catch { /* already gone */ } }
153
+ };
154
+ /**
155
+ * Put the moved wrapper back only onto an empty path, and only while the aside is still it. If the
156
+ * link fails (hard links became unavailable mid-refresh), an identity-checked rename keeps the
157
+ * launcher from staying missing.
158
+ */
159
+ const restoreAside = (): void => {
160
+ if (!moved || existsSync(wrapperPath) || !entryIs(aside, moved.identity)) return;
161
+ if (linkExpected(aside, wrapperPath, moved.identity) === "published") { discard(aside, moved); return; }
162
+ if (!existsSync(wrapperPath) && entryIs(aside, moved.identity)) { try { renameSync(aside, wrapperPath); } catch { /* the aside copy stays recoverable */ } }
163
+ };
164
+
165
+ // Never displace a working wrapper on a filesystem that cannot publish its replacement.
166
+ if (!hardLinksWork(dirname(wrapperPath))) return false;
167
+ try { renameSync(wrapperPath, aside); } catch { return false; }
168
+ try {
169
+ const movedFingerprint = shimPathFingerprint(aside);
170
+ moved = holdExisting(aside);
171
+ if (!moved || !movedFingerprint || !sameFingerprintAfterRename(observed.fingerprint, movedFingerprint)) {
172
+ // Not the observed wrapper: hand the path back to whatever was moved, untouched.
173
+ const foreign = entryIdentity(aside);
174
+ if (foreign && !existsSync(wrapperPath) && linkExpected(aside, wrapperPath, foreign) === "published" && entryIs(aside, foreign)) {
175
+ try { unlinkSync(aside); } catch { /* the restored link keeps the content */ }
176
+ }
177
+ return false;
178
+ }
179
+ try {
180
+ stage = createExclusive(staged, render());
181
+ } catch (error) {
182
+ restoreAside();
183
+ const code = (error as NodeJS.ErrnoException).code;
184
+ if (code === "EEXIST" || code === "ELOOP") return false;
185
+ throw error;
186
+ }
187
+ if (!entryIs(staged, stage.identity)) {
188
+ restoreAside();
189
+ return false;
190
+ }
191
+ const outcome = linkExpected(staged, wrapperPath, stage.identity);
192
+ discard(staged, stage);
193
+ if (outcome === "published") {
194
+ discard(aside, moved);
195
+ return true;
196
+ }
197
+ // Occupied: a concurrent publisher owns the path now; only our obsolete copy is discarded.
198
+ if (outcome === "occupied") discard(aside, moved);
199
+ else restoreAside();
200
+ return false;
201
+ } finally {
202
+ release(stage);
203
+ release(moved);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Automatic Windows revision recovery: refresh exactly the admitted obsolete wrappers, and only
209
+ * while every current sibling is still the file observed before the stability wait. It never
210
+ * adopts a replacement launcher or rewrites a backup; anything unexpected defers.
211
+ */
212
+ export function refreshAdmittedWindowsShims(
213
+ obsolete: ReadonlyMap<string, StableShimPathProbe>,
214
+ current: ReadonlyMap<string, StableShimPathProbe>,
215
+ render: (wrapperPath: string) => string,
216
+ ): boolean {
217
+ for (const [path, first] of current) {
218
+ const second = stableShimPathProbe(path);
219
+ if (!second || !sameStableShimPathProbe(first, second)) return false;
220
+ }
221
+ let all = true;
222
+ for (const [path, observed] of obsolete) all = refreshObsoleteWindowsShim(path, observed, () => render(path)) && all;
223
+ return all;
224
+ }
package/src/codex/shim.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from "./shim-templates";
27
27
  import {
28
28
  hasUsableBackingPath,
29
+ isCurrentShimProbe,
29
30
  isCurrentUnixShimProbe,
30
31
  isHealthyShimProbe,
31
32
  isVersionManagerOwnedCodexPath,
@@ -55,6 +56,7 @@ import {
55
56
  type UnixShimProbeResult,
56
57
  } from "./shim-probe";
57
58
  import { tryAcquireShimRestoreLock } from "./shim-restore-lock";
59
+ import { refreshAdmittedWindowsShims, refreshObsoleteWindowsShim } from "./shim-windows-refresh";
58
60
  import {
59
61
  isWindowsInteropDir,
60
62
  isExecutableCodexCandidate,
@@ -348,24 +350,22 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry
348
350
  * updater's wrapper can carry them, and a replacement landing between the write
349
351
  * and the observation is otherwise indistinguishable from our own file.
350
352
  */
353
+ /** The Windows wrapper for `wrapperPath`'s flavor (.ps1, .cmd/.bat, or extensionless Git-Bash sh). */
354
+ function windowsShimContent(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): string {
355
+ const { bun, bunRuntimeSource, cli } = cliEntry(runtime);
356
+ const lower = wrapperPath.toLowerCase();
357
+ // UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI
358
+ // codepage, which mangles non-ASCII paths embedded in the shim.
359
+ if (lower.endsWith(".ps1")) return `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`;
360
+ if (lower.endsWith(".cmd") || lower.endsWith(".bat")) return buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource);
361
+ // Extensionless Git-Bash sh launcher: sh shim with forward-slash paths.
362
+ return buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath()));
363
+ }
364
+
351
365
  function writeShim(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): { dev: number; ino: number } | undefined {
352
366
  const { bun, bunRuntimeSource, cli } = cliEntry(runtime);
353
367
  if (process.platform === "win32") {
354
- const lower = wrapperPath.toLowerCase();
355
- if (lower.endsWith(".ps1")) {
356
- // UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI
357
- // codepage, which mangles non-ASCII paths embedded in the shim.
358
- writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`, "utf8");
359
- } else if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
360
- writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource), "utf8");
361
- } else {
362
- // Extensionless Git-Bash sh launcher: sh shim with forward-slash paths.
363
- writeFileSync(
364
- wrapperPath,
365
- buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath())),
366
- "utf8",
367
- );
368
- }
368
+ writeFileSync(wrapperPath, windowsShimContent(wrapperPath, realCodexPath, runtime), "utf8");
369
369
  return undefined;
370
370
  } else {
371
371
  // Stage the wrapper as its own inode and rename it into place, so ownership
@@ -509,6 +509,15 @@ function refreshShimFile(file: ShimFileState, runtime: DurableBunRuntime): boole
509
509
  writeShim(file.wrapperPath, file.realPath ?? file.backupPath, runtime);
510
510
  return true;
511
511
  }
512
+ // An owned Windows wrapper from an older template revision (e.g. one that loads the service
513
+ // token before honoring OCX_SHIM_BYPASS) is replaced over the same backing launcher through an
514
+ // ownership-checked stage-and-link transaction that never clobbers a concurrent replacement.
515
+ if (process.platform === "win32" && existsSync(file.wrapperPath) && isShim(file.wrapperPath) && hasUsableBackingPath(file)) {
516
+ const probe = stableShimPathProbe(file.wrapperPath);
517
+ if (probe && probe.prefix.includes(SHIM_MARKER) && !isCurrentShimProbe(probe, "win32")) {
518
+ return refreshObsoleteWindowsShim(file.wrapperPath, probe, () => windowsShimContent(file.wrapperPath, file.realPath ?? file.backupPath, runtime));
519
+ }
520
+ }
512
521
  return false;
513
522
  }
514
523
 
@@ -884,7 +893,8 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
884
893
  if (filesBefore.length > 0 && filesBefore.every(file => {
885
894
  if (file.preserveOnly) return existsSync(file.backupPath) && !existsSync(file.originalPath);
886
895
  const probe = stableShimPathProbe(file.wrapperPath);
887
- return hasUsableBackingPath(file) && probe !== null && isHealthyShimProbe(probe, process.platform);
896
+ return hasUsableBackingPath(file) && probe !== null && isHealthyShimProbe(probe, process.platform)
897
+ && (process.platform !== "win32" || isCurrentShimProbe(probe, "win32"));
888
898
  })) {
889
899
  return { installed: false, message: "Codex autostart shim is already installed." };
890
900
  }
@@ -934,6 +944,12 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
934
944
  }
935
945
  let refreshed = false;
936
946
  for (const file of files) refreshed = refreshShimFile(file, runtime) || refreshed;
947
+ // A Windows refresh that refused to publish over a concurrent replacement is not a success.
948
+ if (process.platform === "win32" && files.some(file => {
949
+ if (file.preserveOnly) return false;
950
+ const probe = stableShimPathProbe(file.wrapperPath);
951
+ return !probe || !probe.prefix.includes(SHIM_MARKER) || !isCurrentShimProbe(probe, "win32");
952
+ })) return { installed: false, message: "Codex shim refresh deferred because a tracked launcher changed during the refresh." };
937
953
  const allInstalled = files.every(file => file.preserveOnly
938
954
  ? existsSync(file.backupPath) && !existsSync(file.originalPath)
939
955
  : existsSync(file.wrapperPath)
@@ -1106,7 +1122,7 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & {
1106
1122
  const replacementProbes = new Map<string, StableShimPathProbe>();
1107
1123
  const obsoleteShimProbes = new Map<string, StableShimPathProbe>();
1108
1124
  const seen = new Set<string>();
1109
- let healthyCount = 0;
1125
+ const currentShimProbes = new Map<string, StableShimPathProbe>();
1110
1126
  for (const file of files) {
1111
1127
  if (seen.has(file.wrapperPath)) return { status: "ineligible" };
1112
1128
  seen.add(file.wrapperPath);
@@ -1125,11 +1141,11 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & {
1125
1141
  if (!isHealthyShimProbe(probe, state.platform)) {
1126
1142
  return { status: "ineligible", message: destroyedShimMessage(file) };
1127
1143
  }
1128
- if (state.platform !== "win32" && !isCurrentUnixShimProbe(probe)) {
1144
+ if (!isCurrentShimProbe(probe, state.platform)) {
1129
1145
  obsoleteShimProbes.set(file.wrapperPath, probe);
1130
1146
  continue;
1131
1147
  }
1132
- healthyCount += 1;
1148
+ currentShimProbes.set(file.wrapperPath, probe);
1133
1149
  continue;
1134
1150
  }
1135
1151
  // A surviving backup would otherwise let the replacement path below wrap the
@@ -1143,7 +1159,10 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & {
1143
1159
 
1144
1160
  if (replacementProbes.size === 0 && obsoleteShimProbes.size === 0) return { status: "healthy" };
1145
1161
  if (!options.enabled()) return { status: "disabled" };
1146
- if (files.length > 1 && (healthyCount > 0 || (replacementProbes.size > 0 && obsoleteShimProbes.size > 0))) {
1162
+ // Windows current + obsolete siblings (a partial revision upgrade) resume: only the obsolete
1163
+ // wrappers are refreshed, so a transient publish failure cannot block recovery forever.
1164
+ const revisionOnlyMix = state.platform === "win32" && replacementProbes.size === 0;
1165
+ if (files.length > 1 && ((currentShimProbes.size > 0 && !revisionOnlyMix) || (replacementProbes.size > 0 && obsoleteShimProbes.size > 0))) {
1147
1166
  return {
1148
1167
  status: "deferred",
1149
1168
  message: "Codex shim auto-restore deferred because tracked launcher siblings are in a mixed shim/replacement state.",
@@ -1166,9 +1185,14 @@ export function autoRestoreCodexShim(options: RuntimePreflightDeps & {
1166
1185
  if (obsoleteShimProbes.size > 0) {
1167
1186
  for (const [path, firstProbe] of obsoleteShimProbes) {
1168
1187
  const secondProbe = stableShimPathProbe(path);
1169
- if (!secondProbe || isCurrentUnixShimProbe(secondProbe)
1188
+ if (!secondProbe || isCurrentShimProbe(secondProbe, state.platform)
1170
1189
  || !sameStableShimPathProbe(firstProbe, secondProbe)) return { status: "deferred" };
1171
1190
  }
1191
+ if (state.platform === "win32") {
1192
+ const realFor = (path: string) => { const file = files.find(entry => entry.wrapperPath === path)!; return file.realPath ?? file.backupPath; };
1193
+ return refreshAdmittedWindowsShims(obsoleteShimProbes, currentShimProbes, path => windowsShimContent(path, realFor(path), runtime))
1194
+ ? { status: "restored", message: `Upgraded Codex autostart shim at ${[...obsoleteShimProbes.keys()].join(", ")}.` } : { status: "deferred" };
1195
+ }
1172
1196
  const result = refreshObsoleteUnixShims(files);
1173
1197
  return result.installed
1174
1198
  ? { status: "restored", message: result.message }
@@ -38,6 +38,7 @@ import {
38
38
  isCanonicalOpenAiForwardProvider,
39
39
  } from "../providers/openai-tiers";
40
40
  import { routeModel, type RouteResult } from "../router";
41
+ import { guardFallbackHop, type FallbackAdmission } from "../routing/fallback-policy";
41
42
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
42
43
  import { codexAccountNamespaceForModel } from "./account-namespace-match";
43
44
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_MAIN_DRAIN_SENTINEL_MODELS } from "./catalog/native-models";
@@ -629,7 +630,8 @@ export function applySubagentModelFallback(
629
630
  modelEligibleAccountIdsForModel?: SubagentModelEligibleAccountIds,
630
631
  resolvedFallbackChain?: readonly string[] | null,
631
632
  restrictedRouteEligible?: SubagentFallbackRouteEligibility,
632
- ): { from?: string; to?: string; skipped?: string[] } | null {
633
+ admission?: FallbackAdmission,
634
+ ): { from?: string; to?: string; skipped?: string[]; refusal?: Response } | null {
633
635
  if (!isThreadSpawnRequest(headers)) return null;
634
636
  const fallbackChain = resolvedFallbackChain === undefined
635
637
  ? resolveSubagentFallbackChain(parsed, config)
@@ -653,10 +655,31 @@ export function applySubagentModelFallback(
653
655
  ? { from: parsed.modelId, to: parsed.modelId, skipped: selection.skipped }
654
656
  : null;
655
657
  const from = parsed.modelId;
658
+ // OCX-11: the rewrite is a proactive hop away from an unavailable primary. Refused, the spawn
659
+ // keeps its model and the caller answers with the refusal instead of sending anything.
660
+ const hop = guardFallbackHop(config, {
661
+ site: "subagent",
662
+ route: from,
663
+ from: fallbackTargetOf(config, from),
664
+ to: fallbackTargetOf(config, selection.model),
665
+ trigger: "unavailable",
666
+ admission,
667
+ now,
668
+ });
669
+ if (!hop.ok) return { from, to: from, skipped: selection.skipped, refusal: hop.response };
670
+ // The rewrite is this site's admission: the spawn now carries the fallback model.
671
+ hop.commit();
656
672
  rewriteParsedModel(parsed, selection.model);
657
673
  return { from, to: selection.model, skipped: selection.skipped };
658
674
  }
659
675
 
676
+ function fallbackTargetOf(config: OcxConfig, model: string): { provider: string; model: string } {
677
+ const route = tryRouteFallbackModel(config, model);
678
+ if (route) return { provider: route.providerName, model: route.modelId };
679
+ const slash = model.indexOf("/");
680
+ return slash > 0 ? { provider: model.slice(0, slash), model: model.slice(slash + 1) } : { provider: "", model };
681
+ }
682
+
660
683
  /** Resolve the effective fallback chain once for one logical spawn request. */
661
684
  export function resolveSubagentFallbackChain(
662
685
  parsed: OcxParsedRequest,
@@ -2,6 +2,7 @@ import { parseResetCooldownMs } from "../codex/routing";
2
2
  import { classifyError, isCyberPolicyCode } from "../lib/errors";
3
3
  import { isNonReplayableUpstreamCode } from "../lib/upstream-retry";
4
4
  import { isAnthropicBillingFailure } from "../providers/anthropic-billing-refusal";
5
+ import { isKiroAccountProfileRefusal } from "../adapters/kiro-refusal";
5
6
  import type { OcxComboTarget } from "../types";
6
7
  import { targetKey } from "./types";
7
8
  import {
@@ -772,7 +773,7 @@ export function codexAccountModelRefusal(
772
773
  export function comboFailureDecision(
773
774
  status: number,
774
775
  message: string,
775
- options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal },
776
+ options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal; failedAdapter?: string },
776
777
  ): ComboFailureDecision {
777
778
  if (status === 499) return "stop";
778
779
  if (message.toLowerCase().includes("origin_rejected")) return "stop";
@@ -856,6 +857,12 @@ export function comboFailureDecision(
856
857
  // could have served the request (#4903).
857
858
  if (isResponseFormatCapabilityRefusal(status, message, options?.code)) return "hop";
858
859
  if (isAnthropicBillingFailure(status, message, options?.code)) return "hop";
860
+ // Kiro's body validator rejects the request for the identity it was routed under. The account
861
+ // layer rotates logins first; this is the no-alternate case, so the next combo target is the
862
+ // only remaining chance to serve the turn. Only a caller that knows the failed target is a Kiro
863
+ // adapter grants it (the words alone prove nothing for another provider); it must precede the
864
+ // generic invalid_request_error stop.
865
+ if (options?.failedAdapter === "kiro" && isKiroAccountProfileRefusal(status, message)) return "hop";
859
866
  if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
860
867
  return "stop";
861
868
  }
@@ -23,6 +23,7 @@ export {
23
23
  advanceComboAfterFailure,
24
24
  clearComboSelectionState,
25
25
  comboExclusionReasons,
26
+ comboTargetSkipTrigger,
26
27
  NoAvailableComboTargetsError,
27
28
  noteComboFailure,
28
29
  noteComboSuccess,
@@ -74,6 +74,18 @@ function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget, now:
74
74
  return providerExclusionReason(config, target, now) === undefined;
75
75
  }
76
76
 
77
+ /**
78
+ * OCX-11: the coarse fallback trigger for a target the picker passed over for a reason other
79
+ * than a cooldown (provider missing or disabled, cached quota spent, ineligible for the request).
80
+ */
81
+ export function comboTargetSkipTrigger(
82
+ config: OcxConfig,
83
+ target: OcxComboTarget,
84
+ now = Date.now(),
85
+ ): "quota_exhausted" | "unavailable" {
86
+ return providerExclusionReason(config, target, now) === "quota exhausted" ? "quota_exhausted" : "unavailable";
87
+ }
88
+
77
89
  /**
78
90
  * Why each target of a combo cannot be picked right now, for a zero-attempt refusal. Only the
79
91
  * configured provider/model and a coarse label are named, never account or request data.
@@ -11,6 +11,7 @@ import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-r
11
11
  import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility";
12
12
  import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
13
13
  import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback";
14
+ import { fallbackPolicyError } from "./schema/fallback-policy";
14
15
  import type { OcxConfig } from "../types";
15
16
  import { anthropicAccountPoolConfigError, anthropicOAuthInstanceConfigError, anthropicSidecarConfigError } from "./schema/anthropic-account-pool";
16
17
  import { configReasoningPinsConfigError } from "./provider-validation";
@@ -145,6 +146,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
145
146
  if (blockedModelRedirectsError(rawParsed)) warnings.push("blockedModelRedirects ignored: expected a map of nonempty model keys to nonempty string targets");
146
147
  if (nativeReasoningRetentionConfigError(rawParsed)) warnings.push("nativeReasoningRetention ignored: expected only optional boolean modelSwitch and accountSwitch fields; both remain off");
147
148
  if (mainAccountHardLockFallbackError(rawParsed)) warnings.push("codexMainAccountHardLockFallback ignored: expected a nonblank model selector and an optional declared reasoningEffort");
149
+ if (fallbackPolicyError(rawParsed)) warnings.push("fallback partly ignored: expected boolean model/provider switches, providers/routes/devices maps of them, and devices.<id>.localFallback; ignored leaves keep today's fallback behavior");
148
150
  const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized);
149
151
  if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning);
150
152
  if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
@@ -729,6 +731,9 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
729
731
  const anthropicError = anthropicOAuthInstanceConfigError(value) ?? anthropicAccountPoolConfigError(value) ?? anthropicSidecarConfigError(value);
730
732
  if (anthropicError) return { ok: false, error: anthropicError };
731
733
  const boundaryError = blockedModelRedirectsError(value)
734
+ // OCX-11: the read schema degrades a malformed fallback leaf to absent, which would silently
735
+ // drop a refusal on the next whole-config save; the write boundary rejects it instead.
736
+ ?? fallbackPolicyError(value)
732
737
  ?? nativeReasoningRetentionConfigError(value)
733
738
  ?? mainAccountHardLockFallbackError(value)
734
739
  ?? compactionRecoveryConfigError(value) ?? desktopCompatibilityConfigError(value) ?? configReasoningPinsConfigError(value)
@@ -7,6 +7,7 @@ import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-r
7
7
  import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility";
8
8
  import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
9
9
  import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback";
10
+ import { fallbackPolicyError } from "./schema/fallback-policy";
10
11
  import { anthropicSidecarConfigError } from "./schema/anthropic-account-pool";
11
12
  import {
12
13
  modelPinnedEffortsConfigError,
@@ -142,6 +143,7 @@ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxCon
142
143
  if (desktopCompatibilityConfigError(rawParsed)) console.warn("Invalid desktopCompatibility startup preference ignored; no desktop compatibility service will start automatically.");
143
144
  if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available");
144
145
  if (mainAccountHardLockFallbackError(rawParsed)) console.warn("⚠️ invalid codexMainAccountHardLockFallback ignored; the main-account hard lock still refuses while blocked");
146
+ if (fallbackPolicyError(rawParsed)) console.warn("⚠️ invalid fallback leaves ignored; those scopes keep today's fallback behavior");
145
147
  const chatgptDesktop = chatgptDesktopConfigIssue(rawParsed);
146
148
  if (chatgptDesktop) console.warn(`⚠️ config.json ${chatgptDesktop} — the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`);
147
149
  warnDegradedStreamMode(rawParsed, validated);
@@ -463,7 +463,7 @@ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = f
463
463
  if (allowTombstones && row === null) continue;
464
464
  if (!capabilityRecord(row)) return "modelCapabilities rows must be plain objects";
465
465
  for (const [axis, declaration] of Object.entries(row)) {
466
- if (!["inputModalities", "contextTier", "video"].includes(axis)) return "modelCapabilities contains an unknown axis";
466
+ if (!["inputModalities", "contextTier", "video", "deepseekImageFiles"].includes(axis)) return "modelCapabilities contains an unknown axis";
467
467
  if (allowTombstones && declaration === null) continue;
468
468
  if (axis === "inputModalities") {
469
469
  if (!Array.isArray(declaration) || declaration.length === 0
@@ -472,6 +472,8 @@ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = f
472
472
  }
473
473
  } else if (axis === "contextTier") {
474
474
  if (declaration !== "default" && declaration !== "long_context") return "modelCapabilities contextTier must be default or long_context";
475
+ } else if (axis === "deepseekImageFiles") {
476
+ if (typeof declaration !== "boolean") return "modelCapabilities deepseekImageFiles must be a boolean";
475
477
  } else {
476
478
  if (!capabilityRecord(declaration) || Object.keys(declaration).some(key => key !== "processing")) {
477
479
  return "modelCapabilities video must be a plain object containing only processing";
@@ -501,6 +503,9 @@ export function mergeModelCapabilities(
501
503
  } else if (axis === "contextTier") {
502
504
  if (value === null) delete row.contextTier;
503
505
  else row.contextTier = value as ModelCapabilities["contextTier"];
506
+ } else if (axis === "deepseekImageFiles") {
507
+ if (value === null) delete row.deepseekImageFiles;
508
+ else row.deepseekImageFiles = value as boolean;
504
509
  } else if (axis === "video") {
505
510
  if (value === null) delete row.video;
506
511
  else {
@@ -528,7 +533,7 @@ export function sanitizeModelCapabilitiesForLoad(value: unknown): Record<string,
528
533
  for (const [id, raw] of Object.entries(value).slice(0, MODEL_DISCOVERY_MAX_MODELS)) {
529
534
  if (!capabilityRecord(raw) || !isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) continue;
530
535
  const row: Record<string, unknown> = {};
531
- for (const axis of ["inputModalities", "contextTier", "video"] as const) {
536
+ for (const axis of ["inputModalities", "contextTier", "video", "deepseekImageFiles"] as const) {
532
537
  if (!Object.hasOwn(raw, axis)) continue;
533
538
  const declaration = raw[axis];
534
539
  if (modelCapabilitiesConfigError({ [id]: { [axis]: declaration } }) === null) row[axis] = declaration;
@@ -8,6 +8,7 @@ import { nativeReasoningRetentionSchema } from "./native-reasoning-retention";
8
8
  import { desktopCompatibilitySchema } from "./desktop-compatibility";
9
9
  import { blockedModelRedirectsSchema } from "./blocked-model-redirects";
10
10
  import { mainAccountHardLockFallbackSchema } from "./main-account-hard-lock-fallback";
11
+ import { fallbackPolicySchema } from "./fallback-policy";
11
12
  import {
12
13
  agentTaskRecoverySchema,
13
14
  catalogAutoRefreshSchema,
@@ -241,6 +242,8 @@ export const configSchema = z.object({
241
242
  // A malformed fallback section disables only the reroute, never the lock's refusal
242
243
  // behavior; the management write boundary still rejects the bad value.
243
244
  codexMainAccountHardLockFallback: mainAccountHardLockFallbackSchema.optional().catch(undefined),
245
+ // OCX-11: a malformed section reads as absent, which keeps today's fallback behavior.
246
+ fallback: fallbackPolicySchema.optional().catch(undefined),
244
247
  // #2999: a malformed hand edit falls back to auto detection rather than rejecting the config.
245
248
  nativeMainRefresh: z.enum(["codex", "opencodex"]).optional().catch(undefined),
246
249
  // Future versions remain opaque through passthrough-compatible whole-config saves.