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