@yansigit/opencodex 2.31.2 → 2.32.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 (118) hide show
  1. package/bin/ocx.mjs +99 -70
  2. package/gui/dist/assets/index-BG43zwVe.js +102 -0
  3. package/gui/dist/assets/index-CiSI-jrP.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +6 -8
  7. package/src/adapters/base.ts +2 -0
  8. package/src/adapters/command-code-project-context.ts +377 -0
  9. package/src/adapters/command-code.ts +7 -4
  10. package/src/adapters/cursor/live-models.ts +8 -0
  11. package/src/adapters/cursor/live-transport.ts +21 -0
  12. package/src/adapters/cursor/native-exec-bridge.ts +141 -0
  13. package/src/adapters/cursor/native-exec-desktop.ts +16 -0
  14. package/src/adapters/cursor/tool-definitions.ts +1 -1
  15. package/src/adapters/google-http.ts +12 -2
  16. package/src/adapters/google-wire-compiler.ts +83 -2
  17. package/src/adapters/google.ts +62 -21
  18. package/src/adapters/kiro.ts +0 -3
  19. package/src/adapters/openai-responses.ts +3 -0
  20. package/src/adapters/tool-catalog-nudge.ts +1 -1
  21. package/src/adapters/xai-web-search.ts +7 -2
  22. package/src/bridge.ts +21 -15
  23. package/src/cli/dispatch.ts +50 -2
  24. package/src/cli/doctor.ts +24 -11
  25. package/src/cli/help.ts +4 -3
  26. package/src/cli/index.ts +11 -4
  27. package/src/cli/models.ts +13 -3
  28. package/src/cli/observe.ts +20 -5
  29. package/src/cli/provider.ts +2 -1
  30. package/src/cli/registry.ts +7 -5
  31. package/src/cli/status.ts +2 -1
  32. package/src/cli/system-restart-client.ts +1 -1
  33. package/src/cli/usage-report.ts +134 -0
  34. package/src/codex/app-server-processes.ts +3 -1
  35. package/src/codex/catalog/aggregation.ts +13 -1
  36. package/src/codex/catalog/effort.ts +24 -9
  37. package/src/codex/catalog/model-metadata.ts +566 -0
  38. package/src/codex/catalog/parsing.ts +33 -0
  39. package/src/codex/catalog/provider-fetch.ts +123 -32
  40. package/src/codex/catalog/sync.ts +5 -4
  41. package/src/codex/desktop-app-restart.ts +342 -0
  42. package/src/codex/history-job.ts +32 -3
  43. package/src/codex/history-manifest.ts +112 -0
  44. package/src/codex/history-migration-guardian.ts +5 -5
  45. package/src/codex/history-provider.ts +825 -247
  46. package/src/codex/history-worker.ts +8 -5
  47. package/src/codex/inject.ts +49 -21
  48. package/src/codex/injected-marker.ts +1 -1
  49. package/src/codex/internal/history-writer.ts +4 -3
  50. package/src/codex/native-profile-startup.ts +157 -27
  51. package/src/codex/native-residue.ts +26 -33
  52. package/src/combos/failover.ts +27 -0
  53. package/src/compatibility/index.ts +26 -0
  54. package/src/compatibility/manifest.ts +253 -0
  55. package/src/compatibility/openai-responses.ts +81 -0
  56. package/src/config/atomic-write.ts +219 -0
  57. package/src/config/paths.ts +40 -0
  58. package/src/config/process-state.ts +308 -0
  59. package/src/config/provider-validation.ts +177 -0
  60. package/src/config.ts +77 -812
  61. package/src/generated/compatibility-version.json +162 -94
  62. package/src/images/plan.ts +5 -4
  63. package/src/integrations/ownership-policy.ts +141 -0
  64. package/src/integrations/ownership.ts +10 -0
  65. package/src/integrations/state.ts +44 -5
  66. package/src/integrations/writer.ts +6 -0
  67. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  68. package/src/lib/bounded-body.ts +14 -2
  69. package/src/lib/process-control.ts +2 -1
  70. package/src/lib/state-store-registrations.ts +2 -0
  71. package/src/lib/tool-argument-integers.ts +56 -5
  72. package/src/oauth/health.ts +1 -1
  73. package/src/oauth/index.ts +3 -0
  74. package/src/providers/registry.ts +1 -1
  75. package/src/reasoning-effort.ts +19 -2
  76. package/src/responses/apply-patch-envelope.ts +63 -0
  77. package/src/responses/custom-tool-compat.ts +132 -38
  78. package/src/responses/parser.ts +3 -2
  79. package/src/responses/reasoning-replay-cache.ts +81 -3
  80. package/src/routing/compatibility/behavior.ts +3 -0
  81. package/src/server/auth-cors.ts +9 -7
  82. package/src/server/index.ts +102 -21
  83. package/src/server/local-management-read-client.ts +1 -1
  84. package/src/server/local-provider-reload-client.ts +1 -1
  85. package/src/server/management/agent-settings-routes.ts +1 -1
  86. package/src/server/management/config-routes.ts +4 -1
  87. package/src/server/management/context.ts +1 -1
  88. package/src/server/management/logs-usage-routes.ts +27 -6
  89. package/src/server/management/model-routes.ts +8 -4
  90. package/src/server/management/native-integration-routes.ts +2 -1
  91. package/src/server/management/provider-capability-config.ts +1 -1
  92. package/src/server/management/system-restart.ts +1 -1
  93. package/src/server/port-reclaim.ts +1 -1
  94. package/src/server/proxy-liveness.ts +2 -1
  95. package/src/server/request-log-conversation.ts +30 -0
  96. package/src/server/responses/codex-auth-error.ts +55 -0
  97. package/src/server/responses/combo-stream-preflight.ts +171 -0
  98. package/src/server/responses/compact.ts +6 -21
  99. package/src/server/responses/core.ts +242 -101
  100. package/src/server/responses/fetch-helpers.ts +2 -97
  101. package/src/server/responses-custom-tool-repair.ts +41 -5
  102. package/src/server/responses-undeclared-tool-guard.ts +156 -15
  103. package/src/service.ts +8 -4
  104. package/src/types/provider.ts +7 -0
  105. package/src/types/request.ts +12 -1
  106. package/src/types/tools.ts +87 -11
  107. package/src/types.ts +1 -1
  108. package/src/update/index.ts +5 -4
  109. package/src/update/job.ts +3 -1
  110. package/src/update/transactional-install.mjs +8 -1
  111. package/src/usage/log.ts +16 -8
  112. package/src/usage/summary.ts +201 -8
  113. package/src/vision/describe.ts +18 -13
  114. package/src/web-search/executor.ts +10 -3
  115. package/src/web-search/gemini-executor.ts +35 -13
  116. package/src/web-search/index.ts +85 -1
  117. package/gui/dist/assets/index-BAMgarF9.js +0 -102
  118. package/gui/dist/assets/index-CGoDO3uO.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,17 @@ 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";
