@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
package/src/config.ts CHANGED
@@ -1,10 +1,25 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { join, resolve } from "node:path";
5
+ import { dirname, join, resolve } from "node:path";
6
6
  import { Database } from "bun:sqlite";
7
7
  import * as z from "zod/v4";
8
+ import {
9
+ bumpConfigGenerationAtPath,
10
+ bumpCurrentConfigGeneration,
11
+ initializeConfigGeneration,
12
+ observeConfigGenerationAtPath,
13
+ readConfigGenerationAtPath,
14
+ readConfigGenerationInTransaction,
15
+ type ConfigGenerationObservation,
16
+ } from "./codex/generation";
17
+ import type {
18
+ BumpConfigGeneration,
19
+ ConfigGeneration,
20
+ ReadConfigGeneration,
21
+ WithExpectedConfigGenerationSync,
22
+ } from "./codex/convergence-types";
8
23
  import {
9
24
  CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
10
25
  codexAccountNamespaceForModel,
@@ -13,8 +28,9 @@ import {
13
28
  MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
14
29
  } from "./codex/account-namespace-match";
15
30
  import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
31
+ import { routingProfileIssues } from "./routing/profile";
16
32
  import {
17
- forgetHardenedSecretPath,
33
+ forgetEphemeralSecretPath,
18
34
  hardenSecretDir,
19
35
  hardenSecretPath,
20
36
  hardenSecretPathAsync,
@@ -23,11 +39,13 @@ import {
23
39
  import { recordOwnedConfigPath } from "./lib/config-ownership";
24
40
  import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
25
41
  import { providerDestinationConfigError } from "./lib/destination-policy";
42
+ import { redactSecretString } from "./lib/redact";
26
43
  import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
27
44
  import {
28
45
  isWirePinnedModel,
29
46
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
30
47
  OPENAI_PROVIDER_TIER_VERSION,
48
+ pinnedWireAdapter,
31
49
  REASONING_SUMMARY_DELIVERY_VALUES,
32
50
  type OcxClaudeCodeConfig,
33
51
  type OcxConfig,
@@ -35,6 +53,12 @@ import {
35
53
  type OcxProviderConfig,
36
54
  } from "./types";
37
55
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
56
+ import {
57
+ getProviderRegistryEntry,
58
+ providerMatchesRegistryTransport,
59
+ providerModelWireDefault,
60
+ } from "./providers/registry";
61
+ import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models";
38
62
  import { parseDesktopProfile } from "./claude/desktop-profile";
39
63
  import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
40
64
  import {
@@ -42,6 +66,7 @@ import {
42
66
  MAX_APP_OWNED_MEMORY_BUDGET_MB,
43
67
  MIN_APP_OWNED_MEMORY_BUDGET_MB,
44
68
  } from "./lib/app-owned-memory";
69
+ import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy";
45
70
 
46
71
  let _atomicSeq = 0;
47
72
 
@@ -104,25 +129,92 @@ function isMissingPathError(error: unknown): boolean {
104
129
  return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
105
130
  }
106
131
 
132
+ /**
133
+ * Resolve a write target through any symlink before the temp+rename dance.
134
+ *
135
+ * rename(2) replaces a directory ENTRY. When the entry is itself a symlink
136
+ * (a dotfiles-managed `~/.codex/config.toml` -> `~/dotfiles/.codex/config.toml`,
137
+ * say), renaming a sibling temp file over it destroys the link and leaves a plain
138
+ * file behind — the repo silently stops receiving writes. Resolving first puts both
139
+ * the temp file and the rename target inside the link's real directory, so the entry
140
+ * being replaced is the real file and the symlink survives.
141
+ *
142
+ * Same-filesystem atomicity is preserved because the temp file stays beside its
143
+ * resolved target. A genuinely absent destination (not yet created) falls back to
144
+ * the literal path, which is the correct target for a first write.
145
+ *
146
+ * An EXISTING symlink that cannot be resolved — dangling because its target volume
147
+ * is unmounted, an ELOOP chain, an EACCES parent — is refused instead. Falling back
148
+ * to the literal path there would let the rename replace the link, recreating the
149
+ * exact dotfiles-divergence failure this helper exists to prevent (audit: wt4 wp2).
150
+ */
151
+ export function resolveWriteTarget(path: string): string {
152
+ try {
153
+ return realpathSync(path);
154
+ } catch (cause) {
155
+ let entry;
156
+ try {
157
+ entry = lstatSync(path);
158
+ } catch (error) {
159
+ if (isMissingPathError(error)) return path; // no entry at all — first write
160
+ throw error;
161
+ }
162
+ if (entry.isSymbolicLink()) {
163
+ throw new Error(`refusing to replace unresolvable symlinked write target: ${path}`, { cause });
164
+ }
165
+ return path;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Re-apply the real-home guard to a RESOLVED write target.
171
+ *
172
+ * Callers such as saveConfig check only their logical config dir, which passes when
173
+ * OPENCODEX_HOME points at a temp fixture. Following a symlink out of that fixture
174
+ * would land on the protected home the caller's own check just cleared, so the guard
175
+ * has to run again on wherever the write actually terminates. Inert in production,
176
+ * where the guard is disarmed.
177
+ */
178
+ function assertResolvedTargetAllowed(path: string, target: string): void {
179
+ // The file itself may resolve literally while its PARENT is a symlink out
180
+ // of the fixture (a first write beneath a symlinked config dir). Guard the
181
+ // directory the write actually lands in either way.
182
+ if (target === path) {
183
+ let realParent: string;
184
+ try {
185
+ realParent = realpathSync(dirname(target));
186
+ } catch {
187
+ return; // unresolvable parent: resolveWriteTarget already owns that refusal
188
+ }
189
+ if (realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent);
190
+ return;
191
+ }
192
+ assertNotRealHomeUnderTest(dirname(target));
193
+ }
194
+
107
195
  export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO = {
108
196
  write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
109
197
  harden: target => {
110
198
  try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
111
- if (process.platform === "win32") hardenSecretPath(target, { required: true });
199
+ // Timeout memo keyed by the stable destination (matches the async writer):
200
+ // a failed temp harden must not mint a new unique-temp key on every write.
201
+ if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: path });
112
202
  },
113
203
  rename: renameAtomicFile,
114
204
  truncate: target => truncateSync(target, 0),
115
205
  unlink: unlinkSync,
116
206
  }): void {
117
207
  recordOwnedConfigPath(resolveConfigDir(), path);
118
- const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
208
+ const target = resolveWriteTarget(path);
209
+ assertResolvedTargetAllowed(path, target);
210
+ const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
119
211
  let hardened = false;
120
212
  try {
121
213
  io.write(tmp, content);
122
214
  io.harden(tmp);
123
215
  hardened = true;
124
- io.rename(tmp, path);
125
- forgetHardenedSecretPath(tmp);
216
+ io.rename(tmp, target);
217
+ forgetEphemeralSecretPath(tmp);
126
218
  } catch (cause) {
127
219
  let scrubbed = false;
128
220
  try {
@@ -149,7 +241,7 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
149
241
  if (!removed && !hardened) {
150
242
  try { io.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
151
243
  }
152
- if (removed) forgetHardenedSecretPath(tmp);
244
+ if (removed) forgetEphemeralSecretPath(tmp);
153
245
  if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
154
246
  throw cause;
155
247
  }
@@ -164,6 +256,11 @@ export interface AtomicWriteAsyncIO {
164
256
  unlink: (path: string) => void | Promise<void>;
165
257
  }
166
258
 
259
+ /** Test-only crash seam. Production callers leave this undefined. */
260
+ export interface AtomicWriteAsyncTestSeam {
261
+ afterTempWrite?: (tempPath: string) => void | Promise<void>;
262
+ }
263
+
167
264
  async function renameAtomicFileAsync(source: string, destination: string): Promise<void> {
168
265
  for (let attempt = 0; ; attempt += 1) {
169
266
  try {
@@ -188,6 +285,7 @@ export async function atomicWriteFileAsync(
188
285
  path: string,
189
286
  content: string,
190
287
  io?: AtomicWriteAsyncIO,
288
+ testSeam?: AtomicWriteAsyncTestSeam,
191
289
  ): Promise<void> {
192
290
  const effective: AtomicWriteAsyncIO = io ?? {
193
291
  write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
@@ -201,14 +299,17 @@ export async function atomicWriteFileAsync(
201
299
  truncate: target => truncateSync(target, 0),
202
300
  unlink: unlinkSync,
203
301
  };
204
- const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
302
+ const target = resolveWriteTarget(path);
303
+ assertResolvedTargetAllowed(path, target);
304
+ const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
205
305
  let hardened = false;
206
306
  try {
207
307
  await effective.write(tmp, content);
308
+ await testSeam?.afterTempWrite?.(tmp);
208
309
  await effective.harden(tmp);
209
310
  hardened = true;
210
- await effective.rename(tmp, path);
211
- forgetHardenedSecretPath(tmp);
311
+ await effective.rename(tmp, target);
312
+ forgetEphemeralSecretPath(tmp);
212
313
  } catch (cause) {
213
314
  let scrubbed = false;
214
315
  try {
@@ -235,7 +336,7 @@ export async function atomicWriteFileAsync(
235
336
  if (!removed && !hardened) {
236
337
  try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
237
338
  }
238
- if (removed) forgetHardenedSecretPath(tmp);
339
+ if (removed) forgetEphemeralSecretPath(tmp);
239
340
  if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
240
341
  throw cause;
241
342
  }
@@ -379,7 +480,7 @@ export function backupConfigBeforeOpenAiTierMigration(
379
480
  }
380
481
  }
381
482
  }
382
- if (removed) forgetHardenedSecretPath(temp);
483
+ if (removed) forgetEphemeralSecretPath(temp);
383
484
  if (!removed && !scrubbed) throw new OpenAiTierBackupSecretResidualError(temp);
384
485
  if (!removed) throw new OpenAiTierBackupCleanupError();
385
486
  };
@@ -400,16 +501,16 @@ export function backupConfigBeforeOpenAiTierMigration(
400
501
  published = true;
401
502
  try {
402
503
  io.unlink(temp);
403
- forgetHardenedSecretPath(temp);
504
+ forgetEphemeralSecretPath(temp);
404
505
  } catch (firstError) {
405
506
  if (isMissingPathError(firstError)) {
406
- forgetHardenedSecretPath(temp);
507
+ forgetEphemeralSecretPath(temp);
407
508
  } else try {
408
509
  io.unlink(temp);
409
- forgetHardenedSecretPath(temp);
510
+ forgetEphemeralSecretPath(temp);
410
511
  } catch (secondError) {
411
512
  if (isMissingPathError(secondError)) {
412
- forgetHardenedSecretPath(temp);
513
+ forgetEphemeralSecretPath(temp);
413
514
  return "created";
414
515
  }
415
516
  // temp and backup are hard links to the same inode. Roll back the backup
@@ -473,6 +574,27 @@ export function reconcileConfigWarningMemos(generation: number): number {
473
574
  return removed;
474
575
  }
475
576
 
577
+ /**
578
+ * Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth
579
+ * shared by the config schema, the load-time sanitizer, and the management write
580
+ * boundary. Strict, so an unknown key is rejected at every validation boundary instead
581
+ * of being silently ignored (the load-time sanitizer still degrades unknown keys with a
582
+ * warning before schema validation, so hand-edited configs keep loading).
583
+ */
584
+ const retryOn429PolicySchema = z.object({
585
+ enabled: z.boolean().optional(),
586
+ attempts: z.number().int().min(1).max(20).optional(),
587
+ intervalMs: z.number().int().min(100).max(600_000).optional(),
588
+ // The effective cap for a single wait is MAX_COOLDOWN_MS (10 min) in key-failover.ts;
589
+ // larger configured values would be dead config.
590
+ maxIntervalMs: z.number().int().min(100).max(600_000).optional(),
591
+ respectRetryAfter: z.boolean().optional(),
592
+ }).strict();
593
+
594
+ /**
595
+ * Zod schema for one provider entry: known fields are validated strictly while unknown
596
+ * fields pass through (preserved for runtime extensions).
597
+ */
476
598
  const providerConfigSchema = z.object({
477
599
  adapter: z.string().min(1),
478
600
  baseUrl: z.string().min(1),
@@ -482,16 +604,32 @@ const providerConfigSchema = z.object({
482
604
  apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
483
605
  responsesPath: z.string().min(1).optional(),
484
606
  statelessResponses: z.boolean().optional(),
607
+ supportsServiceTier: z.boolean().optional(),
608
+ preserveResponsesReasoningContent: z.boolean().optional(),
485
609
  allowPrivateNetwork: z.boolean().optional(),
610
+ retryOn429: retryOn429PolicySchema.optional(),
486
611
  codexAccountMode: z.enum(["pool", "direct"]).optional(),
487
612
  responsesItemIdRepair: z.object({
488
613
  message: z.array(z.string().min(1)).optional(),
489
614
  reasoning: z.array(z.string().min(1)).optional(),
490
615
  repairMissingTerminalIds: z.boolean().optional(),
616
+ repairInvalidIds: z.boolean().optional(),
491
617
  }).strict().optional(),
618
+ responsesSnapshotRepair: z.boolean().optional(),
492
619
  }).passthrough();
493
620
 
494
- const RESERVED_PROVIDER_NAMES = new Set(["__proto__", "prototype", "constructor"]);
621
+ const RESERVED_PROVIDER_NAMES = new Set([
622
+ // JavaScript prototype-pollution guards.
623
+ "__proto__",
624
+ "prototype",
625
+ "constructor",
626
+ // System-reserved routing namespace (resolved before provider/account
627
+ // namespaces in routeModelInternal). "combo" is intentionally NOT reserved:
628
+ // a physical provider named `combo` is a supported pattern (combo aliases
629
+ // hosted on the combo provider), and the combo selector only wins when an
630
+ // actual combo id matches.
631
+ "policy",
632
+ ]);
495
633
  const PROVIDER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;
496
634
  const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
497
635
  const SENSITIVE_PROVIDER_HEADERS = new Set([
@@ -631,6 +769,100 @@ export function reasoningSummaryDeliveryRecordConfigError(
631
769
  return null;
632
770
  }
633
771
 
772
+ const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]);
773
+
774
+ export function modelPreferHostedToolsConfigError(
775
+ value: unknown,
776
+ field: string,
777
+ providerName: string,
778
+ provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown },
779
+ ): string | null {
780
+ if (value === undefined) return null;
781
+ if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
782
+ const prototype = Object.getPrototypeOf(value);
783
+ if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
784
+ const entries = Object.entries(value);
785
+ const registry = getProviderRegistryEntry(providerName);
786
+ // Effective transport: a `preserveCustomDestination` registry row reused under a
787
+ // different endpoint keeps its own adapter AND its own auth at runtime, because
788
+ // `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the
789
+ // wire check below and the forward-auth check here have to start from the same
790
+ // decision, or validation accepts a preference the adapter never applies —
791
+ // `preferConfiguredHostedTools()` runs only on the non-forward branch.
792
+ const registryTransportMatches = typeof provider.baseUrl === "string"
793
+ && providerMatchesRegistryTransport(providerName, {
794
+ baseUrl: provider.baseUrl,
795
+ adapter: provider.adapter as OcxProviderConfig["adapter"],
796
+ ...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
797
+ });
798
+ const effectiveForwardAuth = registryTransportMatches
799
+ ? registry?.authKind === "forward"
800
+ : provider.authMode === "forward";
801
+ if (entries.length > 0 && effectiveForwardAuth) {
802
+ return `${field} is not supported on forward-auth Responses providers`;
803
+ }
804
+ const requestedWireFor = (modelId: string): unknown => provider.modelAdapters
805
+ && typeof provider.modelAdapters === "object"
806
+ && !Array.isArray(provider.modelAdapters)
807
+ ? (provider.modelAdapters as Record<string, unknown>)[modelId]
808
+ : undefined;
809
+ const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => {
810
+ const pinned = pinnedWireAdapter(providerName, modelId);
811
+ if (pinned) return pinned;
812
+ const requestedWire = requestedWireFor(modelId);
813
+ if (typeof requestedWire === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requestedWire)) {
814
+ return requestedWire;
815
+ }
816
+ // No explicit override: fall back to the registry's per-model wire default before
817
+ // the provider-wide adapter, because that is the order `resolveModelAdapter()`
818
+ // uses at request time (src/server/adapter-resolve.ts:38-48). Skipping it rejected
819
+ // preferences the runtime would have honored — DeepSeek routes `deepseek-v4-flash`
820
+ // over native Responses for a Responses inbound while the provider-wide wire stays
821
+ // openai-chat. Hosted-tool preferences only apply to Responses traffic, so the
822
+ // inbound to ask about is "responses".
823
+ const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string"
824
+ ? providerModelWireDefault(
825
+ providerName,
826
+ {
827
+ baseUrl: provider.baseUrl,
828
+ adapter: currentWire,
829
+ ...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
830
+ },
831
+ modelId,
832
+ MODEL_ADAPTER_OVERRIDE_ALLOWED,
833
+ "responses",
834
+ )
835
+ : undefined;
836
+ return registryDefault ?? currentWire;
837
+ };
838
+ for (const [key, entry] of entries) {
839
+ if (!key.trim()) return `${field} keys must be nonblank model ids`;
840
+ if (!Array.isArray(entry)) return `${field}.${key} must be an array`;
841
+ if (entry.length === 0) return `${field}.${key} must include image_generation`;
842
+ for (const tool of entry) {
843
+ if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) {
844
+ return `${field}.${key} supports only image_generation`;
845
+ }
846
+ if (isHostedToolUnsupportedForModel(key, tool)) {
847
+ return `${field}.${key} cannot prefer ${tool}: the model does not support it`;
848
+ }
849
+ }
850
+ // Same `registryTransportMatches` decision the forward-auth check above uses:
851
+ // start from the registry adapter only when this config still points at the
852
+ // registry's documented transport.
853
+ const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter;
854
+ let effectiveWire = resolveEffectiveWire(key, baseWire);
855
+ const virtualWireModel = resolveOpenAiVirtualModel(providerName, key)?.wireModelId;
856
+ if (virtualWireModel && virtualWireModel !== key) {
857
+ effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire);
858
+ }
859
+ if (effectiveWire !== "openai-responses") {
860
+ return `${field}.${key} requires the openai-responses wire`;
861
+ }
862
+ }
863
+ return null;
864
+ }
865
+
634
866
  /**
635
867
  * Validate a provider's per-model wire override map (#404).
636
868
  *
@@ -742,6 +974,20 @@ const apiKeyEntrySchema = z.object({
742
974
  createdAt: z.string().catch(""),
743
975
  }).passthrough();
744
976
 
977
+ /**
978
+ * Durable per-client intent.
979
+ *
980
+ * `.passthrough()` is load-bearing: a binary that only knows `codex` must not
981
+ * erase a key a later version wrote during a field-scoped mutation. And each key
982
+ * degrades on its own — a hand edit of `{"codex": "false", "future": false}`
983
+ * drops `codex` to absent (which reads as ON) and keeps `future`, rather than
984
+ * invalidating the object or, worse, the whole config.
985
+ */
986
+ const clientIntegrationsSchema = z.object({
987
+ codex: z.boolean().optional().catch(undefined),
988
+ grok: z.boolean().optional().catch(undefined),
989
+ }).passthrough();
990
+
745
991
  const configSchema = z.object({
746
992
  port: z.number().int().min(0).max(65535).default(10100),
747
993
  managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
@@ -763,6 +1009,7 @@ const configSchema = z.object({
763
1009
  // Invalid hand edits must not discard an otherwise usable config. Treat them as
764
1010
  // pre-migration so startup can safely re-run the one-time normalization.
765
1011
  googleAntigravityStaticCatalogVersion: z.literal(1).optional().catch(undefined),
1012
+ clientIntegrations: clientIntegrationsSchema.optional().catch(undefined),
766
1013
  providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
767
1014
  contextCapValue: z.number().int().positive().optional(),
768
1015
  multiAgentGuidanceEnabled: z.boolean().optional(),
@@ -856,7 +1103,7 @@ const configSchema = z.object({
856
1103
  ctx.addIssue({
857
1104
  code: "custom",
858
1105
  path: ["providers", name],
859
- message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys",
1106
+ message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys or routing namespaces (policy)",
860
1107
  });
861
1108
  }
862
1109
  const provider = config.providers[name];
@@ -935,6 +1182,19 @@ const configSchema = z.object({
935
1182
  message: modelAdaptersError,
936
1183
  });
937
1184
  }
1185
+ const preferHostedToolsError = modelPreferHostedToolsConfigError(
1186
+ (provider as { modelPreferHostedTools?: unknown }).modelPreferHostedTools,
1187
+ "modelPreferHostedTools",
1188
+ name,
1189
+ provider,
1190
+ );
1191
+ if (preferHostedToolsError) {
1192
+ ctx.addIssue({
1193
+ code: "custom",
1194
+ path: ["providers", name, "modelPreferHostedTools"],
1195
+ message: preferHostedToolsError,
1196
+ });
1197
+ }
938
1198
  const maxInputError = positiveIntegerRecordConfigError(
939
1199
  (provider as { modelMaxInputTokens?: unknown }).modelMaxInputTokens,
940
1200
  "modelMaxInputTokens",
@@ -1045,6 +1305,27 @@ const configSchema = z.object({
1045
1305
  }
1046
1306
  }
1047
1307
  }
1308
+ const routingProfiles = (config as { routingProfiles?: unknown }).routingProfiles;
1309
+ if (routingProfiles !== undefined) {
1310
+ if (!routingProfiles || typeof routingProfiles !== "object" || Array.isArray(routingProfiles)) {
1311
+ ctx.addIssue({ code: "custom", path: ["routingProfiles"], message: "routingProfiles must be an object" });
1312
+ } else {
1313
+ for (const [id, raw] of Object.entries(routingProfiles as Record<string, unknown>)) {
1314
+ for (const issue of routingProfileIssues(id, raw, {
1315
+ providers: config.providers,
1316
+ combos: combos as Record<string, import("./types").OcxComboConfig> | undefined,
1317
+ routingProfiles: routingProfiles as Record<string, import("./types").OcxRoutingProfileConfig>,
1318
+ codexAccountNamespaces: accountNamespaces,
1319
+ }, { excludeProfileId: id })) {
1320
+ ctx.addIssue({
1321
+ code: "custom",
1322
+ path: ["routingProfiles", id, ...issue.path],
1323
+ message: issue.message,
1324
+ });
1325
+ }
1326
+ }
1327
+ }
1328
+ }
1048
1329
  });
1049
1330
 
1050
1331
  /**
@@ -1076,6 +1357,9 @@ export function getRuntimePortPath(): string {
1076
1357
 
1077
1358
  export function hardenConfigDir(): void {
1078
1359
  const dir = getConfigDir();
1360
+ // The guard runs BEFORE any mutation: refusing the write after chmod/ACL
1361
+ // would already have changed the protected directory (review round 2).
1362
+ assertNotRealHomeUnderTest(dir);
1079
1363
  if (existsSync(dir)) {
1080
1364
  try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
1081
1365
  if (process.platform === "win32") {
@@ -1105,6 +1389,96 @@ function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void
1105
1389
  }
1106
1390
  }
1107
1391
 
1392
+ /**
1393
+ * Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional
1394
+ * field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every
1395
+ * provider/key behind a default config. Invalid fields are dropped with a warning; the management
1396
+ * write boundary still rejects invalid policies explicitly.
1397
+ */
1398
+ function sanitizeRetryOn429ForLoad(parsed: unknown): void {
1399
+ if (!parsed || typeof parsed !== "object") return;
1400
+ const root = parsed as Record<string, unknown>;
1401
+ const providers = root.providers;
1402
+ if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
1403
+ for (const [name, provider] of Object.entries(providers as Record<string, unknown>)) {
1404
+ // This sanitizer runs BEFORE schema validation, so the provider name is untrusted: redact
1405
+ // secret-shaped names and JSON-escape control characters before it reaches any warning.
1406
+ const safeProviderName = JSON.stringify(redactSecretString(name));
1407
+ if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
1408
+ const p = provider as Record<string, unknown>;
1409
+ const policy = p.retryOn429;
1410
+ if (policy === undefined) continue;
1411
+ if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
1412
+ delete p.retryOn429;
1413
+ // Never serialize the value: an accidental `retryOn429: "sk-..."` would leak the secret.
1414
+ console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 (${typeof policy}) is invalid — ignoring the policy`);
1415
+ continue;
1416
+ }
1417
+ const policyRecord = policy as Record<string, unknown>;
1418
+ // An explicitly present but invalid master switch must not silently default to ENABLED:
1419
+ // drop the whole policy so a hand-edit that tried to disable retries stays disabled.
1420
+ if ("enabled" in policyRecord && typeof policyRecord.enabled !== "boolean") {
1421
+ delete p.retryOn429;
1422
+ console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.enabled (${typeof policyRecord.enabled}) is invalid — ignoring the whole policy`);
1423
+ continue;
1424
+ }
1425
+ // Field checks derive from the shared policy schema so the bounds cannot drift
1426
+ // between the load-time sanitizer, the config schema, and the write boundary.
1427
+ const policyShape = retryOn429PolicySchema.shape;
1428
+ const hadPolicyEntries = Object.keys(policyRecord).length > 0;
1429
+ const cleaned: Record<string, unknown> = {};
1430
+ for (const [key, fieldSchema] of Object.entries(policyShape)) {
1431
+ const value = policyRecord[key];
1432
+ if (value === undefined) continue;
1433
+ if (fieldSchema.safeParse(value).success) cleaned[key] = value;
1434
+ // Log only the received type, never the value (provider config can hold secrets).
1435
+ else console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${key} (${typeof value}) is invalid — ignoring the field`);
1436
+ }
1437
+ const knownKeys = new Set(Object.keys(policyShape));
1438
+ for (const key of Object.keys(policyRecord)) {
1439
+ if (!knownKeys.has(key)) {
1440
+ // Redact the field NAME before logging: a malformed hand-edit can place a secret in a
1441
+ // property name (`retryOn429: { "sk-...": true }`). Ordinary typos (e.g. `attempt`)
1442
+ // stay readable, secret-shaped names become [REDACTED]. JSON-escape afterwards so a
1443
+ // control-character property name (newline/ANSI) can never forge a log line.
1444
+ console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${JSON.stringify(redactSecretString(key))} is not a recognized field — ignoring it`);
1445
+ }
1446
+ }
1447
+ if (hadPolicyEntries && Object.keys(cleaned).length === 0) {
1448
+ // Every supplied field was invalid: drop the whole policy. Persisting `{}` here would
1449
+ // opt IN to retries with defaults, which is the opposite of what a malformed
1450
+ // disable-oriented edit (`retryOn429: { enabled: "false" }`, `attempts: 0`) asked for.
1451
+ delete p.retryOn429;
1452
+ console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 has no valid fields left — removing the policy (an empty policy would enable retries with defaults)`);
1453
+ } else {
1454
+ // Preserve an intentionally empty `retryOn429: {}` (presence = opt-in with defaults).
1455
+ p.retryOn429 = cleaned;
1456
+ }
1457
+ }
1458
+ }
1459
+
1460
+ /**
1461
+ * Management write-boundary validation for `retryOn429` (fail closed). Unlike the
1462
+ * lenient load-time sanitizer, invalid values and unknown keys are rejected outright so
1463
+ * a POST/PATCH cannot persist a policy the proxy would then silently degrade. Reuses the
1464
+ * shared policy schema. Never echoes values, and secret-shaped unknown field names are
1465
+ * redacted (a malformed write can place a secret in a property name).
1466
+ */
1467
+ export function retryOn429PolicyConfigError(policy: unknown): string | null {
1468
+ if (policy === undefined) return null;
1469
+ const result = retryOn429PolicySchema.safeParse(policy);
1470
+ if (result.success) return null;
1471
+ const first = result.error.issues[0];
1472
+ if (!first) return "retryOn429 is invalid";
1473
+ if (first.code === "unrecognized_keys") {
1474
+ const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", ");
1475
+ return `retryOn429 has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`;
1476
+ }
1477
+ if (first.path.length === 0) return `retryOn429 is invalid (${first.message})`;
1478
+ const field = String(first.path[first.path.length - 1]);
1479
+ return `retryOn429.${field} is invalid (${first.message})`;
1480
+ }
1481
+
1108
1482
  /**
1109
1483
  * Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind
1110
1484
  * falls back to loopback, which is the safe direction but not what the file asked for —
@@ -1316,6 +1690,7 @@ export function loadConfig(): OcxConfig {
1316
1690
  try {
1317
1691
  const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
1318
1692
  const parsed = JSON.parse(raw);
1693
+ sanitizeRetryOn429ForLoad(parsed);
1319
1694
  const result = configSchema.safeParse(parsed);
1320
1695
  if (result.success) {
1321
1696
  const config = normalizeApiKeyIds(result.data as OcxConfig);
@@ -1483,6 +1858,10 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
1483
1858
  function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
1484
1859
  try {
1485
1860
  const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
1861
+ // Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the
1862
+ // schema and send the caller a default-config fallback (the config command could then
1863
+ // persist that fallback over the user's providers/keys).
1864
+ sanitizeRetryOn429ForLoad(parsed);
1486
1865
  const result = configSchema.safeParse(parsed);
1487
1866
  if (result.success) {
1488
1867
  return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed);
@@ -1519,6 +1898,54 @@ export function readConfigDiagnostics(): ConfigDiagnostics {
1519
1898
  return readConfigFileSnapshot().diagnostics;
1520
1899
  }
1521
1900
 
1901
+ /**
1902
+ * The persisted config, plus a digest of the EXACT bytes it was parsed from.
1903
+ *
1904
+ * A union rather than a nullable digest, because `{ kind: "read" }` with no
1905
+ * digest is a state that cannot occur — and a state that cannot occur should
1906
+ * not be a state that can be written down. Refusing it at runtime is a check
1907
+ * somebody eventually forgets; making it unrepresentable is not.
1908
+ *
1909
+ * Why a byte digest at all: the Codex write lock compares an authority snapshot
1910
+ * taken before the lock against one taken while holding it, and its config
1911
+ * component used to hash the PARSED object. Two files that differ only in
1912
+ * whitespace or key order parse identically, so a non-cooperating writer could
1913
+ * rewrite the file between admission and commit and the comparison would see
1914
+ * nothing. Hashing what was actually read closes that.
1915
+ *
1916
+ * `readConfigFileSnapshot` stays private on purpose. Its `raw` carries provider
1917
+ * API keys and admission tokens, and `privacy:scan` reads tracked source text,
1918
+ * not runtime values — so it would not catch a caller that logged or serialized
1919
+ * that string. The digest travels; the bytes do not.
1920
+ */
1921
+ export type ConfigAdmissionSnapshot =
1922
+ | Readonly<{ kind: "read"; diagnostics: ConfigDiagnostics; contentSha256: string }>
1923
+ | Readonly<{ kind: "unreadable"; diagnostics: ConfigDiagnostics; contentSha256: null }>;
1924
+
1925
+ export function readConfigAdmissionSnapshot(): ConfigAdmissionSnapshot {
1926
+ let bytes: Buffer;
1927
+ try {
1928
+ // ONE read. Hashing the file and then reading it again to parse would leave
1929
+ // a window for the two to disagree, which is the exact hazard this exists
1930
+ // to detect — the check would become a second chance to be wrong.
1931
+ bytes = readFileSync(getConfigPath());
1932
+ } catch (error) {
1933
+ return {
1934
+ kind: "unreadable",
1935
+ diagnostics: isMissingPathError(error)
1936
+ ? { config: getDefaultConfig(), source: "default", error: null }
1937
+ : { config: getDefaultConfig(), source: "fallback", error: "invalid_json" },
1938
+ contentSha256: null,
1939
+ };
1940
+ }
1941
+ return {
1942
+ kind: "read",
1943
+ // Decoded from the same buffer that was hashed, not re-read from disk.
1944
+ diagnostics: configDiagnosticsFromRaw(bytes.toString("utf-8")),
1945
+ contentSha256: createHash("sha256").update(bytes).digest("hex"),
1946
+ };
1947
+ }
1948
+
1522
1949
  const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite";
1523
1950
  const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const;
1524
1951
  let warnedConfigMutationDirectoryAcl = false;
@@ -1567,6 +1994,7 @@ function configMutationDatabasePath(): string {
1567
1994
  }
1568
1995
 
1569
1996
  let configMutationLockDepth = 0;
1997
+ let configMutationDatabase: Database | null = null;
1570
1998
 
1571
1999
  /**
1572
2000
  * Serialize synchronous config and Codex credential-generation commits across processes with an
@@ -1593,7 +2021,11 @@ export function withConfigMutationLockSync<T>(fn: () => T): T {
1593
2021
  try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ }
1594
2022
  database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
1595
2023
  transactionOpen = true;
2024
+ initializeConfigGeneration(database);
1596
2025
  } catch (cause) {
2026
+ if (transactionOpen) {
2027
+ try { database?.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
2028
+ }
1597
2029
  try { database?.close(); } catch { /* acquisition already failed */ }
1598
2030
  const code = cause && typeof cause === "object" && "code" in cause
1599
2031
  ? String((cause as { code?: unknown }).code)
@@ -1605,6 +2037,7 @@ export function withConfigMutationLockSync<T>(fn: () => T): T {
1605
2037
  }
1606
2038
 
1607
2039
  configMutationLockDepth = 1;
2040
+ configMutationDatabase = database;
1608
2041
  try {
1609
2042
  const value = fn();
1610
2043
  database.exec("COMMIT");
@@ -1618,19 +2051,118 @@ export function withConfigMutationLockSync<T>(fn: () => T): T {
1618
2051
  throw error;
1619
2052
  } finally {
1620
2053
  configMutationLockDepth = 0;
2054
+ configMutationDatabase = null;
1621
2055
  try { database.close(); } catch { /* the OS lock is released with the handle */ }
1622
2056
  }
1623
2057
  }
1624
2058
 
1625
- function persistConfigUnlocked(config: OcxConfig): void {
2059
+ function bumpGenerationForCooperatingConfigWrite(): void {
2060
+ if (!configMutationDatabase) {
2061
+ throw new Error("A cooperating config write requires the config mutation transaction.");
2062
+ }
2063
+ bumpCurrentConfigGeneration(configMutationDatabase);
2064
+ }
2065
+
2066
+ export const readConfigGeneration: ReadConfigGeneration = () => {
2067
+ try {
2068
+ return readConfigGenerationAtPath(configMutationDatabasePath());
2069
+ } catch {
2070
+ return { kind: "unavailable", reason: "database" };
2071
+ }
2072
+ };
2073
+
2074
+ export function observeConfigGeneration(): ConfigGenerationObservation {
2075
+ return observeConfigGenerationAtPath(join(getConfigDir(), CONFIG_MUTATION_DB_FILENAME));
2076
+ }
2077
+
2078
+ /**
2079
+ * Read the generation from the transaction that is open RIGHT NOW.
2080
+ *
2081
+ * The observer cannot do this job. On the very first acquisition the
2082
+ * `BEGIN IMMEDIATE` that creates the table has not committed yet, so a separate
2083
+ * read-only connection cannot read a generation from it — measured, not
2084
+ * assumed. A caller that compared a pre-lock observation against an observer
2085
+ * re-read would therefore refuse every first write as stale.
2086
+ *
2087
+ * Throwing when no transaction is open is deliberate. Being called outside the
2088
+ * lock is broken plumbing, and returning a typed "unavailable" would let that
2089
+ * bug arrive disguised as an environmental failure — retried forever, on a
2090
+ * machine where nothing is wrong.
2091
+ */
2092
+ export function readConfigGenerationInCurrentMutationTransaction(): ConfigGeneration {
2093
+ if (configMutationLockDepth < 1 || !configMutationDatabase) {
2094
+ throw new Error(
2095
+ "readConfigGenerationInCurrentMutationTransaction requires an open config mutation transaction.",
2096
+ );
2097
+ }
2098
+ return readConfigGenerationInTransaction(configMutationDatabase);
2099
+ }
2100
+
2101
+ export const bumpConfigGeneration: BumpConfigGeneration = expected => {
2102
+ try {
2103
+ return bumpConfigGenerationAtPath(configMutationDatabasePath(), expected);
2104
+ } catch {
2105
+ return { kind: "unavailable", reason: "database" };
2106
+ }
2107
+ };
2108
+
2109
+ function configGenerationFailureReason(error: unknown): "busy" | "database" {
2110
+ const cause = error instanceof ConfigMutationLockError ? error.cause : error;
2111
+ const code = cause && typeof cause === "object" && "code" in cause
2112
+ ? String((cause as { code?: unknown }).code)
2113
+ : "";
2114
+ const message = cause instanceof Error ? cause.message : "";
2115
+ return code === "SQLITE_BUSY"
2116
+ || code === "SQLITE_LOCKED"
2117
+ || /database (?:is|table is) locked/i.test(message)
2118
+ ? "busy"
2119
+ : "database";
2120
+ }
2121
+
2122
+ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync = (
2123
+ expected,
2124
+ commit,
2125
+ ) => {
2126
+ let callbackThrew = false;
2127
+ let callbackError: unknown;
2128
+ try {
2129
+ return withConfigMutationLockSync(() => {
2130
+ const database = configMutationDatabase;
2131
+ if (!database) throw new Error("Config mutation transaction database is unavailable.");
2132
+ const current = readConfigGenerationInTransaction(database);
2133
+ if (current.value !== expected.value) return { kind: "conflict", current };
2134
+ try {
2135
+ return { kind: "matched", generation: current, value: commit() };
2136
+ } catch (error) {
2137
+ callbackThrew = true;
2138
+ callbackError = error;
2139
+ throw error;
2140
+ }
2141
+ });
2142
+ } catch (error) {
2143
+ if (callbackThrew && error === callbackError) throw error;
2144
+ return { kind: "unavailable", reason: configGenerationFailureReason(error) };
2145
+ }
2146
+ };
2147
+
2148
+ function persistConfigUnlocked(config: OcxConfig): boolean {
1626
2149
  const configPath = getConfigPath();
1627
- atomicWriteFile(configPath, JSON.stringify(config, null, 2) + "\n");
2150
+ const bytes = JSON.stringify(config, null, 2) + "\n";
2151
+ try {
2152
+ if (readFileSync(configPath, "utf8") === bytes) return false;
2153
+ } catch (error) {
2154
+ if (!isMissingPathError(error)) throw error;
2155
+ }
2156
+ atomicWriteFile(configPath, bytes);
2157
+ return true;
1628
2158
  }
1629
2159
 
1630
2160
  export function saveConfig(config: OcxConfig): void {
1631
2161
  // Keep the real-home assertion ahead of even lock-directory preparation.
1632
2162
  assertNotRealHomeUnderTest(getConfigDir());
1633
- withConfigMutationLockSync(() => persistConfigUnlocked(config));
2163
+ withConfigMutationLockSync(() => {
2164
+ if (persistConfigUnlocked(config)) bumpGenerationForCooperatingConfigWrite();
2165
+ });
1634
2166
  }
1635
2167
 
1636
2168
  export type PersistedConfigMutation<T> = {
@@ -1710,7 +2242,7 @@ export function mutatePersistedConfig<T>(
1710
2242
  continue;
1711
2243
  }
1712
2244
 
1713
- persistConfigUnlocked(confirmedConfig);
2245
+ if (persistConfigUnlocked(confirmedConfig)) bumpGenerationForCooperatingConfigWrite();
1714
2246
  return { status: "committed", value: confirmed.value };
1715
2247
  }
1716
2248
  return { status: "unavailable", reason: "conflict" };
@@ -1969,10 +2501,10 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
1969
2501
  const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port };
1970
2502
  if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
1971
2503
  else persistedConfig.hostname = persistedBinding.hostname;
1972
- persistConfigUnlocked(persistedConfig);
2504
+ if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite();
1973
2505
  persistedLiveServerBinding.set(config, persistedBinding);
1974
2506
  } else {
1975
- persistConfigUnlocked(config);
2507
+ if (persistConfigUnlocked(config)) bumpGenerationForCooperatingConfigWrite();
1976
2508
  }
1977
2509
  if (claudeCodeBaseline.has(config)) {
1978
2510
  claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
@@ -2062,6 +2594,8 @@ export function applyProxyEnv(config: OcxConfig): void {
2062
2594
 
2063
2595
  export function writePid(pid: number): void {
2064
2596
  const dir = getConfigDir();
2597
+ // Guard before ANY directory mutation (mkdir or chmod), not just the write.
2598
+ assertNotRealHomeUnderTest(dir);
2065
2599
  if (!existsSync(dir)) {
2066
2600
  mkdirSync(dir, { recursive: true, mode: 0o700 });
2067
2601
  } else {
@@ -2090,6 +2624,8 @@ function isValidRuntimePortState(value: unknown): value is RuntimePortState {
2090
2624
 
2091
2625
  export function writeRuntimePort(state: RuntimePortState): void {
2092
2626
  const dir = getConfigDir();
2627
+ // Guard before ANY directory mutation (mkdir or chmod), not just the write.
2628
+ assertNotRealHomeUnderTest(dir);
2093
2629
  if (!existsSync(dir)) {
2094
2630
  mkdirSync(dir, { recursive: true, mode: 0o700 });
2095
2631
  } else {