@yansigit/opencodex 2.31.3 → 2.33.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.
- package/README.md +2 -2
- package/bin/ocx.mjs +99 -70
- package/gui/dist/assets/index-DKLr4LTE.js +102 -0
- package/gui/dist/assets/index-DrSQdTRd.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +6 -5
- package/src/adapters/anthropic.ts +25 -13
- package/src/adapters/azure.ts +20 -4
- package/src/adapters/base.ts +5 -1
- package/src/adapters/command-code.ts +42 -10
- package/src/adapters/cursor/live-models.ts +8 -0
- package/src/adapters/cursor/live-transport.ts +1 -1
- package/src/adapters/cursor/native-exec-desktop.ts +16 -0
- package/src/adapters/cursor/protobuf-events.ts +158 -7
- package/src/adapters/cursor/protobuf-request.ts +33 -15
- package/src/adapters/cursor/request-builder.ts +4 -3
- package/src/adapters/cursor/tool-definitions.ts +27 -1
- package/src/adapters/cursor/types.ts +4 -3
- package/src/adapters/cursor.ts +9 -0
- package/src/adapters/google-antigravity-replay.ts +2 -2
- package/src/adapters/google-antigravity-wire.ts +7 -0
- package/src/adapters/google-errors.ts +6 -2
- package/src/adapters/google-http.ts +30 -7
- package/src/adapters/google-truncation.ts +5 -0
- package/src/adapters/google-wire-compiler.ts +38 -6
- package/src/adapters/google.ts +154 -33
- package/src/adapters/kiro-tools.ts +20 -9
- package/src/adapters/kiro.ts +0 -3
- package/src/adapters/openai-chat.ts +9 -0
- package/src/adapters/openai-responses.ts +4 -1
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-web-search.ts +7 -2
- package/src/bridge.ts +133 -24
- package/src/claude/context-windows.ts +16 -9
- package/src/cli/dispatch.ts +50 -2
- package/src/cli/doctor.ts +26 -13
- package/src/cli/help.ts +4 -3
- package/src/cli/index.ts +20 -6
- package/src/cli/models.ts +13 -3
- package/src/cli/observe.ts +20 -5
- package/src/cli/provider.ts +8 -1
- package/src/cli/registry.ts +7 -5
- package/src/cli/status.ts +25 -1
- package/src/cli/system-restart-client.ts +1 -1
- package/src/cli/usage-report.ts +134 -0
- package/src/codex/app-server-processes.ts +3 -1
- package/src/codex/auth-api.ts +4 -2
- package/src/codex/autostart-health.ts +16 -0
- package/src/codex/catalog/aggregation.ts +12 -0
- package/src/codex/catalog/effort.ts +42 -12
- package/src/codex/catalog/metadata.ts +27 -1
- package/src/codex/catalog/model-metadata.ts +593 -0
- package/src/codex/catalog/parsing.ts +71 -27
- package/src/codex/catalog/provider-fetch.ts +189 -33
- package/src/codex/catalog/sync.ts +6 -5
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desktop-app-restart.ts +342 -0
- package/src/codex/history-job.ts +32 -3
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +5 -5
- package/src/codex/history-provider.ts +825 -247
- package/src/codex/history-worker.ts +8 -5
- package/src/codex/inject.ts +49 -21
- package/src/codex/injected-marker.ts +1 -1
- package/src/codex/internal/history-writer.ts +4 -3
- package/src/codex/native-profile-startup.ts +157 -27
- package/src/codex/native-residue.ts +26 -33
- package/src/codex/shim.ts +56 -3
- package/src/combos/failover.ts +27 -0
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +81 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +308 -0
- package/src/config/provider-validation.ts +214 -0
- package/src/config.ts +153 -814
- package/src/generated/compatibility-version.json +232 -148
- package/src/images/loop.ts +37 -6
- package/src/images/plan.ts +5 -4
- package/src/integrations/ownership-policy.ts +141 -0
- package/src/integrations/ownership.ts +10 -0
- package/src/integrations/state.ts +44 -5
- package/src/integrations/writer.ts +6 -0
- package/src/lib/azure-identity.ts +154 -0
- package/src/lib/bounded-body.ts +14 -2
- package/src/lib/debug.ts +42 -0
- package/src/lib/errors.ts +14 -0
- package/src/lib/process-control.ts +2 -1
- package/src/lib/provider-outbound.ts +45 -33
- package/src/lib/provider-tls-profile.ts +309 -0
- package/src/lib/proxy-env.ts +49 -0
- package/src/lib/redact.ts +10 -1
- package/src/lib/state-store-registrations.ts +2 -0
- package/src/lib/tool-argument-integers.ts +56 -5
- package/src/oauth/antigravity-routing.ts +282 -236
- package/src/oauth/callback-server.ts +22 -2
- package/src/oauth/command-code.ts +5 -16
- package/src/oauth/google-antigravity.ts +42 -5
- package/src/oauth/health.ts +1 -1
- package/src/oauth/index.ts +15 -3
- package/src/oauth/kimi.ts +9 -1
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +6 -0
- package/src/providers/antigravity-quota.ts +3 -1
- package/src/providers/api-keys.ts +2 -1
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/derive.ts +4 -0
- package/src/providers/key-failover.ts +5 -1
- package/src/providers/openai-tiers.ts +5 -0
- package/src/providers/provider-id-rewrite.ts +1 -0
- package/src/providers/quota.ts +59 -13
- package/src/providers/registry.ts +4 -2
- package/src/providers/request-pacing.ts +33 -6
- package/src/providers/xai-transport.ts +21 -0
- package/src/reasoning-effort.ts +19 -2
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/custom-tool-compat.ts +132 -38
- package/src/responses/google-provider-options.ts +36 -0
- package/src/responses/namespace-tool-compat.ts +84 -4
- package/src/responses/parser.ts +14 -2
- package/src/responses/provider-opaque-metadata.ts +3 -3
- package/src/responses/reasoning-replay-cache.ts +81 -3
- package/src/responses/schema.ts +37 -0
- package/src/responses/state.ts +94 -4
- package/src/router.ts +8 -2
- package/src/server/auth-cors.ts +37 -7
- package/src/server/images.ts +19 -35
- package/src/server/index.ts +102 -21
- package/src/server/local-management-read-client.ts +1 -1
- package/src/server/local-provider-reload-client.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +206 -16
- package/src/server/management/combo-routes.ts +6 -0
- package/src/server/management/config-routes.ts +35 -6
- package/src/server/management/context.ts +1 -1
- package/src/server/management/logs-usage-routes.ts +27 -6
- package/src/server/management/model-routes.ts +8 -4
- package/src/server/management/model-rows.ts +4 -0
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +25 -4
- package/src/server/management/provider-capability-config.ts +1 -1
- package/src/server/management/provider-routes.ts +113 -15
- package/src/server/management/routing-profile-routes.ts +3 -0
- package/src/server/management/system-restart.ts +1 -1
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/proxy-liveness.ts +2 -1
- package/src/server/request-log-conversation.ts +30 -0
- package/src/server/request-log.ts +21 -0
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/codex-auth-error.ts +55 -0
- package/src/server/responses/combo-stream-preflight.ts +171 -0
- package/src/server/responses/compact.ts +36 -22
- package/src/server/responses/core.ts +584 -247
- package/src/server/responses/empty-completion-guard.ts +35 -6
- package/src/server/responses/fetch-helpers.ts +20 -102
- package/src/server/responses/v2-native-parent-override.ts +59 -0
- package/src/server/responses/ws-upstream.ts +75 -2
- package/src/server/responses-custom-tool-repair.ts +41 -5
- package/src/server/responses-undeclared-tool-guard.ts +241 -18
- package/src/service.ts +9 -5
- package/src/types/config.ts +16 -1
- package/src/types/provider.ts +16 -0
- package/src/types/request.ts +34 -1
- package/src/types/tools.ts +114 -11
- package/src/types.ts +7 -1
- package/src/update/index.ts +5 -4
- package/src/update/job.ts +3 -1
- package/src/update/transactional-install.mjs +8 -1
- package/src/usage/log.ts +16 -8
- package/src/usage/summary.ts +201 -8
- package/src/vision/describe.ts +18 -13
- package/src/web-search/executor.ts +10 -3
- package/src/web-search/gemini-executor.ts +6 -4
- package/src/web-search/loop.ts +42 -6
- package/gui/dist/assets/index-CGoDO3uO.css +0 -1
- package/gui/dist/assets/index-Cxt5fZMP.js +0 -102
package/src/config.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
+
azureCredentialConfigError,
|
|
10
|
+
booleanRecordConfigError,
|
|
11
|
+
modelAdapterRecordConfigError,
|
|
12
|
+
nonBlankStringArrayConfigError,
|
|
13
|
+
normalizeNonBlankStringArray,
|
|
14
|
+
positiveIntegerConfigError,
|
|
15
|
+
positiveIntegerRecordConfigError,
|
|
16
|
+
providerBaseUrlConfigError,
|
|
17
|
+
providerHeadersConfigError,
|
|
18
|
+
reasoningSummaryDeliveryRecordConfigError,
|
|
19
|
+
upstreamHttpVersionConfigError,
|
|
20
|
+
} from "./config/provider-validation";
|
|
9
21
|
import {
|
|
10
22
|
bumpConfigGenerationAtPath,
|
|
11
23
|
bumpCurrentConfigGeneration,
|
|
@@ -42,25 +54,18 @@ import {
|
|
|
42
54
|
forgetEphemeralSecretPath,
|
|
43
55
|
hardenSecretDir,
|
|
44
56
|
hardenSecretPath,
|
|
45
|
-
hardenSecretPathAsync,
|
|
46
57
|
windowsSecretAclApplies,
|
|
47
58
|
} from "./lib/windows-secret-acl";
|
|
48
59
|
import { recordOwnedConfigPath } from "./lib/config-ownership";
|
|
49
60
|
import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
|
|
50
|
-
import { isLocalAttestationSecret } from "./lib/local-management-attestation";
|
|
51
61
|
import { providerDestinationConfigError } from "./lib/destination-policy";
|
|
52
62
|
import { redactSecretString } from "./lib/redact";
|
|
53
|
-
import {
|
|
54
|
-
resolveTrustedWindowsPowerShellExe,
|
|
55
|
-
resolveTrustedWindowsSystemDirectory,
|
|
56
|
-
} from "./lib/windows-elevation";
|
|
63
|
+
import { antigravityOAuthDestinationConfigError, providerTlsProfileConfigError } from "./lib/provider-tls-profile";
|
|
57
64
|
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
|
|
58
65
|
import {
|
|
59
|
-
isWirePinnedModel,
|
|
60
66
|
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
61
67
|
OPENAI_PROVIDER_TIER_VERSION,
|
|
62
68
|
pinnedWireAdapter,
|
|
63
|
-
REASONING_SUMMARY_DELIVERY_VALUES,
|
|
64
69
|
UPSTREAM_HTTP_VERSION_VALUES,
|
|
65
70
|
type OcxClaudeCodeConfig,
|
|
66
71
|
type OcxConfig,
|
|
@@ -69,7 +74,8 @@ import {
|
|
|
69
74
|
type FastWire,
|
|
70
75
|
type ProviderCostOverlay,
|
|
71
76
|
} from "./types";
|
|
72
|
-
import {
|
|
77
|
+
import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
|
|
78
|
+
import { modelAutoCompactTokenLimitsConfigError } from "./providers/auto-compact-budget";
|
|
73
79
|
import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire";
|
|
74
80
|
import {
|
|
75
81
|
getProviderRegistryEntry,
|
|
@@ -79,7 +85,7 @@ import {
|
|
|
79
85
|
} from "./providers/registry";
|
|
80
86
|
import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models";
|
|
81
87
|
import { parseDesktopProfile } from "./claude/desktop-profile";
|
|
82
|
-
import { isCodexReasoningEffort
|
|
88
|
+
import { isCodexReasoningEffort } from "./reasoning-effort";
|
|
83
89
|
import { parseSubagentRoles, salvageSubagentRoles } from "./codex/agent-roles";
|
|
84
90
|
import {
|
|
85
91
|
COST4_RATE_KEYS,
|
|
@@ -95,244 +101,49 @@ import {
|
|
|
95
101
|
MIN_APP_OWNED_MEMORY_BUDGET_MB,
|
|
96
102
|
} from "./lib/app-owned-memory";
|
|
97
103
|
import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy";
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
}
|
|
104
|
+
import {
|
|
105
|
+
atomicWriteFile,
|
|
106
|
+
isMissingPathError,
|
|
107
|
+
nextAtomicTempSequence,
|
|
108
|
+
} from "./config/atomic-write";
|
|
109
|
+
export {
|
|
110
|
+
AtomicWriteResidualTempError,
|
|
111
|
+
AtomicWriteSecretResidualError,
|
|
112
|
+
atomicWriteFile,
|
|
113
|
+
atomicWriteFileAsync,
|
|
114
|
+
renameAtomicFile,
|
|
115
|
+
resolveWriteTarget,
|
|
116
|
+
type AtomicRenameIO,
|
|
117
|
+
type AtomicWriteAsyncIO,
|
|
118
|
+
type AtomicWriteAsyncTestSeam,
|
|
119
|
+
type AtomicWriteIO,
|
|
120
|
+
} from "./config/atomic-write";
|
|
121
|
+
import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
|
|
122
|
+
export { expandUserPath, getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
|
|
123
|
+
export {
|
|
124
|
+
getPidPath,
|
|
125
|
+
getRuntimePortPath,
|
|
126
|
+
isOcxStartCommandLine,
|
|
127
|
+
ocxStartProcessCacheSizeForTests,
|
|
128
|
+
parsePidFile,
|
|
129
|
+
readAlivePid,
|
|
130
|
+
readPid,
|
|
131
|
+
readPidFileValue,
|
|
132
|
+
readRuntimePort,
|
|
133
|
+
removePid,
|
|
134
|
+
removePidIfValueIs,
|
|
135
|
+
removeRuntimePort,
|
|
136
|
+
removeRuntimePortIfPidIs,
|
|
137
|
+
setOcxStartProcessCacheForTests,
|
|
138
|
+
setOcxStartProcessProbeForTests,
|
|
139
|
+
setProcessCommandLineExecForTests,
|
|
140
|
+
setProcessCommandLinePlatformForTests,
|
|
141
|
+
sweepDeadOcxStartProcessCache,
|
|
142
|
+
verifyPidIdentity,
|
|
143
|
+
writePid,
|
|
144
|
+
writeRuntimePort,
|
|
145
|
+
type RuntimePortState,
|
|
146
|
+
} from "./config/process-state";
|
|
336
147
|
|
|
337
148
|
export class OpenAiTierBackupCleanupError extends Error {
|
|
338
149
|
constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
|
|
@@ -453,7 +264,7 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
453
264
|
return "reused";
|
|
454
265
|
}
|
|
455
266
|
}
|
|
456
|
-
const temp = `${backup}.ocx.${process.pid}.${
|
|
267
|
+
const temp = `${backup}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
|
|
457
268
|
let published = false;
|
|
458
269
|
let cleanupAttempted = false;
|
|
459
270
|
|
|
@@ -597,39 +408,6 @@ export function preserveOpenAiTierRollbackSnapshot(
|
|
|
597
408
|
throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" });
|
|
598
409
|
}
|
|
599
410
|
|
|
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
411
|
const warnedConfigFallbacks = new Set<string>();
|
|
634
412
|
const warnedInheritedFastWireConflicts = new Set<string>();
|
|
635
413
|
let lastWarningReconciledGeneration = 0;
|
|
@@ -664,18 +442,21 @@ const requestPacingRuleSchema = z.object({
|
|
|
664
442
|
// Keep the RPM-derived timer within the same one-hour bound as minIntervalMs.
|
|
665
443
|
requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
|
|
666
444
|
minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
|
|
667
|
-
|
|
668
|
-
|
|
445
|
+
jitterMs: z.number().int().min(0).max(60_000).optional(),
|
|
446
|
+
}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined || value.jitterMs !== undefined, {
|
|
447
|
+
message: "request pacing rules need requestsPerMinute, minIntervalMs, or jitterMs",
|
|
669
448
|
});
|
|
670
449
|
|
|
671
450
|
const requestPacingSchema = z.object({
|
|
672
451
|
enabled: z.boolean(),
|
|
673
452
|
requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
|
|
674
453
|
minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
|
|
454
|
+
jitterMs: z.number().int().min(0).max(60_000).optional(),
|
|
675
455
|
models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(),
|
|
676
456
|
}).strict().refine(value => value.enabled === false
|
|
677
457
|
|| value.requestsPerMinute !== undefined
|
|
678
458
|
|| value.minIntervalMs !== undefined
|
|
459
|
+
|| value.jitterMs !== undefined
|
|
679
460
|
|| (value.models !== undefined && Object.keys(value.models).length > 0), {
|
|
680
461
|
message: "enabled request pacing needs a provider rule or model override",
|
|
681
462
|
});
|
|
@@ -704,7 +485,14 @@ const fastWireSchema = z.object({
|
|
|
704
485
|
const providerConfigSchema = z.object({
|
|
705
486
|
adapter: z.string().min(1),
|
|
706
487
|
baseUrl: z.string().min(1),
|
|
488
|
+
azureCredential: z.object({
|
|
489
|
+
type: z.literal("default-azure-credential"),
|
|
490
|
+
managedIdentityClientId: z.string().trim().min(1).optional(),
|
|
491
|
+
}).strict().transform(value => value.managedIdentityClientId === undefined
|
|
492
|
+
? value
|
|
493
|
+
: { ...value, managedIdentityClientId: value.managedIdentityClientId.trim() }).optional(),
|
|
707
494
|
requestPacing: requestPacingSchema.optional().catch(undefined),
|
|
495
|
+
tlsProfile: z.literal("antigravity-browser").optional(),
|
|
708
496
|
mcpMaxTools: z.number().int().positive().optional(),
|
|
709
497
|
mcpMaxSchemaBytes: z.number().int().positive().optional(),
|
|
710
498
|
mcpMaxResultBytes: z.number().int().positive().optional(),
|
|
@@ -746,30 +534,22 @@ const providerConfigSchema = z.object({
|
|
|
746
534
|
responsesSnapshotRepair: z.boolean().optional(),
|
|
747
535
|
}).passthrough();
|
|
748
536
|
|
|
749
|
-
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
750
|
-
const SENSITIVE_PROVIDER_HEADERS = new Set([
|
|
751
|
-
"authorization",
|
|
752
|
-
"cookie",
|
|
753
|
-
"set-cookie",
|
|
754
|
-
"proxy-authorization",
|
|
755
|
-
"x-api-key",
|
|
756
|
-
"x-goog-api-key",
|
|
757
|
-
"x-amz-security-token",
|
|
758
|
-
]);
|
|
759
|
-
|
|
760
537
|
export { isValidProviderName, hasOwnProvider } from "./config/provider-name";
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
538
|
+
export {
|
|
539
|
+
apiKeyTransportConfigError,
|
|
540
|
+
azureCredentialConfigError,
|
|
541
|
+
isAzureIdentityProvider,
|
|
542
|
+
booleanRecordConfigError,
|
|
543
|
+
modelAdapterRecordConfigError,
|
|
544
|
+
nonBlankStringArrayConfigError,
|
|
545
|
+
normalizeNonBlankStringArray,
|
|
546
|
+
positiveIntegerConfigError,
|
|
547
|
+
positiveIntegerRecordConfigError,
|
|
548
|
+
providerBaseUrlConfigError,
|
|
549
|
+
providerHeadersConfigError,
|
|
550
|
+
reasoningSummaryDeliveryRecordConfigError,
|
|
551
|
+
upstreamHttpVersionConfigError,
|
|
552
|
+
} from "./config/provider-validation";
|
|
773
553
|
|
|
774
554
|
function providerResponsesPathConfigError(responsesPath: string | undefined): string | null {
|
|
775
555
|
if (responsesPath === undefined) return null;
|
|
@@ -783,19 +563,6 @@ function providerResponsesPathConfigError(responsesPath: string | undefined): st
|
|
|
783
563
|
return null;
|
|
784
564
|
}
|
|
785
565
|
|
|
786
|
-
export function providerHeadersConfigError(headers: unknown): string | null {
|
|
787
|
-
if (headers === undefined) return null;
|
|
788
|
-
if (!headers || typeof headers !== "object" || Array.isArray(headers)) return "headers must be an object";
|
|
789
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
790
|
-
const normalized = name.trim().toLowerCase();
|
|
791
|
-
if (!normalized || !HEADER_NAME_PATTERN.test(name)) return "headers must use valid HTTP header names";
|
|
792
|
-
if (SENSITIVE_PROVIDER_HEADERS.has(normalized)) return `headers must not include sensitive header "${name}"; use apiKey/authMode instead`;
|
|
793
|
-
if (typeof value !== "string") return `header "${name}" value must be a string`;
|
|
794
|
-
if (/[\r\n]/.test(value)) return `header "${name}" value must not include line breaks`;
|
|
795
|
-
}
|
|
796
|
-
return null;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
566
|
/**
|
|
800
567
|
* Validate `providers.<name>.modelCosts`: a plain object keyed by exact model
|
|
801
568
|
* id, each value a 4-tuple of non-negative finite USD-per-1M-token rates.
|
|
@@ -865,119 +632,6 @@ export function sanitizeModelCostsForDisplay(costs: unknown): Record<string, Pro
|
|
|
865
632
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
866
633
|
}
|
|
867
634
|
|
|
868
|
-
/** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
|
|
869
|
-
export function apiKeyTransportConfigError(
|
|
870
|
-
provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
|
|
871
|
-
): string | null {
|
|
872
|
-
if (provider.apiKeyTransport === undefined) return null;
|
|
873
|
-
if (provider.apiKeyTransport !== "x-api-key" && provider.apiKeyTransport !== "bearer") {
|
|
874
|
-
return 'apiKeyTransport must be "x-api-key" or "bearer"';
|
|
875
|
-
}
|
|
876
|
-
if (provider.adapter !== "anthropic") {
|
|
877
|
-
return "apiKeyTransport is supported only by the anthropic adapter";
|
|
878
|
-
}
|
|
879
|
-
if (provider.authMode === "oauth" || provider.authMode === "forward" || provider.authMode === "local") {
|
|
880
|
-
return "apiKeyTransport requires Anthropic API-key authentication";
|
|
881
|
-
}
|
|
882
|
-
return null;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
/**
|
|
886
|
-
* Shared runtime boundary for the per-provider upstream HTTP-version pin (#1668). Used by
|
|
887
|
-
* the management write path (providerManagementConfigError / PATCH) so it can never disagree
|
|
888
|
-
* with the strict zod load schema: a value that survives POST/PATCH is always loadable, and
|
|
889
|
-
* a value the loader rejects is rejected at write time too.
|
|
890
|
-
*/
|
|
891
|
-
export function upstreamHttpVersionConfigError(value: unknown): string | null {
|
|
892
|
-
if (value === undefined || value === null) return null;
|
|
893
|
-
if (typeof value !== "string" || !(UPSTREAM_HTTP_VERSION_VALUES as readonly string[]).includes(value)) {
|
|
894
|
-
return 'upstreamHttpVersion must be one of "auto", "http1.1", "h1", "http2", "h2", or null to clear';
|
|
895
|
-
}
|
|
896
|
-
return null;
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null {
|
|
900
|
-
if (value === undefined) return null;
|
|
901
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
902
|
-
const prototype = Object.getPrototypeOf(value);
|
|
903
|
-
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
904
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
905
|
-
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
906
|
-
if (typeof entry !== "number" || !Number.isFinite(entry) || !Number.isInteger(entry) || entry <= 0) {
|
|
907
|
-
return `${field}.${key} must be a positive finite integer`;
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
return null;
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
export function positiveIntegerConfigError(value: unknown, field: string): string | null {
|
|
914
|
-
if (value === undefined) return null;
|
|
915
|
-
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
916
|
-
return `${field} must be a positive finite integer`;
|
|
917
|
-
}
|
|
918
|
-
return null;
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
export function nonBlankStringArrayConfigError(value: unknown, field: string): string | null {
|
|
922
|
-
if (value === undefined) return null;
|
|
923
|
-
if (!Array.isArray(value)) return `${field} must be an array`;
|
|
924
|
-
for (const [index, entry] of value.entries()) {
|
|
925
|
-
if (typeof entry !== "string" || !entry.trim()) {
|
|
926
|
-
return `${field}.${index} must be a nonblank model id`;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
return null;
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
/**
|
|
933
|
-
* Keep hand-edited config and management writes on one canonical model-id list.
|
|
934
|
-
* Validation happens separately so an all-whitespace value is rejected rather than
|
|
935
|
-
* normalized into a model id that can never match at runtime.
|
|
936
|
-
*/
|
|
937
|
-
export function normalizeNonBlankStringArray(value: readonly string[]): string[] {
|
|
938
|
-
return [...new Set(value.map(entry => entry.trim()))];
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
export function booleanRecordConfigError(value: unknown, field: string): string | null {
|
|
942
|
-
if (value === undefined) return null;
|
|
943
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
944
|
-
const prototype = Object.getPrototypeOf(value);
|
|
945
|
-
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
946
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
947
|
-
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
948
|
-
if (typeof entry !== "boolean") return `${field}.${key} must be a boolean`;
|
|
949
|
-
}
|
|
950
|
-
return null;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
const REASONING_SUMMARY_DELIVERY_SET = new Set<string>(REASONING_SUMMARY_DELIVERY_VALUES);
|
|
954
|
-
|
|
955
|
-
export function reasoningSummaryDeliveryRecordConfigError(
|
|
956
|
-
value: unknown,
|
|
957
|
-
supportsReasoningSummaries: unknown,
|
|
958
|
-
field = "modelReasoningSummaryDelivery",
|
|
959
|
-
): string | null {
|
|
960
|
-
if (value === undefined) return null;
|
|
961
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
962
|
-
const prototype = Object.getPrototypeOf(value);
|
|
963
|
-
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
964
|
-
|
|
965
|
-
const supports = booleanRecordConfigError(supportsReasoningSummaries, "modelSupportsReasoningSummaries") === null
|
|
966
|
-
&& supportsReasoningSummaries && typeof supportsReasoningSummaries === "object"
|
|
967
|
-
? supportsReasoningSummaries as Record<string, boolean>
|
|
968
|
-
: undefined;
|
|
969
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
970
|
-
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
971
|
-
if (typeof entry !== "string" || !REASONING_SUMMARY_DELIVERY_SET.has(entry)) {
|
|
972
|
-
return `${field}.${key} must be one of: ${REASONING_SUMMARY_DELIVERY_VALUES.join(", ")}`;
|
|
973
|
-
}
|
|
974
|
-
if (modelRecordValue(supports, key) === false) {
|
|
975
|
-
return `${field}.${key} conflicts with modelSupportsReasoningSummaries=false`;
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
return null;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
635
|
const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]);
|
|
982
636
|
|
|
983
637
|
export function modelPreferHostedToolsConfigError(
|
|
@@ -1072,41 +726,6 @@ export function modelPreferHostedToolsConfigError(
|
|
|
1072
726
|
return null;
|
|
1073
727
|
}
|
|
1074
728
|
|
|
1075
|
-
/**
|
|
1076
|
-
* Validate a provider's per-model wire override map (#404).
|
|
1077
|
-
*
|
|
1078
|
-
* Rejects, rather than silently ignoring, configurations the resolver would refuse:
|
|
1079
|
-
* a value outside the allowed wires, a model the upstream pins to one wire, and any
|
|
1080
|
-
* override on a canonical forward provider (where switching wires would drop the
|
|
1081
|
-
* caller's forwarded credential). Silently dropping them would leave the user
|
|
1082
|
-
* believing an override is in effect.
|
|
1083
|
-
*/
|
|
1084
|
-
export function modelAdapterRecordConfigError(
|
|
1085
|
-
value: unknown,
|
|
1086
|
-
field: string,
|
|
1087
|
-
providerName: string,
|
|
1088
|
-
provider: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown },
|
|
1089
|
-
): string | null {
|
|
1090
|
-
if (value === undefined) return null;
|
|
1091
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
1092
|
-
const prototype = Object.getPrototypeOf(value);
|
|
1093
|
-
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
1094
|
-
const entries = Object.entries(value);
|
|
1095
|
-
if (entries.length > 0 && isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig)) {
|
|
1096
|
-
return `${field} is not supported on the canonical ChatGPT forward provider`;
|
|
1097
|
-
}
|
|
1098
|
-
for (const [key, entry] of entries) {
|
|
1099
|
-
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
1100
|
-
if (typeof entry !== "string" || !MODEL_ADAPTER_OVERRIDE_ALLOWED.has(entry)) {
|
|
1101
|
-
return `${field}.${key} must be one of: ${[...MODEL_ADAPTER_OVERRIDE_ALLOWED].join(", ")}`;
|
|
1102
|
-
}
|
|
1103
|
-
if (isWirePinnedModel(providerName, key.trim())) {
|
|
1104
|
-
return `${field}.${key} cannot be overridden: the upstream only speaks one wire for this model`;
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
return null;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
729
|
const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR =
|
|
1111
730
|
"codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids";
|
|
1112
731
|
const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR =
|
|
@@ -1232,6 +851,11 @@ const agentTaskRecoverySchema = z.object({
|
|
|
1232
851
|
cacheEntries: z.number().int().min(1).max(512).optional(),
|
|
1233
852
|
}).strict();
|
|
1234
853
|
|
|
854
|
+
const v2NativeParentOverrideSchema = z.object({
|
|
855
|
+
enabled: z.boolean().optional(),
|
|
856
|
+
model: z.string().trim().min(1).optional(),
|
|
857
|
+
}).strict();
|
|
858
|
+
|
|
1235
859
|
const configSchema = z.object({
|
|
1236
860
|
port: z.number().int().min(0).max(65535).default(10100),
|
|
1237
861
|
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
|
|
@@ -1265,6 +889,9 @@ const configSchema = z.object({
|
|
|
1265
889
|
defaultProvider: z.string().min(1).default("openai"),
|
|
1266
890
|
// A retry can be billable, so absence and malformed hand edits both stay off.
|
|
1267
891
|
emptyCompletionRetry: z.boolean().optional().catch(false),
|
|
892
|
+
// A malformed hand edit must not silently stop opening the browser: fall back
|
|
893
|
+
// to undefined, which resolves to the historical auto-open behavior.
|
|
894
|
+
oauthOpenBrowser: z.boolean().optional().catch(undefined),
|
|
1268
895
|
openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
|
|
1269
896
|
// Invalid hand edits must not discard an otherwise usable config.
|
|
1270
897
|
googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined),
|
|
@@ -1272,6 +899,8 @@ const configSchema = z.object({
|
|
|
1272
899
|
providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
|
|
1273
900
|
contextCapValue: z.number().int().positive().optional(),
|
|
1274
901
|
multiAgentGuidanceEnabled: z.boolean().optional(),
|
|
902
|
+
// Invalid hand edits disable only this experimental opt-in subtree.
|
|
903
|
+
v2NativeParentOverride: v2NativeParentOverrideSchema.optional().catch(undefined),
|
|
1275
904
|
// Invalid optional recovery config must not discard unrelated provider/account state.
|
|
1276
905
|
agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined),
|
|
1277
906
|
// These selections pre-date schema validation and used to pass through as
|
|
@@ -1440,6 +1069,14 @@ const configSchema = z.object({
|
|
|
1440
1069
|
message: responsesPathError,
|
|
1441
1070
|
});
|
|
1442
1071
|
}
|
|
1072
|
+
const tlsProfileError = providerTlsProfileConfigError(name, provider);
|
|
1073
|
+
if (tlsProfileError) {
|
|
1074
|
+
ctx.addIssue({
|
|
1075
|
+
code: "custom",
|
|
1076
|
+
path: ["providers", redactSecretString(name), "tlsProfile"],
|
|
1077
|
+
message: tlsProfileError,
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1443
1080
|
const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers);
|
|
1444
1081
|
if (headersError) {
|
|
1445
1082
|
ctx.addIssue({
|
|
@@ -1466,6 +1103,14 @@ const configSchema = z.object({
|
|
|
1466
1103
|
message: apiKeyTransportError,
|
|
1467
1104
|
});
|
|
1468
1105
|
}
|
|
1106
|
+
const azureCredentialError = azureCredentialConfigError(provider);
|
|
1107
|
+
if (azureCredentialError) {
|
|
1108
|
+
ctx.addIssue({
|
|
1109
|
+
code: "custom",
|
|
1110
|
+
path: ["providers", redactSecretString(name), "azureCredential"],
|
|
1111
|
+
message: azureCredentialError,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1469
1114
|
const modelAdaptersError = modelAdapterRecordConfigError(
|
|
1470
1115
|
(provider as { modelAdapters?: unknown }).modelAdapters,
|
|
1471
1116
|
"modelAdapters",
|
|
@@ -1503,6 +1148,17 @@ const configSchema = z.object({
|
|
|
1503
1148
|
message: maxInputError,
|
|
1504
1149
|
});
|
|
1505
1150
|
}
|
|
1151
|
+
const autoCompactError = modelAutoCompactTokenLimitsConfigError(
|
|
1152
|
+
(provider as { modelAutoCompactTokenLimits?: unknown }).modelAutoCompactTokenLimits,
|
|
1153
|
+
{ requireNativeIds: name === OPENAI_CODEX_PROVIDER_ID },
|
|
1154
|
+
);
|
|
1155
|
+
if (autoCompactError) {
|
|
1156
|
+
ctx.addIssue({
|
|
1157
|
+
code: "custom",
|
|
1158
|
+
path: ["providers", redactSecretString(name), "modelAutoCompactTokenLimits"],
|
|
1159
|
+
message: autoCompactError,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1506
1162
|
const reasoningSummariesError = booleanRecordConfigError(
|
|
1507
1163
|
(provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
|
|
1508
1164
|
"modelSupportsReasoningSummaries",
|
|
@@ -1658,35 +1314,6 @@ const configSchema = z.object({
|
|
|
1658
1314
|
*/
|
|
1659
1315
|
export const DEFAULT_SUBAGENT_MODELS = ["gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4-mini"];
|
|
1660
1316
|
|
|
1661
|
-
export function getConfigDir(): string {
|
|
1662
|
-
return resolveConfigDir();
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
export function getConfigPath(): string {
|
|
1666
|
-
return resolveConfigPath();
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
export function getPidPath(): string {
|
|
1670
|
-
return resolvePidPath();
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
export function getRuntimePortPath(): string {
|
|
1674
|
-
return resolveRuntimePortPath();
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
export function hardenConfigDir(): void {
|
|
1678
|
-
const dir = getConfigDir();
|
|
1679
|
-
// The guard runs BEFORE any mutation: refusing the write after chmod/ACL
|
|
1680
|
-
// would already have changed the protected directory (review round 2).
|
|
1681
|
-
assertNotRealHomeUnderTest(dir);
|
|
1682
|
-
if (existsSync(dir)) {
|
|
1683
|
-
try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
|
|
1684
|
-
if (process.platform === "win32") {
|
|
1685
|
-
hardenSecretDir(dir, { required: false });
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
1317
|
export function hardenExistingSecret(path: string): void {
|
|
1691
1318
|
if (existsSync(path)) {
|
|
1692
1319
|
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
@@ -2468,6 +2095,16 @@ function agentTaskRecoveryError(value: unknown): string | null {
|
|
|
2468
2095
|
return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
|
|
2469
2096
|
}
|
|
2470
2097
|
|
|
2098
|
+
function v2NativeParentOverrideError(value: unknown): string | null {
|
|
2099
|
+
const raw = rawConfigRecord(value);
|
|
2100
|
+
if (!raw || !Object.hasOwn(raw, "v2NativeParentOverride") || raw.v2NativeParentOverride === undefined) return null;
|
|
2101
|
+
const result = v2NativeParentOverrideSchema.safeParse(raw.v2NativeParentOverride);
|
|
2102
|
+
if (result.success) return null;
|
|
2103
|
+
const issue = result.error.issues[0];
|
|
2104
|
+
const field = issue?.path.join(".");
|
|
2105
|
+
return `schema_invalid: v2NativeParentOverride${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2471
2108
|
/**
|
|
2472
2109
|
* Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
|
|
2473
2110
|
* malformed selection-order map to undefined, which on a write would drop every entry the
|
|
@@ -2527,6 +2164,14 @@ function emptyCompletionRetryError(value: unknown): string | null {
|
|
|
2527
2164
|
return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted";
|
|
2528
2165
|
}
|
|
2529
2166
|
|
|
2167
|
+
function oauthOpenBrowserError(value: unknown): string | null {
|
|
2168
|
+
const raw = rawConfigRecord(value);
|
|
2169
|
+
if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null;
|
|
2170
|
+
const enabled = raw.oauthOpenBrowser;
|
|
2171
|
+
if (enabled === undefined || typeof enabled === "boolean") return null;
|
|
2172
|
+
return "schema_invalid: oauthOpenBrowser: must be a boolean or omitted";
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2530
2175
|
/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
|
|
2531
2176
|
/**
|
|
2532
2177
|
* Reject a loopback-listener port that collides with the proxy port (#1102).
|
|
@@ -2573,15 +2218,21 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
|
|
|
2573
2218
|
?? appOwnedMemoryBudgetError(value)
|
|
2574
2219
|
?? upstreamHostCircuitThresholdError(value)
|
|
2575
2220
|
?? agentTaskRecoveryError(value)
|
|
2221
|
+
?? v2NativeParentOverrideError(value)
|
|
2576
2222
|
?? googleAntigravityStaticCatalogVersionError(value)
|
|
2577
2223
|
?? codexAccountPrioritiesError(value)
|
|
2578
2224
|
?? codexAccountPickerEnabledError(value)
|
|
2579
2225
|
?? emptyCompletionRetryError(value)
|
|
2226
|
+
?? oauthOpenBrowserError(value)
|
|
2580
2227
|
?? loopbackListenerPortError(value);
|
|
2581
2228
|
if (boundaryError) return { ok: false, error: boundaryError };
|
|
2582
2229
|
const result = configSchema.safeParse(value);
|
|
2583
2230
|
if (result.success) {
|
|
2584
2231
|
const config = normalizeApiKeyIds(result.data as OcxConfig);
|
|
2232
|
+
for (const [name, provider] of Object.entries(config.providers)) {
|
|
2233
|
+
const antigravityError = antigravityOAuthDestinationConfigError(name, provider);
|
|
2234
|
+
if (antigravityError) return { ok: false, error: `providers.${name}.baseUrl: ${antigravityError}` };
|
|
2235
|
+
}
|
|
2585
2236
|
if (value && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, "subagentRoles")) {
|
|
2586
2237
|
const parsedRoles = parseSubagentRoles((value as { subagentRoles?: unknown }).subagentRoles);
|
|
2587
2238
|
if (!parsedRoles.ok) return { ok: false, error: `schema_invalid: ${parsedRoles.error}` };
|
|
@@ -3511,91 +3162,6 @@ export function applyProxyEnv(config: OcxConfig): void {
|
|
|
3511
3162
|
process.env.NO_PROXY = entries.join(",");
|
|
3512
3163
|
}
|
|
3513
3164
|
|
|
3514
|
-
export function writePid(pid: number): void {
|
|
3515
|
-
const dir = getConfigDir();
|
|
3516
|
-
// Guard before ANY directory mutation (mkdir or chmod), not just the write.
|
|
3517
|
-
assertNotRealHomeUnderTest(dir);
|
|
3518
|
-
if (!existsSync(dir)) {
|
|
3519
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
3520
|
-
} else {
|
|
3521
|
-
hardenConfigDir();
|
|
3522
|
-
}
|
|
3523
|
-
atomicWriteFile(getPidPath(), String(pid));
|
|
3524
|
-
}
|
|
3525
|
-
|
|
3526
|
-
export type RuntimePortState = {
|
|
3527
|
-
pid: number;
|
|
3528
|
-
port: number;
|
|
3529
|
-
hostname?: string;
|
|
3530
|
-
/** Per-process proof key; protected by the config directory and never served. */
|
|
3531
|
-
attestationSecret?: string;
|
|
3532
|
-
};
|
|
3533
|
-
|
|
3534
|
-
function isValidRuntimePortState(value: unknown): value is RuntimePortState {
|
|
3535
|
-
if (!value || typeof value !== "object") return false;
|
|
3536
|
-
const state = value as Record<string, unknown>;
|
|
3537
|
-
const hostnameOk = state.hostname === undefined || typeof state.hostname === "string";
|
|
3538
|
-
const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret);
|
|
3539
|
-
return Number.isSafeInteger(state.pid)
|
|
3540
|
-
&& Number(state.pid) > 0
|
|
3541
|
-
&& Number.isInteger(state.port)
|
|
3542
|
-
&& Number(state.port) > 0
|
|
3543
|
-
&& Number(state.port) <= 65535
|
|
3544
|
-
&& hostnameOk
|
|
3545
|
-
&& attestationOk;
|
|
3546
|
-
}
|
|
3547
|
-
|
|
3548
|
-
export function writeRuntimePort(state: RuntimePortState): void {
|
|
3549
|
-
const dir = getConfigDir();
|
|
3550
|
-
// Guard before ANY directory mutation (mkdir or chmod), not just the write.
|
|
3551
|
-
assertNotRealHomeUnderTest(dir);
|
|
3552
|
-
if (!existsSync(dir)) {
|
|
3553
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
3554
|
-
} else {
|
|
3555
|
-
hardenConfigDir();
|
|
3556
|
-
}
|
|
3557
|
-
atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n");
|
|
3558
|
-
}
|
|
3559
|
-
|
|
3560
|
-
export function readPid(): number | null {
|
|
3561
|
-
const pidPath = getPidPath();
|
|
3562
|
-
if (!existsSync(pidPath)) return null;
|
|
3563
|
-
try {
|
|
3564
|
-
const raw = readFileSync(pidPath, "utf-8").trim();
|
|
3565
|
-
const pid = parsePidFile(raw);
|
|
3566
|
-
if (pid === null) return null;
|
|
3567
|
-
try {
|
|
3568
|
-
process.kill(pid, 0);
|
|
3569
|
-
return isLikelyOcxStartProcess(pid) ? pid : null;
|
|
3570
|
-
} catch (e: unknown) {
|
|
3571
|
-
if ((e as NodeJS.ErrnoException).code === "EPERM") {
|
|
3572
|
-
return isLikelyOcxStartProcess(pid) ? pid : null;
|
|
3573
|
-
}
|
|
3574
|
-
return null;
|
|
3575
|
-
}
|
|
3576
|
-
} catch {
|
|
3577
|
-
return null;
|
|
3578
|
-
}
|
|
3579
|
-
}
|
|
3580
|
-
|
|
3581
|
-
export function readRuntimePort(expectedPid?: number): RuntimePortState | null {
|
|
3582
|
-
try {
|
|
3583
|
-
const parsed = JSON.parse(readFileSync(getRuntimePortPath(), "utf-8"));
|
|
3584
|
-
if (!isValidRuntimePortState(parsed)) return null;
|
|
3585
|
-
if (expectedPid !== undefined && parsed.pid !== expectedPid) return null;
|
|
3586
|
-
return parsed;
|
|
3587
|
-
} catch {
|
|
3588
|
-
return null;
|
|
3589
|
-
}
|
|
3590
|
-
}
|
|
3591
|
-
|
|
3592
|
-
export function removePid(expectedPid?: number): void {
|
|
3593
|
-
if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return;
|
|
3594
|
-
try {
|
|
3595
|
-
unlinkSync(getPidPath());
|
|
3596
|
-
} catch { /* ignore */ }
|
|
3597
|
-
}
|
|
3598
|
-
|
|
3599
3165
|
function warnConfigRepaired(configPath: string, error: z.ZodError): void {
|
|
3600
3166
|
if (warnedConfigFallbacks.has(configPath)) return;
|
|
3601
3167
|
warnedConfigFallbacks.add(configPath);
|
|
@@ -3791,233 +3357,6 @@ function warnDroppedConfigSections(configPath: string, dropped: string[], issues
|
|
|
3791
3357
|
);
|
|
3792
3358
|
}
|
|
3793
3359
|
|
|
3794
|
-
export function readPidFileValue(): number | null {
|
|
3795
|
-
try {
|
|
3796
|
-
return parsePidFile(readFileSync(getPidPath(), "utf-8"));
|
|
3797
|
-
} catch {
|
|
3798
|
-
return null;
|
|
3799
|
-
}
|
|
3800
|
-
}
|
|
3801
|
-
|
|
3802
|
-
export function removeRuntimePort(expectedPid?: number): void {
|
|
3803
|
-
if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return;
|
|
3804
|
-
try {
|
|
3805
|
-
unlinkSync(getRuntimePortPath());
|
|
3806
|
-
} catch { /* ignore */ }
|
|
3807
|
-
}
|
|
3808
|
-
|
|
3809
|
-
/**
|
|
3810
|
-
* Snapshot-guarded stale-state purge: remove the pid/runtime files only when their content
|
|
3811
|
-
* still matches what the caller saw BEFORE its liveness probe. A concurrent `ocx start` can
|
|
3812
|
-
* write fresh records mid-probe; an unconditional purge would erase the new proxy's state.
|
|
3813
|
-
*/
|
|
3814
|
-
export function removePidIfValueIs(snapshot: number | null): void {
|
|
3815
|
-
if (!existsSync(getPidPath())) return;
|
|
3816
|
-
if (readPidFileValue() !== snapshot) return;
|
|
3817
|
-
try {
|
|
3818
|
-
unlinkSync(getPidPath());
|
|
3819
|
-
} catch { /* ignore */ }
|
|
3820
|
-
}
|
|
3821
|
-
|
|
3822
|
-
export function removeRuntimePortIfPidIs(snapshotPid: number | null): void {
|
|
3823
|
-
const current = readRuntimePort();
|
|
3824
|
-
if ((current?.pid ?? null) !== snapshotPid) return;
|
|
3825
|
-
try {
|
|
3826
|
-
unlinkSync(getRuntimePortPath());
|
|
3827
|
-
} catch { /* ignore */ }
|
|
3828
|
-
}
|
|
3829
|
-
|
|
3830
|
-
export function parsePidFile(raw: string): number | null {
|
|
3831
|
-
const trimmed = raw.trim();
|
|
3832
|
-
if (!/^\d+$/.test(trimmed)) return null;
|
|
3833
|
-
const pid = Number.parseInt(trimmed, 10);
|
|
3834
|
-
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
3835
|
-
}
|
|
3836
|
-
|
|
3837
|
-
export function isOcxStartCommandLine(commandLine: string): boolean {
|
|
3838
|
-
const normalized = commandLine.toLowerCase().replace(/\\/g, "/");
|
|
3839
|
-
// "src/cli.ts" matches pre-restructure installs still running; "src/cli/index.ts" is current.
|
|
3840
|
-
// `@bitkyc08/.opencodex-*` is npm's in-place rename of the global package during
|
|
3841
|
-
// `npm install -g` — a Windows service wrapper can respawn from that temp tree
|
|
3842
|
-
// mid-update, and must still count as ocx for port reclaim.
|
|
3843
|
-
const hasOcxEntrypoint = normalized.includes("src/cli.ts")
|
|
3844
|
-
|| normalized.includes("src/cli/index.ts")
|
|
3845
|
-
|| /@[a-z0-9._-]+\/opencodex(?:[/'"\s]|$)/.test(normalized)
|
|
3846
|
-
|| /@[a-z0-9._-]+\/\.opencodex-/.test(normalized)
|
|
3847
|
-
|| /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized);
|
|
3848
|
-
return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
|
|
3849
|
-
}
|
|
3850
|
-
|
|
3851
|
-
/** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */
|
|
3852
|
-
const ocxStartProcessCache = new Map<number, boolean>();
|
|
3853
|
-
let ocxStartProcessSweepCursor = 0;
|
|
3854
|
-
let ocxStartProcessProbe: (pid: number) => void = pid => { process.kill(pid, 0); };
|
|
3855
|
-
|
|
3856
|
-
export function setOcxStartProcessProbeForTests(probe: ((pid: number) => void) | null): void {
|
|
3857
|
-
ocxStartProcessProbe = probe ?? (pid => { process.kill(pid, 0); });
|
|
3858
|
-
}
|
|
3859
|
-
|
|
3860
|
-
export function setOcxStartProcessCacheForTests(entries: Iterable<readonly [number, boolean]>): void {
|
|
3861
|
-
ocxStartProcessCache.clear();
|
|
3862
|
-
for (const [pid, value] of entries) ocxStartProcessCache.set(pid, value);
|
|
3863
|
-
ocxStartProcessSweepCursor = 0;
|
|
3864
|
-
}
|
|
3865
|
-
|
|
3866
|
-
export function sweepDeadOcxStartProcessCache(maxProbes = 64): number {
|
|
3867
|
-
const pids: number[] = [];
|
|
3868
|
-
let removed = 0;
|
|
3869
|
-
for (const pid of ocxStartProcessCache.keys()) {
|
|
3870
|
-
if (Number.isSafeInteger(pid) && pid > 0) pids.push(pid);
|
|
3871
|
-
else if (ocxStartProcessCache.delete(pid)) removed += 1;
|
|
3872
|
-
}
|
|
3873
|
-
if (pids.length === 0 || maxProbes <= 0) {
|
|
3874
|
-
ocxStartProcessSweepCursor = 0;
|
|
3875
|
-
return removed;
|
|
3876
|
-
}
|
|
3877
|
-
const probeCount = Math.min(Math.floor(maxProbes), pids.length);
|
|
3878
|
-
const start = ocxStartProcessSweepCursor % pids.length;
|
|
3879
|
-
for (let offset = 0; offset < probeCount; offset += 1) {
|
|
3880
|
-
const pid = pids[(start + offset) % pids.length]!;
|
|
3881
|
-
try {
|
|
3882
|
-
ocxStartProcessProbe(pid);
|
|
3883
|
-
} catch (error) {
|
|
3884
|
-
if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue;
|
|
3885
|
-
if (ocxStartProcessCache.delete(pid)) removed += 1;
|
|
3886
|
-
}
|
|
3887
|
-
}
|
|
3888
|
-
ocxStartProcessSweepCursor = (start + probeCount) % pids.length;
|
|
3889
|
-
return removed;
|
|
3890
|
-
}
|
|
3891
|
-
|
|
3892
|
-
export function ocxStartProcessCacheSizeForTests(): number {
|
|
3893
|
-
return ocxStartProcessCache.size;
|
|
3894
|
-
}
|
|
3895
|
-
|
|
3896
|
-
function isLikelyOcxStartProcess(pid: number): boolean {
|
|
3897
|
-
const cached = ocxStartProcessCache.get(pid);
|
|
3898
|
-
if (cached !== undefined) return cached;
|
|
3899
|
-
const commandLine = readProcessCommandLine(pid);
|
|
3900
|
-
if (commandLine === undefined) return false;
|
|
3901
|
-
const ok = isOcxStartCommandLine(commandLine);
|
|
3902
|
-
ocxStartProcessCache.set(pid, ok);
|
|
3903
|
-
return ok;
|
|
3904
|
-
}
|
|
3905
|
-
|
|
3906
|
-
/**
|
|
3907
|
-
* Alive pid from the pid file without the expensive Windows command-line probe.
|
|
3908
|
-
* Safe for liveness polls: callers still identity-check /healthz before trusting the proxy.
|
|
3909
|
-
* Destructive stop/kill paths should keep using {@link readPid}, which verifies the cmdline.
|
|
3910
|
-
*/
|
|
3911
|
-
export function readAlivePid(): number | null {
|
|
3912
|
-
const pid = readPidFileValue();
|
|
3913
|
-
if (pid === null) return null;
|
|
3914
|
-
try {
|
|
3915
|
-
process.kill(pid, 0);
|
|
3916
|
-
return pid;
|
|
3917
|
-
} catch (e: unknown) {
|
|
3918
|
-
if ((e as NodeJS.ErrnoException).code === "EPERM") return pid;
|
|
3919
|
-
return null;
|
|
3920
|
-
}
|
|
3921
|
-
}
|
|
3922
|
-
|
|
3923
|
-
/**
|
|
3924
|
-
* Full identity check of a KNOWN candidate pid (alive + ocx-start command line).
|
|
3925
|
-
* Companion to {@link readAlivePid}: liveness discovery may be cheap, but any pid
|
|
3926
|
-
* handed to a destructive caller must pass this check — and must equal the candidate
|
|
3927
|
-
* it was asked about, so a pidfile rewrite between discovery and verification can
|
|
3928
|
-
* never swap in a different process (TOCTOU guard).
|
|
3929
|
-
*/
|
|
3930
|
-
export function verifyPidIdentity(candidatePid: number): number | null {
|
|
3931
|
-
try {
|
|
3932
|
-
process.kill(candidatePid, 0);
|
|
3933
|
-
} catch (e: unknown) {
|
|
3934
|
-
if ((e as NodeJS.ErrnoException).code !== "EPERM") return null;
|
|
3935
|
-
}
|
|
3936
|
-
return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null;
|
|
3937
|
-
}
|
|
3938
|
-
|
|
3939
|
-
type ProcessCommandLineExec = (
|
|
3940
|
-
executable: string,
|
|
3941
|
-
args: string[],
|
|
3942
|
-
options: {
|
|
3943
|
-
encoding: BufferEncoding;
|
|
3944
|
-
stdio: ["ignore", "pipe", "ignore"];
|
|
3945
|
-
timeout: number;
|
|
3946
|
-
windowsHide: boolean;
|
|
3947
|
-
},
|
|
3948
|
-
) => string;
|
|
3949
|
-
|
|
3950
|
-
const defaultProcessCommandLineExec: ProcessCommandLineExec = (executable, args, options) =>
|
|
3951
|
-
execFileSync(executable, args, options);
|
|
3952
|
-
let processCommandLineExec = defaultProcessCommandLineExec;
|
|
3953
|
-
let processCommandLinePlatformForTests: NodeJS.Platform | null = null;
|
|
3954
|
-
|
|
3955
|
-
/** Test-only seam for verifying the exact system executable selected by pid identity probes. */
|
|
3956
|
-
export function setProcessCommandLineExecForTests(next: ProcessCommandLineExec | null): void {
|
|
3957
|
-
processCommandLineExec = next ?? defaultProcessCommandLineExec;
|
|
3958
|
-
}
|
|
3959
|
-
|
|
3960
|
-
/** Test-only seam so cross-platform tests do not mutate process.platform. */
|
|
3961
|
-
export function setProcessCommandLinePlatformForTests(next: NodeJS.Platform | null): void {
|
|
3962
|
-
processCommandLinePlatformForTests = next;
|
|
3963
|
-
}
|
|
3964
|
-
|
|
3965
|
-
function readProcessCommandLine(pid: number): string | undefined {
|
|
3966
|
-
if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
|
|
3967
|
-
const platform = processCommandLinePlatformForTests ?? process.platform;
|
|
3968
|
-
try {
|
|
3969
|
-
if (platform === "linux") {
|
|
3970
|
-
try {
|
|
3971
|
-
const output = readFileSync(`/proc/${pid}/cmdline`, "utf-8");
|
|
3972
|
-
const value = output.replace(/\0/g, " ").trim();
|
|
3973
|
-
if (value) return value;
|
|
3974
|
-
} catch {
|
|
3975
|
-
/* procfs unavailable — use the fixed ps fallback below */
|
|
3976
|
-
}
|
|
3977
|
-
}
|
|
3978
|
-
if (platform === "win32") {
|
|
3979
|
-
// Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash.
|
|
3980
|
-
// Fall back to PowerShell when WMIC is absent (newer Windows images).
|
|
3981
|
-
const wmic = join(resolveTrustedWindowsSystemDirectory(), "wbem", "WMIC.exe");
|
|
3982
|
-
try {
|
|
3983
|
-
const output = processCommandLineExec(wmic, [
|
|
3984
|
-
"process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE",
|
|
3985
|
-
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
|
|
3986
|
-
const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, ""));
|
|
3987
|
-
const value = match?.[1]?.trim();
|
|
3988
|
-
if (value) return value;
|
|
3989
|
-
} catch {
|
|
3990
|
-
/* WMIC missing or failed — fall through */
|
|
3991
|
-
}
|
|
3992
|
-
const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [
|
|
3993
|
-
"-NoProfile",
|
|
3994
|
-
"-NoLogo",
|
|
3995
|
-
"-NonInteractive",
|
|
3996
|
-
"-Command",
|
|
3997
|
-
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
|
|
3998
|
-
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
|
|
3999
|
-
return output.trim() || undefined;
|
|
4000
|
-
}
|
|
4001
|
-
for (const ps of ["/bin/ps", "/usr/bin/ps"]) {
|
|
4002
|
-
try {
|
|
4003
|
-
const output = processCommandLineExec(ps, ["-p", String(pid), "-o", "command="], {
|
|
4004
|
-
encoding: "utf-8",
|
|
4005
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
4006
|
-
timeout: 1000,
|
|
4007
|
-
windowsHide: true,
|
|
4008
|
-
});
|
|
4009
|
-
const value = output.trim();
|
|
4010
|
-
if (value) return value;
|
|
4011
|
-
} catch {
|
|
4012
|
-
/* try the other fixed system path */
|
|
4013
|
-
}
|
|
4014
|
-
}
|
|
4015
|
-
return undefined;
|
|
4016
|
-
} catch {
|
|
4017
|
-
return undefined;
|
|
4018
|
-
}
|
|
4019
|
-
}
|
|
4020
|
-
|
|
4021
3360
|
function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
|
|
4022
3361
|
if (warnedConfigFallbacks.has(configPath)) return;
|
|
4023
3362
|
warnedConfigFallbacks.add(configPath);
|