58
63
  import {
59
- isWirePinnedModel,
60
64
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
61
65
  OPENAI_PROVIDER_TIER_VERSION,
62
66
  pinnedWireAdapter,
63
- REASONING_SUMMARY_DELIVERY_VALUES,
64
67
  UPSTREAM_HTTP_VERSION_VALUES,
65
68
  type OcxClaudeCodeConfig,
66
69
  type OcxConfig,
@@ -69,7 +72,7 @@ import {
69
72
  type FastWire,
70
73
  type ProviderCostOverlay,
71
74
  } from "./types";
72
- import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
75
+ import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
73
76
  import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire";
74
77
  import {
75
78
  getProviderRegistryEntry,
@@ -79,7 +82,7 @@ import {
79
82
  } from "./providers/registry";
80
83
  import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models";
81
84
  import { parseDesktopProfile } from "./claude/desktop-profile";
82
- import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
85
+ import { isCodexReasoningEffort } from "./reasoning-effort";
83
86
  import { parseSubagentRoles, salvageSubagentRoles } from "./codex/agent-roles";
84
87
  import {
85
88
  COST4_RATE_KEYS,
@@ -95,244 +98,49 @@ import {
95
98
  MIN_APP_OWNED_MEMORY_BUDGET_MB,
96
99
  } from "./lib/app-owned-memory";
97
100
  import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy";
98
-
99
- let _atomicSeq = 0;
100
-
101
- // The Windows-tolerant replace lives in lib/windows-atomic-replace: config-ownership
102
- // is one of its callers and this module already imports config-ownership, so
103
- // exporting it from here would close an import cycle. Re-exported because these
104
- // names are part of this module's public surface and its callers.
105
- export type { AtomicRenameIO } from "./lib/windows-atomic-replace";
106
- export { renameAtomicFile } from "./lib/windows-atomic-replace";
107
- import { renameAtomicFile, renameAtomicFileAsync } from "./lib/windows-atomic-replace";
108
-
109
- /**
110
- * Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the
111
- * proxy's own shutdown handler both restoring Codex — can never leave a half-written file.
112
- */
113
- export interface AtomicWriteIO {
114
- write: (path: string, content: string) => void;
115
- harden: (path: string) => void;
116
- rename: (source: string, destination: string) => void;
117
- truncate: (path: string) => void;
118
- unlink: (path: string) => void;
119
- }
120
-
121
- export class AtomicWriteResidualTempError extends Error {
122
- constructor(readonly tempPath: string, readonly hardened = true, options?: ErrorOptions) {
123
- super(`Atomic config write left a ${hardened ? "hardened " : ""}zero-byte temporary file`, options);
124
- this.name = "AtomicWriteResidualTempError";
125
- }
126
- }
127
-
128
- export class AtomicWriteSecretResidualError extends Error {
129
- constructor(readonly tempPath: string, options?: ErrorOptions) {
130
- super("Atomic config write could not scrub or remove a secret-bearing temporary file", options);
131
- this.name = "AtomicWriteSecretResidualError";
132
- }
133
- }
134
-
135
- function isMissingPathError(error: unknown): boolean {
136
- return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
137
- }
138
-
139
- /**
140
- * Resolve a write target through any symlink before the temp+rename dance.
141
- *
142
- * rename(2) replaces a directory ENTRY. When the entry is itself a symlink
143
- * (a dotfiles-managed `~/.codex/config.toml` -> `~/dotfiles/.codex/config.toml`,
144
- * say), renaming a sibling temp file over it destroys the link and leaves a plain
145
- * file behind — the repo silently stops receiving writes. Resolving first puts both
146
- * the temp file and the rename target inside the link's real directory, so the entry
147
- * being replaced is the real file and the symlink survives.
148
- *
149
- * Same-filesystem atomicity is preserved because the temp file stays beside its
150
- * resolved target. A genuinely absent destination (not yet created) falls back to
151
- * the literal path, which is the correct target for a first write.
152
- *
153
- * An EXISTING symlink that cannot be resolved — dangling because its target volume
154
- * is unmounted, an ELOOP chain, an EACCES parent — is refused instead. Falling back
155
- * to the literal path there would let the rename replace the link, recreating the
156
- * exact dotfiles-divergence failure this helper exists to prevent (audit: wt4 wp2).
157
- */
158
- export function resolveWriteTarget(path: string): string {
159
- try {
160
- return realpathSync(path);
161
- } catch (cause) {
162
- let entry;
163
- try {
164
- entry = lstatSync(path);
165
- } catch (error) {
166
- if (isMissingPathError(error)) return path; // no entry at all — first write
167
- throw error;
168
- }
169
- if (entry.isSymbolicLink()) {
170
- throw new Error(`refusing to replace unresolvable symlinked write target: ${path}`, { cause });
171
- }
172
- return path;
173
- }
174
- }
175
-
176
- /**
177
- * Re-apply the real-home guard to a RESOLVED write target.
178
- *
179
- * Callers such as saveConfig check only their logical config dir, which passes when
180
- * OPENCODEX_HOME points at a temp fixture. Following a symlink out of that fixture
181
- * would land on the protected home the caller's own check just cleared, so the guard
182
- * has to run again on wherever the write actually terminates. Inert in production,
183
- * where the guard is disarmed.
184
- */
185
- function assertResolvedTargetAllowed(path: string, target: string): void {
186
- // The file itself may resolve literally while its PARENT is a symlink out
187
- // of the fixture (a first write beneath a symlinked config dir). Guard the
188
- // directory the write actually lands in either way.
189
- if (target === path) {
190
- let realParent: string;
191
- try {
192
- realParent = realpathSync(dirname(target));
193
- } catch {
194
- return; // unresolvable parent: resolveWriteTarget already owns that refusal
195
- }
196
- if (realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent);
197
- return;
198
- }
199
- assertNotRealHomeUnderTest(dirname(target));
200
- }
201
-
202
- export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO = {
203
- write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
204
- harden: target => {
205
- try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
206
- // Timeout memo keyed by the stable destination (matches the async writer):
207
- // a failed temp harden must not mint a new unique-temp key on every write.
208
- if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: path });
209
- },
210
- rename: renameAtomicFile,
211
- truncate: target => truncateSync(target, 0),
212
- unlink: unlinkSync,
213
- }): void {
214
- recordOwnedConfigPath(resolveConfigDir(), path);
215
- const target = resolveWriteTarget(path);
216
- assertResolvedTargetAllowed(path, target);
217
- const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
218
- let hardened = false;
219
- try {
220
- io.write(tmp, content);
221
- io.harden(tmp);
222
- hardened = true;
223
- io.rename(tmp, target);
224
- forgetEphemeralSecretPath(tmp);
225
- } catch (cause) {
226
- let scrubbed = false;
227
- try {
228
- io.truncate(tmp);
229
- scrubbed = true;
230
- } catch (error) {
231
- if (isMissingPathError(error)) scrubbed = true;
232
- else {
233
- try { io.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
234
- }
235
- }
236
- let removed = false;
237
- try {
238
- io.unlink(tmp);
239
- removed = true;
240
- } catch (error) {
241
- if (isMissingPathError(error)) removed = true;
242
- else {
243
- try { io.unlink(tmp); removed = true; }
244
- catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
245
- }
246
- }
247
- if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
248
- if (!removed && !hardened) {
249
- try { io.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
250
- }
251
- if (removed) forgetEphemeralSecretPath(tmp);
252
- if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
253
- throw cause;
254
- }
255
- }
256
-
257
- /** Async atomic-write I/O: harden may await icacls without blocking the event loop (#612). */
258
- export interface AtomicWriteAsyncIO {
259
- write: (path: string, content: string) => void | Promise<void>;
260
- harden: (path: string) => void | Promise<void>;
261
- rename: (source: string, destination: string) => void | Promise<void>;
262
- truncate: (path: string) => void | Promise<void>;
263
- unlink: (path: string) => void | Promise<void>;
264
- }
265
-
266
- /** Test-only crash seam. Production callers leave this undefined. */
267
- export interface AtomicWriteAsyncTestSeam {
268
- afterTempWrite?: (tempPath: string) => void | Promise<void>;
269
- }
270
-
271
- /**
272
- * Async atomic write (#612): same temp+harden+rename and residual-temp policy as
273
- * atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed
274
- * by the final destination path (not the unique temp, not the parent directory).
275
- */
276
- export async function atomicWriteFileAsync(
277
- path: string,
278
- content: string,
279
- io?: AtomicWriteAsyncIO,
280
- testSeam?: AtomicWriteAsyncTestSeam,
281
- ): Promise<void> {
282
- const effective: AtomicWriteAsyncIO = io ?? {
283
- write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
284
- harden: async target => {
285
- try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
286
- if (process.platform === "win32") {
287
- await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path });
288
- }
289
- },
290
- rename: renameAtomicFileAsync,
291
- truncate: target => truncateSync(target, 0),
292
- unlink: unlinkSync,
293
- };
294
- const target = resolveWriteTarget(path);
295
- assertResolvedTargetAllowed(path, target);
296
- const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
297
- let hardened = false;
298
- try {
299
- await effective.write(tmp, content);
300
- await testSeam?.afterTempWrite?.(tmp);
301
- await effective.harden(tmp);
302
- hardened = true;
303
- await effective.rename(tmp, target);
304
- forgetEphemeralSecretPath(tmp);
305
- } catch (cause) {
306
- let scrubbed = false;
307
- try {
308
- await effective.truncate(tmp);
309
- scrubbed = true;
310
- } catch (error) {
311
- if (isMissingPathError(error)) scrubbed = true;
312
- else {
313
- try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
314
- }
315
- }
316
- let removed = false;
317
- try {
318
- await effective.unlink(tmp);
319
- removed = true;
320
- } catch (error) {
321
- if (isMissingPathError(error)) removed = true;
322
- else {
323
- try { await effective.unlink(tmp); removed = true; }
324
- catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
325
- }
326
- }
327
- if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
328
- if (!removed && !hardened) {
329
- try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
330
- }
331
- if (removed) forgetEphemeralSecretPath(tmp);
332
- if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
333
- throw cause;
334
- }
335
- }
101
+ import {
102
+ atomicWriteFile,
103
+ isMissingPathError,
104
+ nextAtomicTempSequence,
105
+ } from "./config/atomic-write";
106
+ export {
107
+ AtomicWriteResidualTempError,
108
+ AtomicWriteSecretResidualError,
109
+ atomicWriteFile,
110
+ atomicWriteFileAsync,
111
+ renameAtomicFile,
112
+ resolveWriteTarget,
113
+ type AtomicRenameIO,
114
+ type AtomicWriteAsyncIO,
115
+ type AtomicWriteAsyncTestSeam,
116
+ type AtomicWriteIO,
117
+ } from "./config/atomic-write";
118
+ import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
119
+ export { expandUserPath, getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
120
+ export {
121
+ getPidPath,
122
+ getRuntimePortPath,
123
+ isOcxStartCommandLine,
124
+ ocxStartProcessCacheSizeForTests,
125
+ parsePidFile,
126
+ readAlivePid,
127
+ readPid,
128
+ readPidFileValue,
129
+ readRuntimePort,
130
+ removePid,
131
+ removePidIfValueIs,
132
+ removeRuntimePort,
133
+ removeRuntimePortIfPidIs,
134
+ setOcxStartProcessCacheForTests,
135
+ setOcxStartProcessProbeForTests,
136
+ setProcessCommandLineExecForTests,
137
+ setProcessCommandLinePlatformForTests,
138
+ sweepDeadOcxStartProcessCache,
139
+ verifyPidIdentity,
140
+ writePid,
141
+ writeRuntimePort,
142
+ type RuntimePortState,
143
+ } from "./config/process-state";
336
144
 
337
145
  export class OpenAiTierBackupCleanupError extends Error {
338
146
  constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
@@ -453,7 +261,7 @@ export function backupConfigBeforeOpenAiTierMigration(
453
261
  return "reused";
454
262
  }
455
263
  }
456
- const temp = `${backup}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
264
+ const temp = `${backup}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
457
265
  let published = false;
458
266
  let cleanupAttempted = false;
459
267
 
@@ -597,39 +405,6 @@ export function preserveOpenAiTierRollbackSnapshot(
597
405
  throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" });
598
406
  }
599
407
 
600
- /**
601
- * Expand a leading `~` to the home directory in user-supplied paths
602
- * (OPENCODEX_HOME/CODEX_HOME set from GUIs/service files where no shell expanded it).
603
- * `~user` and `%VAR%`/`$VAR` forms pass through untouched — those belong to the shell.
604
- */
605
- export function expandUserPath(raw: string): string {
606
- if (raw === "~") return homedir();
607
- if (raw.startsWith("~/") || raw.startsWith("~\\")) return join(homedir(), raw.slice(2));
608
- return raw;
609
- }
610
-
611
- let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null;
612
-
613
- function resolveConfigDir(): string {
614
- const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined;
615
- if (resolvedConfigDirCache && resolvedConfigDirCache.raw === raw) return resolvedConfigDirCache.path;
616
- const path = raw ? resolve(expandUserPath(raw)) : join(homedir(), ".opencodex");
617
- resolvedConfigDirCache = { raw, path };
618
- return path;
619
- }
620
-
621
- function resolveConfigPath(): string {
622
- return join(resolveConfigDir(), "config.json");
623
- }
624
-
625
- function resolvePidPath(): string {
626
- return join(resolveConfigDir(), "ocx.pid");
627
- }
628
-
629
- function resolveRuntimePortPath(): string {
630
- return join(resolveConfigDir(), "runtime-port.json");
631
- }
632
-
633
408
  const warnedConfigFallbacks = new Set<string>();
634
409
  const warnedInheritedFastWireConflicts = new Set<string>();
635
410
  let lastWarningReconciledGeneration = 0;
@@ -735,6 +510,8 @@ const providerConfigSchema = z.object({
735
510
  // accepted, persisted, and then silently resolved to the `code_mode_only` default — the
736
511
  // operator asked for shell mode, got code mode, and was told nothing (#2106).
737
512
  codexToolMode: z.enum(["code_mode_only", "shell"]).optional(),
513
+ // Validated rather than passed through: same rationale as codexToolMode above.
514
+ projectContext: z.enum(["off", "on"]).optional(),
738
515
  responsesItemIdRepair: z.object({
739
516
  message: z.array(z.string().min(1)).optional(),
740
517
  reasoning: z.array(z.string().min(1)).optional(),
@@ -744,30 +521,20 @@ const providerConfigSchema = z.object({
744
521
  responsesSnapshotRepair: z.boolean().optional(),
745
522
  }).passthrough();
746
523
 
747
- const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
748
- const SENSITIVE_PROVIDER_HEADERS = new Set([
749
- "authorization",
750
- "cookie",
751
- "set-cookie",
752
- "proxy-authorization",
753
- "x-api-key",
754
- "x-goog-api-key",
755
- "x-amz-security-token",
756
- ]);
757
-
758
524
  export { isValidProviderName, hasOwnProvider } from "./config/provider-name";
759
-
760
- export function providerBaseUrlConfigError(baseUrl: string): string | null {
761
- try {
762
- const parsed = new URL(baseUrl.trim());
763
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "baseUrl must be an http(s) URL";
764
- if (parsed.username || parsed.password) return "baseUrl must not include embedded credentials";
765
- if (parsed.search || parsed.hash) return "baseUrl must not include query strings or fragments";
766
- } catch {
767
- return "baseUrl must be a valid URL";
768
- }
769
- return null;
770
- }
525
+ export {
526
+ apiKeyTransportConfigError,
527
+ booleanRecordConfigError,
528
+ modelAdapterRecordConfigError,
529
+ nonBlankStringArrayConfigError,
530
+ normalizeNonBlankStringArray,
531
+ positiveIntegerConfigError,
532
+ positiveIntegerRecordConfigError,
533
+ providerBaseUrlConfigError,
534
+ providerHeadersConfigError,
535
+ reasoningSummaryDeliveryRecordConfigError,
536
+ upstreamHttpVersionConfigError,
537
+ } from "./config/provider-validation";
771
538
 
772
539
  function providerResponsesPathConfigError(responsesPath: string | undefined): string | null {
773
540
  if (responsesPath === undefined) return null;
@@ -781,19 +548,6 @@ function providerResponsesPathConfigError(responsesPath: string | undefined): st
781
548
  return null;
782
549
  }
783
550
 
784
- export function providerHeadersConfigError(headers: unknown): string | null {
785
- if (headers === undefined) return null;
786
- if (!headers || typeof headers !== "object" || Array.isArray(headers)) return "headers must be an object";
787
- for (const [name, value] of Object.entries(headers)) {
788
- const normalized = name.trim().toLowerCase();
789
- if (!normalized || !HEADER_NAME_PATTERN.test(name)) return "headers must use valid HTTP header names";
790
- if (SENSITIVE_PROVIDER_HEADERS.has(normalized)) return `headers must not include sensitive header "${name}"; use apiKey/authMode instead`;
791
- if (typeof value !== "string") return `header "${name}" value must be a string`;
792
- if (/[\r\n]/.test(value)) return `header "${name}" value must not include line breaks`;
793
- }
794
- return null;
795
- }
796
-
797
551
  /**
798
552
  * Validate `providers.<name>.modelCosts`: a plain object keyed by exact model
799
553
  * id, each value a 4-tuple of non-negative finite USD-per-1M-token rates.
@@ -863,119 +617,6 @@ export function sanitizeModelCostsForDisplay(costs: unknown): Record<string, Pro
863
617
  return Object.keys(out).length > 0 ? out : undefined;
864
618
  }
865
619
 
866
- /** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
867
- export function apiKeyTransportConfigError(
868
- provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
869
- ): string | null {
870
- if (provider.apiKeyTransport === undefined) return null;
871
- if (provider.apiKeyTransport !== "x-api-key" && provider.apiKeyTransport !== "bearer") {
872
- return 'apiKeyTransport must be "x-api-key" or "bearer"';
873
- }
874
- if (provider.adapter !== "anthropic") {
875
- return "apiKeyTransport is supported only by the anthropic adapter";
876
- }
877
- if (provider.authMode === "oauth" || provider.authMode === "forward" || provider.authMode === "local") {
878
- return "apiKeyTransport requires Anthropic API-key authentication";
879
- }
880
- return null;
881
- }
882
-
883
- /**
884
- * Shared runtime boundary for the per-provider upstream HTTP-version pin (#1668). Used by
885
- * the management write path (providerManagementConfigError / PATCH) so it can never disagree
886
- * with the strict zod load schema: a value that survives POST/PATCH is always loadable, and
887
- * a value the loader rejects is rejected at write time too.
888
- */
889
- export function upstreamHttpVersionConfigError(value: unknown): string | null {
890
- if (value === undefined || value === null) return null;
891
- if (typeof value !== "string" || !(UPSTREAM_HTTP_VERSION_VALUES as readonly string[]).includes(value)) {
892
- return 'upstreamHttpVersion must be one of "auto", "http1.1", "h1", "http2", "h2", or null to clear';
893
- }
894
- return null;
895
- }
896
-
897
- export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null {
898
- if (value === undefined) return null;
899
- if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
900
- const prototype = Object.getPrototypeOf(value);
901
- if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
902
- for (const [key, entry] of Object.entries(value)) {
903
- if (!key.trim()) return `${field} keys must be nonblank model ids`;
904
- if (typeof entry !== "number" || !Number.isFinite(entry) || !Number.isInteger(entry) || entry <= 0) {
905
- return `${field}.${key} must be a positive finite integer`;
906
- }
907
- }
908
- return null;
909
- }
910
-
911
- export function positiveIntegerConfigError(value: unknown, field: string): string | null {
912
- if (value === undefined) return null;
913
- if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
914
- return `${field} must be a positive finite integer`;
915
- }
916
- return null;
917
- }
918
-
919
- export function nonBlankStringArrayConfigError(value: unknown, field: string): string | null {
920
- if (value === undefined) return null;
921
- if (!Array.isArray(value)) return `${field} must be an array`;
922
- for (const [index, entry] of value.entries()) {
923
- if (typeof entry !== "string" || !entry.trim()) {
924
- return `${field}.${index} must be a nonblank model id`;
925
- }
926
- }
927
- return null;
928
- }
929
-
930
- /**
931
- * Keep hand-edited config and management writes on one canonical model-id list.
932
- * Validation happens separately so an all-whitespace value is rejected rather than
933
- * normalized into a model id that can never match at runtime.
934
- */
935
- export function normalizeNonBlankStringArray(value: readonly string[]): string[] {
936
- return [...new Set(value.map(entry => entry.trim()))];
937
- }
938
-
939
- export function booleanRecordConfigError(value: unknown, field: string): string | null {
940
- if (value === undefined) return null;
941
- if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
942
- const prototype = Object.getPrototypeOf(value);
943
- if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
944
- for (const [key, entry] of Object.entries(value)) {
945
- if (!key.trim()) return `${field} keys must be nonblank model ids`;
946
- if (typeof entry !== "boolean") return `${field}.${key} must be a boolean`;
947
- }
948
- return null;
949
- }
950
-
951
- const REASONING_SUMMARY_DELIVERY_SET = new Set<string>(REASONING_SUMMARY_DELIVERY_VALUES);
952
-
953
- export function reasoningSummaryDeliveryRecordConfigError(
954
- value: unknown,
955
- supportsReasoningSummaries: unknown,
956
- field = "modelReasoningSummaryDelivery",
957
- ): string | null {
958
- if (value === undefined) return null;
959
- if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
960
- const prototype = Object.getPrototypeOf(value);
961
- if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
962
-
963
- const supports = booleanRecordConfigError(supportsReasoningSummaries, "modelSupportsReasoningSummaries") === null
964
- && supportsReasoningSummaries && typeof supportsReasoningSummaries === "object"
965
- ? supportsReasoningSummaries as Record<string, boolean>
966
- : undefined;
967
- for (const [key, entry] of Object.entries(value)) {
968
- if (!key.trim()) return `${field} keys must be nonblank model ids`;
969
- if (typeof entry !== "string" || !REASONING_SUMMARY_DELIVERY_SET.has(entry)) {
970
- return `${field}.${key} must be one of: ${REASONING_SUMMARY_DELIVERY_VALUES.join(", ")}`;
971
- }
972
- if (modelRecordValue(supports, key) === false) {
973
- return `${field}.${key} conflicts with modelSupportsReasoningSummaries=false`;
974
- }
975
- }
976
- return null;
977
- }
978
-
979
620
  const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]);
980
621
 
981
622
  export function modelPreferHostedToolsConfigError(
@@ -1070,41 +711,6 @@ export function modelPreferHostedToolsConfigError(
1070
711
  return null;
1071
712
  }
1072
713
 
1073
- /**
1074
- * Validate a provider's per-model wire override map (#404).
1075
- *
1076
- * Rejects, rather than silently ignoring, configurations the resolver would refuse:
1077
- * a value outside the allowed wires, a model the upstream pins to one wire, and any
1078
- * override on a canonical forward provider (where switching wires would drop the
1079
- * caller's forwarded credential). Silently dropping them would leave the user
1080
- * believing an override is in effect.
1081
- */
1082
- export function modelAdapterRecordConfigError(
1083
- value: unknown,
1084
- field: string,
1085
- providerName: string,
1086
- provider: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown },
1087
- ): string | null {
1088
- if (value === undefined) return null;
1089
- if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
1090
- const prototype = Object.getPrototypeOf(value);
1091
- if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
1092
- const entries = Object.entries(value);
1093
- if (entries.length > 0 && isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig)) {
1094
- return `${field} is not supported on the canonical ChatGPT forward provider`;
1095
- }
1096
- for (const [key, entry] of entries) {
1097
- if (!key.trim()) return `${field} keys must be nonblank model ids`;
1098
- if (typeof entry !== "string" || !MODEL_ADAPTER_OVERRIDE_ALLOWED.has(entry)) {
1099
- return `${field}.${key} must be one of: ${[...MODEL_ADAPTER_OVERRIDE_ALLOWED].join(", ")}`;
1100
- }
1101
- if (isWirePinnedModel(providerName, key.trim())) {
1102
- return `${field}.${key} cannot be overridden: the upstream only speaks one wire for this model`;
1103
- }
1104
- }
1105
- return null;
1106
- }
1107
-
1108
714
  const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR =
1109
715
  "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids";
1110
716
  const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR =
@@ -1656,35 +1262,6 @@ const configSchema = z.object({
1656
1262
  */
1657
1263
  export const DEFAULT_SUBAGENT_MODELS = ["gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4-mini"];
1658
1264
 
1659
- export function getConfigDir(): string {
1660
- return resolveConfigDir();
1661
- }
1662
-
1663
- export function getConfigPath(): string {
1664
- return resolveConfigPath();
1665
- }
1666
-
1667
- export function getPidPath(): string {
1668
- return resolvePidPath();
1669
- }
1670
-
1671
- export function getRuntimePortPath(): string {
1672
- return resolveRuntimePortPath();
1673
- }
1674
-
1675
- export function hardenConfigDir(): void {
1676
- const dir = getConfigDir();
1677
- // The guard runs BEFORE any mutation: refusing the write after chmod/ACL
1678
- // would already have changed the protected directory (review round 2).
1679
- assertNotRealHomeUnderTest(dir);
1680
- if (existsSync(dir)) {
1681
- try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
1682
- if (process.platform === "win32") {
1683
- hardenSecretDir(dir, { required: false });
1684
- }
1685
- }
1686
- }
1687
-
1688
1265
  export function hardenExistingSecret(path: string): void {
1689
1266
  if (existsSync(path)) {
1690
1267
  try { chmodSync(path, 0o600); } catch { /* best-effort */ }
@@ -3509,91 +3086,6 @@ export function applyProxyEnv(config: OcxConfig): void {
3509
3086
  process.env.NO_PROXY = entries.join(",");
3510
3087
  }
3511
3088
 
3512
- export function writePid(pid: number): void {
3513
- const dir = getConfigDir();
3514
- // Guard before ANY directory mutation (mkdir or chmod), not just the write.
3515
- assertNotRealHomeUnderTest(dir);
3516
- if (!existsSync(dir)) {
3517
- mkdirSync(dir, { recursive: true, mode: 0o700 });
3518
- } else {
3519
- hardenConfigDir();
3520
- }
3521
- atomicWriteFile(getPidPath(), String(pid));
3522
- }
3523
-
3524
- export type RuntimePortState = {
3525
- pid: number;
3526
- port: number;
3527
- hostname?: string;
3528
- /** Per-process proof key; protected by the config directory and never served. */
3529
- attestationSecret?: string;
3530
- };
3531
-
3532
- function isValidRuntimePortState(value: unknown): value is RuntimePortState {
3533
- if (!value || typeof value !== "object") return false;
3534
- const state = value as Record<string, unknown>;
3535
- const hostnameOk = state.hostname === undefined || typeof state.hostname === "string";
3536
- const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret);
3537
- return Number.isSafeInteger(state.pid)
3538
- && Number(state.pid) > 0
3539
- && Number.isInteger(state.port)
3540
- && Number(state.port) > 0
3541
- && Number(state.port) <= 65535
3542
- && hostnameOk
3543
- && attestationOk;
3544
- }
3545
-
3546
- export function writeRuntimePort(state: RuntimePortState): void {
3547
- const dir = getConfigDir();
3548
- // Guard before ANY directory mutation (mkdir or chmod), not just the write.
3549
- assertNotRealHomeUnderTest(dir);
3550
- if (!existsSync(dir)) {
3551
- mkdirSync(dir, { recursive: true, mode: 0o700 });
3552
- } else {
3553
- hardenConfigDir();
3554
- }
3555
- atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n");
3556
- }
3557
-
3558
- export function readPid(): number | null {
3559
- const pidPath = getPidPath();
3560
- if (!existsSync(pidPath)) return null;
3561
- try {
3562
- const raw = readFileSync(pidPath, "utf-8").trim();
3563
- const pid = parsePidFile(raw);
3564
- if (pid === null) return null;
3565
- try {
3566
- process.kill(pid, 0);
3567
- return isLikelyOcxStartProcess(pid) ? pid : null;
3568
- } catch (e: unknown) {
3569
- if ((e as NodeJS.ErrnoException).code === "EPERM") {
3570
- return isLikelyOcxStartProcess(pid) ? pid : null;
3571
- }
3572
- return null;
3573
- }
3574
- } catch {
3575
- return null;
3576
- }
3577
- }
3578
-
3579
- export function readRuntimePort(expectedPid?: number): RuntimePortState | null {
3580
- try {
3581
- const parsed = JSON.parse(readFileSync(getRuntimePortPath(), "utf-8"));
3582
- if (!isValidRuntimePortState(parsed)) return null;
3583
- if (expectedPid !== undefined && parsed.pid !== expectedPid) return null;
3584
- return parsed;
3585
- } catch {
3586
- return null;
3587
- }
3588
- }
3589
-
3590
- export function removePid(expectedPid?: number): void {
3591
- if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return;
3592
- try {
3593
- unlinkSync(getPidPath());
3594
- } catch { /* ignore */ }
3595
- }
3596
-
3597
3089
  function warnConfigRepaired(configPath: string, error: z.ZodError): void {
3598
3090
  if (warnedConfigFallbacks.has(configPath)) return;
3599
3091
  warnedConfigFallbacks.add(configPath);
@@ -3789,233 +3281,6 @@ function warnDroppedConfigSections(configPath: string, dropped: string[], issues
3789
3281
  );
3790
3282
  }
3791
3283
 
3792
- export function readPidFileValue(): number | null {
3793
- try {
3794
- return parsePidFile(readFileSync(getPidPath(), "utf-8"));
3795
- } catch {
3796
- return null;
3797
- }
3798
- }
3799
-
3800
- export function removeRuntimePort(expectedPid?: number): void {
3801
- if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return;
3802
- try {
3803
- unlinkSync(getRuntimePortPath());
3804
- } catch { /* ignore */ }
3805
- }
3806
-
3807
- /**
3808
- * Snapshot-guarded stale-state purge: remove the pid/runtime files only when their content
3809
- * still matches what the caller saw BEFORE its liveness probe. A concurrent `ocx start` can
3810
- * write fresh records mid-probe; an unconditional purge would erase the new proxy's state.
3811
- */
3812
- export function removePidIfValueIs(snapshot: number | null): void {
3813
- if (!existsSync(getPidPath())) return;
3814
- if (readPidFileValue() !== snapshot) return;
3815
- try {
3816
- unlinkSync(getPidPath());
3817
- } catch { /* ignore */ }
3818
- }
3819
-
3820
- export function removeRuntimePortIfPidIs(snapshotPid: number | null): void {
3821
- const current = readRuntimePort();
3822
- if ((current?.pid ?? null) !== snapshotPid) return;
3823
- try {
3824
- unlinkSync(getRuntimePortPath());
3825
- } catch { /* ignore */ }
3826
- }
3827
-
3828
- export function parsePidFile(raw: string): number | null {
3829
- const trimmed = raw.trim();
3830
- if (!/^\d+$/.test(trimmed)) return null;
3831
- const pid = Number.parseInt(trimmed, 10);
3832
- return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
3833
- }
3834
-
3835
- export function isOcxStartCommandLine(commandLine: string): boolean {
3836
- const normalized = commandLine.toLowerCase().replace(/\\/g, "/");
3837
- // "src/cli.ts" matches pre-restructure installs still running; "src/cli/index.ts" is current.
3838
- // `@bitkyc08/.opencodex-*` is npm's in-place rename of the global package during
3839
- // `npm install -g` — a Windows service wrapper can respawn from that temp tree
3840
- // mid-update, and must still count as ocx for port reclaim.
3841
- const hasOcxEntrypoint = normalized.includes("src/cli.ts")
3842
- || normalized.includes("src/cli/index.ts")
3843
- || /@[a-z0-9._-]+\/opencodex(?:[/'"\s]|$)/.test(normalized)
3844
- || /@[a-z0-9._-]+\/\.opencodex-/.test(normalized)
3845
- || /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized);
3846
- return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
3847
- }
3848
-
3849
- /** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */
3850
- const ocxStartProcessCache = new Map<number, boolean>();
3851
- let ocxStartProcessSweepCursor = 0;
3852
- let ocxStartProcessProbe: (pid: number) => void = pid => { process.kill(pid, 0); };
3853
-
3854
- export function setOcxStartProcessProbeForTests(probe: ((pid: number) => void) | null): void {
3855
- ocxStartProcessProbe = probe ?? (pid => { process.kill(pid, 0); });
3856
- }
3857
-
3858
- export function setOcxStartProcessCacheForTests(entries: Iterable<readonly [number, boolean]>): void {
3859
- ocxStartProcessCache.clear();
3860
- for (const [pid, value] of entries) ocxStartProcessCache.set(pid, value);
3861
- ocxStartProcessSweepCursor = 0;
3862
- }
3863
-
3864
- export function sweepDeadOcxStartProcessCache(maxProbes = 64): number {
3865
- const pids: number[] = [];
3866
- let removed = 0;
3867
- for (const pid of ocxStartProcessCache.keys()) {
3868
- if (Number.isSafeInteger(pid) && pid > 0) pids.push(pid);
3869
- else if (ocxStartProcessCache.delete(pid)) removed += 1;
3870
- }
3871
- if (pids.length === 0 || maxProbes <= 0) {
3872
- ocxStartProcessSweepCursor = 0;
3873
- return removed;
3874
- }
3875
- const probeCount = Math.min(Math.floor(maxProbes), pids.length);
3876
- const start = ocxStartProcessSweepCursor % pids.length;
3877
- for (let offset = 0; offset < probeCount; offset += 1) {
3878
- const pid = pids[(start + offset) % pids.length]!;
3879
- try {
3880
- ocxStartProcessProbe(pid);
3881
- } catch (error) {
3882
- if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue;
3883
- if (ocxStartProcessCache.delete(pid)) removed += 1;
3884
- }
3885
- }
3886
- ocxStartProcessSweepCursor = (start + probeCount) % pids.length;
3887
- return removed;
3888
- }
3889
-
3890
- export function ocxStartProcessCacheSizeForTests(): number {
3891
- return ocxStartProcessCache.size;
3892
- }
3893
-
3894
- function isLikelyOcxStartProcess(pid: number): boolean {
3895
- const cached = ocxStartProcessCache.get(pid);
3896
- if (cached !== undefined) return cached;
3897
- const commandLine = readProcessCommandLine(pid);
3898
- if (commandLine === undefined) return false;
3899
- const ok = isOcxStartCommandLine(commandLine);
3900
- ocxStartProcessCache.set(pid, ok);
3901
- return ok;
3902
- }
3903
-
3904
- /**
3905
- * Alive pid from the pid file without the expensive Windows command-line probe.
3906
- * Safe for liveness polls: callers still identity-check /healthz before trusting the proxy.
3907
- * Destructive stop/kill paths should keep using {@link readPid}, which verifies the cmdline.
3908
- */
3909
- export function readAlivePid(): number | null {
3910
- const pid = readPidFileValue();
3911
- if (pid === null) return null;
3912
- try {
3913
- process.kill(pid, 0);
3914
- return pid;
3915
- } catch (e: unknown) {
3916
- if ((e as NodeJS.ErrnoException).code === "EPERM") return pid;
3917
- return null;
3918
- }
3919
- }
3920
-
3921
- /**
3922
- * Full identity check of a KNOWN candidate pid (alive + ocx-start command line).
3923
- * Companion to {@link readAlivePid}: liveness discovery may be cheap, but any pid
3924
- * handed to a destructive caller must pass this check — and must equal the candidate
3925
- * it was asked about, so a pidfile rewrite between discovery and verification can
3926
- * never swap in a different process (TOCTOU guard).
3927
- */
3928
- export function verifyPidIdentity(candidatePid: number): number | null {
3929
- try {
3930
- process.kill(candidatePid, 0);
3931
- } catch (e: unknown) {
3932
- if ((e as NodeJS.ErrnoException).code !== "EPERM") return null;
3933
- }
3934
- return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null;
3935
- }
3936
-
3937
- type ProcessCommandLineExec = (
3938
- executable: string,
3939
- args: string[],
3940
- options: {
3941
- encoding: BufferEncoding;
3942
- stdio: ["ignore", "pipe", "ignore"];
3943
- timeout: number;
3944
- windowsHide: boolean;
3945
- },
3946
- ) => string;
3947
-
3948
- const defaultProcessCommandLineExec: ProcessCommandLineExec = (executable, args, options) =>
3949
- execFileSync(executable, args, options);
3950
- let processCommandLineExec = defaultProcessCommandLineExec;
3951
- let processCommandLinePlatformForTests: NodeJS.Platform | null = null;
3952
-
3953
- /** Test-only seam for verifying the exact system executable selected by pid identity probes. */
3954
- export function setProcessCommandLineExecForTests(next: ProcessCommandLineExec | null): void {
3955
- processCommandLineExec = next ?? defaultProcessCommandLineExec;
3956
- }
3957
-
3958
- /** Test-only seam so cross-platform tests do not mutate process.platform. */
3959
- export function setProcessCommandLinePlatformForTests(next: NodeJS.Platform | null): void {
3960
- processCommandLinePlatformForTests = next;
3961
- }
3962
-
3963
- function readProcessCommandLine(pid: number): string | undefined {
3964
- if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
3965
- const platform = processCommandLinePlatformForTests ?? process.platform;
3966
- try {
3967
- if (platform === "linux") {
3968
- try {
3969
- const output = readFileSync(`/proc/${pid}/cmdline`, "utf-8");
3970
- const value = output.replace(/\0/g, " ").trim();
3971
- if (value) return value;
3972
- } catch {
3973
- /* procfs unavailable — use the fixed ps fallback below */
3974
- }
3975
- }
3976
- if (platform === "win32") {
3977
- // Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash.
3978
- // Fall back to PowerShell when WMIC is absent (newer Windows images).
3979
- const wmic = join(resolveTrustedWindowsSystemDirectory(), "wbem", "WMIC.exe");
3980
- try {
3981
- const output = processCommandLineExec(wmic, [
3982
- "process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE",
3983
- ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
3984
- const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, ""));
3985
- const value = match?.[1]?.trim();
3986
- if (value) return value;
3987
- } catch {
3988
- /* WMIC missing or failed — fall through */
3989
- }
3990
- const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [
3991
- "-NoProfile",
3992
- "-NoLogo",
3993
- "-NonInteractive",
3994
- "-Command",
3995
- `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
3996
- ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
3997
- return output.trim() || undefined;
3998
- }
3999
- for (const ps of ["/bin/ps", "/usr/bin/ps"]) {
4000
- try {
4001
- const output = processCommandLineExec(ps, ["-p", String(pid), "-o", "command="], {
4002
- encoding: "utf-8",
4003
- stdio: ["ignore", "pipe", "ignore"],
4004
- timeout: 1000,
4005
- windowsHide: true,
4006
- });
4007
- const value = output.trim();
4008
- if (value) return value;
4009
- } catch {
4010
- /* try the other fixed system path */
4011
- }
4012
- }
4013
- return undefined;
4014
- } catch {
4015
- return undefined;
4016
- }
4017
- }
4018
-
4019
3284
  function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
4020
3285
  if (warnedConfigFallbacks.has(configPath)) return;
4021
3286
  warnedConfigFallbacks.add(configPath);