@iislee/opencodex 2.31.0 → 2.35.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 (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
package/src/config.ts CHANGED
@@ -1,11 +1,22 @@
1
- import { execFileSync } from "node:child_process";
2
- import { createHash, randomUUID } from "node:crypto";
3
- import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { dirname, join, resolve } from "node:path";
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
6
4
  import { Database } from "bun:sqlite";
7
5
  import * as z from "zod/v4";
8
6
  import { isValidProviderName, hasOwnProvider } from "./config/provider-name";
7
+ import {
8
+ apiKeyTransportConfigError,
9
+ booleanRecordConfigError,
10
+ modelAdapterRecordConfigError,
11
+ nonBlankStringArrayConfigError,
12
+ normalizeNonBlankStringArray,
13
+ positiveIntegerConfigError,
14
+ positiveIntegerRecordConfigError,
15
+ providerBaseUrlConfigError,
16
+ providerHeadersConfigError,
17
+ reasoningSummaryDeliveryRecordConfigError,
18
+ upstreamHttpVersionConfigError,
19
+ } from "./config/provider-validation";
9
20
  import {
10
21
  bumpConfigGenerationAtPath,
11
22
  bumpCurrentConfigGeneration,
@@ -42,25 +53,18 @@ import {
42
53
  forgetEphemeralSecretPath,
43
54
  hardenSecretDir,
44
55
  hardenSecretPath,
45
- hardenSecretPathAsync,
46
56
  windowsSecretAclApplies,
47
57
  } from "./lib/windows-secret-acl";
48
58
  import { recordOwnedConfigPath } from "./lib/config-ownership";
49
59
  import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
50
- import { isLocalAttestationSecret } from "./lib/local-management-attestation";
51
60
  import { providerDestinationConfigError } from "./lib/destination-policy";
52
61
  import { redactSecretString } from "./lib/redact";
53
- import {
54
- resolveTrustedWindowsPowerShellExe,
55
- resolveTrustedWindowsSystemDirectory,
56
- } from "./lib/windows-elevation";
57
62
  import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
63
+ import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases";
58
64
  import {
59
- isWirePinnedModel,
60
65
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
61
66
  OPENAI_PROVIDER_TIER_VERSION,
62
67
  pinnedWireAdapter,
63
- REASONING_SUMMARY_DELIVERY_VALUES,
64
68
  UPSTREAM_HTTP_VERSION_VALUES,
65
69
  type OcxClaudeCodeConfig,
66
70
  type OcxConfig,
@@ -69,7 +73,8 @@ import {
69
73
  type FastWire,
70
74
  type ProviderCostOverlay,
71
75
  } from "./types";
72
- import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
76
+ import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
77
+ import { modelAutoCompactTokenLimitsConfigError } from "./providers/auto-compact-budget";
73
78
  import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire";
74
79
  import {
75
80
  getProviderRegistryEntry,
@@ -79,7 +84,7 @@ import {
79
84
  } from "./providers/registry";
80
85
  import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models";
81
86
  import { parseDesktopProfile } from "./claude/desktop-profile";
82
- import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
87
+ import { isCodexReasoningEffort } from "./reasoning-effort";
83
88
  import {
84
89
  COST4_RATE_KEYS,
85
90
  isValidCost4Rate,
@@ -94,244 +99,58 @@ import {
94
99
  MIN_APP_OWNED_MEMORY_BUDGET_MB,
95
100
  } from "./lib/app-owned-memory";
96
101
  import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy";
97
-
98
- let _atomicSeq = 0;
99
-
100
- // The Windows-tolerant replace lives in lib/windows-atomic-replace: config-ownership
101
- // is one of its callers and this module already imports config-ownership, so
102
- // exporting it from here would close an import cycle. Re-exported because these
103
- // names are part of this module's public surface and its callers.
104
- export type { AtomicRenameIO } from "./lib/windows-atomic-replace";
105
- export { renameAtomicFile } from "./lib/windows-atomic-replace";
106
- import { renameAtomicFile, renameAtomicFileAsync } from "./lib/windows-atomic-replace";
107
-
108
- /**
109
- * Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the
110
- * proxy's own shutdown handler both restoring Codex — can never leave a half-written file.
111
- */
112
- export interface AtomicWriteIO {
113
- write: (path: string, content: string) => void;
114
- harden: (path: string) => void;
115
- rename: (source: string, destination: string) => void;
116
- truncate: (path: string) => void;
117
- unlink: (path: string) => void;
118
- }
119
-
120
- export class AtomicWriteResidualTempError extends Error {
121
- constructor(readonly tempPath: string, readonly hardened = true, options?: ErrorOptions) {
122
- super(`Atomic config write left a ${hardened ? "hardened " : ""}zero-byte temporary file`, options);
123
- this.name = "AtomicWriteResidualTempError";
124
- }
125
- }
126
-
127
- export class AtomicWriteSecretResidualError extends Error {
128
- constructor(readonly tempPath: string, options?: ErrorOptions) {
129
- super("Atomic config write could not scrub or remove a secret-bearing temporary file", options);
130
- this.name = "AtomicWriteSecretResidualError";
131
- }
132
- }
133
-
134
- function isMissingPathError(error: unknown): boolean {
135
- return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
136
- }
137
-
138
- /**
139
- * Resolve a write target through any symlink before the temp+rename dance.
140
- *
141
- * rename(2) replaces a directory ENTRY. When the entry is itself a symlink
142
- * (a dotfiles-managed `~/.codex/config.toml` -> `~/dotfiles/.codex/config.toml`,
143
- * say), renaming a sibling temp file over it destroys the link and leaves a plain
144
- * file behind — the repo silently stops receiving writes. Resolving first puts both
145
- * the temp file and the rename target inside the link's real directory, so the entry
146
- * being replaced is the real file and the symlink survives.
147
- *
148
- * Same-filesystem atomicity is preserved because the temp file stays beside its
149
- * resolved target. A genuinely absent destination (not yet created) falls back to
150
- * the literal path, which is the correct target for a first write.
151
- *
152
- * An EXISTING symlink that cannot be resolved — dangling because its target volume
153
- * is unmounted, an ELOOP chain, an EACCES parent — is refused instead. Falling back
154
- * to the literal path there would let the rename replace the link, recreating the
155
- * exact dotfiles-divergence failure this helper exists to prevent (audit: wt4 wp2).
156
- */
157
- export function resolveWriteTarget(path: string): string {
158
- try {
159
- return realpathSync(path);
160
- } catch (cause) {
161
- let entry;
162
- try {
163
- entry = lstatSync(path);
164
- } catch (error) {
165
- if (isMissingPathError(error)) return path; // no entry at all — first write
166
- throw error;
167
- }
168
- if (entry.isSymbolicLink()) {
169
- throw new Error(`refusing to replace unresolvable symlinked write target: ${path}`, { cause });
170
- }
171
- return path;
172
- }
173
- }
174
-
175
- /**
176
- * Re-apply the real-home guard to a RESOLVED write target.
177
- *
178
- * Callers such as saveConfig check only their logical config dir, which passes when
179
- * OPENCODEX_HOME points at a temp fixture. Following a symlink out of that fixture
180
- * would land on the protected home the caller's own check just cleared, so the guard
181
- * has to run again on wherever the write actually terminates. Inert in production,
182
- * where the guard is disarmed.
183
- */
184
- function assertResolvedTargetAllowed(path: string, target: string): void {
185
- // The file itself may resolve literally while its PARENT is a symlink out
186
- // of the fixture (a first write beneath a symlinked config dir). Guard the
187
- // directory the write actually lands in either way.
188
- if (target === path) {
189
- let realParent: string;
190
- try {
191
- realParent = realpathSync(dirname(target));
192
- } catch {
193
- return; // unresolvable parent: resolveWriteTarget already owns that refusal
194
- }
195
- if (realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent);
196
- return;
197
- }
198
- assertNotRealHomeUnderTest(dirname(target));
199
- }
200
-
201
- export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO = {
202
- write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
203
- harden: target => {
204
- try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
205
- // Timeout memo keyed by the stable destination (matches the async writer):
206
- // a failed temp harden must not mint a new unique-temp key on every write.
207
- if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: path });
208
- },
209
- rename: renameAtomicFile,
210
- truncate: target => truncateSync(target, 0),
211
- unlink: unlinkSync,
212
- }): void {
213
- recordOwnedConfigPath(resolveConfigDir(), path);
214
- const target = resolveWriteTarget(path);
215
- assertResolvedTargetAllowed(path, target);
216
- const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
217
- let hardened = false;
218
- try {
219
- io.write(tmp, content);
220
- io.harden(tmp);
221
- hardened = true;
222
- io.rename(tmp, target);
223
- forgetEphemeralSecretPath(tmp);
224
- } catch (cause) {
225
- let scrubbed = false;
226
- try {
227
- io.truncate(tmp);
228
- scrubbed = true;
229
- } catch (error) {
230
- if (isMissingPathError(error)) scrubbed = true;
231
- else {
232
- try { io.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
233
- }
234
- }
235
- let removed = false;
236
- try {
237
- io.unlink(tmp);
238
- removed = true;
239
- } catch (error) {
240
- if (isMissingPathError(error)) removed = true;
241
- else {
242
- try { io.unlink(tmp); removed = true; }
243
- catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
244
- }
245
- }
246
- if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
247
- if (!removed && !hardened) {
248
- try { io.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
249
- }
250
- if (removed) forgetEphemeralSecretPath(tmp);
251
- if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
252
- throw cause;
253
- }
254
- }
255
-
256
- /** Async atomic-write I/O: harden may await icacls without blocking the event loop (#612). */
257
- export interface AtomicWriteAsyncIO {
258
- write: (path: string, content: string) => void | Promise<void>;
259
- harden: (path: string) => void | Promise<void>;
260
- rename: (source: string, destination: string) => void | Promise<void>;
261
- truncate: (path: string) => void | Promise<void>;
262
- unlink: (path: string) => void | Promise<void>;
263
- }
264
-
265
- /** Test-only crash seam. Production callers leave this undefined. */
266
- export interface AtomicWriteAsyncTestSeam {
267
- afterTempWrite?: (tempPath: string) => void | Promise<void>;
268
- }
269
-
270
- /**
271
- * Async atomic write (#612): same temp+harden+rename and residual-temp policy as
272
- * atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed
273
- * by the final destination path (not the unique temp, not the parent directory).
274
- */
275
- export async function atomicWriteFileAsync(
276
- path: string,
277
- content: string,
278
- io?: AtomicWriteAsyncIO,
279
- testSeam?: AtomicWriteAsyncTestSeam,
280
- ): Promise<void> {
281
- const effective: AtomicWriteAsyncIO = io ?? {
282
- write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
283
- harden: async target => {
284
- try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
285
- if (process.platform === "win32") {
286
- await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path });
287
- }
288
- },
289
- rename: renameAtomicFileAsync,
290
- truncate: target => truncateSync(target, 0),
291
- unlink: unlinkSync,
292
- };
293
- const target = resolveWriteTarget(path);
294
- assertResolvedTargetAllowed(path, target);
295
- const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
296
- let hardened = false;
297
- try {
298
- await effective.write(tmp, content);
299
- await testSeam?.afterTempWrite?.(tmp);
300
- await effective.harden(tmp);
301
- hardened = true;
302
- await effective.rename(tmp, target);
303
- forgetEphemeralSecretPath(tmp);
304
- } catch (cause) {
305
- let scrubbed = false;
306
- try {
307
- await effective.truncate(tmp);
308
- scrubbed = true;
309
- } catch (error) {
310
- if (isMissingPathError(error)) scrubbed = true;
311
- else {
312
- try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
313
- }
314
- }
315
- let removed = false;
316
- try {
317
- await effective.unlink(tmp);
318
- removed = true;
319
- } catch (error) {
320
- if (isMissingPathError(error)) removed = true;
321
- else {
322
- try { await effective.unlink(tmp); removed = true; }
323
- catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
324
- }
325
- }
326
- if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
327
- if (!removed && !hardened) {
328
- try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
329
- }
330
- if (removed) forgetEphemeralSecretPath(tmp);
331
- if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
332
- throw cause;
333
- }
334
- }
102
+ import {
103
+ atomicWriteFile,
104
+ isMissingPathError,
105
+ nextAtomicTempSequence,
106
+ } from "./config/atomic-write";
107
+ export {
108
+ AtomicWriteResidualTempError,
109
+ AtomicWriteSecretResidualError,
110
+ atomicWriteFile,
111
+ atomicWriteFileAsync,
112
+ renameAtomicFile,
113
+ resolveWriteTarget,
114
+ type AtomicRenameIO,
115
+ type AtomicWriteAsyncIO,
116
+ type AtomicWriteAsyncTestSeam,
117
+ type AtomicWriteIO,
118
+ } from "./config/atomic-write";
119
+ import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
120
+ export { expandUserPath, getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
121
+ export {
122
+ getPidPath,
123
+ getRuntimePortPath,
124
+ isOcxStartCommandLine,
125
+ ocxStartProcessCacheSizeForTests,
126
+ parsePidFile,
127
+ readAlivePid,
128
+ readPid,
129
+ readPidFileValue,
130
+ readRuntimePort,
131
+ removePid,
132
+ removePidIfValueIs,
133
+ removeRuntimePort,
134
+ removeRuntimePortIfPidIs,
135
+ setOcxStartProcessCacheForTests,
136
+ setOcxStartProcessProbeForTests,
137
+ setProcessCommandLineExecForTests,
138
+ setProcessCommandLinePlatformForTests,
139
+ sweepDeadOcxStartProcessCache,
140
+ verifyPidIdentity,
141
+ writePid,
142
+ writeRuntimePort,
143
+ type RuntimePortState,
144
+ } from "./config/process-state";
145
+ import {
146
+ clearPendingConfigTopLevelDeletions,
147
+ configHasRebaseProvenance,
148
+ configRebaseDeletionKeys,
149
+ CONFIG_REBASE_PROVENANCE_KEY,
150
+ deleteConfigTopLevelKey,
151
+ projectConfigRebaseProvenance,
152
+ } from "./config/rebase-provenance";
153
+ export { deleteConfigTopLevelKey } from "./config/rebase-provenance";
335
154
 
336
155
  export class OpenAiTierBackupCleanupError extends Error {
337
156
  constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
@@ -452,7 +271,7 @@ export function backupConfigBeforeOpenAiTierMigration(
452
271
  return "reused";
453
272
  }
454
273
  }
455
- const temp = `${backup}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
274
+ const temp = `${backup}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
456
275
  let published = false;
457
276
  let cleanupAttempted = false;
458
277
 
@@ -596,39 +415,6 @@ export function preserveOpenAiTierRollbackSnapshot(
596
415
  throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" });
597
416
  }
598
417
 
599
- /**
600
- * Expand a leading `~` to the home directory in user-supplied paths
601
- * (OPENCODEX_HOME/CODEX_HOME set from GUIs/service files where no shell expanded it).
602
- * `~user` and `%VAR%`/`$VAR` forms pass through untouched — those belong to the shell.
603
- */
604
- export function expandUserPath(raw: string): string {
605
- if (raw === "~") return homedir();
606
- if (raw.startsWith("~/") || raw.startsWith("~\\")) return join(homedir(), raw.slice(2));
607
- return raw;
608
- }
609
-
610
- let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null;
611
-
612
- function resolveConfigDir(): string {
613
- const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined;
614
- if (resolvedConfigDirCache && resolvedConfigDirCache.raw === raw) return resolvedConfigDirCache.path;
615
- const path = raw ? resolve(expandUserPath(raw)) : join(homedir(), ".opencodex");
616
- resolvedConfigDirCache = { raw, path };
617
- return path;
618
- }
619
-
620
- function resolveConfigPath(): string {
621
- return join(resolveConfigDir(), "config.json");
622
- }
623
-
624
- function resolvePidPath(): string {
625
- return join(resolveConfigDir(), "ocx.pid");
626
- }
627
-
628
- function resolveRuntimePortPath(): string {
629
- return join(resolveConfigDir(), "runtime-port.json");
630
- }
631
-
632
418
  const warnedConfigFallbacks = new Set<string>();
633
419
  const warnedInheritedFastWireConflicts = new Set<string>();
634
420
  let lastWarningReconciledGeneration = 0;
@@ -703,6 +489,9 @@ const fastWireSchema = z.object({
703
489
  const providerConfigSchema = z.object({
704
490
  adapter: z.string().min(1),
705
491
  baseUrl: z.string().min(1),
492
+ alias: z.string().optional(),
493
+ modelAliases: z.record(z.string(), z.string()).optional(),
494
+ defaultAliases: z.boolean().optional(),
706
495
  requestPacing: requestPacingSchema.optional().catch(undefined),
707
496
  mcpMaxTools: z.number().int().positive().optional(),
708
497
  mcpMaxSchemaBytes: z.number().int().positive().optional(),
@@ -743,30 +532,20 @@ const providerConfigSchema = z.object({
743
532
  responsesSnapshotRepair: z.boolean().optional(),
744
533
  }).passthrough();
745
534
 
746
- const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
747
- const SENSITIVE_PROVIDER_HEADERS = new Set([
748
- "authorization",
749
- "cookie",
750
- "set-cookie",
751
- "proxy-authorization",
752
- "x-api-key",
753
- "x-goog-api-key",
754
- "x-amz-security-token",
755
- ]);
756
-
757
535
  export { isValidProviderName, hasOwnProvider } from "./config/provider-name";
758
-
759
- export function providerBaseUrlConfigError(baseUrl: string): string | null {
760
- try {
761
- const parsed = new URL(baseUrl.trim());
762
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "baseUrl must be an http(s) URL";
763
- if (parsed.username || parsed.password) return "baseUrl must not include embedded credentials";
764
- if (parsed.search || parsed.hash) return "baseUrl must not include query strings or fragments";
765
- } catch {
766
- return "baseUrl must be a valid URL";
767
- }
768
- return null;
769
- }
536
+ export {
537
+ apiKeyTransportConfigError,
538
+ booleanRecordConfigError,
539
+ modelAdapterRecordConfigError,
540
+ nonBlankStringArrayConfigError,
541
+ normalizeNonBlankStringArray,
542
+ positiveIntegerConfigError,
543
+ positiveIntegerRecordConfigError,
544
+ providerBaseUrlConfigError,
545
+ providerHeadersConfigError,
546
+ reasoningSummaryDeliveryRecordConfigError,
547
+ upstreamHttpVersionConfigError,
548
+ } from "./config/provider-validation";
770
549
 
771
550
  function providerResponsesPathConfigError(responsesPath: string | undefined): string | null {
772
551
  if (responsesPath === undefined) return null;
@@ -780,19 +559,6 @@ function providerResponsesPathConfigError(responsesPath: string | undefined): st
780
559
  return null;
781
560
  }
782
561
 
783
- export function providerHeadersConfigError(headers: unknown): string | null {
784
- if (headers === undefined) return null;
785
- if (!headers || typeof headers !== "object" || Array.isArray(headers)) return "headers must be an object";
786
- for (const [name, value] of Object.entries(headers)) {
787
- const normalized = name.trim().toLowerCase();
788
- if (!normalized || !HEADER_NAME_PATTERN.test(name)) return "headers must use valid HTTP header names";
789
- if (SENSITIVE_PROVIDER_HEADERS.has(normalized)) return `headers must not include sensitive header "${name}"; use apiKey/authMode instead`;
790
- if (typeof value !== "string") return `header "${name}" value must be a string`;
791
- if (/[\r\n]/.test(value)) return `header "${name}" value must not include line breaks`;
792
- }
793
- return null;
794
- }
795
-
796
562
  /**
797
563
  * Validate `providers.<name>.modelCosts`: a plain object keyed by exact model
798
564
  * id, each value a 4-tuple of non-negative finite USD-per-1M-token rates.
@@ -862,119 +628,6 @@ export function sanitizeModelCostsForDisplay(costs: unknown): Record<string, Pro
862
628
  return Object.keys(out).length > 0 ? out : undefined;
863
629
  }
864
630
 
865
- /** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
866
- export function apiKeyTransportConfigError(
867
- provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
868
- ): string | null {
869
- if (provider.apiKeyTransport === undefined) return null;
870
- if (provider.apiKeyTransport !== "x-api-key" && provider.apiKeyTransport !== "bearer") {
871
- return 'apiKeyTransport must be "x-api-key" or "bearer"';
872
- }
873
- if (provider.adapter !== "anthropic") {
874
- return "apiKeyTransport is supported only by the anthropic adapter";
875
- }
876
- if (provider.authMode === "oauth" || provider.authMode === "forward" || provider.authMode === "local") {
877
- return "apiKeyTransport requires Anthropic API-key authentication";
878
- }
879
- return null;
880
- }
881
-
882
- /**
883
- * Shared runtime boundary for the per-provider upstream HTTP-version pin (#1668). Used by
884
- * the management write path (providerManagementConfigError / PATCH) so it can never disagree
885
- * with the strict zod load schema: a value that survives POST/PATCH is always loadable, and
886
- * a value the loader rejects is rejected at write time too.
887
- */
888
- export function upstreamHttpVersionConfigError(value: unknown): string | null {
889
- if (value === undefined || value === null) return null;
890
- if (typeof value !== "string" || !(UPSTREAM_HTTP_VERSION_VALUES as readonly string[]).includes(value)) {
891
- return 'upstreamHttpVersion must be one of "auto", "http1.1", "h1", "http2", "h2", or null to clear';
892
- }
893
- return null;
894
- }
895
-
896
- export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null {
897
- if (value === undefined) return null;
898
- if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
899
- const prototype = Object.getPrototypeOf(value);
900
- if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
901
- for (const [key, entry] of Object.entries(value)) {
902
- if (!key.trim()) return `${field} keys must be nonblank model ids`;
903
- if (typeof entry !== "number" || !Number.isFinite(entry) || !Number.isInteger(entry) || entry <= 0) {
904
- return `${field}.${key} must be a positive finite integer`;
905
- }
906
- }
907
- return null;
908
- }
909
-
910
- export function positiveIntegerConfigError(value: unknown, field: string): string | null {
911
- if (value === undefined) return null;
912
- if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
913
- return `${field} must be a positive finite integer`;
914
- }
915
- return null;
916
- }
917
-
918
- export function nonBlankStringArrayConfigError(value: unknown, field: string): string | null {
919
- if (value === undefined) return null;
920
- if (!Array.isArray(value)) return `${field} must be an array`;
921
- for (const [index, entry] of value.entries()) {
922
- if (typeof entry !== "string" || !entry.trim()) {
923
- return `${field}.${index} must be a nonblank model id`;
924
- }
925
- }
926
- return null;
927
- }
928
-
929
- /**
930
- * Keep hand-edited config and management writes on one canonical model-id list.
931
- * Validation happens separately so an all-whitespace value is rejected rather than
932
- * normalized into a model id that can never match at runtime.
933
- */
934
- export function normalizeNonBlankStringArray(value: readonly string[]): string[] {
935
- return [...new Set(value.map(entry => entry.trim()))];
936
- }
937
-
938
- export function booleanRecordConfigError(value: unknown, field: string): string | null {
939
- if (value === undefined) return null;
940
- if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
941
- const prototype = Object.getPrototypeOf(value);
942
- if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
943
- for (const [key, entry] of Object.entries(value)) {
944
- if (!key.trim()) return `${field} keys must be nonblank model ids`;
945
- if (typeof entry !== "boolean") return `${field}.${key} must be a boolean`;
946
- }
947
- return null;
948
- }
949
-
950
- const REASONING_SUMMARY_DELIVERY_SET = new Set<string>(REASONING_SUMMARY_DELIVERY_VALUES);
951
-
952
- export function reasoningSummaryDeliveryRecordConfigError(
953
- value: unknown,
954
- supportsReasoningSummaries: unknown,
955
- field = "modelReasoningSummaryDelivery",
956
- ): string | null {
957
- if (value === undefined) return null;
958
- if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
959
- const prototype = Object.getPrototypeOf(value);
960
- if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
961
-
962
- const supports = booleanRecordConfigError(supportsReasoningSummaries, "modelSupportsReasoningSummaries") === null
963
- && supportsReasoningSummaries && typeof supportsReasoningSummaries === "object"
964
- ? supportsReasoningSummaries as Record<string, boolean>
965
- : undefined;
966
- for (const [key, entry] of Object.entries(value)) {
967
- if (!key.trim()) return `${field} keys must be nonblank model ids`;
968
- if (typeof entry !== "string" || !REASONING_SUMMARY_DELIVERY_SET.has(entry)) {
969
- return `${field}.${key} must be one of: ${REASONING_SUMMARY_DELIVERY_VALUES.join(", ")}`;
970
- }
971
- if (modelRecordValue(supports, key) === false) {
972
- return `${field}.${key} conflicts with modelSupportsReasoningSummaries=false`;
973
- }
974
- }
975
- return null;
976
- }
977
-
978
631
  const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]);
979
632
 
980
633
  export function modelPreferHostedToolsConfigError(
@@ -1069,41 +722,6 @@ export function modelPreferHostedToolsConfigError(
1069
722
  return null;
1070
723
  }
1071
724
 
1072
- /**
1073
- * Validate a provider's per-model wire override map (#404).
1074
- *
1075
- * Rejects, rather than silently ignoring, configurations the resolver would refuse:
1076
- * a value outside the allowed wires, a model the upstream pins to one wire, and any
1077
- * override on a canonical forward provider (where switching wires would drop the
1078
- * caller's forwarded credential). Silently dropping them would leave the user
1079
- * believing an override is in effect.
1080
- */
1081
- export function modelAdapterRecordConfigError(
1082
- value: unknown,
1083
- field: string,
1084
- providerName: string,
1085
- provider: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown },
1086
- ): string | null {
1087
- if (value === undefined) return null;
1088
- if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
1089
- const prototype = Object.getPrototypeOf(value);
1090
- if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
1091
- const entries = Object.entries(value);
1092
- if (entries.length > 0 && isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig)) {
1093
- return `${field} is not supported on the canonical ChatGPT forward provider`;
1094
- }
1095
- for (const [key, entry] of entries) {
1096
- if (!key.trim()) return `${field} keys must be nonblank model ids`;
1097
- if (typeof entry !== "string" || !MODEL_ADAPTER_OVERRIDE_ALLOWED.has(entry)) {
1098
- return `${field}.${key} must be one of: ${[...MODEL_ADAPTER_OVERRIDE_ALLOWED].join(", ")}`;
1099
- }
1100
- if (isWirePinnedModel(providerName, key.trim())) {
1101
- return `${field}.${key} cannot be overridden: the upstream only speaks one wire for this model`;
1102
- }
1103
- }
1104
- return null;
1105
- }
1106
-
1107
725
  const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR =
1108
726
  "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids";
1109
727
  const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR =
@@ -1260,8 +878,15 @@ const configSchema = z.object({
1260
878
  ]).optional().catch(undefined),
1261
879
  providers: z.record(z.string(), providerConfigSchema),
1262
880
  defaultProvider: z.string().min(1).default("openai"),
881
+ defaultModelAliases: z.boolean().optional(),
882
+ // Future versions remain opaque through passthrough-compatible whole-config saves.
883
+ // Only version 1 grants deletion authority in the rebase path.
884
+ configRebaseProvenance: z.unknown().optional(),
1263
885
  // A retry can be billable, so absence and malformed hand edits both stay off.
1264
886
  emptyCompletionRetry: z.boolean().optional().catch(false),
887
+ // A malformed hand edit must not silently stop opening the browser: fall back
888
+ // to undefined, which resolves to the historical auto-open behavior.
889
+ oauthOpenBrowser: z.boolean().optional().catch(undefined),
1265
890
  openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
1266
891
  // Invalid hand edits must not discard an otherwise usable config.
1267
892
  googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined),
@@ -1296,6 +921,9 @@ const configSchema = z.object({
1296
921
  // A malformed hand edit must degrade to false without discarding providers, accounts,
1297
922
  // or the exact selector map. Live writes remain strict.
1298
923
  codexAccountPickerEnabled: z.boolean().optional().catch(false),
924
+ // Same degrade-not-reject rule: a malformed hand edit hides Spark rather than discarding the
925
+ // whole config. Hidden is also the default, so `catch(false)` and the default agree.
926
+ showCodexSparkQuota: z.boolean().optional().catch(false),
1299
927
  // Model ids excluded from the Grok Build managed block (dashboard switches).
1300
928
  grokExcludedModels: z.array(z.string()).optional(),
1301
929
  // Invalid values degrade to undefined ("auto") instead of failing the whole
@@ -1497,6 +1125,17 @@ const configSchema = z.object({
1497
1125
  message: maxInputError,
1498
1126
  });
1499
1127
  }
1128
+ const autoCompactError = modelAutoCompactTokenLimitsConfigError(
1129
+ (provider as { modelAutoCompactTokenLimits?: unknown }).modelAutoCompactTokenLimits,
1130
+ { requireNativeIds: name === OPENAI_CODEX_PROVIDER_ID },
1131
+ );
1132
+ if (autoCompactError) {
1133
+ ctx.addIssue({
1134
+ code: "custom",
1135
+ path: ["providers", redactSecretString(name), "modelAutoCompactTokenLimits"],
1136
+ message: autoCompactError,
1137
+ });
1138
+ }
1500
1139
  const reasoningSummariesError = booleanRecordConfigError(
1501
1140
  (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
1502
1141
  "modelSupportsReasoningSummaries",
@@ -1508,6 +1147,17 @@ const configSchema = z.object({
1508
1147
  message: reasoningSummariesError,
1509
1148
  });
1510
1149
  }
1150
+ const verbositySupportError = booleanRecordConfigError(
1151
+ (provider as { modelSupportsVerbosity?: unknown }).modelSupportsVerbosity,
1152
+ "modelSupportsVerbosity",
1153
+ );
1154
+ if (verbositySupportError) {
1155
+ ctx.addIssue({
1156
+ code: "custom",
1157
+ path: ["providers", redactSecretString(name), "modelSupportsVerbosity"],
1158
+ message: verbositySupportError,
1159
+ });
1160
+ }
1511
1161
  const serviceTierModelsError = booleanRecordConfigError(
1512
1162
  (provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier,
1513
1163
  "modelSupportsServiceTier",
@@ -1652,35 +1302,6 @@ const configSchema = z.object({
1652
1302
  */
1653
1303
  export const DEFAULT_SUBAGENT_MODELS = ["gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4-mini"];
1654
1304
 
1655
- export function getConfigDir(): string {
1656
- return resolveConfigDir();
1657
- }
1658
-
1659
- export function getConfigPath(): string {
1660
- return resolveConfigPath();
1661
- }
1662
-
1663
- export function getPidPath(): string {
1664
- return resolvePidPath();
1665
- }
1666
-
1667
- export function getRuntimePortPath(): string {
1668
- return resolveRuntimePortPath();
1669
- }
1670
-
1671
- export function hardenConfigDir(): void {
1672
- const dir = getConfigDir();
1673
- // The guard runs BEFORE any mutation: refusing the write after chmod/ACL
1674
- // would already have changed the protected directory (review round 2).
1675
- assertNotRealHomeUnderTest(dir);
1676
- if (existsSync(dir)) {
1677
- try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
1678
- if (process.platform === "win32") {
1679
- hardenSecretDir(dir, { required: false });
1680
- }
1681
- }
1682
- }
1683
-
1684
1305
  export function hardenExistingSecret(path: string): void {
1685
1306
  if (existsSync(path)) {
1686
1307
  try { chmodSync(path, 0o600); } catch { /* best-effort */ }
@@ -2193,6 +1814,7 @@ export function loadConfig(): OcxConfig {
2193
1814
  try {
2194
1815
  const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
2195
1816
  const parsed = JSON.parse(raw);
1817
+ sanitizeAliasesForLoad(parsed);
2196
1818
  sanitizeRetryOn429ForLoad(parsed);
2197
1819
  sanitizeModelCostsForLoad(parsed);
2198
1820
  const result = configSchema.safeParse(parsed);
@@ -2264,6 +1886,43 @@ export function loadConfig(): OcxConfig {
2264
1886
  }
2265
1887
  }
2266
1888
 
1889
+ /** Hand-edited alias mistakes disable only the bad alias; providers and routing survive. */
1890
+ function sanitizeAliasesForLoad(raw: unknown): void {
1891
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
1892
+ const root = raw as Record<string, unknown>;
1893
+ if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return;
1894
+ const providers = root.providers as Record<string, Record<string, unknown>>;
1895
+ const providerNames = new Set(Object.keys(providers).map(name => name.toLowerCase()));
1896
+ const claimedProviders = new Set<string>();
1897
+ const comboAliases = new Set(Object.values((root.combos as Record<string, { alias?: unknown }> | undefined) ?? {})
1898
+ .map(combo => typeof combo?.alias === "string" ? combo.alias.toLowerCase() : "").filter(Boolean));
1899
+ const accountNamespaces = new Set(Object.keys((root.codexAccountNamespaces as Record<string, unknown> | undefined) ?? {}).map(name => name.toLowerCase()));
1900
+ for (const provider of Object.values(providers)) {
1901
+ const alias = provider.alias;
1902
+ if (typeof alias !== "string" || !isValidProviderName(alias)
1903
+ || providerNames.has(alias.toLowerCase()) || claimedProviders.has(alias.toLowerCase())
1904
+ || comboAliases.has(alias.toLowerCase()) || accountNamespaces.has(alias.toLowerCase())) {
1905
+ if (alias !== undefined) console.warn("Ignoring invalid or colliding provider alias in config.json");
1906
+ delete provider.alias;
1907
+ } else claimedProviders.add(alias.toLowerCase());
1908
+ if (!provider.modelAliases || typeof provider.modelAliases !== "object" || Array.isArray(provider.modelAliases)) {
1909
+ if (provider.modelAliases !== undefined) delete provider.modelAliases;
1910
+ continue;
1911
+ }
1912
+ const aliases = provider.modelAliases as Record<string, unknown>;
1913
+ const nativeIds = new Set((Array.isArray(provider.models) ? provider.models : []).filter((id): id is string => typeof id === "string").map(id => id.toLowerCase()));
1914
+ const claimed = new Set<string>();
1915
+ for (const [id, value] of Object.entries(aliases)) {
1916
+ const lower = typeof value === "string" ? value.toLowerCase() : "";
1917
+ if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value) || claimed.has(lower)
1918
+ || nativeIds.has(lower) || comboAliases.has(lower) || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) {
1919
+ console.warn(`Ignoring invalid or colliding model alias for ${id} in config.json`);
1920
+ delete aliases[id];
1921
+ } else claimed.add(lower);
1922
+ }
1923
+ }
1924
+ }
1925
+
2267
1926
  /** Refresh the user cost-overlay registry from `config` and return it unchanged. */
2268
1927
  function withRefreshedCostOverlays(config: OcxConfig): OcxConfig {
2269
1928
  refreshUserCostOverlays(config);
@@ -2465,6 +2124,14 @@ function emptyCompletionRetryError(value: unknown): string | null {
2465
2124
  return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted";
2466
2125
  }
2467
2126
 
2127
+ function oauthOpenBrowserError(value: unknown): string | null {
2128
+ const raw = rawConfigRecord(value);
2129
+ if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null;
2130
+ const enabled = raw.oauthOpenBrowser;
2131
+ if (enabled === undefined || typeof enabled === "boolean") return null;
2132
+ return "schema_invalid: oauthOpenBrowser: must be a boolean or omitted";
2133
+ }
2134
+
2468
2135
  /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
2469
2136
  /**
2470
2137
  * Reject a loopback-listener port that collides with the proxy port (#1102).
@@ -2514,6 +2181,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
2514
2181
  ?? codexAccountPrioritiesError(value)
2515
2182
  ?? codexAccountPickerEnabledError(value)
2516
2183
  ?? emptyCompletionRetryError(value)
2184
+ ?? oauthOpenBrowserError(value)
2517
2185
  ?? loopbackListenerPortError(value);
2518
2186
  if (boundaryError) return { ok: false, error: boundaryError };
2519
2187
  const result = configSchema.safeParse(value);
@@ -2846,7 +2514,12 @@ function persistConfigUnlocked(config: OcxConfig): boolean {
2846
2514
  // External editors can add provider rows the live config deliberately does
2847
2515
  // not route with yet; merge them at the serialization boundary so an
2848
2516
  // unrelated in-process save cannot erase the provider or its overlay.
2517
+ // Provider preservation reads symbol-keyed live-owner state, which structuredClone
2518
+ // intentionally drops. Resolve that ownership before projecting JSON provenance.
2519
+ const provenanceProjection = projectConfigRebaseProvenance(config);
2849
2520
  const persisted = withPreservedDiskOnlyProviders(config);
2521
+ if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance;
2522
+ else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
2850
2523
  const bytes = JSON.stringify(persisted, null, 2) + "\n";
2851
2524
  let unchanged = false;
2852
2525
  try {
@@ -2874,9 +2547,15 @@ export function saveConfig(config: OcxConfig): void {
2874
2547
  // Keep the real-home assertion ahead of even lock-directory preparation.
2875
2548
  assertNotRealHomeUnderTest(getConfigDir());
2876
2549
  withConfigMutationLockSync(() => {
2877
- const projected = projectCustomModelCatalogMigration(readRawConfigJson(), config);
2878
- if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite();
2879
- adoptCustomModelCatalogMigration(config, projected);
2550
+ const withProvenance = projectCustomModelCatalogMigration(
2551
+ readRawConfigJson(),
2552
+ projectConfigRebaseProvenance(config),
2553
+ );
2554
+ if (persistConfigUnlocked(withProvenance)) bumpGenerationForCooperatingConfigWrite();
2555
+ adoptCustomModelCatalogMigration(config, withProvenance);
2556
+ if (withProvenance.configRebaseProvenance === undefined) delete config.configRebaseProvenance;
2557
+ else config.configRebaseProvenance = structuredClone(withProvenance.configRebaseProvenance);
2558
+ clearPendingConfigTopLevelDeletions(config);
2880
2559
  });
2881
2560
  }
2882
2561
 
@@ -2995,7 +2674,6 @@ const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
2995
2674
  * reconciliation paths below.
2996
2675
  */
2997
2676
  const liveConfigBaseline = new WeakMap<OcxConfig, OcxConfig>();
2998
-
2999
2677
  /**
3000
2678
  * The live config retains the address of the socket Bun actually opened, while
3001
2679
  * this map retains the operator's desired address for the next process start.
@@ -3299,6 +2977,8 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
3299
2977
  if (baseline && onDisk !== undefined) {
3300
2978
  const persistedDiagnostics = configDiagnosticsFromRaw(JSON.stringify(onDisk));
3301
2979
  if (persistedDiagnostics.source === "file") {
2980
+ const deletedKeys = configRebaseDeletionKeys(config);
2981
+ const provenanceExists = configHasRebaseProvenance(config);
3302
2982
  // Only keys this live config is actually known to have diverged on may be
3303
2983
  // rebased. The baseline is captured once when the server arms it, so any key
3304
2984
  // that appeared on disk afterwards — through saveConfig(), a hand edit, or
@@ -3312,8 +2992,11 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
3312
2992
  const rebaseableKeys = new Set([
3313
2993
  ...Object.keys(baseline as unknown as Record<string, unknown>),
3314
2994
  ...Object.keys(config as unknown as Record<string, unknown>),
2995
+ ...(provenanceExists
2996
+ ? Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)
2997
+ : []),
3315
2998
  ]);
3316
- const skipped = new Set(["hostname", "port", "claudeCode"]);
2999
+ const skipped = new Set(["hostname", "port", "claudeCode", CONFIG_REBASE_PROVENANCE_KEY]);
3317
3000
  for (const key of Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)) {
3318
3001
  if (!rebaseableKeys.has(key)) skipped.add(key);
3319
3002
  }
@@ -3323,6 +3006,7 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
3323
3006
  persistedDiagnostics.config as unknown as Record<string, unknown>,
3324
3007
  skipped,
3325
3008
  );
3009
+ for (const key of deletedKeys) delete (config as unknown as Record<string, unknown>)[key];
3326
3010
  }
3327
3011
  }
3328
3012
  if (claudeCodeBaseline.has(config)) {
@@ -3336,10 +3020,13 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
3336
3020
  }
3337
3021
  }
3338
3022
  }
3023
+ const provenanceProjection = projectConfigRebaseProvenance(config);
3339
3024
  const projectedConfig = projectCustomModelCatalogMigration(
3340
3025
  onDisk,
3341
3026
  config,
3342
3027
  );
3028
+ if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance;
3029
+ else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
3343
3030
  const persistedBinding = bindingBaseline && onDisk
3344
3031
  ? readPersistedServerBinding(onDisk, bindingBaseline)
3345
3032
  : bindingBaseline;
@@ -3357,8 +3044,11 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
3357
3044
  claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
3358
3045
  }
3359
3046
  if (liveConfigBaseline.has(config)) {
3360
- liveConfigBaseline.set(config, structuredClone(config));
3047
+ if (projectedConfig.configRebaseProvenance === undefined) delete config.configRebaseProvenance;
3048
+ else config.configRebaseProvenance = structuredClone(projectedConfig.configRebaseProvenance);
3049
+ liveConfigBaseline.set(config, structuredClone(projectedConfig));
3361
3050
  }
3051
+ clearPendingConfigTopLevelDeletions(config);
3362
3052
  });
3363
3053
  }
3364
3054
 
@@ -3434,100 +3124,22 @@ export function applyProxyEnv(config: OcxConfig): void {
3434
3124
  const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
3435
3125
  const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
3436
3126
  const seen = new Set(entries.map(e => e.toLowerCase()));
3437
- for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) {
3438
- if (!seen.has(host)) {
3127
+ // Configured entries first, then loopback: loopback is unconditional, so appending it last
3128
+ // keeps it present even when the operator lists a loopback host themselves.
3129
+ const raw = config.noProxy;
3130
+ const configured = (Array.isArray(raw) ? raw : (resolveEnvValue(raw) ?? "").split(","))
3131
+ .map(entry => entry.trim())
3132
+ .filter(Boolean);
3133
+ for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) {
3134
+ const key = host.toLowerCase();
3135
+ if (!seen.has(key)) {
3439
3136
  entries.push(host);
3440
- seen.add(host);
3137
+ seen.add(key);
3441
3138
  }
3442
3139
  }
3443
3140
  process.env.NO_PROXY = entries.join(",");
3444
3141
  }
3445
3142
 
3446
- export function writePid(pid: number): void {
3447
- const dir = getConfigDir();
3448
- // Guard before ANY directory mutation (mkdir or chmod), not just the write.
3449
- assertNotRealHomeUnderTest(dir);
3450
- if (!existsSync(dir)) {
3451
- mkdirSync(dir, { recursive: true, mode: 0o700 });
3452
- } else {
3453
- hardenConfigDir();
3454
- }
3455
- atomicWriteFile(getPidPath(), String(pid));
3456
- }
3457
-
3458
- export type RuntimePortState = {
3459
- pid: number;
3460
- port: number;
3461
- hostname?: string;
3462
- /** Per-process proof key; protected by the config directory and never served. */
3463
- attestationSecret?: string;
3464
- };
3465
-
3466
- function isValidRuntimePortState(value: unknown): value is RuntimePortState {
3467
- if (!value || typeof value !== "object") return false;
3468
- const state = value as Record<string, unknown>;
3469
- const hostnameOk = state.hostname === undefined || typeof state.hostname === "string";
3470
- const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret);
3471
- return Number.isSafeInteger(state.pid)
3472
- && Number(state.pid) > 0
3473
- && Number.isInteger(state.port)
3474
- && Number(state.port) > 0
3475
- && Number(state.port) <= 65535
3476
- && hostnameOk
3477
- && attestationOk;
3478
- }
3479
-
3480
- export function writeRuntimePort(state: RuntimePortState): void {
3481
- const dir = getConfigDir();
3482
- // Guard before ANY directory mutation (mkdir or chmod), not just the write.
3483
- assertNotRealHomeUnderTest(dir);
3484
- if (!existsSync(dir)) {
3485
- mkdirSync(dir, { recursive: true, mode: 0o700 });
3486
- } else {
3487
- hardenConfigDir();
3488
- }
3489
- atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n");
3490
- }
3491
-
3492
- export function readPid(): number | null {
3493
- const pidPath = getPidPath();
3494
- if (!existsSync(pidPath)) return null;
3495
- try {
3496
- const raw = readFileSync(pidPath, "utf-8").trim();
3497
- const pid = parsePidFile(raw);
3498
- if (pid === null) return null;
3499
- try {
3500
- process.kill(pid, 0);
3501
- return isLikelyOcxStartProcess(pid) ? pid : null;
3502
- } catch (e: unknown) {
3503
- if ((e as NodeJS.ErrnoException).code === "EPERM") {
3504
- return isLikelyOcxStartProcess(pid) ? pid : null;
3505
- }
3506
- return null;
3507
- }
3508
- } catch {
3509
- return null;
3510
- }
3511
- }
3512
-
3513
- export function readRuntimePort(expectedPid?: number): RuntimePortState | null {
3514
- try {
3515
- const parsed = JSON.parse(readFileSync(getRuntimePortPath(), "utf-8"));
3516
- if (!isValidRuntimePortState(parsed)) return null;
3517
- if (expectedPid !== undefined && parsed.pid !== expectedPid) return null;
3518
- return parsed;
3519
- } catch {
3520
- return null;
3521
- }
3522
- }
3523
-
3524
- export function removePid(expectedPid?: number): void {
3525
- if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return;
3526
- try {
3527
- unlinkSync(getPidPath());
3528
- } catch { /* ignore */ }
3529
- }
3530
-
3531
3143
  function warnConfigRepaired(configPath: string, error: z.ZodError): void {
3532
3144
  if (warnedConfigFallbacks.has(configPath)) return;
3533
3145
  warnedConfigFallbacks.add(configPath);
@@ -3723,236 +3335,6 @@ function warnDroppedConfigSections(configPath: string, dropped: string[], issues
3723
3335
  );
3724
3336
  }
3725
3337
 
3726
- export function readPidFileValue(): number | null {
3727
- try {
3728
- return parsePidFile(readFileSync(getPidPath(), "utf-8"));
3729
- } catch {
3730
- return null;
3731
- }
3732
- }
3733
-
3734
- export function removeRuntimePort(expectedPid?: number): void {
3735
- if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return;
3736
- try {
3737
- unlinkSync(getRuntimePortPath());
3738
- } catch { /* ignore */ }
3739
- }
3740
-
3741
- /**
3742
- * Snapshot-guarded stale-state purge: remove the pid/runtime files only when their content
3743
- * still matches what the caller saw BEFORE its liveness probe. A concurrent `ocx start` can
3744
- * write fresh records mid-probe; an unconditional purge would erase the new proxy's state.
3745
- */
3746
- export function removePidIfValueIs(snapshot: number | null): void {
3747
- if (!existsSync(getPidPath())) return;
3748
- if (readPidFileValue() !== snapshot) return;
3749
- try {
3750
- unlinkSync(getPidPath());
3751
- } catch { /* ignore */ }
3752
- }
3753
-
3754
- export function removeRuntimePortIfPidIs(snapshotPid: number | null): void {
3755
- const current = readRuntimePort();
3756
- if ((current?.pid ?? null) !== snapshotPid) return;
3757
- try {
3758
- unlinkSync(getRuntimePortPath());
3759
- } catch { /* ignore */ }
3760
- }
3761
-
3762
- export function parsePidFile(raw: string): number | null {
3763
- const trimmed = raw.trim();
3764
- if (!/^\d+$/.test(trimmed)) return null;
3765
- const pid = Number.parseInt(trimmed, 10);
3766
- return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
3767
- }
3768
-
3769
- export function isOcxStartCommandLine(commandLine: string): boolean {
3770
- const normalized = commandLine.toLowerCase().replace(/\\/g, "/");
3771
- // "src/cli.ts" matches pre-restructure installs still running; "src/cli/index.ts" is current.
3772
- // `@scope/.opencodex-*` is npm's in-place rename of the global package during
3773
- // `npm install -g` — a Windows service wrapper can respawn from that temp tree
3774
- // mid-update, and must still count as ocx for port reclaim.
3775
- // Match both the upstream scope (@bitkyc08) and this fork's publish scope (@iislee).
3776
- const hasOcxEntrypoint = normalized.includes("src/cli.ts")
3777
- || normalized.includes("src/cli/index.ts")
3778
- || normalized.includes("@bitkyc08/opencodex")
3779
- || normalized.includes("@iislee/opencodex")
3780
- || /@bitkyc08\/\.opencodex-/.test(normalized)
3781
- || /@iislee\/\.opencodex-/.test(normalized)
3782
- || /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized);
3783
- return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
3784
- }
3785
-
3786
- /** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */
3787
- const ocxStartProcessCache = new Map<number, boolean>();
3788
- let ocxStartProcessSweepCursor = 0;
3789
- let ocxStartProcessProbe: (pid: number) => void = pid => { process.kill(pid, 0); };
3790
-
3791
- export function setOcxStartProcessProbeForTests(probe: ((pid: number) => void) | null): void {
3792
- ocxStartProcessProbe = probe ?? (pid => { process.kill(pid, 0); });
3793
- }
3794
-
3795
- export function setOcxStartProcessCacheForTests(entries: Iterable<readonly [number, boolean]>): void {
3796
- ocxStartProcessCache.clear();
3797
- for (const [pid, value] of entries) ocxStartProcessCache.set(pid, value);
3798
- ocxStartProcessSweepCursor = 0;
3799
- }
3800
-
3801
- export function sweepDeadOcxStartProcessCache(maxProbes = 64): number {
3802
- const pids: number[] = [];
3803
- let removed = 0;
3804
- for (const pid of ocxStartProcessCache.keys()) {
3805
- if (Number.isSafeInteger(pid) && pid > 0) pids.push(pid);
3806
- else if (ocxStartProcessCache.delete(pid)) removed += 1;
3807
- }
3808
- if (pids.length === 0 || maxProbes <= 0) {
3809
- ocxStartProcessSweepCursor = 0;
3810
- return removed;
3811
- }
3812
- const probeCount = Math.min(Math.floor(maxProbes), pids.length);
3813
- const start = ocxStartProcessSweepCursor % pids.length;
3814
- for (let offset = 0; offset < probeCount; offset += 1) {
3815
- const pid = pids[(start + offset) % pids.length]!;
3816
- try {
3817
- ocxStartProcessProbe(pid);
3818
- } catch (error) {
3819
- if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue;
3820
- if (ocxStartProcessCache.delete(pid)) removed += 1;
3821
- }
3822
- }
3823
- ocxStartProcessSweepCursor = (start + probeCount) % pids.length;
3824
- return removed;
3825
- }
3826
-
3827
- export function ocxStartProcessCacheSizeForTests(): number {
3828
- return ocxStartProcessCache.size;
3829
- }
3830
-
3831
- function isLikelyOcxStartProcess(pid: number): boolean {
3832
- const cached = ocxStartProcessCache.get(pid);
3833
- if (cached !== undefined) return cached;
3834
- const commandLine = readProcessCommandLine(pid);
3835
- if (commandLine === undefined) return false;
3836
- const ok = isOcxStartCommandLine(commandLine);
3837
- ocxStartProcessCache.set(pid, ok);
3838
- return ok;
3839
- }
3840
-
3841
- /**
3842
- * Alive pid from the pid file without the expensive Windows command-line probe.
3843
- * Safe for liveness polls: callers still identity-check /healthz before trusting the proxy.
3844
- * Destructive stop/kill paths should keep using {@link readPid}, which verifies the cmdline.
3845
- */
3846
- export function readAlivePid(): number | null {
3847
- const pid = readPidFileValue();
3848
- if (pid === null) return null;
3849
- try {
3850
- process.kill(pid, 0);
3851
- return pid;
3852
- } catch (e: unknown) {
3853
- if ((e as NodeJS.ErrnoException).code === "EPERM") return pid;
3854
- return null;
3855
- }
3856
- }
3857
-
3858
- /**
3859
- * Full identity check of a KNOWN candidate pid (alive + ocx-start command line).
3860
- * Companion to {@link readAlivePid}: liveness discovery may be cheap, but any pid
3861
- * handed to a destructive caller must pass this check — and must equal the candidate
3862
- * it was asked about, so a pidfile rewrite between discovery and verification can
3863
- * never swap in a different process (TOCTOU guard).
3864
- */
3865
- export function verifyPidIdentity(candidatePid: number): number | null {
3866
- try {
3867
- process.kill(candidatePid, 0);
3868
- } catch (e: unknown) {
3869
- if ((e as NodeJS.ErrnoException).code !== "EPERM") return null;
3870
- }
3871
- return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null;
3872
- }
3873
-
3874
- type ProcessCommandLineExec = (
3875
- executable: string,
3876
- args: string[],
3877
- options: {
3878
- encoding: BufferEncoding;
3879
- stdio: ["ignore", "pipe", "ignore"];
3880
- timeout: number;
3881
- windowsHide: boolean;
3882
- },
3883
- ) => string;
3884
-
3885
- const defaultProcessCommandLineExec: ProcessCommandLineExec = (executable, args, options) =>
3886
- execFileSync(executable, args, options);
3887
- let processCommandLineExec = defaultProcessCommandLineExec;
3888
- let processCommandLinePlatformForTests: NodeJS.Platform | null = null;
3889
-
3890
- /** Test-only seam for verifying the exact system executable selected by pid identity probes. */
3891
- export function setProcessCommandLineExecForTests(next: ProcessCommandLineExec | null): void {
3892
- processCommandLineExec = next ?? defaultProcessCommandLineExec;
3893
- }
3894
-
3895
- /** Test-only seam so cross-platform tests do not mutate process.platform. */
3896
- export function setProcessCommandLinePlatformForTests(next: NodeJS.Platform | null): void {
3897
- processCommandLinePlatformForTests = next;
3898
- }
3899
-
3900
- function readProcessCommandLine(pid: number): string | undefined {
3901
- if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
3902
- const platform = processCommandLinePlatformForTests ?? process.platform;
3903
- try {
3904
- if (platform === "linux") {
3905
- try {
3906
- const output = readFileSync(`/proc/${pid}/cmdline`, "utf-8");
3907
- const value = output.replace(/\0/g, " ").trim();
3908
- if (value) return value;
3909
- } catch {
3910
- /* procfs unavailable — use the fixed ps fallback below */
3911
- }
3912
- }
3913
- if (platform === "win32") {
3914
- // Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash.
3915
- // Fall back to PowerShell when WMIC is absent (newer Windows images).
3916
- const wmic = join(resolveTrustedWindowsSystemDirectory(), "wbem", "WMIC.exe");
3917
- try {
3918
- const output = processCommandLineExec(wmic, [
3919
- "process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE",
3920
- ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
3921
- const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, ""));
3922
- const value = match?.[1]?.trim();
3923
- if (value) return value;
3924
- } catch {
3925
- /* WMIC missing or failed — fall through */
3926
- }
3927
- const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [
3928
- "-NoProfile",
3929
- "-NoLogo",
3930
- "-NonInteractive",
3931
- "-Command",
3932
- `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
3933
- ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
3934
- return output.trim() || undefined;
3935
- }
3936
- for (const ps of ["/bin/ps", "/usr/bin/ps"]) {
3937
- try {
3938
- const output = processCommandLineExec(ps, ["-p", String(pid), "-o", "command="], {
3939
- encoding: "utf-8",
3940
- stdio: ["ignore", "pipe", "ignore"],
3941
- timeout: 1000,
3942
- windowsHide: true,
3943
- });
3944
- const value = output.trim();
3945
- if (value) return value;
3946
- } catch {
3947
- /* try the other fixed system path */
3948
- }
3949
- }
3950
- return undefined;
3951
- } catch {
3952
- return undefined;
3953
- }
3954
- }
3955
-
3956
3338
  function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
3957
3339
  if (warnedConfigFallbacks.has(configPath)) return;
3958
3340
  warnedConfigFallbacks.add(configPath);