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