@remodex/rmx 1.0.2
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 +91 -0
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/assets/architecture.png +0 -0
- package/assets/banner.png +0 -0
- package/assets/claude-code-models.gif +0 -0
- package/assets/codex-app-picker.png +0 -0
- package/bin/ocx.mjs +584 -0
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-CZqebSPQ.css +1 -0
- package/gui/dist/assets/index-CkETtt7P.js +71 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/fonts/google-sans-cyrillic.woff2 +0 -0
- package/gui/dist/fonts/google-sans-latin.woff2 +0 -0
- package/gui/dist/icons.svg +24 -0
- package/gui/dist/index.html +25 -0
- package/gui/dist/logo.png +0 -0
- package/gui/dist/provider-icons/alibaba-color.svg +1 -0
- package/gui/dist/provider-icons/antigravity-color.svg +1 -0
- package/gui/dist/provider-icons/claude-color.svg +1 -0
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -0
- package/gui/dist/provider-icons/cursor-color.svg +2 -0
- package/gui/dist/provider-icons/deepseek-color.svg +1 -0
- package/gui/dist/provider-icons/discord.svg +1 -0
- package/gui/dist/provider-icons/firepass-color.svg +1 -0
- package/gui/dist/provider-icons/fireworks-color.svg +1 -0
- package/gui/dist/provider-icons/gemini-color.svg +1 -0
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
- package/gui/dist/provider-icons/grok.svg +1 -0
- package/gui/dist/provider-icons/groq-color.svg +1 -0
- package/gui/dist/provider-icons/huggingface-color.svg +1 -0
- package/gui/dist/provider-icons/kimi-color.svg +1 -0
- package/gui/dist/provider-icons/kiro-color.svg +15 -0
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
- package/gui/dist/provider-icons/mistral-color.svg +1 -0
- package/gui/dist/provider-icons/moonshot-color.svg +1 -0
- package/gui/dist/provider-icons/nvidia-color.svg +1 -0
- package/gui/dist/provider-icons/ollama-color.svg +1 -0
- package/gui/dist/provider-icons/openai.svg +1 -0
- package/gui/dist/provider-icons/opencode.svg +2 -0
- package/gui/dist/provider-icons/openrouter-color.svg +1 -0
- package/gui/dist/provider-icons/pi.svg +21 -0
- package/gui/dist/provider-icons/qianfan-color.svg +1 -0
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
- package/gui/dist/provider-icons/telegram.svg +1 -0
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/vllm-color.svg +1 -0
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
- package/package.json +118 -0
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic-image-guard.ts +251 -0
- package/src/adapters/anthropic-image-normalize.ts +518 -0
- package/src/adapters/anthropic.ts +1205 -0
- package/src/adapters/azure.ts +36 -0
- package/src/adapters/base.ts +83 -0
- package/src/adapters/client-fingerprint.ts +59 -0
- package/src/adapters/command-code.ts +453 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +104 -0
- package/src/adapters/cursor/cursor-errors.ts +165 -0
- package/src/adapters/cursor/discovery.ts +276 -0
- package/src/adapters/cursor/effort-map.ts +139 -0
- package/src/adapters/cursor/exec-policy.ts +88 -0
- package/src/adapters/cursor/framing.ts +250 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
- package/src/adapters/cursor/kv-store.ts +52 -0
- package/src/adapters/cursor/live-models.ts +153 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +1235 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +333 -0
- package/src/adapters/cursor/message-mapper.ts +49 -0
- package/src/adapters/cursor/native-exec-common.ts +59 -0
- package/src/adapters/cursor/native-exec-desktop.ts +184 -0
- package/src/adapters/cursor/native-exec-fs.ts +332 -0
- package/src/adapters/cursor/native-exec-mcp.ts +153 -0
- package/src/adapters/cursor/native-exec-network.ts +43 -0
- package/src/adapters/cursor/native-exec-shell.ts +548 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +604 -0
- package/src/adapters/cursor/protobuf-events.ts +735 -0
- package/src/adapters/cursor/protobuf-request.ts +719 -0
- package/src/adapters/cursor/request-builder.ts +280 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/tool-definitions.ts +621 -0
- package/src/adapters/cursor/transport-retry.ts +132 -0
- package/src/adapters/cursor/transport.ts +57 -0
- package/src/adapters/cursor/types.ts +59 -0
- package/src/adapters/cursor.ts +196 -0
- package/src/adapters/google-antigravity-replay.ts +520 -0
- package/src/adapters/google-antigravity-wire.ts +140 -0
- package/src/adapters/google-errors.ts +85 -0
- package/src/adapters/google-http.ts +100 -0
- package/src/adapters/google-tool-schema.ts +173 -0
- package/src/adapters/google-truncation.ts +24 -0
- package/src/adapters/google-wire-compiler.ts +232 -0
- package/src/adapters/google.ts +859 -0
- package/src/adapters/identity.ts +77 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/kiro-constants.ts +16 -0
- package/src/adapters/kiro-errors.ts +208 -0
- package/src/adapters/kiro-events.ts +197 -0
- package/src/adapters/kiro-images.ts +129 -0
- package/src/adapters/kiro-retry.ts +312 -0
- package/src/adapters/kiro-thinking.ts +104 -0
- package/src/adapters/kiro-tool-fallback.ts +36 -0
- package/src/adapters/kiro-tools.ts +224 -0
- package/src/adapters/kiro-truncation.ts +33 -0
- package/src/adapters/kiro-wire.ts +129 -0
- package/src/adapters/kiro.ts +1924 -0
- package/src/adapters/mimo-free.ts +263 -0
- package/src/adapters/openai-chat.ts +1265 -0
- package/src/adapters/openai-responses.ts +1309 -0
- package/src/adapters/run-turn-queue.ts +114 -0
- package/src/adapters/tool-catalog-nudge.ts +71 -0
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/android-remote/assets.ts +218 -0
- package/src/android-remote/attachments.ts +168 -0
- package/src/android-remote/auth.ts +237 -0
- package/src/android-remote/cloudflare-provisioning.ts +409 -0
- package/src/android-remote/cloudflare-secret.ts +106 -0
- package/src/android-remote/cloudflare-tunnel.ts +488 -0
- package/src/android-remote/cloudflared.ts +286 -0
- package/src/android-remote/codex-app-server.ts +565 -0
- package/src/android-remote/desktop-history-page.ts +936 -0
- package/src/android-remote/desktop-ipc.ts +3643 -0
- package/src/android-remote/desktop-ownership-store.ts +98 -0
- package/src/android-remote/desktop-project-registration.ts +129 -0
- package/src/android-remote/desktop-session-stream.ts +1110 -0
- package/src/android-remote/desktop-workspace-state.ts +443 -0
- package/src/android-remote/file-change-parser.ts +112 -0
- package/src/android-remote/gateway.ts +9108 -0
- package/src/android-remote/mutation-store.ts +299 -0
- package/src/android-remote/projection.ts +1780 -0
- package/src/android-remote/queued-turn-store.ts +248 -0
- package/src/android-remote/session-command-recovery.ts +1384 -0
- package/src/android-remote/store.ts +466 -0
- package/src/android-remote/thread-reconciliation.ts +133 -0
- package/src/android-remote/thread-source-paths.ts +307 -0
- package/src/android-remote/thread-stream.ts +546 -0
- package/src/android-remote/turn-activity.ts +235 -0
- package/src/android-remote/user-message-identity.ts +94 -0
- package/src/bridge.ts +1793 -0
- package/src/chat/inbound.ts +295 -0
- package/src/chat/outbound.ts +821 -0
- package/src/claude/agents-inject.ts +267 -0
- package/src/claude/alias.ts +149 -0
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/context-windows.ts +189 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p-paths.ts +84 -0
- package/src/claude/desktop-3p.ts +601 -0
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/gateway-cache.ts +70 -0
- package/src/claude/inbound-debug.ts +163 -0
- package/src/claude/inbound.ts +519 -0
- package/src/claude/model-info.ts +154 -0
- package/src/claude/outbound.ts +898 -0
- package/src/cli/access.ts +108 -0
- package/src/cli/account-api.ts +296 -0
- package/src/cli/account-auth.ts +250 -0
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +476 -0
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +297 -0
- package/src/cli/agent-driven.ts +70 -0
- package/src/cli/agent.ts +184 -0
- package/src/cli/catalog-prewarm.ts +27 -0
- package/src/cli/claude-desktop.ts +211 -0
- package/src/cli/claude.ts +302 -0
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/codex-shim-readiness.ts +69 -0
- package/src/cli/combo.ts +124 -0
- package/src/cli/config-command.ts +183 -0
- package/src/cli/debug.ts +228 -0
- package/src/cli/desktop-first-run.ts +25 -0
- package/src/cli/doctor.ts +1022 -0
- package/src/cli/export-command.ts +201 -0
- package/src/cli/help.ts +370 -0
- package/src/cli/index.ts +1565 -0
- package/src/cli/init.ts +224 -0
- package/src/cli/integrations.ts +225 -0
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/internal-dispatch.ts +35 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/models-runtime.ts +224 -0
- package/src/cli/models.ts +340 -0
- package/src/cli/observe.ts +170 -0
- package/src/cli/opencode.ts +587 -0
- package/src/cli/provider-runtime.ts +179 -0
- package/src/cli/provider.ts +476 -0
- package/src/cli/ready.ts +301 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +328 -0
- package/src/cli/star-prompt.ts +211 -0
- package/src/cli/status-oauth.ts +78 -0
- package/src/cli/status.ts +321 -0
- package/src/cli/system-command.ts +196 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +205 -0
- package/src/cli/v2.ts +200 -0
- package/src/cli.ts +10 -0
- package/src/clients/config-export.ts +1109 -0
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-label.ts +34 -0
- package/src/codex/account-lifecycle.ts +172 -0
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +195 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/account-runtime-state.ts +31 -0
- package/src/codex/account-store.ts +517 -0
- package/src/codex/account-usability.ts +40 -0
- package/src/codex/admission.ts +263 -0
- package/src/codex/app-server-processes.ts +799 -0
- package/src/codex/auth-api.ts +2098 -0
- package/src/codex/auth-collision.ts +107 -0
- package/src/codex/auth-context.ts +480 -0
- package/src/codex/autostart-health.ts +156 -0
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +471 -0
- package/src/codex/catalog/bundled.ts +533 -0
- package/src/codex/catalog/effort.ts +432 -0
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +287 -0
- package/src/codex/catalog/native-models.ts +7 -0
- package/src/codex/catalog/parsing.ts +503 -0
- package/src/codex/catalog/provider-fetch.ts +2267 -0
- package/src/codex/catalog/sync.ts +1606 -0
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-refresh-status.ts +87 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +15 -0
- package/src/codex/codex-write-lock.ts +384 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +580 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/desktop-client-processes.ts +521 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/features.ts +1091 -0
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +347 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-migration-guardian.ts +115 -0
- package/src/codex/history-provider.ts +1075 -0
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +204 -0
- package/src/codex/home.ts +206 -0
- package/src/codex/inject-coordination.ts +257 -0
- package/src/codex/inject.ts +1857 -0
- package/src/codex/injected-marker.ts +79 -0
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +105 -0
- package/src/codex/journal.ts +172 -0
- package/src/codex/main-account-cache.ts +56 -0
- package/src/codex/main-account.ts +40 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/model-cache.ts +267 -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 +162 -0
- package/src/codex/native-main-owner.ts +329 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1531 -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 +348 -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 +691 -0
- package/src/codex/paths.ts +78 -0
- package/src/codex/plugins-doctor.ts +242 -0
- package/src/codex/pool-rotation.ts +295 -0
- package/src/codex/project-config-warnings.ts +426 -0
- 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/provider-adoption.ts +242 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +494 -0
- package/src/codex/refresh.ts +60 -0
- package/src/codex/routing.ts +1855 -0
- package/src/codex/runtime.ts +659 -0
- package/src/codex/shim.ts +1215 -0
- package/src/codex/subagent-defaults.ts +557 -0
- package/src/codex/subagent-model-fallback.ts +560 -0
- package/src/codex/sync.ts +238 -0
- package/src/codex/transition-state.ts +612 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +374 -0
- package/src/codex/warmup.ts +192 -0
- package/src/codex/websocket-registry.ts +100 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +140 -0
- package/src/combos/index.ts +44 -0
- package/src/combos/request.ts +64 -0
- package/src/combos/resolve.ts +232 -0
- package/src/combos/types.ts +392 -0
- package/src/config.ts +3270 -0
- package/src/generated/model-metadata.ts +144 -0
- package/src/github/star-state.ts +203 -0
- package/src/grok/inject.ts +540 -0
- package/src/grok/inspect.ts +45 -0
- package/src/grok/status.ts +127 -0
- package/src/grok/sync.ts +66 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +149 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +922 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/index.ts +22 -0
- 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 +202 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +108 -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/abort.ts +146 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bounded-body.ts +242 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +184 -0
- package/src/lib/bun-stream-caps.ts +127 -0
- package/src/lib/config-ownership.ts +438 -0
- package/src/lib/crash-guard.ts +344 -0
- package/src/lib/debug-log-buffer.ts +83 -0
- package/src/lib/debug-settings.ts +108 -0
- package/src/lib/debug.ts +31 -0
- package/src/lib/destination-policy.ts +316 -0
- package/src/lib/errors.ts +364 -0
- package/src/lib/eventstream-decoder.ts +253 -0
- package/src/lib/gcp-adc.ts +341 -0
- package/src/lib/injection-debug-log.ts +58 -0
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/open-url.ts +25 -0
- package/src/lib/pinned-http.ts +182 -0
- package/src/lib/privacy.ts +20 -0
- package/src/lib/process-control.ts +168 -0
- package/src/lib/provider-environment.ts +470 -0
- package/src/lib/provider-outbound.ts +203 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/redact.ts +510 -0
- package/src/lib/remodex-home.ts +616 -0
- package/src/lib/retry-after.ts +55 -0
- package/src/lib/service-secrets.ts +178 -0
- package/src/lib/shadow-call.ts +54 -0
- package/src/lib/sidecar-tracker.ts +52 -0
- package/src/lib/sse-decoder.ts +364 -0
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/test-home-guard.ts +98 -0
- package/src/lib/token-estimate.ts +69 -0
- package/src/lib/translator-budget.ts +366 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +508 -0
- package/src/lib/win-exec.ts +115 -0
- package/src/lib/win-paths.ts +68 -0
- package/src/lib/windows-elevation.ts +705 -0
- package/src/lib/windows-secret-acl.ts +817 -0
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +402 -0
- package/src/model-sources.ts +73 -0
- package/src/oauth/anthropic-routing.ts +594 -0
- package/src/oauth/anthropic.ts +177 -0
- package/src/oauth/callback-server.ts +294 -0
- package/src/oauth/chatgpt.ts +150 -0
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +231 -0
- package/src/oauth/github-copilot.ts +428 -0
- package/src/oauth/google-antigravity.ts +230 -0
- package/src/oauth/health.ts +443 -0
- package/src/oauth/index.ts +1280 -0
- package/src/oauth/key-providers.ts +128 -0
- package/src/oauth/kimi.ts +213 -0
- package/src/oauth/kiro-credentials.ts +726 -0
- package/src/oauth/kiro.ts +621 -0
- package/src/oauth/local-token-detect.ts +121 -0
- package/src/oauth/log.ts +48 -0
- package/src/oauth/login-cli.ts +163 -0
- package/src/oauth/pkce.ts +15 -0
- package/src/oauth/store.ts +655 -0
- package/src/oauth/token-guardian.ts +309 -0
- package/src/oauth/types.ts +62 -0
- package/src/oauth/xai.ts +241 -0
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +156 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/antigravity-models.ts +317 -0
- package/src/providers/api-keys.ts +140 -0
- package/src/providers/base-url-choices.ts +64 -0
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/command-code-efforts.ts +85 -0
- package/src/providers/context-cap.ts +73 -0
- package/src/providers/derive.ts +451 -0
- package/src/providers/free-directory.ts +187 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +271 -0
- package/src/providers/kiro-models.ts +67 -0
- package/src/providers/label.ts +19 -0
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +361 -0
- package/src/providers/openai-sidecar.ts +235 -0
- package/src/providers/openai-tier-startup.ts +27 -0
- package/src/providers/openai-tiers.ts +301 -0
- package/src/providers/openai-virtual-models.ts +83 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +179 -0
- package/src/providers/quota.ts +1942 -0
- package/src/providers/reasoning-capabilities.ts +336 -0
- package/src/providers/registry.ts +2375 -0
- package/src/providers/slug-codec.ts +74 -0
- package/src/providers/xai-transport.ts +149 -0
- package/src/reasoning-effort.ts +243 -0
- package/src/responses/compaction.ts +124 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +714 -0
- package/src/responses/reasoning-envelope.ts +60 -0
- package/src/responses/reasoning-replay-cache.ts +106 -0
- package/src/responses/schema.ts +159 -0
- package/src/responses/spill-store.ts +431 -0
- package/src/responses/state.ts +1039 -0
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +802 -0
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +205 -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 +605 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +424 -0
- package/src/routing/quota.ts +145 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/adapter-resolve.ts +83 -0
- package/src/server/auth-cors.ts +606 -0
- package/src/server/chat-completions.ts +379 -0
- package/src/server/claude-messages.ts +980 -0
- package/src/server/effort-policy.ts +251 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +152 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/images.ts +485 -0
- package/src/server/index.ts +1633 -0
- package/src/server/lifecycle.ts +482 -0
- package/src/server/live.ts +609 -0
- package/src/server/management/agent-settings-routes.ts +1180 -0
- package/src/server/management/android-remote-routes.ts +390 -0
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +244 -0
- package/src/server/management/config-routes.ts +602 -0
- package/src/server/management/context.ts +88 -0
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +516 -0
- package/src/server/management/model-routes.ts +519 -0
- package/src/server/management/model-rows.ts +143 -0
- package/src/server/management/native-integration-routes.ts +781 -0
- package/src/server/management/oauth-account-routes.ts +573 -0
- package/src/server/management/provider-routes.ts +781 -0
- 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 +384 -0
- package/src/server/management/shared.ts +277 -0
- package/src/server/management/sidebar-routes.ts +106 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +433 -0
- package/src/server/management/system-routes.ts +141 -0
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +269 -0
- package/src/server/management-auth.ts +353 -0
- package/src/server/memory-watchdog.ts +156 -0
- package/src/server/port-reclaim.ts +307 -0
- package/src/server/ports.ts +156 -0
- package/src/server/proxy-liveness.ts +326 -0
- package/src/server/proxy-stop.ts +92 -0
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +353 -0
- package/src/server/relay.ts +1179 -0
- package/src/server/request-decompress.ts +132 -0
- package/src/server/request-log-conversation.ts +168 -0
- package/src/server/request-log.ts +1072 -0
- package/src/server/responses/collaboration.ts +409 -0
- package/src/server/responses/compact.ts +710 -0
- package/src/server/responses/core.ts +3561 -0
- package/src/server/responses/encrypted-payload.ts +308 -0
- package/src/server/responses/fetch-helpers.ts +171 -0
- package/src/server/responses/passthrough-error.ts +78 -0
- package/src/server/responses/policy-fallback.ts +152 -0
- package/src/server/responses/terminal-guard.ts +230 -0
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/responses-image-gen-repair.ts +132 -0
- package/src/server/responses-item-id-repair.ts +272 -0
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses.ts +10 -0
- package/src/server/search.ts +181 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +263 -0
- package/src/server/startup-action-control.ts +308 -0
- package/src/server/startup-health-cache.ts +119 -0
- package/src/server/system-env.ts +418 -0
- package/src/server/windows-tcp-drop.ts +184 -0
- package/src/server/windows-tray-control.ts +41 -0
- package/src/server/ws-bridge.ts +470 -0
- package/src/service-manager-probe.ts +824 -0
- package/src/service.ts +3011 -0
- package/src/stall-timeout.ts +20 -0
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +3085 -0
- package/src/storage/policy-job.ts +457 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +59 -0
- package/src/storage/policy.ts +527 -0
- package/src/storage/restore-job.ts +299 -0
- package/src/storage/restore-worker.ts +58 -0
- package/src/storage/scanner.ts +238 -0
- package/src/storage/storage-mutation-coordinator.ts +139 -0
- package/src/storage/worker-lifecycle.ts +215 -0
- package/src/tray/assets/opencodex-tray-offline.ico +0 -0
- package/src/tray/assets/opencodex-tray-online.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning.ico +0 -0
- package/src/tray/assets/opencodex-tray.png +0 -0
- package/src/tray/windows-tray.ps1 +364 -0
- package/src/tray/windows.ts +738 -0
- package/src/types.ts +1531 -0
- package/src/update/badge.ts +72 -0
- package/src/update/desktop-release.ts +1620 -0
- package/src/update/index.ts +402 -0
- package/src/update/job.ts +1906 -0
- package/src/update/notify.ts +261 -0
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/update/tray-update-plan.d.mts +18 -0
- package/src/update/tray-update-plan.mjs +38 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/debug.ts +97 -0
- package/src/usage/expected-prices.ts +283 -0
- package/src/usage/log.ts +695 -0
- package/src/usage/summary.ts +585 -0
- package/src/usage/totals.ts +14 -0
- package/src/vision/anthropic-describe.ts +185 -0
- package/src/vision/describe.ts +127 -0
- package/src/vision/index.ts +558 -0
- package/src/vision/reasoning.ts +55 -0
- package/src/web-search/anthropic-executor.ts +189 -0
- package/src/web-search/executor.ts +105 -0
- package/src/web-search/format-result.ts +89 -0
- package/src/web-search/index.ts +196 -0
- package/src/web-search/loop.ts +791 -0
- package/src/web-search/parse.ts +235 -0
- package/src/web-search/progress-stream.ts +342 -0
- package/src/web-search/synthetic-tool.ts +47 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,3270 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
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
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { Database } from "bun:sqlite";
|
|
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";
|
|
23
|
+
import {
|
|
24
|
+
CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
|
|
25
|
+
codexAccountNamespaceForModel,
|
|
26
|
+
codexProviderNamespaceKey,
|
|
27
|
+
isValidCodexAccountNamespaceTarget,
|
|
28
|
+
MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
|
|
29
|
+
} from "./codex/account-namespace-match";
|
|
30
|
+
import { isCodexAccountPriorityKey } from "./codex/account-priority";
|
|
31
|
+
import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health";
|
|
32
|
+
import {
|
|
33
|
+
adoptCustomModelCatalogMigration,
|
|
34
|
+
projectCustomModelCatalogMigration,
|
|
35
|
+
} from "./codex/custom-model-catalog-migration";
|
|
36
|
+
import { parseAccountPriority } from "./codex/pool-rotation";
|
|
37
|
+
import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
|
|
38
|
+
import { routingProfileIssues } from "./routing/profile";
|
|
39
|
+
import { POLICY_NAMESPACE } from "./routing/profile-namespace";
|
|
40
|
+
import {
|
|
41
|
+
forgetEphemeralSecretPath,
|
|
42
|
+
hardenSecretDir,
|
|
43
|
+
hardenSecretPath,
|
|
44
|
+
hardenSecretPathAsync,
|
|
45
|
+
windowsSecretAclApplies,
|
|
46
|
+
} from "./lib/windows-secret-acl";
|
|
47
|
+
import { rebaseConfigOwnershipRoot, recordOwnedConfigPath } from "./lib/config-ownership";
|
|
48
|
+
import { assertNotRealHomeUnderTest, isTestHomeGuardArmed } from "./lib/test-home-guard";
|
|
49
|
+
import { resolveDefaultRemodexHome } from "./lib/remodex-home";
|
|
50
|
+
import { isLocalAttestationSecret } from "./lib/local-management-attestation";
|
|
51
|
+
import { providerDestinationConfigError } from "./lib/destination-policy";
|
|
52
|
+
import { redactSecretString } from "./lib/redact";
|
|
53
|
+
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
|
|
54
|
+
import {
|
|
55
|
+
isWirePinnedModel,
|
|
56
|
+
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
57
|
+
OPENAI_PROVIDER_TIER_VERSION,
|
|
58
|
+
pinnedWireAdapter,
|
|
59
|
+
REASONING_SUMMARY_DELIVERY_VALUES,
|
|
60
|
+
type OcxClaudeCodeConfig,
|
|
61
|
+
type OcxConfig,
|
|
62
|
+
type OcxApiKeyEntry,
|
|
63
|
+
type OcxProviderConfig,
|
|
64
|
+
} from "./types";
|
|
65
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
|
|
66
|
+
import {
|
|
67
|
+
getProviderRegistryEntry,
|
|
68
|
+
providerMatchesRegistryTransport,
|
|
69
|
+
providerModelWireDefault,
|
|
70
|
+
} from "./providers/registry";
|
|
71
|
+
import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models";
|
|
72
|
+
import { parseDesktopProfile } from "./claude/desktop-profile";
|
|
73
|
+
import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
|
|
74
|
+
import {
|
|
75
|
+
DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES,
|
|
76
|
+
MAX_APP_OWNED_MEMORY_BUDGET_MB,
|
|
77
|
+
MIN_APP_OWNED_MEMORY_BUDGET_MB,
|
|
78
|
+
} from "./lib/app-owned-memory";
|
|
79
|
+
import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy";
|
|
80
|
+
|
|
81
|
+
let _atomicSeq = 0;
|
|
82
|
+
|
|
83
|
+
interface AtomicRenameIO {
|
|
84
|
+
platform: NodeJS.Platform;
|
|
85
|
+
rename: (source: string, destination: string) => void;
|
|
86
|
+
sleep: (milliseconds: number) => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renameAtomicFile(
|
|
90
|
+
source: string,
|
|
91
|
+
destination: string,
|
|
92
|
+
io: AtomicRenameIO = {
|
|
93
|
+
platform: process.platform,
|
|
94
|
+
rename: renameSync,
|
|
95
|
+
sleep: Bun.sleepSync,
|
|
96
|
+
},
|
|
97
|
+
): void {
|
|
98
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
99
|
+
try {
|
|
100
|
+
io.rename(source, destination);
|
|
101
|
+
return;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
104
|
+
const transientWindowsError = io.platform === "win32"
|
|
105
|
+
&& (code === "EBUSY" || code === "EPERM" || code === "EACCES");
|
|
106
|
+
if (!transientWindowsError || attempt >= 2) throw error;
|
|
107
|
+
io.sleep(25 * (attempt + 1));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Write a file atomically (temp + rename) so concurrent writers — e.g. `rmx stop` and the
|
|
114
|
+
* proxy's own shutdown handler both restoring Codex — can never leave a half-written file.
|
|
115
|
+
*/
|
|
116
|
+
export interface AtomicWriteIO {
|
|
117
|
+
write: (path: string, content: string) => void;
|
|
118
|
+
harden: (path: string) => void;
|
|
119
|
+
rename: (source: string, destination: string) => void;
|
|
120
|
+
truncate: (path: string) => void;
|
|
121
|
+
unlink: (path: string) => void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class AtomicWriteResidualTempError extends Error {
|
|
125
|
+
constructor(readonly tempPath: string, readonly hardened = true, options?: ErrorOptions) {
|
|
126
|
+
super(`Atomic config write left a ${hardened ? "hardened " : ""}zero-byte temporary file`, options);
|
|
127
|
+
this.name = "AtomicWriteResidualTempError";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class AtomicWriteSecretResidualError extends Error {
|
|
132
|
+
constructor(readonly tempPath: string, options?: ErrorOptions) {
|
|
133
|
+
super("Atomic config write could not scrub or remove a secret-bearing temporary file", options);
|
|
134
|
+
this.name = "AtomicWriteSecretResidualError";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isMissingPathError(error: unknown): boolean {
|
|
139
|
+
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolve a write target through any symlink before the temp+rename dance.
|
|
144
|
+
*
|
|
145
|
+
* rename(2) replaces a directory ENTRY. When the entry is itself a symlink
|
|
146
|
+
* (a dotfiles-managed `~/.codex/config.toml` -> `~/dotfiles/.codex/config.toml`,
|
|
147
|
+
* say), renaming a sibling temp file over it destroys the link and leaves a plain
|
|
148
|
+
* file behind — the repo silently stops receiving writes. Resolving first puts both
|
|
149
|
+
* the temp file and the rename target inside the link's real directory, so the entry
|
|
150
|
+
* being replaced is the real file and the symlink survives.
|
|
151
|
+
*
|
|
152
|
+
* Same-filesystem atomicity is preserved because the temp file stays beside its
|
|
153
|
+
* resolved target. A genuinely absent destination (not yet created) falls back to
|
|
154
|
+
* the literal path, which is the correct target for a first write.
|
|
155
|
+
*
|
|
156
|
+
* An EXISTING symlink that cannot be resolved — dangling because its target volume
|
|
157
|
+
* is unmounted, an ELOOP chain, an EACCES parent — is refused instead. Falling back
|
|
158
|
+
* to the literal path there would let the rename replace the link, recreating the
|
|
159
|
+
* exact dotfiles-divergence failure this helper exists to prevent (audit: wt4 wp2).
|
|
160
|
+
*/
|
|
161
|
+
export function resolveWriteTarget(path: string): string {
|
|
162
|
+
try {
|
|
163
|
+
return realpathSync(path);
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
let entry;
|
|
166
|
+
try {
|
|
167
|
+
entry = lstatSync(path);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (isMissingPathError(error)) return path; // no entry at all — first write
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
if (entry.isSymbolicLink()) {
|
|
173
|
+
throw new Error(`refusing to replace unresolvable symlinked write target: ${path}`, { cause });
|
|
174
|
+
}
|
|
175
|
+
return path;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Re-apply the real-home guard to a RESOLVED write target.
|
|
181
|
+
*
|
|
182
|
+
* Callers such as saveConfig check only their logical config dir, which passes when
|
|
183
|
+
* OPENCODEX_HOME points at a temp fixture. Following a symlink out of that fixture
|
|
184
|
+
* would land on the protected home the caller's own check just cleared, so the guard
|
|
185
|
+
* has to run again on wherever the write actually terminates. Inert in production,
|
|
186
|
+
* where the guard is disarmed.
|
|
187
|
+
*/
|
|
188
|
+
function assertResolvedTargetAllowed(path: string, target: string): void {
|
|
189
|
+
// The file itself may resolve literally while its PARENT is a symlink out
|
|
190
|
+
// of the fixture (a first write beneath a symlinked config dir). Guard the
|
|
191
|
+
// directory the write actually lands in either way.
|
|
192
|
+
if (target === path) {
|
|
193
|
+
let realParent: string;
|
|
194
|
+
try {
|
|
195
|
+
realParent = realpathSync(dirname(target));
|
|
196
|
+
} catch {
|
|
197
|
+
return; // unresolvable parent: resolveWriteTarget already owns that refusal
|
|
198
|
+
}
|
|
199
|
+
if (realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
assertNotRealHomeUnderTest(dirname(target));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO = {
|
|
206
|
+
write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
|
|
207
|
+
harden: target => {
|
|
208
|
+
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
209
|
+
// Timeout memo keyed by the stable destination (matches the async writer):
|
|
210
|
+
// a failed temp harden must not mint a new unique-temp key on every write.
|
|
211
|
+
if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: path });
|
|
212
|
+
},
|
|
213
|
+
rename: renameAtomicFile,
|
|
214
|
+
truncate: target => truncateSync(target, 0),
|
|
215
|
+
unlink: unlinkSync,
|
|
216
|
+
}): void {
|
|
217
|
+
recordOwnedConfigPath(resolveConfigDir(), path);
|
|
218
|
+
const target = resolveWriteTarget(path);
|
|
219
|
+
assertResolvedTargetAllowed(path, target);
|
|
220
|
+
const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
221
|
+
let hardened = false;
|
|
222
|
+
try {
|
|
223
|
+
io.write(tmp, content);
|
|
224
|
+
io.harden(tmp);
|
|
225
|
+
hardened = true;
|
|
226
|
+
io.rename(tmp, target);
|
|
227
|
+
forgetEphemeralSecretPath(tmp);
|
|
228
|
+
} catch (cause) {
|
|
229
|
+
let scrubbed = false;
|
|
230
|
+
try {
|
|
231
|
+
io.truncate(tmp);
|
|
232
|
+
scrubbed = true;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (isMissingPathError(error)) scrubbed = true;
|
|
235
|
+
else {
|
|
236
|
+
try { io.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
let removed = false;
|
|
240
|
+
try {
|
|
241
|
+
io.unlink(tmp);
|
|
242
|
+
removed = true;
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (isMissingPathError(error)) removed = true;
|
|
245
|
+
else {
|
|
246
|
+
try { io.unlink(tmp); removed = true; }
|
|
247
|
+
catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
|
|
251
|
+
if (!removed && !hardened) {
|
|
252
|
+
try { io.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
|
|
253
|
+
}
|
|
254
|
+
if (removed) forgetEphemeralSecretPath(tmp);
|
|
255
|
+
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
|
|
256
|
+
throw cause;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Async atomic-write I/O: harden may await icacls without blocking the event loop (#612). */
|
|
261
|
+
export interface AtomicWriteAsyncIO {
|
|
262
|
+
write: (path: string, content: string) => void | Promise<void>;
|
|
263
|
+
harden: (path: string) => void | Promise<void>;
|
|
264
|
+
rename: (source: string, destination: string) => void | Promise<void>;
|
|
265
|
+
truncate: (path: string) => void | Promise<void>;
|
|
266
|
+
unlink: (path: string) => void | Promise<void>;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Test-only crash seam. Production callers leave this undefined. */
|
|
270
|
+
export interface AtomicWriteAsyncTestSeam {
|
|
271
|
+
afterTempWrite?: (tempPath: string) => void | Promise<void>;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function renameAtomicFileAsync(source: string, destination: string): Promise<void> {
|
|
275
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
276
|
+
try {
|
|
277
|
+
renameSync(source, destination);
|
|
278
|
+
return;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
281
|
+
const transientWindowsError = process.platform === "win32"
|
|
282
|
+
&& (code === "EBUSY" || code === "EPERM" || code === "EACCES");
|
|
283
|
+
if (!transientWindowsError || attempt >= 2) throw error;
|
|
284
|
+
await Bun.sleep(25 * (attempt + 1));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Async atomic write (#612): same temp+harden+rename and residual-temp policy as
|
|
291
|
+
* atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed
|
|
292
|
+
* by the final destination path (not the unique temp, not the parent directory).
|
|
293
|
+
*/
|
|
294
|
+
export async function atomicWriteFileAsync(
|
|
295
|
+
path: string,
|
|
296
|
+
content: string,
|
|
297
|
+
io?: AtomicWriteAsyncIO,
|
|
298
|
+
testSeam?: AtomicWriteAsyncTestSeam,
|
|
299
|
+
): Promise<void> {
|
|
300
|
+
const effective: AtomicWriteAsyncIO = io ?? {
|
|
301
|
+
write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
|
|
302
|
+
harden: async target => {
|
|
303
|
+
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
304
|
+
if (process.platform === "win32") {
|
|
305
|
+
await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path });
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
rename: renameAtomicFileAsync,
|
|
309
|
+
truncate: target => truncateSync(target, 0),
|
|
310
|
+
unlink: unlinkSync,
|
|
311
|
+
};
|
|
312
|
+
const target = resolveWriteTarget(path);
|
|
313
|
+
assertResolvedTargetAllowed(path, target);
|
|
314
|
+
const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
315
|
+
let hardened = false;
|
|
316
|
+
try {
|
|
317
|
+
await effective.write(tmp, content);
|
|
318
|
+
await testSeam?.afterTempWrite?.(tmp);
|
|
319
|
+
await effective.harden(tmp);
|
|
320
|
+
hardened = true;
|
|
321
|
+
await effective.rename(tmp, target);
|
|
322
|
+
forgetEphemeralSecretPath(tmp);
|
|
323
|
+
} catch (cause) {
|
|
324
|
+
let scrubbed = false;
|
|
325
|
+
try {
|
|
326
|
+
await effective.truncate(tmp);
|
|
327
|
+
scrubbed = true;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (isMissingPathError(error)) scrubbed = true;
|
|
330
|
+
else {
|
|
331
|
+
try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
let removed = false;
|
|
335
|
+
try {
|
|
336
|
+
await effective.unlink(tmp);
|
|
337
|
+
removed = true;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (isMissingPathError(error)) removed = true;
|
|
340
|
+
else {
|
|
341
|
+
try { await effective.unlink(tmp); removed = true; }
|
|
342
|
+
catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
|
|
346
|
+
if (!removed && !hardened) {
|
|
347
|
+
try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
|
|
348
|
+
}
|
|
349
|
+
if (removed) forgetEphemeralSecretPath(tmp);
|
|
350
|
+
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
|
|
351
|
+
throw cause;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export class OpenAiTierBackupCleanupError extends Error {
|
|
356
|
+
constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export class OpenAiTierBackupRollbackError extends Error {
|
|
360
|
+
constructor() { super("OpenAI tier backup rollback failed"); this.name = "OpenAiTierBackupRollbackError"; }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export class OpenAiTierBackupCollisionError extends Error {
|
|
364
|
+
constructor() { super("Existing OpenAI tier backup differs from the current config"); this.name = "OpenAiTierBackupCollisionError"; }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export class OpenAiTierBackupSecretResidualError extends Error {
|
|
368
|
+
constructor(readonly tempPath: string, options?: ErrorOptions) {
|
|
369
|
+
super("OpenAI tier backup could not scrub or remove a secret-bearing temporary file", options);
|
|
370
|
+
this.name = "OpenAiTierBackupSecretResidualError";
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface OpenAiTierBackupIO {
|
|
375
|
+
exists(path: string): boolean;
|
|
376
|
+
read(path: string): Uint8Array;
|
|
377
|
+
createExclusive(path: string): void;
|
|
378
|
+
write(path: string, bytes: Uint8Array): void;
|
|
379
|
+
harden(path: string): void;
|
|
380
|
+
publishNoReplace(temp: string, backup: string): void;
|
|
381
|
+
truncate(path: string): void;
|
|
382
|
+
unlink(path: string): void;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
386
|
+
return left.byteLength === right.byteLength && left.every((value, index) => value === right[index]);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function isAlreadyExistsError(error: unknown): boolean {
|
|
390
|
+
return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Classify an existing `.pre-openai-tiers-v2.bak` snapshot.
|
|
395
|
+
*
|
|
396
|
+
* - `"stale"`: unparseable JSON (not written by us / truncated) or already a
|
|
397
|
+
* post-migration (tier v2) snapshot — safe to delete or replace.
|
|
398
|
+
* - `"rollback"`: parses as a valid pre-migration (v1) config — a
|
|
399
|
+
* user-intentional rollback point that must never be silently destroyed.
|
|
400
|
+
*
|
|
401
|
+
* Shared by the startup migration backup path and `rmx init` cleanup so both
|
|
402
|
+
* apply the same preservation policy (issue #257 / sol review 260722).
|
|
403
|
+
*/
|
|
404
|
+
export function classifyOpenAiTierBackup(backupBytes: Uint8Array): "stale" | "rollback" {
|
|
405
|
+
try {
|
|
406
|
+
// Use Buffer.from to ensure proper UTF-8 decoding from Uint8Array/Buffer.
|
|
407
|
+
const parsed = JSON.parse(Buffer.from(backupBytes).toString("utf8")) as Record<string, unknown>;
|
|
408
|
+
return parsed.openaiProviderTierVersion === 2 ? "stale" : "rollback";
|
|
409
|
+
} catch {
|
|
410
|
+
// Unparseable: not a config file we created, treat as stale.
|
|
411
|
+
return "stale";
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function backupConfigBeforeOpenAiTierMigration(
|
|
416
|
+
configPath = getConfigPath(),
|
|
417
|
+
io: OpenAiTierBackupIO = {
|
|
418
|
+
exists: existsSync,
|
|
419
|
+
read: target => readFileSync(target),
|
|
420
|
+
createExclusive: target => { writeFileSync(target, new Uint8Array(), { flag: "wx", mode: 0o600 }); },
|
|
421
|
+
write: (target, bytes) => writeFileSync(target, bytes),
|
|
422
|
+
harden: target => {
|
|
423
|
+
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
424
|
+
// Soft-fail: a wedged/failed icacls on CI temp volumes must not abort
|
|
425
|
+
// startServer mid-suite (timeout + EBUSY cascade on shared TEST_DIR).
|
|
426
|
+
// chmod above still applies; live credential writes keep required:true.
|
|
427
|
+
if (process.platform === "win32") hardenSecretPath(target, { required: false });
|
|
428
|
+
},
|
|
429
|
+
publishNoReplace: (temp, backup) => linkSync(temp, backup),
|
|
430
|
+
truncate: target => truncateSync(target, 0),
|
|
431
|
+
unlink: unlinkSync,
|
|
432
|
+
},
|
|
433
|
+
): "absent" | "created" | "reused" {
|
|
434
|
+
const source = configPath;
|
|
435
|
+
if (!io.exists(source)) return "absent";
|
|
436
|
+
const original = io.read(source);
|
|
437
|
+
// v2 snapshot path. The historical `.pre-openai-tiers-v1.bak` is read only by restore
|
|
438
|
+
// docs/fixtures and is never reused or overwritten as the v2 snapshot.
|
|
439
|
+
const backup = `${source}.pre-openai-tiers-v2.bak`;
|
|
440
|
+
if (io.exists(backup)) {
|
|
441
|
+
if (!sameBytes(original, io.read(backup))) {
|
|
442
|
+
// The backup differs from the current config. Only treat it as stale when it is
|
|
443
|
+
// clearly not a user-intentional rollback point:
|
|
444
|
+
// - unparseable JSON: written by a different tool or truncated
|
|
445
|
+
// - already at tier version 2: the backup is from a post-migration config (e.g.
|
|
446
|
+
// rmx init wrote a fresh v2 config, making the old backup obsolete)
|
|
447
|
+
// A backup that parses as a valid pre-migration (v1) config is kept as-is and
|
|
448
|
+
// we throw a collision error, because silently replacing a user-created rollback
|
|
449
|
+
// point would be surprising and potentially destructive.
|
|
450
|
+
const backupBytes = io.read(backup);
|
|
451
|
+
if (classifyOpenAiTierBackup(backupBytes) === "rollback") {
|
|
452
|
+
throw new OpenAiTierBackupCollisionError();
|
|
453
|
+
}
|
|
454
|
+
console.warn("[openai-provider-migration] Replacing stale pre-migration backup (post-migration config was rewritten since last migration).");
|
|
455
|
+
io.unlink(backup);
|
|
456
|
+
} else {
|
|
457
|
+
return "reused";
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const temp = `${backup}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
461
|
+
let published = false;
|
|
462
|
+
let cleanupAttempted = false;
|
|
463
|
+
|
|
464
|
+
const scrubUnpublishedTemp = (): void => {
|
|
465
|
+
cleanupAttempted = true;
|
|
466
|
+
let scrubbed = false;
|
|
467
|
+
try {
|
|
468
|
+
io.truncate(temp);
|
|
469
|
+
scrubbed = true;
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (isMissingPathError(error)) scrubbed = true;
|
|
472
|
+
else {
|
|
473
|
+
try { io.write(temp, new Uint8Array()); scrubbed = true; } catch { /* removal may still succeed */ }
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
let removed = false;
|
|
477
|
+
try {
|
|
478
|
+
io.unlink(temp);
|
|
479
|
+
removed = true;
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (isMissingPathError(error)) {
|
|
482
|
+
removed = true;
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
try { io.unlink(temp); removed = true; }
|
|
486
|
+
catch (retryError) {
|
|
487
|
+
if (isMissingPathError(retryError)) {
|
|
488
|
+
removed = true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (removed) forgetEphemeralSecretPath(temp);
|
|
494
|
+
if (!removed && !scrubbed) throw new OpenAiTierBackupSecretResidualError(temp);
|
|
495
|
+
if (!removed) throw new OpenAiTierBackupCleanupError();
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
io.createExclusive(temp);
|
|
500
|
+
io.write(temp, original);
|
|
501
|
+
io.harden(temp);
|
|
502
|
+
try {
|
|
503
|
+
io.publishNoReplace(temp, backup);
|
|
504
|
+
} catch (cause) {
|
|
505
|
+
if (!isAlreadyExistsError(cause)) throw cause;
|
|
506
|
+
const winner = io.read(backup);
|
|
507
|
+
if (!sameBytes(original, winner)) throw new OpenAiTierBackupCollisionError();
|
|
508
|
+
scrubUnpublishedTemp();
|
|
509
|
+
return "reused";
|
|
510
|
+
}
|
|
511
|
+
published = true;
|
|
512
|
+
try {
|
|
513
|
+
io.unlink(temp);
|
|
514
|
+
forgetEphemeralSecretPath(temp);
|
|
515
|
+
} catch (firstError) {
|
|
516
|
+
if (isMissingPathError(firstError)) {
|
|
517
|
+
forgetEphemeralSecretPath(temp);
|
|
518
|
+
} else try {
|
|
519
|
+
io.unlink(temp);
|
|
520
|
+
forgetEphemeralSecretPath(temp);
|
|
521
|
+
} catch (secondError) {
|
|
522
|
+
if (isMissingPathError(secondError)) {
|
|
523
|
+
forgetEphemeralSecretPath(temp);
|
|
524
|
+
return "created";
|
|
525
|
+
}
|
|
526
|
+
// temp and backup are hard links to the same inode. Roll back the backup
|
|
527
|
+
// link before any truncation so the downgrade snapshot is never zeroed.
|
|
528
|
+
try { io.unlink(backup); } catch { throw new OpenAiTierBackupRollbackError(); }
|
|
529
|
+
published = false;
|
|
530
|
+
scrubUnpublishedTemp();
|
|
531
|
+
throw new OpenAiTierBackupCleanupError();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return "created";
|
|
535
|
+
} catch (cause) {
|
|
536
|
+
if (!published && !cleanupAttempted) {
|
|
537
|
+
scrubUnpublishedTemp();
|
|
538
|
+
}
|
|
539
|
+
throw cause;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Expand a leading `~` to the home directory in user-supplied paths
|
|
545
|
+
* (OPENCODEX_HOME/CODEX_HOME set from GUIs/service files where no shell expanded it).
|
|
546
|
+
* `~user` and `%VAR%`/`$VAR` forms pass through untouched — those belong to the shell.
|
|
547
|
+
*/
|
|
548
|
+
export function expandUserPath(raw: string): string {
|
|
549
|
+
if (raw === "~") return homedir();
|
|
550
|
+
if (raw.startsWith("~/") || raw.startsWith("~\\")) return join(homedir(), raw.slice(2));
|
|
551
|
+
return raw;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null;
|
|
555
|
+
|
|
556
|
+
function resolveConfigDir(): string {
|
|
557
|
+
const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined;
|
|
558
|
+
if (resolvedConfigDirCache && resolvedConfigDirCache.raw === raw) return resolvedConfigDirCache.path;
|
|
559
|
+
let path: string;
|
|
560
|
+
if (raw) {
|
|
561
|
+
// An explicit OPENCODEX_HOME is authoritative, including when it points
|
|
562
|
+
// at the historical `.opencodex` directory. Never auto-migrate an
|
|
563
|
+
// operator-selected path.
|
|
564
|
+
path = resolve(expandUserPath(raw));
|
|
565
|
+
} else {
|
|
566
|
+
const resolution = resolveDefaultRemodexHome({ migrate: !isTestHomeGuardArmed() });
|
|
567
|
+
path = resolution.path;
|
|
568
|
+
if (resolution.outcome === "migrated") {
|
|
569
|
+
if (!rebaseConfigOwnershipRoot(resolution.legacyPath, resolution.canonicalPath)) {
|
|
570
|
+
console.warn(
|
|
571
|
+
`Remodex moved state to ${resolution.canonicalPath}, but ownership metadata could not be rebased. `
|
|
572
|
+
+ "Configuration writes remain available; repair ownership before uninstalling.",
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
resolvedConfigDirCache = { raw, path };
|
|
578
|
+
return path;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function resolveConfigPath(): string {
|
|
582
|
+
return join(resolveConfigDir(), "config.json");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function resolvePidPath(): string {
|
|
586
|
+
return join(resolveConfigDir(), "ocx.pid");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function resolveRuntimePortPath(): string {
|
|
590
|
+
return join(resolveConfigDir(), "runtime-port.json");
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const warnedConfigFallbacks = new Set<string>();
|
|
594
|
+
let lastWarningReconciledGeneration = 0;
|
|
595
|
+
|
|
596
|
+
export function reconcileConfigWarningMemos(generation: number): number {
|
|
597
|
+
if (generation <= lastWarningReconciledGeneration) return 0;
|
|
598
|
+
const removed = warnedConfigFallbacks.size;
|
|
599
|
+
warnedConfigFallbacks.clear();
|
|
600
|
+
lastWarningReconciledGeneration = generation;
|
|
601
|
+
return removed;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth
|
|
606
|
+
* shared by the config schema, the load-time sanitizer, and the management write
|
|
607
|
+
* boundary. Strict, so an unknown key is rejected at every validation boundary instead
|
|
608
|
+
* of being silently ignored (the load-time sanitizer still degrades unknown keys with a
|
|
609
|
+
* warning before schema validation, so hand-edited configs keep loading).
|
|
610
|
+
*/
|
|
611
|
+
const retryOn429PolicySchema = z.object({
|
|
612
|
+
enabled: z.boolean().optional(),
|
|
613
|
+
attempts: z.number().int().min(1).max(20).optional(),
|
|
614
|
+
intervalMs: z.number().int().min(100).max(600_000).optional(),
|
|
615
|
+
// The effective cap for a single wait is MAX_COOLDOWN_MS (10 min) in key-failover.ts;
|
|
616
|
+
// larger configured values would be dead config.
|
|
617
|
+
maxIntervalMs: z.number().int().min(100).max(600_000).optional(),
|
|
618
|
+
respectRetryAfter: z.boolean().optional(),
|
|
619
|
+
}).strict();
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Zod schema for one provider entry: known fields are validated strictly while unknown
|
|
623
|
+
* fields pass through (preserved for runtime extensions).
|
|
624
|
+
*/
|
|
625
|
+
const providerConfigSchema = z.object({
|
|
626
|
+
adapter: z.string().min(1),
|
|
627
|
+
baseUrl: z.string().min(1),
|
|
628
|
+
mcpMaxTools: z.number().int().positive().optional(),
|
|
629
|
+
mcpMaxSchemaBytes: z.number().int().positive().optional(),
|
|
630
|
+
mcpMaxResultBytes: z.number().int().positive().optional(),
|
|
631
|
+
apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
|
|
632
|
+
responsesPath: z.string().min(1).optional(),
|
|
633
|
+
statelessResponses: z.boolean().optional(),
|
|
634
|
+
supportsServiceTier: z.boolean().optional(),
|
|
635
|
+
preserveResponsesReasoningContent: z.boolean().optional(),
|
|
636
|
+
allowPrivateNetwork: z.boolean().optional(),
|
|
637
|
+
retryOn429: retryOn429PolicySchema.optional(),
|
|
638
|
+
codexAccountMode: z.enum(["pool", "direct"]).optional(),
|
|
639
|
+
responsesItemIdRepair: z.object({
|
|
640
|
+
message: z.array(z.string().min(1)).optional(),
|
|
641
|
+
reasoning: z.array(z.string().min(1)).optional(),
|
|
642
|
+
repairMissingTerminalIds: z.boolean().optional(),
|
|
643
|
+
repairInvalidIds: z.boolean().optional(),
|
|
644
|
+
}).strict().optional(),
|
|
645
|
+
responsesSnapshotRepair: z.boolean().optional(),
|
|
646
|
+
}).passthrough();
|
|
647
|
+
|
|
648
|
+
const RESERVED_PROVIDER_NAMES = new Set([
|
|
649
|
+
// JavaScript prototype-pollution guards.
|
|
650
|
+
"__proto__",
|
|
651
|
+
"prototype",
|
|
652
|
+
"constructor",
|
|
653
|
+
// System-reserved routing namespace (resolved before provider/account
|
|
654
|
+
// namespaces in routeModelInternal). "combo" is intentionally NOT reserved:
|
|
655
|
+
// a physical provider named `combo` is a supported pattern (combo aliases
|
|
656
|
+
// hosted on the combo provider), and the combo selector only wins when an
|
|
657
|
+
// actual combo id matches.
|
|
658
|
+
"policy",
|
|
659
|
+
]);
|
|
660
|
+
const PROVIDER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;
|
|
661
|
+
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
662
|
+
const SENSITIVE_PROVIDER_HEADERS = new Set([
|
|
663
|
+
"authorization",
|
|
664
|
+
"cookie",
|
|
665
|
+
"set-cookie",
|
|
666
|
+
"proxy-authorization",
|
|
667
|
+
"x-api-key",
|
|
668
|
+
"x-goog-api-key",
|
|
669
|
+
"x-amz-security-token",
|
|
670
|
+
]);
|
|
671
|
+
|
|
672
|
+
export function isValidProviderName(name: string): boolean {
|
|
673
|
+
const trimmed = name.trim();
|
|
674
|
+
return trimmed === name
|
|
675
|
+
&& PROVIDER_NAME_PATTERN.test(name)
|
|
676
|
+
&& !RESERVED_PROVIDER_NAMES.has(name.toLowerCase());
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export function hasOwnProvider(providers: Record<string, unknown>, name: string): boolean {
|
|
680
|
+
return Object.prototype.hasOwnProperty.call(providers, name);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export function providerBaseUrlConfigError(baseUrl: string): string | null {
|
|
684
|
+
try {
|
|
685
|
+
const parsed = new URL(baseUrl.trim());
|
|
686
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "baseUrl must be an http(s) URL";
|
|
687
|
+
if (parsed.username || parsed.password) return "baseUrl must not include embedded credentials";
|
|
688
|
+
if (parsed.search || parsed.hash) return "baseUrl must not include query strings or fragments";
|
|
689
|
+
} catch {
|
|
690
|
+
return "baseUrl must be a valid URL";
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function providerResponsesPathConfigError(responsesPath: string | undefined): string | null {
|
|
696
|
+
if (responsesPath === undefined) return null;
|
|
697
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(responsesPath) || responsesPath.includes("://")) {
|
|
698
|
+
return "responsesPath must be a relative path without a URL scheme";
|
|
699
|
+
}
|
|
700
|
+
if (!responsesPath.startsWith("/")) return "responsesPath must start with /";
|
|
701
|
+
if (responsesPath.includes("?") || responsesPath.includes("#")) {
|
|
702
|
+
return "responsesPath must not include query strings or fragments";
|
|
703
|
+
}
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export function providerHeadersConfigError(headers: unknown): string | null {
|
|
708
|
+
if (headers === undefined) return null;
|
|
709
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) return "headers must be an object";
|
|
710
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
711
|
+
const normalized = name.trim().toLowerCase();
|
|
712
|
+
if (!normalized || !HEADER_NAME_PATTERN.test(name)) return "headers must use valid HTTP header names";
|
|
713
|
+
if (SENSITIVE_PROVIDER_HEADERS.has(normalized)) return `headers must not include sensitive header "${name}"; use apiKey/authMode instead`;
|
|
714
|
+
if (typeof value !== "string") return `header "${name}" value must be a string`;
|
|
715
|
+
if (/[\r\n]/.test(value)) return `header "${name}" value must not include line breaks`;
|
|
716
|
+
}
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
|
|
721
|
+
export function apiKeyTransportConfigError(
|
|
722
|
+
provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
|
|
723
|
+
): string | null {
|
|
724
|
+
if (provider.apiKeyTransport === undefined) return null;
|
|
725
|
+
if (provider.apiKeyTransport !== "x-api-key" && provider.apiKeyTransport !== "bearer") {
|
|
726
|
+
return 'apiKeyTransport must be "x-api-key" or "bearer"';
|
|
727
|
+
}
|
|
728
|
+
if (provider.adapter !== "anthropic") {
|
|
729
|
+
return "apiKeyTransport is supported only by the anthropic adapter";
|
|
730
|
+
}
|
|
731
|
+
if (provider.authMode === "oauth" || provider.authMode === "forward" || provider.authMode === "local") {
|
|
732
|
+
return "apiKeyTransport requires Anthropic API-key authentication";
|
|
733
|
+
}
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null {
|
|
738
|
+
if (value === undefined) return null;
|
|
739
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
740
|
+
const prototype = Object.getPrototypeOf(value);
|
|
741
|
+
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
742
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
743
|
+
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
744
|
+
if (typeof entry !== "number" || !Number.isFinite(entry) || !Number.isInteger(entry) || entry <= 0) {
|
|
745
|
+
return `${field}.${key} must be a positive finite integer`;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export function positiveIntegerConfigError(value: unknown, field: string): string | null {
|
|
752
|
+
if (value === undefined) return null;
|
|
753
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
754
|
+
return `${field} must be a positive finite integer`;
|
|
755
|
+
}
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export function booleanRecordConfigError(value: unknown, field: string): string | null {
|
|
760
|
+
if (value === undefined) return null;
|
|
761
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
762
|
+
const prototype = Object.getPrototypeOf(value);
|
|
763
|
+
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
764
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
765
|
+
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
766
|
+
if (typeof entry !== "boolean") return `${field}.${key} must be a boolean`;
|
|
767
|
+
}
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const REASONING_SUMMARY_DELIVERY_SET = new Set<string>(REASONING_SUMMARY_DELIVERY_VALUES);
|
|
772
|
+
|
|
773
|
+
export function reasoningSummaryDeliveryRecordConfigError(
|
|
774
|
+
value: unknown,
|
|
775
|
+
supportsReasoningSummaries: unknown,
|
|
776
|
+
field = "modelReasoningSummaryDelivery",
|
|
777
|
+
): string | null {
|
|
778
|
+
if (value === undefined) return null;
|
|
779
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
780
|
+
const prototype = Object.getPrototypeOf(value);
|
|
781
|
+
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
782
|
+
|
|
783
|
+
const supports = booleanRecordConfigError(supportsReasoningSummaries, "modelSupportsReasoningSummaries") === null
|
|
784
|
+
&& supportsReasoningSummaries && typeof supportsReasoningSummaries === "object"
|
|
785
|
+
? supportsReasoningSummaries as Record<string, boolean>
|
|
786
|
+
: undefined;
|
|
787
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
788
|
+
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
789
|
+
if (typeof entry !== "string" || !REASONING_SUMMARY_DELIVERY_SET.has(entry)) {
|
|
790
|
+
return `${field}.${key} must be one of: ${REASONING_SUMMARY_DELIVERY_VALUES.join(", ")}`;
|
|
791
|
+
}
|
|
792
|
+
if (modelRecordValue(supports, key) === false) {
|
|
793
|
+
return `${field}.${key} conflicts with modelSupportsReasoningSummaries=false`;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]);
|
|
800
|
+
|
|
801
|
+
export function modelPreferHostedToolsConfigError(
|
|
802
|
+
value: unknown,
|
|
803
|
+
field: string,
|
|
804
|
+
providerName: string,
|
|
805
|
+
provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown },
|
|
806
|
+
): string | null {
|
|
807
|
+
if (value === undefined) return null;
|
|
808
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
809
|
+
const prototype = Object.getPrototypeOf(value);
|
|
810
|
+
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
811
|
+
const entries = Object.entries(value);
|
|
812
|
+
const registry = getProviderRegistryEntry(providerName);
|
|
813
|
+
// Effective transport: a `preserveCustomDestination` registry row reused under a
|
|
814
|
+
// different endpoint keeps its own adapter AND its own auth at runtime, because
|
|
815
|
+
// `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the
|
|
816
|
+
// wire check below and the forward-auth check here have to start from the same
|
|
817
|
+
// decision, or validation accepts a preference the adapter never applies —
|
|
818
|
+
// `preferConfiguredHostedTools()` runs only on the non-forward branch.
|
|
819
|
+
const registryTransportMatches = typeof provider.baseUrl === "string"
|
|
820
|
+
&& providerMatchesRegistryTransport(providerName, {
|
|
821
|
+
baseUrl: provider.baseUrl,
|
|
822
|
+
adapter: provider.adapter as OcxProviderConfig["adapter"],
|
|
823
|
+
...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
|
|
824
|
+
});
|
|
825
|
+
const effectiveForwardAuth = registryTransportMatches
|
|
826
|
+
? registry?.authKind === "forward"
|
|
827
|
+
: provider.authMode === "forward";
|
|
828
|
+
if (entries.length > 0 && effectiveForwardAuth) {
|
|
829
|
+
return `${field} is not supported on forward-auth Responses providers`;
|
|
830
|
+
}
|
|
831
|
+
const requestedWireFor = (modelId: string): unknown => provider.modelAdapters
|
|
832
|
+
&& typeof provider.modelAdapters === "object"
|
|
833
|
+
&& !Array.isArray(provider.modelAdapters)
|
|
834
|
+
? (provider.modelAdapters as Record<string, unknown>)[modelId]
|
|
835
|
+
: undefined;
|
|
836
|
+
const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => {
|
|
837
|
+
const pinned = pinnedWireAdapter(providerName, modelId);
|
|
838
|
+
if (pinned) return pinned;
|
|
839
|
+
const requestedWire = requestedWireFor(modelId);
|
|
840
|
+
if (typeof requestedWire === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requestedWire)) {
|
|
841
|
+
return requestedWire;
|
|
842
|
+
}
|
|
843
|
+
// No explicit override: fall back to the registry's per-model wire default before
|
|
844
|
+
// the provider-wide adapter, because that is the order `resolveModelAdapter()`
|
|
845
|
+
// uses at request time (src/server/adapter-resolve.ts:38-48). Skipping it rejected
|
|
846
|
+
// preferences the runtime would have honored — DeepSeek routes `deepseek-v4-flash`
|
|
847
|
+
// over native Responses for a Responses inbound while the provider-wide wire stays
|
|
848
|
+
// openai-chat. Hosted-tool preferences only apply to Responses traffic, so the
|
|
849
|
+
// inbound to ask about is "responses".
|
|
850
|
+
const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string"
|
|
851
|
+
? providerModelWireDefault(
|
|
852
|
+
providerName,
|
|
853
|
+
{
|
|
854
|
+
baseUrl: provider.baseUrl,
|
|
855
|
+
adapter: currentWire,
|
|
856
|
+
...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
|
|
857
|
+
},
|
|
858
|
+
modelId,
|
|
859
|
+
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
860
|
+
"responses",
|
|
861
|
+
)
|
|
862
|
+
: undefined;
|
|
863
|
+
return registryDefault ?? currentWire;
|
|
864
|
+
};
|
|
865
|
+
for (const [key, entry] of entries) {
|
|
866
|
+
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
867
|
+
if (!Array.isArray(entry)) return `${field}.${key} must be an array`;
|
|
868
|
+
if (entry.length === 0) return `${field}.${key} must include image_generation`;
|
|
869
|
+
for (const tool of entry) {
|
|
870
|
+
if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) {
|
|
871
|
+
return `${field}.${key} supports only image_generation`;
|
|
872
|
+
}
|
|
873
|
+
if (isHostedToolUnsupportedForModel(key, tool)) {
|
|
874
|
+
return `${field}.${key} cannot prefer ${tool}: the model does not support it`;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
// Same `registryTransportMatches` decision the forward-auth check above uses:
|
|
878
|
+
// start from the registry adapter only when this config still points at the
|
|
879
|
+
// registry's documented transport.
|
|
880
|
+
const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter;
|
|
881
|
+
let effectiveWire = resolveEffectiveWire(key, baseWire);
|
|
882
|
+
const virtualWireModel = resolveOpenAiVirtualModel(providerName, key)?.wireModelId;
|
|
883
|
+
if (virtualWireModel && virtualWireModel !== key) {
|
|
884
|
+
effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire);
|
|
885
|
+
}
|
|
886
|
+
if (effectiveWire !== "openai-responses") {
|
|
887
|
+
return `${field}.${key} requires the openai-responses wire`;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Validate a provider's per-model wire override map (#404).
|
|
895
|
+
*
|
|
896
|
+
* Rejects, rather than silently ignoring, configurations the resolver would refuse:
|
|
897
|
+
* a value outside the allowed wires, a model the upstream pins to one wire, and any
|
|
898
|
+
* override on a canonical forward provider (where switching wires would drop the
|
|
899
|
+
* caller's forwarded credential). Silently dropping them would leave the user
|
|
900
|
+
* believing an override is in effect.
|
|
901
|
+
*/
|
|
902
|
+
export function modelAdapterRecordConfigError(
|
|
903
|
+
value: unknown,
|
|
904
|
+
field: string,
|
|
905
|
+
providerName: string,
|
|
906
|
+
provider: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown },
|
|
907
|
+
): string | null {
|
|
908
|
+
if (value === undefined) return null;
|
|
909
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
910
|
+
const prototype = Object.getPrototypeOf(value);
|
|
911
|
+
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
912
|
+
const entries = Object.entries(value);
|
|
913
|
+
if (entries.length > 0 && isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig)) {
|
|
914
|
+
return `${field} is not supported on the canonical ChatGPT forward provider`;
|
|
915
|
+
}
|
|
916
|
+
for (const [key, entry] of entries) {
|
|
917
|
+
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
918
|
+
if (typeof entry !== "string" || !MODEL_ADAPTER_OVERRIDE_ALLOWED.has(entry)) {
|
|
919
|
+
return `${field}.${key} must be one of: ${[...MODEL_ADAPTER_OVERRIDE_ALLOWED].join(", ")}`;
|
|
920
|
+
}
|
|
921
|
+
if (isWirePinnedModel(providerName, key.trim())) {
|
|
922
|
+
return `${field}.${key} cannot be overridden: the upstream only speaks one wire for this model`;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR =
|
|
929
|
+
"codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids";
|
|
930
|
+
const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR =
|
|
931
|
+
"account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys";
|
|
932
|
+
const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR =
|
|
933
|
+
"account selector targets must be @main or valid Codex pool-account ids";
|
|
934
|
+
const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR =
|
|
935
|
+
"account selectors must not collide with configured Codex pool-account ids or account selector targets";
|
|
936
|
+
|
|
937
|
+
function configuredCodexPoolAccountIds(value: unknown): Set<string> {
|
|
938
|
+
const accountIds = new Set<string>();
|
|
939
|
+
if (!Array.isArray(value)) return accountIds;
|
|
940
|
+
for (const account of value) {
|
|
941
|
+
if (!account || typeof account !== "object" || Array.isArray(account)) continue;
|
|
942
|
+
const { id, isMain } = account as { id?: unknown; isMain?: unknown };
|
|
943
|
+
if (typeof id === "string" && isMain !== true) accountIds.add(id);
|
|
944
|
+
}
|
|
945
|
+
return accountIds;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
|
|
949
|
+
(value): value is Record<string, unknown> => !!value
|
|
950
|
+
&& typeof value === "object"
|
|
951
|
+
&& !Array.isArray(value)
|
|
952
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
|
|
953
|
+
{ error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR },
|
|
954
|
+
).superRefine((accountNamespaces, ctx) => {
|
|
955
|
+
// Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
|
|
956
|
+
for (const [namespace, accountId] of Object.entries(accountNamespaces)) {
|
|
957
|
+
if (!isValidProviderName(namespace)) {
|
|
958
|
+
ctx.addIssue({
|
|
959
|
+
code: "custom",
|
|
960
|
+
path: [namespace],
|
|
961
|
+
message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
if (!isValidCodexAccountNamespaceTarget(accountId)) {
|
|
965
|
+
ctx.addIssue({
|
|
966
|
+
code: "custom",
|
|
967
|
+
path: [namespace],
|
|
968
|
+
message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR,
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}).pipe(z.record(z.string(), z.string()));
|
|
973
|
+
|
|
974
|
+
const CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR =
|
|
975
|
+
"codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers";
|
|
976
|
+
const CODEX_ACCOUNT_PRIORITY_KEY_ERROR =
|
|
977
|
+
"selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys";
|
|
978
|
+
const CODEX_ACCOUNT_PRIORITY_VALUE_ERROR =
|
|
979
|
+
"selection order must be an integer between -100 and 100";
|
|
980
|
+
|
|
981
|
+
const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
982
|
+
|
|
983
|
+
const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
|
|
984
|
+
(value): value is Record<string, unknown> => !!value
|
|
985
|
+
&& typeof value === "object"
|
|
986
|
+
&& !Array.isArray(value)
|
|
987
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
|
|
988
|
+
{ error: CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR },
|
|
989
|
+
).superRefine((priorities, ctx) => {
|
|
990
|
+
// Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
|
|
991
|
+
for (const [accountId, priority] of Object.entries(priorities)) {
|
|
992
|
+
if (!isCodexAccountPriorityKey(accountId)) {
|
|
993
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_KEY_ERROR });
|
|
994
|
+
}
|
|
995
|
+
if (parseAccountPriority(priority) === null) {
|
|
996
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_VALUE_ERROR });
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}).pipe(z.record(z.string(), z.number().int()));
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Deliberately permissive. A user's config is not ours to invalidate: a strict
|
|
1003
|
+
* entry fails the whole parse, and loadConfig's fallback then backs the file up
|
|
1004
|
+
* and returns defaults — losing providers and pool accounts because one key name
|
|
1005
|
+
* was too long. Length and charset rules live at the POST/PATCH boundary, where
|
|
1006
|
+
* rejecting produces a 400 instead. `.passthrough()` keeps unknown per-key
|
|
1007
|
+
* properties across a load -> mutate -> save round trip.
|
|
1008
|
+
*
|
|
1009
|
+
* Only `key` is load-bearing: admission compares that string and nothing else
|
|
1010
|
+
* (src/server/auth-cors.ts isDataPlaneAdmissionSecret). So the secret is the one
|
|
1011
|
+
* field that must be a usable string, and every piece of metadata around it
|
|
1012
|
+
* degrades instead of taking the credential down with it. Dropping a working key
|
|
1013
|
+
* because its `name` was hand-edited to a number would be a silent revocation —
|
|
1014
|
+
* and on a remote bind, potentially a server that refuses to start.
|
|
1015
|
+
*
|
|
1016
|
+
* "Usable" matches admission exactly. The presented token is trimmed before the
|
|
1017
|
+
* comparison but the stored value is not, so a key with surrounding whitespace
|
|
1018
|
+
* can never match either form of itself. Keeping one would be worse than dropping
|
|
1019
|
+
* it: `system-env.ts` and `cli/claude.ts` hand `apiKeys[0].key` to launched
|
|
1020
|
+
* clients, so a junk first entry would mask a valid later one.
|
|
1021
|
+
*/
|
|
1022
|
+
const apiKeyEntrySchema = z.object({
|
|
1023
|
+
key: z.string().refine(isUsableApiKeySecret),
|
|
1024
|
+
// Degrades to "" here; every schema consumer then runs `normalizeApiKeyIds`,
|
|
1025
|
+
// which fills it deterministically so the id is stable across loads.
|
|
1026
|
+
id: z.string().catch(""),
|
|
1027
|
+
name: z.string().catch(""),
|
|
1028
|
+
createdAt: z.string().catch(""),
|
|
1029
|
+
}).passthrough();
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Durable per-client intent.
|
|
1033
|
+
*
|
|
1034
|
+
* `.passthrough()` is load-bearing: a binary that only knows `codex` must not
|
|
1035
|
+
* erase a key a later version wrote during a field-scoped mutation. And each key
|
|
1036
|
+
* degrades on its own — a hand edit of `{"codex": "false", "future": false}`
|
|
1037
|
+
* drops `codex` to absent (which reads as ON) and keeps `future`, rather than
|
|
1038
|
+
* invalidating the object or, worse, the whole config.
|
|
1039
|
+
*/
|
|
1040
|
+
const clientIntegrationsSchema = z.object({
|
|
1041
|
+
codex: z.boolean().optional().catch(undefined),
|
|
1042
|
+
grok: z.boolean().optional().catch(undefined),
|
|
1043
|
+
"claude-desktop": z.boolean().optional().catch(undefined),
|
|
1044
|
+
}).passthrough();
|
|
1045
|
+
|
|
1046
|
+
const modelSourceVisibilitySchema = z.unknown().optional().transform(value => {
|
|
1047
|
+
if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1048
|
+
return undefined;
|
|
1049
|
+
}
|
|
1050
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
1051
|
+
.filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean");
|
|
1052
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
const configSchema = z.object({
|
|
1056
|
+
port: z.number().int().min(0).max(65535).default(10100),
|
|
1057
|
+
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
|
|
1058
|
+
// Invalid hand edits disable only this opt-in circuit. Live writes remain strict.
|
|
1059
|
+
upstreamHostCircuitThreshold: z.number().int()
|
|
1060
|
+
.min(0)
|
|
1061
|
+
.max(UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD)
|
|
1062
|
+
.optional()
|
|
1063
|
+
.catch(undefined),
|
|
1064
|
+
appOwnedMemoryBudgetMb: z.number().int()
|
|
1065
|
+
.min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
|
|
1066
|
+
.max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
|
|
1067
|
+
.default(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024))
|
|
1068
|
+
.catch(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024)),
|
|
1069
|
+
// A blank hostname degrades to undefined rather than failing the parse. `getDefaultConfig()`
|
|
1070
|
+
// carries no `hostname` key, so the backup-and-defaults repair path below cannot merge one
|
|
1071
|
+
// away — a hand-edited `"hostname": ""` would fail twice and reset providers/apiKeys to
|
|
1072
|
+
// defaults, which is strictly worse than the bind bug this validation exists for. Degrading
|
|
1073
|
+
// is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time
|
|
1074
|
+
// rejection lives in validateConfigCandidate() so bad values still surface to the caller.
|
|
1075
|
+
hostname: z.string().trim().min(1).optional().catch(undefined),
|
|
1076
|
+
// Discriminated on `enabled` so a disabled entry cannot be forced to carry a port, and an
|
|
1077
|
+
// enabled one cannot omit it (#1102). A malformed value degrades to undefined rather than
|
|
1078
|
+
// failing the whole parse: this is an opt-in convenience surface, and a hand-edit typo here
|
|
1079
|
+
// must never reset providers/apiKeys through the backup-and-defaults repair path.
|
|
1080
|
+
unauthenticatedLoopbackListener: z.union([
|
|
1081
|
+
z.object({ enabled: z.literal(false) }),
|
|
1082
|
+
z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }),
|
|
1083
|
+
]).optional().catch(undefined),
|
|
1084
|
+
providers: z.record(z.string(), providerConfigSchema),
|
|
1085
|
+
// Selector-only source visibility. Salvage valid entries independently so one
|
|
1086
|
+
// malformed hand edit cannot discard the user's other source preferences.
|
|
1087
|
+
modelSourceVisibility: modelSourceVisibilitySchema,
|
|
1088
|
+
defaultProvider: z.string().min(1).default("openai"),
|
|
1089
|
+
openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
|
|
1090
|
+
// Invalid hand edits must not discard an otherwise usable config.
|
|
1091
|
+
googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined),
|
|
1092
|
+
clientIntegrations: clientIntegrationsSchema.optional().catch(undefined),
|
|
1093
|
+
providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
|
|
1094
|
+
contextCapValue: z.number().int().positive().optional(),
|
|
1095
|
+
multiAgentGuidanceEnabled: z.boolean().optional(),
|
|
1096
|
+
// These selections pre-date schema validation and used to pass through as
|
|
1097
|
+
// unknown fields. Invalid hand edits must disable only the optional
|
|
1098
|
+
// delegation/native-default feature, not reject the whole config and hide
|
|
1099
|
+
// otherwise valid providers, accounts, or the configured listen port.
|
|
1100
|
+
injectionModel: z.string().optional().catch(undefined),
|
|
1101
|
+
injectionEffort: z.string().optional().catch(undefined),
|
|
1102
|
+
syncCodexSubagentDefaults: z.boolean().optional().catch(undefined),
|
|
1103
|
+
codexShimAutoRestore: z.boolean().optional(),
|
|
1104
|
+
pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
|
|
1105
|
+
codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
|
|
1106
|
+
// Selection order is a preference, not a safety control like pause: a malformed
|
|
1107
|
+
// map degrades to "no ordering" rather than failing the parse, so a hand-edited
|
|
1108
|
+
// typo cannot trip the backup-and-defaults repair path and wipe providers or
|
|
1109
|
+
// pool accounts. Warning emitted in loadConfig.
|
|
1110
|
+
codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined),
|
|
1111
|
+
activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined),
|
|
1112
|
+
// A malformed hand edit must degrade to false without discarding providers, accounts,
|
|
1113
|
+
// or the exact selector map. Live writes remain strict.
|
|
1114
|
+
codexAccountPickerEnabled: z.boolean().optional().catch(false),
|
|
1115
|
+
// Model ids excluded from the Grok Build managed block (dashboard switches).
|
|
1116
|
+
grokExcludedModels: z.array(z.string()).optional(),
|
|
1117
|
+
// Invalid values degrade to undefined ("auto") instead of failing the whole
|
|
1118
|
+
// parse: a hand-edited typo must never trip the backup-and-defaults repair
|
|
1119
|
+
// path below and wipe providers/pool accounts. Warning emitted in loadConfig.
|
|
1120
|
+
streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
|
|
1121
|
+
// Same degrade-don't-reject rationale as the fields above: a hand-edited
|
|
1122
|
+
// non-string must not trip the backup-and-defaults repair path. Unset then
|
|
1123
|
+
// takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot).
|
|
1124
|
+
experimentalRealtimeWsBaseUrl: z.string().optional().catch(undefined),
|
|
1125
|
+
// Salvage element by element, and never fail the parse. Two spellings were
|
|
1126
|
+
// measured on this zod version and both lose data:
|
|
1127
|
+
// `z.array(entry).catch(undefined)` -> one bad entry discards EVERY key
|
|
1128
|
+
// `z.array(z.unknown())` -> a non-array value still raises
|
|
1129
|
+
// invalid_type, reaching the
|
|
1130
|
+
// backup-and-defaults repair path
|
|
1131
|
+
// Starting from `unknown` is what makes both survivable. A key the user still
|
|
1132
|
+
// has deployed must not be collateral damage for one bad neighbour, and on a
|
|
1133
|
+
// remote bind an emptied array is worse than cosmetic: assertServerAuthConfig
|
|
1134
|
+
// refuses to start without a data credential.
|
|
1135
|
+
apiKeys: z.unknown().optional().transform(value => {
|
|
1136
|
+
if (value === undefined) return undefined;
|
|
1137
|
+
if (!Array.isArray(value)) return undefined;
|
|
1138
|
+
return value
|
|
1139
|
+
.filter(row => apiKeyEntrySchema.safeParse(row).success)
|
|
1140
|
+
.map(row => apiKeyEntrySchema.parse(row) as OcxApiKeyEntry);
|
|
1141
|
+
}),
|
|
1142
|
+
}).passthrough().superRefine((config, ctx) => {
|
|
1143
|
+
const claudeCode = (config as { claudeCode?: unknown }).claudeCode;
|
|
1144
|
+
if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) {
|
|
1145
|
+
ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" });
|
|
1146
|
+
} else if (claudeCode) {
|
|
1147
|
+
const claude = claudeCode as { desktopProfile?: unknown };
|
|
1148
|
+
if (claude.desktopProfile !== undefined) {
|
|
1149
|
+
try {
|
|
1150
|
+
parseDesktopProfile(claude.desktopProfile);
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
ctx.addIssue({
|
|
1153
|
+
code: "custom",
|
|
1154
|
+
path: ["claudeCode", "desktopProfile"],
|
|
1155
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const accountNamespaces = config.codexAccountNamespaces;
|
|
1162
|
+
if (accountNamespaces) {
|
|
1163
|
+
const configuredAccountIds = configuredCodexPoolAccountIds(config.codexAccounts);
|
|
1164
|
+
const configuredProviderNamespaces = new Set([
|
|
1165
|
+
COMBO_NAMESPACE,
|
|
1166
|
+
OPENAI_CODEX_PROVIDER_ID,
|
|
1167
|
+
POLICY_NAMESPACE,
|
|
1168
|
+
...Object.keys(config.providers),
|
|
1169
|
+
].map(codexProviderNamespaceKey));
|
|
1170
|
+
const namespaceTargets = new Set(
|
|
1171
|
+
Object.values(accountNamespaces)
|
|
1172
|
+
.filter(accountId => accountId !== MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET),
|
|
1173
|
+
);
|
|
1174
|
+
for (const namespace of Object.keys(accountNamespaces)) {
|
|
1175
|
+
if (configuredProviderNamespaces.has(codexProviderNamespaceKey(namespace))) {
|
|
1176
|
+
ctx.addIssue({
|
|
1177
|
+
code: "custom",
|
|
1178
|
+
path: ["codexAccountNamespaces", namespace],
|
|
1179
|
+
message: "account selectors must not collide with configured provider, combo, or routing policy namespaces",
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) {
|
|
1183
|
+
ctx.addIssue({
|
|
1184
|
+
code: "custom",
|
|
1185
|
+
path: ["codexAccountNamespaces", namespace],
|
|
1186
|
+
message: CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR,
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
for (const name of Object.keys(config.providers)) {
|
|
1192
|
+
if (!isValidProviderName(name)) {
|
|
1193
|
+
ctx.addIssue({
|
|
1194
|
+
code: "custom",
|
|
1195
|
+
path: ["providers", name],
|
|
1196
|
+
message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys or routing namespaces (policy)",
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
const provider = config.providers[name];
|
|
1200
|
+
const openRouterRoutingError = openRouterRoutingConfigError(provider);
|
|
1201
|
+
if (openRouterRoutingError) {
|
|
1202
|
+
ctx.addIssue({
|
|
1203
|
+
code: "custom",
|
|
1204
|
+
path: [
|
|
1205
|
+
"providers",
|
|
1206
|
+
name,
|
|
1207
|
+
openRouterRoutingError.startsWith("modelOpenRouterRouting")
|
|
1208
|
+
? "modelOpenRouterRouting"
|
|
1209
|
+
: "openRouterRouting",
|
|
1210
|
+
],
|
|
1211
|
+
message: openRouterRoutingError,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
if (Object.hasOwn(provider, "virtualModels")) {
|
|
1215
|
+
ctx.addIssue({
|
|
1216
|
+
code: "custom",
|
|
1217
|
+
path: ["providers", name, "virtualModels"],
|
|
1218
|
+
message: "virtualModels is registry-only and must not be persisted",
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
if (Object.hasOwn(provider, "modelReasoningControls")) {
|
|
1222
|
+
ctx.addIssue({
|
|
1223
|
+
code: "custom",
|
|
1224
|
+
path: ["providers", name, "modelReasoningControls"],
|
|
1225
|
+
message: "modelReasoningControls is runtime-only and must not be persisted",
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
const baseUrlError = providerBaseUrlConfigError(provider.baseUrl);
|
|
1229
|
+
if (baseUrlError) {
|
|
1230
|
+
ctx.addIssue({
|
|
1231
|
+
code: "custom",
|
|
1232
|
+
path: ["providers", name, "baseUrl"],
|
|
1233
|
+
message: baseUrlError,
|
|
1234
|
+
});
|
|
1235
|
+
} else {
|
|
1236
|
+
const destinationError = providerDestinationConfigError(name, provider);
|
|
1237
|
+
if (destinationError) {
|
|
1238
|
+
ctx.addIssue({
|
|
1239
|
+
code: "custom",
|
|
1240
|
+
path: ["providers", name, "baseUrl"],
|
|
1241
|
+
message: destinationError,
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
const responsesPathError = providerResponsesPathConfigError(provider.responsesPath);
|
|
1246
|
+
if (responsesPathError) {
|
|
1247
|
+
ctx.addIssue({
|
|
1248
|
+
code: "custom",
|
|
1249
|
+
path: ["providers", name, "responsesPath"],
|
|
1250
|
+
message: responsesPathError,
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers);
|
|
1254
|
+
if (headersError) {
|
|
1255
|
+
ctx.addIssue({
|
|
1256
|
+
code: "custom",
|
|
1257
|
+
path: ["providers", name, "headers"],
|
|
1258
|
+
message: headersError,
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig);
|
|
1262
|
+
if (apiKeyTransportError) {
|
|
1263
|
+
ctx.addIssue({
|
|
1264
|
+
code: "custom",
|
|
1265
|
+
path: ["providers", name, "apiKeyTransport"],
|
|
1266
|
+
message: apiKeyTransportError,
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
const modelAdaptersError = modelAdapterRecordConfigError(
|
|
1270
|
+
(provider as { modelAdapters?: unknown }).modelAdapters,
|
|
1271
|
+
"modelAdapters",
|
|
1272
|
+
name,
|
|
1273
|
+
provider,
|
|
1274
|
+
);
|
|
1275
|
+
if (modelAdaptersError) {
|
|
1276
|
+
ctx.addIssue({
|
|
1277
|
+
code: "custom",
|
|
1278
|
+
path: ["providers", name, "modelAdapters"],
|
|
1279
|
+
message: modelAdaptersError,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
const preferHostedToolsError = modelPreferHostedToolsConfigError(
|
|
1283
|
+
(provider as { modelPreferHostedTools?: unknown }).modelPreferHostedTools,
|
|
1284
|
+
"modelPreferHostedTools",
|
|
1285
|
+
name,
|
|
1286
|
+
provider,
|
|
1287
|
+
);
|
|
1288
|
+
if (preferHostedToolsError) {
|
|
1289
|
+
ctx.addIssue({
|
|
1290
|
+
code: "custom",
|
|
1291
|
+
path: ["providers", name, "modelPreferHostedTools"],
|
|
1292
|
+
message: preferHostedToolsError,
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
const maxInputError = positiveIntegerRecordConfigError(
|
|
1296
|
+
(provider as { modelMaxInputTokens?: unknown }).modelMaxInputTokens,
|
|
1297
|
+
"modelMaxInputTokens",
|
|
1298
|
+
);
|
|
1299
|
+
if (maxInputError) {
|
|
1300
|
+
ctx.addIssue({
|
|
1301
|
+
code: "custom",
|
|
1302
|
+
path: ["providers", name, "modelMaxInputTokens"],
|
|
1303
|
+
message: maxInputError,
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
const reasoningSummariesError = booleanRecordConfigError(
|
|
1307
|
+
(provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
|
|
1308
|
+
"modelSupportsReasoningSummaries",
|
|
1309
|
+
);
|
|
1310
|
+
if (reasoningSummariesError) {
|
|
1311
|
+
ctx.addIssue({
|
|
1312
|
+
code: "custom",
|
|
1313
|
+
path: ["providers", name, "modelSupportsReasoningSummaries"],
|
|
1314
|
+
message: reasoningSummariesError,
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
const reasoningRequiredError = booleanRecordConfigError(
|
|
1318
|
+
(provider as { modelReasoningRequired?: unknown }).modelReasoningRequired,
|
|
1319
|
+
"modelReasoningRequired",
|
|
1320
|
+
);
|
|
1321
|
+
if (reasoningRequiredError) {
|
|
1322
|
+
ctx.addIssue({
|
|
1323
|
+
code: "custom",
|
|
1324
|
+
path: ["providers", name, "modelReasoningRequired"],
|
|
1325
|
+
message: reasoningRequiredError,
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError(
|
|
1329
|
+
(provider as { modelReasoningSummaryDelivery?: unknown }).modelReasoningSummaryDelivery,
|
|
1330
|
+
(provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
|
|
1331
|
+
);
|
|
1332
|
+
if (reasoningSummaryDeliveryError) {
|
|
1333
|
+
ctx.addIssue({
|
|
1334
|
+
code: "custom",
|
|
1335
|
+
path: ["providers", name, "modelReasoningSummaryDelivery"],
|
|
1336
|
+
message: reasoningSummaryDeliveryError,
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
const defaultMaxOutputError = positiveIntegerConfigError(
|
|
1340
|
+
(provider as { defaultMaxOutputTokens?: unknown }).defaultMaxOutputTokens,
|
|
1341
|
+
"defaultMaxOutputTokens",
|
|
1342
|
+
);
|
|
1343
|
+
if (defaultMaxOutputError) {
|
|
1344
|
+
ctx.addIssue({
|
|
1345
|
+
code: "custom",
|
|
1346
|
+
path: ["providers", name, "defaultMaxOutputTokens"],
|
|
1347
|
+
message: defaultMaxOutputError,
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
const maxOutputError = positiveIntegerRecordConfigError(
|
|
1351
|
+
(provider as { modelMaxOutputTokens?: unknown }).modelMaxOutputTokens,
|
|
1352
|
+
"modelMaxOutputTokens",
|
|
1353
|
+
);
|
|
1354
|
+
if (maxOutputError) {
|
|
1355
|
+
ctx.addIssue({
|
|
1356
|
+
code: "custom",
|
|
1357
|
+
path: ["providers", name, "modelMaxOutputTokens"],
|
|
1358
|
+
message: maxOutputError,
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) {
|
|
1362
|
+
// Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider.
|
|
1363
|
+
// Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them.
|
|
1364
|
+
const canonicalOpenAiShape = name === "openai"
|
|
1365
|
+
&& provider.adapter === "openai-responses"
|
|
1366
|
+
&& (provider as { authMode?: unknown }).authMode === "forward"
|
|
1367
|
+
&& typeof provider.baseUrl === "string"
|
|
1368
|
+
&& provider.baseUrl.replace(/\/+$/, "") === "https://chatgpt.com/backend-api/codex";
|
|
1369
|
+
if (!canonicalOpenAiShape) {
|
|
1370
|
+
ctx.addIssue({
|
|
1371
|
+
code: "custom",
|
|
1372
|
+
path: ["providers", name, "codexAccountMode"],
|
|
1373
|
+
message: "codexAccountMode is valid only on the canonical built-in openai provider",
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
if (!hasOwnProvider(config.providers, config.defaultProvider)) {
|
|
1379
|
+
ctx.addIssue({
|
|
1380
|
+
code: "custom",
|
|
1381
|
+
path: ["defaultProvider"],
|
|
1382
|
+
message: "defaultProvider must exist in providers",
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
const combos = (config as { combos?: unknown }).combos;
|
|
1386
|
+
if (combos !== undefined) {
|
|
1387
|
+
if (!combos || typeof combos !== "object" || Array.isArray(combos)) {
|
|
1388
|
+
ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" });
|
|
1389
|
+
} else {
|
|
1390
|
+
for (const [id, raw] of Object.entries(combos as Record<string, unknown>)) {
|
|
1391
|
+
const alias = raw && typeof raw === "object" && !Array.isArray(raw)
|
|
1392
|
+
? (raw as { alias?: unknown }).alias
|
|
1393
|
+
: undefined;
|
|
1394
|
+
if (typeof alias === "string" && codexAccountNamespaceForModel(accountNamespaces, alias.trim())) {
|
|
1395
|
+
ctx.addIssue({
|
|
1396
|
+
code: "custom",
|
|
1397
|
+
path: ["combos", id, "alias"],
|
|
1398
|
+
message: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
// Pass the full map so cross-combo rules (alias uniqueness) apply at load time
|
|
1402
|
+
// too, not just via the management API; each combo is excluded from its own check.
|
|
1403
|
+
for (const issue of comboConfigIssues(id, raw, config.providers, {
|
|
1404
|
+
combos: combos as Record<string, import("./types").OcxComboConfig>,
|
|
1405
|
+
excludeComboId: id,
|
|
1406
|
+
})) {
|
|
1407
|
+
ctx.addIssue({
|
|
1408
|
+
code: "custom",
|
|
1409
|
+
path: ["combos", id, ...issue.path],
|
|
1410
|
+
message: issue.message,
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
const routingProfiles = (config as { routingProfiles?: unknown }).routingProfiles;
|
|
1417
|
+
if (routingProfiles !== undefined) {
|
|
1418
|
+
if (!routingProfiles || typeof routingProfiles !== "object" || Array.isArray(routingProfiles)) {
|
|
1419
|
+
ctx.addIssue({ code: "custom", path: ["routingProfiles"], message: "routingProfiles must be an object" });
|
|
1420
|
+
} else {
|
|
1421
|
+
for (const [id, raw] of Object.entries(routingProfiles as Record<string, unknown>)) {
|
|
1422
|
+
for (const issue of routingProfileIssues(id, raw, {
|
|
1423
|
+
providers: config.providers,
|
|
1424
|
+
combos: combos as Record<string, import("./types").OcxComboConfig> | undefined,
|
|
1425
|
+
routingProfiles: routingProfiles as Record<string, import("./types").OcxRoutingProfileConfig>,
|
|
1426
|
+
codexAccountNamespaces: accountNamespaces,
|
|
1427
|
+
}, { excludeProfileId: id })) {
|
|
1428
|
+
ctx.addIssue({
|
|
1429
|
+
code: "custom",
|
|
1430
|
+
path: ["routingProfiles", id, ...issue.path],
|
|
1431
|
+
message: issue.message,
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Default featured subagent models (native GPT) seeded on a fresh install and when `subagentModels`
|
|
1441
|
+
* is unset. Codex's spawn_agent advertises the first 5 featured catalog entries, so this seed is a
|
|
1442
|
+
* deliberate 5-list: frontier gpt-5.5 first, the gpt-5.6 preview trio, and gpt-5.4-mini as the cheap
|
|
1443
|
+
* tier. gpt-5.4 / gpt-5.3-codex-spark stay selectable in the GUI's available list. The user can
|
|
1444
|
+
* remove any in the GUI — once they set the list (even to []), it is respected, so removals persist
|
|
1445
|
+
* (start-up only seeds the UNSET case). Kept to ids ChatGPT accepts; the start-up seed prefers the
|
|
1446
|
+
* live catalog's native slugs.
|
|
1447
|
+
*/
|
|
1448
|
+
export const DEFAULT_SUBAGENT_MODELS = ["gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4-mini"];
|
|
1449
|
+
|
|
1450
|
+
export function getConfigDir(): string {
|
|
1451
|
+
return resolveConfigDir();
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* Adopt a default-home directory migration after another subsystem has
|
|
1456
|
+
* stopped its service and renamed the root. Explicit OPENCODEX_HOME overrides
|
|
1457
|
+
* remain authoritative and are never rewritten.
|
|
1458
|
+
*/
|
|
1459
|
+
export function noteDefaultRemodexHomeMigration(
|
|
1460
|
+
previousConfigDir: string,
|
|
1461
|
+
nextConfigDir: string,
|
|
1462
|
+
): void {
|
|
1463
|
+
if (process.env["OPENCODEX_HOME"]?.trim()) return;
|
|
1464
|
+
const cached = resolvedConfigDirCache;
|
|
1465
|
+
if (!cached || resolve(cached.path) !== resolve(previousConfigDir)) return;
|
|
1466
|
+
resolvedConfigDirCache = {
|
|
1467
|
+
raw: undefined,
|
|
1468
|
+
path: resolve(nextConfigDir),
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
export function getConfigPath(): string {
|
|
1473
|
+
return resolveConfigPath();
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
export function getPidPath(): string {
|
|
1477
|
+
return resolvePidPath();
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
export function getRuntimePortPath(): string {
|
|
1481
|
+
return resolveRuntimePortPath();
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
export function hardenConfigDir(): void {
|
|
1485
|
+
const dir = getConfigDir();
|
|
1486
|
+
// The guard runs BEFORE any mutation: refusing the write after chmod/ACL
|
|
1487
|
+
// would already have changed the protected directory (review round 2).
|
|
1488
|
+
assertNotRealHomeUnderTest(dir);
|
|
1489
|
+
if (existsSync(dir)) {
|
|
1490
|
+
try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
|
|
1491
|
+
if (process.platform === "win32") {
|
|
1492
|
+
hardenSecretDir(dir, { required: false });
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
export function hardenExistingSecret(path: string): void {
|
|
1498
|
+
if (existsSync(path)) {
|
|
1499
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
1500
|
+
if (process.platform === "win32") {
|
|
1501
|
+
hardenSecretPath(path, { required: false });
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* The schema's `.catch(undefined)` silently degrades an invalid persisted
|
|
1507
|
+
* `streamMode` to "auto"; surface that once so a hand-edited typo (e.g.
|
|
1508
|
+
* "legacy_tee") is discoverable instead of silently changing stream shape.
|
|
1509
|
+
*/
|
|
1510
|
+
function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void {
|
|
1511
|
+
if (!rawParsed || typeof rawParsed !== "object") return;
|
|
1512
|
+
const raw = (rawParsed as Record<string, unknown>).streamMode;
|
|
1513
|
+
if (raw !== undefined && validated.streamMode === undefined) {
|
|
1514
|
+
console.warn(`⚠️ config.json streamMode ${JSON.stringify(raw)} is invalid (expected "auto", "legacy-tee", or "eager-relay") — falling back to "auto"`);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional
|
|
1520
|
+
* field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every
|
|
1521
|
+
* provider/key behind a default config. Invalid fields are dropped with a warning; the management
|
|
1522
|
+
* write boundary still rejects invalid policies explicitly.
|
|
1523
|
+
*/
|
|
1524
|
+
function sanitizeRetryOn429ForLoad(parsed: unknown): void {
|
|
1525
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
1526
|
+
const root = parsed as Record<string, unknown>;
|
|
1527
|
+
const providers = root.providers;
|
|
1528
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
|
|
1529
|
+
for (const [name, provider] of Object.entries(providers as Record<string, unknown>)) {
|
|
1530
|
+
// This sanitizer runs BEFORE schema validation, so the provider name is untrusted: redact
|
|
1531
|
+
// secret-shaped names and JSON-escape control characters before it reaches any warning.
|
|
1532
|
+
const safeProviderName = JSON.stringify(redactSecretString(name));
|
|
1533
|
+
if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
|
|
1534
|
+
const p = provider as Record<string, unknown>;
|
|
1535
|
+
const policy = p.retryOn429;
|
|
1536
|
+
if (policy === undefined) continue;
|
|
1537
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
1538
|
+
delete p.retryOn429;
|
|
1539
|
+
// Never serialize the value: an accidental `retryOn429: "sk-..."` would leak the secret.
|
|
1540
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 (${typeof policy}) is invalid — ignoring the policy`);
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
const policyRecord = policy as Record<string, unknown>;
|
|
1544
|
+
// An explicitly present but invalid master switch must not silently default to ENABLED:
|
|
1545
|
+
// drop the whole policy so a hand-edit that tried to disable retries stays disabled.
|
|
1546
|
+
if ("enabled" in policyRecord && typeof policyRecord.enabled !== "boolean") {
|
|
1547
|
+
delete p.retryOn429;
|
|
1548
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.enabled (${typeof policyRecord.enabled}) is invalid — ignoring the whole policy`);
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
// Field checks derive from the shared policy schema so the bounds cannot drift
|
|
1552
|
+
// between the load-time sanitizer, the config schema, and the write boundary.
|
|
1553
|
+
const policyShape = retryOn429PolicySchema.shape;
|
|
1554
|
+
const hadPolicyEntries = Object.keys(policyRecord).length > 0;
|
|
1555
|
+
const cleaned: Record<string, unknown> = {};
|
|
1556
|
+
for (const [key, fieldSchema] of Object.entries(policyShape)) {
|
|
1557
|
+
const value = policyRecord[key];
|
|
1558
|
+
if (value === undefined) continue;
|
|
1559
|
+
if (fieldSchema.safeParse(value).success) cleaned[key] = value;
|
|
1560
|
+
// Log only the received type, never the value (provider config can hold secrets).
|
|
1561
|
+
else console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${key} (${typeof value}) is invalid — ignoring the field`);
|
|
1562
|
+
}
|
|
1563
|
+
const knownKeys = new Set(Object.keys(policyShape));
|
|
1564
|
+
for (const key of Object.keys(policyRecord)) {
|
|
1565
|
+
if (!knownKeys.has(key)) {
|
|
1566
|
+
// Redact the field NAME before logging: a malformed hand-edit can place a secret in a
|
|
1567
|
+
// property name (`retryOn429: { "sk-...": true }`). Ordinary typos (e.g. `attempt`)
|
|
1568
|
+
// stay readable, secret-shaped names become [REDACTED]. JSON-escape afterwards so a
|
|
1569
|
+
// control-character property name (newline/ANSI) can never forge a log line.
|
|
1570
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${JSON.stringify(redactSecretString(key))} is not a recognized field — ignoring it`);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
if (hadPolicyEntries && Object.keys(cleaned).length === 0) {
|
|
1574
|
+
// Every supplied field was invalid: drop the whole policy. Persisting `{}` here would
|
|
1575
|
+
// opt IN to retries with defaults, which is the opposite of what a malformed
|
|
1576
|
+
// disable-oriented edit (`retryOn429: { enabled: "false" }`, `attempts: 0`) asked for.
|
|
1577
|
+
delete p.retryOn429;
|
|
1578
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 has no valid fields left — removing the policy (an empty policy would enable retries with defaults)`);
|
|
1579
|
+
} else {
|
|
1580
|
+
// Preserve an intentionally empty `retryOn429: {}` (presence = opt-in with defaults).
|
|
1581
|
+
p.retryOn429 = cleaned;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Management write-boundary validation for `retryOn429` (fail closed). Unlike the
|
|
1588
|
+
* lenient load-time sanitizer, invalid values and unknown keys are rejected outright so
|
|
1589
|
+
* a POST/PATCH cannot persist a policy the proxy would then silently degrade. Reuses the
|
|
1590
|
+
* shared policy schema. Never echoes values, and secret-shaped unknown field names are
|
|
1591
|
+
* redacted (a malformed write can place a secret in a property name).
|
|
1592
|
+
*/
|
|
1593
|
+
export function retryOn429PolicyConfigError(policy: unknown): string | null {
|
|
1594
|
+
if (policy === undefined) return null;
|
|
1595
|
+
const result = retryOn429PolicySchema.safeParse(policy);
|
|
1596
|
+
if (result.success) return null;
|
|
1597
|
+
const first = result.error.issues[0];
|
|
1598
|
+
if (!first) return "retryOn429 is invalid";
|
|
1599
|
+
if (first.code === "unrecognized_keys") {
|
|
1600
|
+
const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", ");
|
|
1601
|
+
return `retryOn429 has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`;
|
|
1602
|
+
}
|
|
1603
|
+
if (first.path.length === 0) return `retryOn429 is invalid (${first.message})`;
|
|
1604
|
+
const field = String(first.path[first.path.length - 1]);
|
|
1605
|
+
return `retryOn429.${field} is invalid (${first.message})`;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind
|
|
1610
|
+
* falls back to loopback, which is the safe direction but not what the file asked for —
|
|
1611
|
+
* say so once instead of silently ignoring the field.
|
|
1612
|
+
*/
|
|
1613
|
+
function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void {
|
|
1614
|
+
if (!rawParsed || typeof rawParsed !== "object") return;
|
|
1615
|
+
const raw = (rawParsed as Record<string, unknown>).hostname;
|
|
1616
|
+
if (raw !== undefined && validated.hostname === undefined) {
|
|
1617
|
+
console.warn(`⚠️ config.json hostname ${JSON.stringify(raw)} is not a usable bind address — falling back to 127.0.0.1`);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Companion to {@link warnDegradedStreamMode} for a malformed selection-order map.
|
|
1623
|
+
* Priority is a preference, so the schema drops the whole map rather than failing
|
|
1624
|
+
* the parse — say so once, otherwise the pool silently reverts to flat ordering.
|
|
1625
|
+
*/
|
|
1626
|
+
function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validated: OcxConfig): string[] {
|
|
1627
|
+
const record = rawConfigRecord(rawParsed);
|
|
1628
|
+
const warnings: string[] = [];
|
|
1629
|
+
// The pin degrades silently otherwise, which reads as the manual selection simply
|
|
1630
|
+
// not having survived the restart.
|
|
1631
|
+
if (record?.activeCodexAccountPinned !== undefined && validated.activeCodexAccountPinned === undefined) {
|
|
1632
|
+
warnings.push("activeCodexAccountPinned is not a valid account id — the manually selected account is no longer pinned");
|
|
1633
|
+
}
|
|
1634
|
+
const raw = record?.codexAccountPriorities;
|
|
1635
|
+
if (raw !== undefined && validated.codexAccountPriorities === undefined) {
|
|
1636
|
+
warnings.push("codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled");
|
|
1637
|
+
}
|
|
1638
|
+
return warnings;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function warnDegradedCodexAccountPriorities(rawParsed: unknown, validated: OcxConfig): void {
|
|
1642
|
+
for (const warning of degradedCodexAccountPriorityWarnings(rawParsed, validated)) {
|
|
1643
|
+
console.warn(`⚠️ config.json ${warning}`);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/**
|
|
1648
|
+
* The apiKeys schema salvages entry by entry rather than failing the parse, so a
|
|
1649
|
+
* dropped key is otherwise invisible — and it will not be re-saved by the next
|
|
1650
|
+
* mutation. Say so out loud. Compares the raw array against the validated one,
|
|
1651
|
+
* the same shape as the degrade warnings above.
|
|
1652
|
+
*/
|
|
1653
|
+
/** One definition of "usable secret", shared by the schema and the warnings. */
|
|
1654
|
+
function isUsableApiKeySecret(value: unknown): value is string {
|
|
1655
|
+
return typeof value === "string" && value.length > 0 && value === value.trim();
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* Give every salvaged key a stable, targetable id.
|
|
1660
|
+
*
|
|
1661
|
+
* Pure and deterministic on purpose. Two earlier spellings were wrong: minting a
|
|
1662
|
+
* UUID inside the schema transform handed out a different id on every parse, and
|
|
1663
|
+
* repairing-then-writing during `loadConfig` put a file write on the read path,
|
|
1664
|
+
* where it could clobber a concurrent legitimate save with a stale snapshot.
|
|
1665
|
+
*
|
|
1666
|
+
* So the replacement id is derived from the entry's position, which is already
|
|
1667
|
+
* how the file orders these rows: same file in, same ids out, no I/O and no
|
|
1668
|
+
* randomness. It is not derived from the secret — a public identifier should
|
|
1669
|
+
* never be a function of key material.
|
|
1670
|
+
*/
|
|
1671
|
+
function normalizeApiKeyIds(config: OcxConfig): OcxConfig {
|
|
1672
|
+
const keys = config.apiKeys;
|
|
1673
|
+
if (!keys?.length) return config;
|
|
1674
|
+
// Reserve every explicit id BEFORE synthesizing any, or a synthetic
|
|
1675
|
+
// `salvaged-1` assigned to row 1 would push a row that legitimately owns that
|
|
1676
|
+
// id onto `salvaged-2`. An id the user already has is the one thing this
|
|
1677
|
+
// repair must never take away.
|
|
1678
|
+
const reserved = new Set<string>();
|
|
1679
|
+
for (const entry of keys) {
|
|
1680
|
+
if (entry.id) reserved.add(entry.id);
|
|
1681
|
+
}
|
|
1682
|
+
const taken = new Set<string>(reserved);
|
|
1683
|
+
const kept = new Set<string>();
|
|
1684
|
+
keys.forEach((entry, index) => {
|
|
1685
|
+
// The first row holding an explicit id keeps it; later collisions are the
|
|
1686
|
+
// ones that move.
|
|
1687
|
+
if (entry.id && !kept.has(entry.id)) {
|
|
1688
|
+
kept.add(entry.id);
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
let candidate = `salvaged-${index + 1}`;
|
|
1692
|
+
let suffix = 1;
|
|
1693
|
+
while (taken.has(candidate)) candidate = `salvaged-${index + 1}-${++suffix}`;
|
|
1694
|
+
entry.id = candidate;
|
|
1695
|
+
taken.add(candidate);
|
|
1696
|
+
kept.add(candidate);
|
|
1697
|
+
});
|
|
1698
|
+
return config;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function warnDegradedApiKeys(rawParsed: unknown, validated: OcxConfig): void {
|
|
1702
|
+
if (!rawParsed || typeof rawParsed !== "object") return;
|
|
1703
|
+
const raw = (rawParsed as Record<string, unknown>).apiKeys;
|
|
1704
|
+
if (raw === undefined) return;
|
|
1705
|
+
if (!Array.isArray(raw)) {
|
|
1706
|
+
console.warn(`⚠️ config.json apiKeys is not an array — ignoring it; generate a new key from the API tab`);
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const dropped = raw.length - (validated.apiKeys?.length ?? 0);
|
|
1710
|
+
if (dropped > 0) {
|
|
1711
|
+
console.warn(`⚠️ config.json apiKeys: skipped ${dropped} malformed entr${dropped === 1 ? "y" : "ies"} — the remaining keys still work`);
|
|
1712
|
+
}
|
|
1713
|
+
// Same-length repairs are invisible to the count above, and they are the ones
|
|
1714
|
+
// that show up as a blank name or an unknown date in the dashboard. Say so.
|
|
1715
|
+
const repaired = raw.filter(row => {
|
|
1716
|
+
if (!row || typeof row !== "object") return false;
|
|
1717
|
+
const entry = row as Record<string, unknown>;
|
|
1718
|
+
// Must match the schema exactly: a row whose key is unusable was DROPPED, and
|
|
1719
|
+
// saying "the key still works" about it would be a lie.
|
|
1720
|
+
if (!isUsableApiKeySecret(entry.key)) return false;
|
|
1721
|
+
return typeof entry.id !== "string" || !entry.id
|
|
1722
|
+
|| typeof entry.name !== "string"
|
|
1723
|
+
|| typeof entry.createdAt !== "string";
|
|
1724
|
+
}).length;
|
|
1725
|
+
if (repaired > 0) {
|
|
1726
|
+
console.warn(`⚠️ config.json apiKeys: repaired metadata on ${repaired} entr${repaired === 1 ? "y" : "ies"} — the key still works, but its name or date may read as unknown`);
|
|
1727
|
+
}
|
|
1728
|
+
// A duplicate id is repaired too, and it is not visible in either count above.
|
|
1729
|
+
const ids = raw.filter(row => row && typeof row === "object" && isUsableApiKeySecret((row as Record<string, unknown>).key))
|
|
1730
|
+
.map(row => (row as Record<string, unknown>).id)
|
|
1731
|
+
.filter((id): id is string => typeof id === "string" && !!id);
|
|
1732
|
+
const duplicates = ids.length - new Set(ids).size;
|
|
1733
|
+
if (duplicates > 0) {
|
|
1734
|
+
console.warn(`⚠️ config.json apiKeys: ${duplicates} entr${duplicates === 1 ? "y" : "ies"} shared an id — reassigned so each key can be renamed and revoked on its own`);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const CLAUDE_SUBAGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
1739
|
+
|
|
1740
|
+
function isClaudeSubagentEffort(value: unknown): value is NonNullable<OcxClaudeCodeConfig["subagentEffort"]> {
|
|
1741
|
+
return typeof value === "string" && CLAUDE_SUBAGENT_EFFORTS.includes(value as typeof CLAUDE_SUBAGENT_EFFORTS[number]);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function rawClaudeSubagentEffort(rawParsed: unknown): unknown {
|
|
1745
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1746
|
+
const claudeCode = raw?.claudeCode;
|
|
1747
|
+
if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) return undefined;
|
|
1748
|
+
return (claudeCode as Record<string, unknown>).subagentEffort;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCode"] {
|
|
1752
|
+
if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) {
|
|
1753
|
+
return claudeCode as OcxConfig["claudeCode"];
|
|
1754
|
+
}
|
|
1755
|
+
const normalized = { ...claudeCode } as Record<string, unknown>;
|
|
1756
|
+
if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) {
|
|
1757
|
+
delete normalized.subagentEffort;
|
|
1758
|
+
}
|
|
1759
|
+
return normalized as OcxConfig["claudeCode"];
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
function normalizeClaudeSubagentEffort(config: OcxConfig, rawParsed: unknown): OcxConfig {
|
|
1763
|
+
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1764
|
+
if (rawEffort === undefined || isClaudeSubagentEffort(rawEffort)) return config;
|
|
1765
|
+
return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) };
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
|
|
1769
|
+
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1770
|
+
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
1771
|
+
console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function malformedUpstreamHostCircuitThresholdWarning(rawParsed: unknown): string | null {
|
|
1776
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1777
|
+
if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
|
|
1778
|
+
const threshold = raw.upstreamHostCircuitThreshold;
|
|
1779
|
+
if (threshold === undefined) return null;
|
|
1780
|
+
if (typeof threshold === "number"
|
|
1781
|
+
&& Number.isInteger(threshold)
|
|
1782
|
+
&& threshold >= 0
|
|
1783
|
+
&& threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
|
|
1784
|
+
return `upstreamHostCircuitThreshold ignored: expected an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function warnDegradedUpstreamHostCircuitThreshold(rawParsed: unknown): void {
|
|
1788
|
+
const warning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
|
|
1789
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
|
|
1793
|
+
|
|
1794
|
+
function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
|
|
1795
|
+
return rawParsed !== null && typeof rawParsed === "object" && !Array.isArray(rawParsed)
|
|
1796
|
+
? rawParsed as Record<string, unknown>
|
|
1797
|
+
: null;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
function malformedNativeSubagentFields(rawParsed: unknown): NativeSubagentPersistedField[] {
|
|
1801
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1802
|
+
if (!raw) return [];
|
|
1803
|
+
const malformed: NativeSubagentPersistedField[] = [];
|
|
1804
|
+
if (Object.hasOwn(raw, "injectionModel") && typeof raw.injectionModel !== "string") {
|
|
1805
|
+
malformed.push("injectionModel");
|
|
1806
|
+
}
|
|
1807
|
+
if (Object.hasOwn(raw, "injectionEffort") && typeof raw.injectionEffort !== "string") {
|
|
1808
|
+
malformed.push("injectionEffort");
|
|
1809
|
+
}
|
|
1810
|
+
if (Object.hasOwn(raw, "syncCodexSubagentDefaults") && typeof raw.syncCodexSubagentDefaults !== "boolean") {
|
|
1811
|
+
malformed.push("syncCodexSubagentDefaults");
|
|
1812
|
+
}
|
|
1813
|
+
return malformed;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField): string {
|
|
1817
|
+
const expected = field === "syncCodexSubagentDefaults" ? "a boolean" : "a string";
|
|
1818
|
+
return `${field} ignored: expected ${expected}`;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function malformedCodexAccountPickerWarning(rawParsed: unknown): string | null {
|
|
1822
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1823
|
+
if (!raw || !Object.hasOwn(raw, "codexAccountPickerEnabled")) return null;
|
|
1824
|
+
if (typeof raw.codexAccountPickerEnabled === "boolean") return null;
|
|
1825
|
+
return "codexAccountPickerEnabled ignored: expected a boolean";
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function warnDegradedCodexAccountPicker(rawParsed: unknown): void {
|
|
1829
|
+
const warning = malformedCodexAccountPickerWarning(rawParsed);
|
|
1830
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null {
|
|
1834
|
+
if (config.syncCodexSubagentDefaults !== true) return null;
|
|
1835
|
+
const malformed = malformedNativeSubagentFields(rawParsed);
|
|
1836
|
+
if (malformed.includes("injectionModel")) return "injectionModel must be a string";
|
|
1837
|
+
if (!config.injectionModel?.trim()) return "a nonblank injectionModel is required";
|
|
1838
|
+
if (malformed.includes("injectionEffort")) return "injectionEffort must be a string or omitted";
|
|
1839
|
+
if (config.injectionEffort !== undefined && !isCodexReasoningEffort(config.injectionEffort)) {
|
|
1840
|
+
return "injectionEffort must be a supported Codex reasoning effort";
|
|
1841
|
+
}
|
|
1842
|
+
return null;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function normalizeNativeSubagentSync(config: OcxConfig, rawParsed?: unknown): OcxConfig {
|
|
1846
|
+
if (!nativeSubagentSyncDisabledReason(config, rawParsed)) return config;
|
|
1847
|
+
const normalized = { ...config };
|
|
1848
|
+
delete normalized.syncCodexSubagentDefaults;
|
|
1849
|
+
return normalized;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig): void {
|
|
1853
|
+
for (const field of malformedNativeSubagentFields(rawParsed)) {
|
|
1854
|
+
console.warn(`⚠️ config.json ${malformedNativeSubagentFieldWarning(field)}. Other settings were preserved.`);
|
|
1855
|
+
}
|
|
1856
|
+
const reason = nativeSubagentSyncDisabledReason(config, rawParsed);
|
|
1857
|
+
if (reason) {
|
|
1858
|
+
console.warn(`⚠️ config.json syncCodexSubagentDefaults was disabled: ${reason}. Other settings were preserved.`);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
export function loadConfig(): OcxConfig {
|
|
1863
|
+
const dir = getConfigDir();
|
|
1864
|
+
const configPath = getConfigPath();
|
|
1865
|
+
hardenConfigDir();
|
|
1866
|
+
hardenExistingSecret(configPath);
|
|
1867
|
+
hardenExistingSecret(join(dir, "auth.json"));
|
|
1868
|
+
if (!existsSync(configPath)) {
|
|
1869
|
+
return getDefaultConfig();
|
|
1870
|
+
}
|
|
1871
|
+
try {
|
|
1872
|
+
const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
|
|
1873
|
+
const parsed = JSON.parse(raw);
|
|
1874
|
+
sanitizeRetryOn429ForLoad(parsed);
|
|
1875
|
+
const result = configSchema.safeParse(parsed);
|
|
1876
|
+
if (result.success) {
|
|
1877
|
+
const config = normalizeApiKeyIds(result.data as OcxConfig);
|
|
1878
|
+
warnDegradedStreamMode(parsed, config);
|
|
1879
|
+
warnDegradedHostname(parsed, config);
|
|
1880
|
+
warnDegradedApiKeys(parsed, config);
|
|
1881
|
+
warnDegradedCodexAccountPriorities(parsed, config);
|
|
1882
|
+
warnDegradedClaudeSubagentEffort(parsed);
|
|
1883
|
+
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1884
|
+
warnDegradedCodexAccountPicker(parsed);
|
|
1885
|
+
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
1886
|
+
return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
|
|
1887
|
+
}
|
|
1888
|
+
// Schema validation failed — merge defaults into the raw object instead of
|
|
1889
|
+
// discarding it entirely, so pool accounts and providers survive a missing
|
|
1890
|
+
// field like defaultProvider.
|
|
1891
|
+
const defaults = getDefaultConfig();
|
|
1892
|
+
const merged = { ...defaults, ...parsed };
|
|
1893
|
+
// Ensure providers from both sides survive
|
|
1894
|
+
if (parsed.providers && defaults.providers) {
|
|
1895
|
+
merged.providers = { ...defaults.providers, ...parsed.providers };
|
|
1896
|
+
}
|
|
1897
|
+
const retryResult = configSchema.safeParse(merged);
|
|
1898
|
+
if (retryResult.success) {
|
|
1899
|
+
warnConfigRepaired(configPath, result.error);
|
|
1900
|
+
const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
|
|
1901
|
+
warnDegradedHostname(parsed, config);
|
|
1902
|
+
warnDegradedApiKeys(parsed, config);
|
|
1903
|
+
warnDegradedCodexAccountPriorities(parsed, config);
|
|
1904
|
+
warnDegradedClaudeSubagentEffort(parsed);
|
|
1905
|
+
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1906
|
+
warnDegradedCodexAccountPicker(parsed);
|
|
1907
|
+
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
1908
|
+
return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
|
|
1909
|
+
}
|
|
1910
|
+
// Merge couldn't fix it — truly broken config
|
|
1911
|
+
warnAndBackupInvalidConfig(configPath, result.error);
|
|
1912
|
+
return getDefaultConfig();
|
|
1913
|
+
} catch (error) {
|
|
1914
|
+
warnAndBackupInvalidConfig(configPath, error);
|
|
1915
|
+
return getDefaultConfig();
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
export type ConfigDiagnostics = {
|
|
1920
|
+
config: OcxConfig;
|
|
1921
|
+
source: "default" | "file" | "fallback";
|
|
1922
|
+
error: string | null;
|
|
1923
|
+
/** Non-fatal config concerns; absent when there are no warnings. */
|
|
1924
|
+
warnings?: string[];
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
type ConfigFileSnapshot = {
|
|
1928
|
+
diagnostics: ConfigDiagnostics;
|
|
1929
|
+
/** Exact file contents, including a possible BOM, used as the optimistic revision. */
|
|
1930
|
+
raw?: string;
|
|
1931
|
+
};
|
|
1932
|
+
|
|
1933
|
+
function configPlaceholderWarnings(config: OcxConfig): string[] {
|
|
1934
|
+
const warnings: string[] = [];
|
|
1935
|
+
for (const [name, provider] of Object.entries(config.providers)) {
|
|
1936
|
+
const placeholder = provider.baseUrl.match(/\{[^}]*\}/)?.[0];
|
|
1937
|
+
if (placeholder) {
|
|
1938
|
+
warnings.push(`providers.${name}.baseUrl contains unresolved ${placeholder}; set the real provider URL`);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
return warnings;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): ConfigDiagnostics {
|
|
1945
|
+
// Unsafe hand-edited optional values are disabled in memory instead of rejecting
|
|
1946
|
+
// the entire config, which would hide unrelated providers/accounts. The next
|
|
1947
|
+
// ordinary save persists the normalized absence.
|
|
1948
|
+
const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed);
|
|
1949
|
+
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1950
|
+
const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
|
|
1951
|
+
const warnings = configPlaceholderWarnings(normalized);
|
|
1952
|
+
warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
|
|
1953
|
+
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
1954
|
+
warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`);
|
|
1955
|
+
}
|
|
1956
|
+
warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning));
|
|
1957
|
+
const pickerWarning = malformedCodexAccountPickerWarning(rawParsed);
|
|
1958
|
+
if (pickerWarning) warnings.push(pickerWarning);
|
|
1959
|
+
const hostCircuitWarning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
|
|
1960
|
+
if (hostCircuitWarning) warnings.push(hostCircuitWarning);
|
|
1961
|
+
if (syncDisabledReason) {
|
|
1962
|
+
warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
|
|
1963
|
+
}
|
|
1964
|
+
return {
|
|
1965
|
+
config: normalized,
|
|
1966
|
+
source: "file",
|
|
1967
|
+
error: null,
|
|
1968
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
export function subagentDefaultSyncEffective(
|
|
1973
|
+
config: Pick<OcxConfig, "syncCodexSubagentDefaults" | "injectionModel">,
|
|
1974
|
+
): boolean {
|
|
1975
|
+
return config.syncCodexSubagentDefaults === true && Boolean(config.injectionModel?.trim());
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
function mergeConfigDefaults(parsed: unknown): unknown {
|
|
1979
|
+
if (!parsed || typeof parsed !== "object") return parsed;
|
|
1980
|
+
const defaults = getDefaultConfig();
|
|
1981
|
+
const raw = parsed as Record<string, unknown>;
|
|
1982
|
+
const merged: Record<string, unknown> = { ...defaults, ...raw };
|
|
1983
|
+
if (raw.providers && typeof raw.providers === "object" && defaults.providers) {
|
|
1984
|
+
merged.providers = { ...defaults.providers, ...(raw.providers as Record<string, unknown>) };
|
|
1985
|
+
}
|
|
1986
|
+
return merged;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function schemaDiagnosticsError(error: z.ZodError): string {
|
|
1990
|
+
const details = error.issues.map(issue => {
|
|
1991
|
+
const path = issue.path.join(".") || "config";
|
|
1992
|
+
return `${path}: ${issue.message}`;
|
|
1993
|
+
});
|
|
1994
|
+
return details.length > 0 ? `schema_invalid: ${details.join("; ")}` : "schema_invalid";
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* Reject a hostname the schema deliberately degrades on read. Load-time has to keep a
|
|
1999
|
+
* blank value non-fatal (see the `hostname` field comment), but an incoming write is a
|
|
2000
|
+
* live caller who can be told the value is wrong — silently rewriting it to loopback
|
|
2001
|
+
* would look like the bind succeeded on the address they asked for.
|
|
2002
|
+
*/
|
|
2003
|
+
function blankHostnameError(value: unknown): string | null {
|
|
2004
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2005
|
+
const hostname = (value as Record<string, unknown>).hostname;
|
|
2006
|
+
if (hostname === undefined) return null;
|
|
2007
|
+
if (typeof hostname !== "string" || !hostname.trim()) {
|
|
2008
|
+
return "schema_invalid: hostname: must be a nonblank bind address";
|
|
2009
|
+
}
|
|
2010
|
+
return null;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function claudeSubagentEffortError(value: unknown): string | null {
|
|
2014
|
+
const effort = rawClaudeSubagentEffort(value);
|
|
2015
|
+
if (effort === undefined || isClaudeSubagentEffort(effort)) return null;
|
|
2016
|
+
return `schema_invalid: claudeCode.subagentEffort: must be one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function appOwnedMemoryBudgetError(value: unknown): string | null {
|
|
2020
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2021
|
+
const budget = (value as Record<string, unknown>).appOwnedMemoryBudgetMb;
|
|
2022
|
+
if (budget === undefined) return null;
|
|
2023
|
+
if (typeof budget !== "number" || !Number.isInteger(budget)
|
|
2024
|
+
|| budget < MIN_APP_OWNED_MEMORY_BUDGET_MB || budget > MAX_APP_OWNED_MEMORY_BUDGET_MB) {
|
|
2025
|
+
return `schema_invalid: appOwnedMemoryBudgetMb: must be an integer from ${MIN_APP_OWNED_MEMORY_BUDGET_MB} to ${MAX_APP_OWNED_MEMORY_BUDGET_MB}`;
|
|
2026
|
+
}
|
|
2027
|
+
return null;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function upstreamHostCircuitThresholdError(value: unknown): string | null {
|
|
2031
|
+
const raw = rawConfigRecord(value);
|
|
2032
|
+
if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
|
|
2033
|
+
const threshold = raw.upstreamHostCircuitThreshold;
|
|
2034
|
+
if (threshold === undefined) return null;
|
|
2035
|
+
if (typeof threshold === "number"
|
|
2036
|
+
&& Number.isInteger(threshold)
|
|
2037
|
+
&& threshold >= 0
|
|
2038
|
+
&& threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
|
|
2039
|
+
return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/**
|
|
2043
|
+
* Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
|
|
2044
|
+
* malformed selection-order map to undefined, which on a write would drop every entry the
|
|
2045
|
+
* user had accumulated and still report success. A load-time degrade leaves the raw map in
|
|
2046
|
+
* the file to be repaired by hand; a degraded write erases it. One bad `rmx config set`
|
|
2047
|
+
* must not cost the whole map, so a live caller is told instead.
|
|
2048
|
+
*/
|
|
2049
|
+
function codexAccountPrioritiesError(value: unknown): string | null {
|
|
2050
|
+
const raw = rawConfigRecord(value);
|
|
2051
|
+
if (!raw) return null;
|
|
2052
|
+
if (raw.codexAccountPriorities !== undefined) {
|
|
2053
|
+
const parsed = codexAccountPrioritiesSchema.safeParse(raw.codexAccountPriorities);
|
|
2054
|
+
if (!parsed.success) {
|
|
2055
|
+
return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities.");
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
// Tested as a string rather than coerced: `String(123)` matches the id pattern, so a
|
|
2059
|
+
// coercing guard waves a non-string pin through to the schema, where `.catch(undefined)`
|
|
2060
|
+
// drops it and reports the write as a success — the exact silent-degrade this guards.
|
|
2061
|
+
const pin = raw.activeCodexAccountPinned;
|
|
2062
|
+
if (pin !== undefined && (typeof pin !== "string" || !CODEX_ACCOUNT_PIN_PATTERN.test(pin))) {
|
|
2063
|
+
return "schema_invalid: activeCodexAccountPinned: must be an account id";
|
|
2064
|
+
}
|
|
2065
|
+
return null;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function googleAntigravityStaticCatalogVersionError(value: unknown): string | null {
|
|
2069
|
+
const raw = rawConfigRecord(value);
|
|
2070
|
+
if (!raw || !Object.hasOwn(raw, "googleAntigravityStaticCatalogVersion")) return null;
|
|
2071
|
+
const version = raw.googleAntigravityStaticCatalogVersion;
|
|
2072
|
+
if (version === undefined || version === 1 || version === 2) return null;
|
|
2073
|
+
return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1, 2, or omitted";
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function codexAccountPickerEnabledError(value: unknown): string | null {
|
|
2077
|
+
const raw = rawConfigRecord(value);
|
|
2078
|
+
if (!raw) return null;
|
|
2079
|
+
const descriptor = Object.getOwnPropertyDescriptor(raw, "codexAccountPickerEnabled");
|
|
2080
|
+
if (!descriptor) {
|
|
2081
|
+
return "codexAccountPickerEnabled" in raw
|
|
2082
|
+
? "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted"
|
|
2083
|
+
: null;
|
|
2084
|
+
}
|
|
2085
|
+
if (!("value" in descriptor)) {
|
|
2086
|
+
return "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted";
|
|
2087
|
+
}
|
|
2088
|
+
const enabled = descriptor.value;
|
|
2089
|
+
if (enabled === undefined || typeof enabled === "boolean") return null;
|
|
2090
|
+
return "schema_invalid: codexAccountPickerEnabled: must be a boolean or omitted";
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
|
|
2094
|
+
/**
|
|
2095
|
+
* Reject a loopback-listener port that collides with the proxy port (#1102).
|
|
2096
|
+
*
|
|
2097
|
+
* The schema can only check the shape of each field on its own; the two ports being distinct
|
|
2098
|
+
* is a relationship between them. Letting the pair through would surface as a startup failure
|
|
2099
|
+
* after the public listener already bound, which reads like an unrelated port conflict.
|
|
2100
|
+
*
|
|
2101
|
+
* This is write-time only, matching `blankHostnameError`: a live caller can be told the value
|
|
2102
|
+
* is wrong, whereas a hand-edited config on the read path degrades to undefined rather than
|
|
2103
|
+
* resetting the whole file.
|
|
2104
|
+
*/
|
|
2105
|
+
function loopbackListenerPortError(value: unknown): string | null {
|
|
2106
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2107
|
+
const listener = (value as Record<string, unknown>).unauthenticatedLoopbackListener;
|
|
2108
|
+
if (listener === undefined) return null;
|
|
2109
|
+
if (!listener || typeof listener !== "object" || Array.isArray(listener)) {
|
|
2110
|
+
return "schema_invalid: unauthenticatedLoopbackListener: must be an object or omitted";
|
|
2111
|
+
}
|
|
2112
|
+
const entry = listener as Record<string, unknown>;
|
|
2113
|
+
// `enabled` must be a real boolean. The schema's `.catch(undefined)` would otherwise DELETE
|
|
2114
|
+
// a `"true"` string entry and report success, leaving an operator convinced they enabled an
|
|
2115
|
+
// unauthenticated listener that is in fact off. Load-time still degrades quietly — a hand
|
|
2116
|
+
// edit must not reset the file — but a live caller gets told.
|
|
2117
|
+
if (typeof entry.enabled !== "boolean") {
|
|
2118
|
+
return "schema_invalid: unauthenticatedLoopbackListener.enabled: must be a boolean";
|
|
2119
|
+
}
|
|
2120
|
+
if (entry.enabled !== true) return null;
|
|
2121
|
+
const listenerPort = entry.port;
|
|
2122
|
+
if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) {
|
|
2123
|
+
return "schema_invalid: unauthenticatedLoopbackListener.port: must be an integer port when enabled";
|
|
2124
|
+
}
|
|
2125
|
+
const proxyPort = (value as Record<string, unknown>).port;
|
|
2126
|
+
if (typeof proxyPort === "number" && proxyPort === listenerPort) {
|
|
2127
|
+
return "schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port";
|
|
2128
|
+
}
|
|
2129
|
+
return null;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
|
|
2133
|
+
const boundaryError = blankHostnameError(value)
|
|
2134
|
+
?? claudeSubagentEffortError(value)
|
|
2135
|
+
?? appOwnedMemoryBudgetError(value)
|
|
2136
|
+
?? upstreamHostCircuitThresholdError(value)
|
|
2137
|
+
?? googleAntigravityStaticCatalogVersionError(value)
|
|
2138
|
+
?? codexAccountPrioritiesError(value)
|
|
2139
|
+
?? codexAccountPickerEnabledError(value)
|
|
2140
|
+
?? loopbackListenerPortError(value);
|
|
2141
|
+
if (boundaryError) return { ok: false, error: boundaryError };
|
|
2142
|
+
const result = configSchema.safeParse(value);
|
|
2143
|
+
if (result.success) return { ok: true, config: normalizeApiKeyIds(result.data as OcxConfig) };
|
|
2144
|
+
return { ok: false, error: schemaDiagnosticsError(result.error) };
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
|
|
2148
|
+
try {
|
|
2149
|
+
const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
|
|
2150
|
+
// Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the
|
|
2151
|
+
// schema and send the caller a default-config fallback (the config command could then
|
|
2152
|
+
// persist that fallback over the user's providers/keys).
|
|
2153
|
+
sanitizeRetryOn429ForLoad(parsed);
|
|
2154
|
+
const result = configSchema.safeParse(parsed);
|
|
2155
|
+
if (result.success) {
|
|
2156
|
+
return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed));
|
|
2160
|
+
if (retryResult.success) {
|
|
2161
|
+
return validFileConfigDiagnostics(normalizeApiKeyIds(retryResult.data as OcxConfig), parsed);
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
|
|
2165
|
+
} catch {
|
|
2166
|
+
return { config: getDefaultConfig(), source: "fallback", error: "invalid_json" };
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
function readConfigFileSnapshot(): ConfigFileSnapshot {
|
|
2171
|
+
try {
|
|
2172
|
+
const raw = readFileSync(getConfigPath(), "utf-8");
|
|
2173
|
+
return { diagnostics: configDiagnosticsFromRaw(raw), raw };
|
|
2174
|
+
} catch (error) {
|
|
2175
|
+
if (isMissingPathError(error)) {
|
|
2176
|
+
return {
|
|
2177
|
+
diagnostics: { config: getDefaultConfig(), source: "default", error: null },
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
return {
|
|
2181
|
+
diagnostics: { config: getDefaultConfig(), source: "fallback", error: "invalid_json" },
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
export function readConfigDiagnostics(): ConfigDiagnostics {
|
|
2187
|
+
return readConfigFileSnapshot().diagnostics;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* The persisted config, plus a digest of the EXACT bytes it was parsed from.
|
|
2192
|
+
*
|
|
2193
|
+
* A union rather than a nullable digest, because `{ kind: "read" }` with no
|
|
2194
|
+
* digest is a state that cannot occur — and a state that cannot occur should
|
|
2195
|
+
* not be a state that can be written down. Refusing it at runtime is a check
|
|
2196
|
+
* somebody eventually forgets; making it unrepresentable is not.
|
|
2197
|
+
*
|
|
2198
|
+
* Why a byte digest at all: the Codex write lock compares an authority snapshot
|
|
2199
|
+
* taken before the lock against one taken while holding it, and its config
|
|
2200
|
+
* component used to hash the PARSED object. Two files that differ only in
|
|
2201
|
+
* whitespace or key order parse identically, so a non-cooperating writer could
|
|
2202
|
+
* rewrite the file between admission and commit and the comparison would see
|
|
2203
|
+
* nothing. Hashing what was actually read closes that.
|
|
2204
|
+
*
|
|
2205
|
+
* `readConfigFileSnapshot` stays private on purpose. Its `raw` carries provider
|
|
2206
|
+
* API keys and admission tokens, and `privacy:scan` reads tracked source text,
|
|
2207
|
+
* not runtime values — so it would not catch a caller that logged or serialized
|
|
2208
|
+
* that string. The digest travels; the bytes do not.
|
|
2209
|
+
*/
|
|
2210
|
+
export type ConfigAdmissionSnapshot =
|
|
2211
|
+
| Readonly<{ kind: "read"; diagnostics: ConfigDiagnostics; contentSha256: string }>
|
|
2212
|
+
| Readonly<{ kind: "unreadable"; diagnostics: ConfigDiagnostics; contentSha256: null }>;
|
|
2213
|
+
|
|
2214
|
+
export function readConfigAdmissionSnapshot(): ConfigAdmissionSnapshot {
|
|
2215
|
+
let bytes: Buffer;
|
|
2216
|
+
try {
|
|
2217
|
+
// ONE read. Hashing the file and then reading it again to parse would leave
|
|
2218
|
+
// a window for the two to disagree, which is the exact hazard this exists
|
|
2219
|
+
// to detect — the check would become a second chance to be wrong.
|
|
2220
|
+
bytes = readFileSync(getConfigPath());
|
|
2221
|
+
} catch (error) {
|
|
2222
|
+
return {
|
|
2223
|
+
kind: "unreadable",
|
|
2224
|
+
diagnostics: isMissingPathError(error)
|
|
2225
|
+
? { config: getDefaultConfig(), source: "default", error: null }
|
|
2226
|
+
: { config: getDefaultConfig(), source: "fallback", error: "invalid_json" },
|
|
2227
|
+
contentSha256: null,
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
return {
|
|
2231
|
+
kind: "read",
|
|
2232
|
+
// Decoded from the same buffer that was hashed, not re-read from disk.
|
|
2233
|
+
diagnostics: configDiagnosticsFromRaw(bytes.toString("utf-8")),
|
|
2234
|
+
contentSha256: createHash("sha256").update(bytes).digest("hex"),
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite";
|
|
2239
|
+
const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const;
|
|
2240
|
+
let warnedConfigMutationDirectoryAcl = false;
|
|
2241
|
+
|
|
2242
|
+
export class ConfigMutationLockError extends Error {
|
|
2243
|
+
readonly code = "CONFIG_MUTATION_LOCK_UNAVAILABLE";
|
|
2244
|
+
|
|
2245
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
2246
|
+
super(message, options);
|
|
2247
|
+
this.name = "ConfigMutationLockError";
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function configMutationDatabasePath(): string {
|
|
2252
|
+
const dir = getConfigDir();
|
|
2253
|
+
// First statement on purpose: a rejected mutation must leave nothing behind, not a
|
|
2254
|
+
// freshly created/chmod'd directory or database. See src/lib/test-home-guard.ts.
|
|
2255
|
+
assertNotRealHomeUnderTest(dir);
|
|
2256
|
+
if (!existsSync(dir)) {
|
|
2257
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
2258
|
+
} else {
|
|
2259
|
+
try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
|
|
2260
|
+
}
|
|
2261
|
+
if (windowsSecretAclApplies()) {
|
|
2262
|
+
try {
|
|
2263
|
+
// Distinct timeout memo from management-token directory harden: a required
|
|
2264
|
+
// management-dir timeout must not poison config mutation on the same home
|
|
2265
|
+
// (windows-latest server-management-auth cases).
|
|
2266
|
+
hardenSecretDir(dir, { required: true, timeoutMemoKey: `${dir}::config-mutation` });
|
|
2267
|
+
} catch (error) {
|
|
2268
|
+
if (!warnedConfigMutationDirectoryAcl) {
|
|
2269
|
+
warnedConfigMutationDirectoryAcl = true;
|
|
2270
|
+
const diagnostics = error instanceof Error ? error.message : "ACL hardening failed";
|
|
2271
|
+
console.warn(
|
|
2272
|
+
`[Remodex] Config mutation coordination directory ACL hardening did not complete; continuing without it. ${diagnostics}`,
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
const path = join(dir, CONFIG_MUTATION_DB_FILENAME);
|
|
2278
|
+
recordOwnedConfigPath(dir, path);
|
|
2279
|
+
for (const suffix of CONFIG_MUTATION_DB_SIDECARS) {
|
|
2280
|
+
recordOwnedConfigPath(dir, `${path}${suffix}`);
|
|
2281
|
+
}
|
|
2282
|
+
return path;
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
let configMutationLockDepth = 0;
|
|
2286
|
+
let configMutationDatabase: Database | null = null;
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* Serialize synchronous config and Codex credential-generation commits across processes with an
|
|
2290
|
+
* OS-backed SQLite write transaction. `busy_timeout=0` is deliberate: runtime request paths must
|
|
2291
|
+
* fail immediately under contention rather than freeze the Bun event loop. Process exit releases
|
|
2292
|
+
* SQLite locks without stale-owner deletion or lease recovery races.
|
|
2293
|
+
*
|
|
2294
|
+
* Reentrancy is limited to the current synchronous call stack; never return a Promise from `fn`.
|
|
2295
|
+
*/
|
|
2296
|
+
export function withConfigMutationLockSync<T>(fn: () => T): T {
|
|
2297
|
+
if (configMutationLockDepth > 0) {
|
|
2298
|
+
configMutationLockDepth += 1;
|
|
2299
|
+
try {
|
|
2300
|
+
return fn();
|
|
2301
|
+
} finally {
|
|
2302
|
+
configMutationLockDepth -= 1;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
const path = configMutationDatabasePath();
|
|
2306
|
+
let database: Database | undefined;
|
|
2307
|
+
let transactionOpen = false;
|
|
2308
|
+
try {
|
|
2309
|
+
database = new Database(path, { create: true });
|
|
2310
|
+
try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ }
|
|
2311
|
+
database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
|
|
2312
|
+
transactionOpen = true;
|
|
2313
|
+
initializeConfigGeneration(database);
|
|
2314
|
+
} catch (cause) {
|
|
2315
|
+
if (transactionOpen) {
|
|
2316
|
+
try { database?.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
|
|
2317
|
+
}
|
|
2318
|
+
try { database?.close(); } catch { /* acquisition already failed */ }
|
|
2319
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
2320
|
+
? String((cause as { code?: unknown }).code)
|
|
2321
|
+
: "";
|
|
2322
|
+
throw new ConfigMutationLockError(
|
|
2323
|
+
code === "SQLITE_BUSY" ? "Config mutation already in progress" : "Could not acquire config mutation transaction",
|
|
2324
|
+
{ cause },
|
|
2325
|
+
);
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
configMutationLockDepth = 1;
|
|
2329
|
+
configMutationDatabase = database;
|
|
2330
|
+
try {
|
|
2331
|
+
const value = fn();
|
|
2332
|
+
database.exec("COMMIT");
|
|
2333
|
+
transactionOpen = false;
|
|
2334
|
+
return value;
|
|
2335
|
+
} catch (error) {
|
|
2336
|
+
if (transactionOpen) {
|
|
2337
|
+
try { database.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
|
|
2338
|
+
transactionOpen = false;
|
|
2339
|
+
}
|
|
2340
|
+
throw error;
|
|
2341
|
+
} finally {
|
|
2342
|
+
configMutationLockDepth = 0;
|
|
2343
|
+
configMutationDatabase = null;
|
|
2344
|
+
try { database.close(); } catch { /* the OS lock is released with the handle */ }
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
function bumpGenerationForCooperatingConfigWrite(): void {
|
|
2349
|
+
if (!configMutationDatabase) {
|
|
2350
|
+
throw new Error("A cooperating config write requires the config mutation transaction.");
|
|
2351
|
+
}
|
|
2352
|
+
bumpCurrentConfigGeneration(configMutationDatabase);
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
export const readConfigGeneration: ReadConfigGeneration = () => {
|
|
2356
|
+
try {
|
|
2357
|
+
return readConfigGenerationAtPath(configMutationDatabasePath());
|
|
2358
|
+
} catch {
|
|
2359
|
+
return { kind: "unavailable", reason: "database" };
|
|
2360
|
+
}
|
|
2361
|
+
};
|
|
2362
|
+
|
|
2363
|
+
export function observeConfigGeneration(): ConfigGenerationObservation {
|
|
2364
|
+
return observeConfigGenerationAtPath(join(getConfigDir(), CONFIG_MUTATION_DB_FILENAME));
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
/**
|
|
2368
|
+
* Read the generation from the transaction that is open RIGHT NOW.
|
|
2369
|
+
*
|
|
2370
|
+
* The observer cannot do this job. On the very first acquisition the
|
|
2371
|
+
* `BEGIN IMMEDIATE` that creates the table has not committed yet, so a separate
|
|
2372
|
+
* read-only connection cannot read a generation from it — measured, not
|
|
2373
|
+
* assumed. A caller that compared a pre-lock observation against an observer
|
|
2374
|
+
* re-read would therefore refuse every first write as stale.
|
|
2375
|
+
*
|
|
2376
|
+
* Throwing when no transaction is open is deliberate. Being called outside the
|
|
2377
|
+
* lock is broken plumbing, and returning a typed "unavailable" would let that
|
|
2378
|
+
* bug arrive disguised as an environmental failure — retried forever, on a
|
|
2379
|
+
* machine where nothing is wrong.
|
|
2380
|
+
*/
|
|
2381
|
+
export function readConfigGenerationInCurrentMutationTransaction(): ConfigGeneration {
|
|
2382
|
+
if (configMutationLockDepth < 1 || !configMutationDatabase) {
|
|
2383
|
+
throw new Error(
|
|
2384
|
+
"readConfigGenerationInCurrentMutationTransaction requires an open config mutation transaction.",
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
return readConfigGenerationInTransaction(configMutationDatabase);
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
export const bumpConfigGeneration: BumpConfigGeneration = expected => {
|
|
2391
|
+
try {
|
|
2392
|
+
return bumpConfigGenerationAtPath(configMutationDatabasePath(), expected);
|
|
2393
|
+
} catch {
|
|
2394
|
+
return { kind: "unavailable", reason: "database" };
|
|
2395
|
+
}
|
|
2396
|
+
};
|
|
2397
|
+
|
|
2398
|
+
function configGenerationFailureReason(error: unknown): "busy" | "database" {
|
|
2399
|
+
const cause = error instanceof ConfigMutationLockError ? error.cause : error;
|
|
2400
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
2401
|
+
? String((cause as { code?: unknown }).code)
|
|
2402
|
+
: "";
|
|
2403
|
+
const message = cause instanceof Error ? cause.message : "";
|
|
2404
|
+
return code === "SQLITE_BUSY"
|
|
2405
|
+
|| code === "SQLITE_LOCKED"
|
|
2406
|
+
|| /database (?:is|table is) locked/i.test(message)
|
|
2407
|
+
? "busy"
|
|
2408
|
+
: "database";
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync = (
|
|
2412
|
+
expected,
|
|
2413
|
+
commit,
|
|
2414
|
+
) => {
|
|
2415
|
+
let callbackThrew = false;
|
|
2416
|
+
let callbackError: unknown;
|
|
2417
|
+
try {
|
|
2418
|
+
return withConfigMutationLockSync(() => {
|
|
2419
|
+
const database = configMutationDatabase;
|
|
2420
|
+
if (!database) throw new Error("Config mutation transaction database is unavailable.");
|
|
2421
|
+
const current = readConfigGenerationInTransaction(database);
|
|
2422
|
+
if (current.value !== expected.value) return { kind: "conflict", current };
|
|
2423
|
+
try {
|
|
2424
|
+
return { kind: "matched", generation: current, value: commit() };
|
|
2425
|
+
} catch (error) {
|
|
2426
|
+
callbackThrew = true;
|
|
2427
|
+
callbackError = error;
|
|
2428
|
+
throw error;
|
|
2429
|
+
}
|
|
2430
|
+
});
|
|
2431
|
+
} catch (error) {
|
|
2432
|
+
if (callbackThrew && error === callbackError) throw error;
|
|
2433
|
+
return { kind: "unavailable", reason: configGenerationFailureReason(error) };
|
|
2434
|
+
}
|
|
2435
|
+
};
|
|
2436
|
+
|
|
2437
|
+
function persistConfigUnlocked(config: OcxConfig): boolean {
|
|
2438
|
+
const configPath = getConfigPath();
|
|
2439
|
+
const bytes = JSON.stringify(config, null, 2) + "\n";
|
|
2440
|
+
try {
|
|
2441
|
+
if (readFileSync(configPath, "utf8") === bytes) return false;
|
|
2442
|
+
} catch (error) {
|
|
2443
|
+
if (!isMissingPathError(error)) throw error;
|
|
2444
|
+
}
|
|
2445
|
+
atomicWriteFile(configPath, bytes);
|
|
2446
|
+
return true;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
export function saveConfig(config: OcxConfig): void {
|
|
2450
|
+
// Keep the real-home assertion ahead of even lock-directory preparation.
|
|
2451
|
+
assertNotRealHomeUnderTest(getConfigDir());
|
|
2452
|
+
withConfigMutationLockSync(() => {
|
|
2453
|
+
const projected = projectCustomModelCatalogMigration(readRawConfigJson(), config);
|
|
2454
|
+
if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite();
|
|
2455
|
+
adoptCustomModelCatalogMigration(config, projected);
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
export type PersistedConfigMutation<T> = {
|
|
2460
|
+
changed: boolean;
|
|
2461
|
+
value: T;
|
|
2462
|
+
};
|
|
2463
|
+
|
|
2464
|
+
export type PersistedConfigMutationOutcome<T> =
|
|
2465
|
+
| { status: "committed" | "unchanged"; value: T }
|
|
2466
|
+
| { status: "unavailable"; reason: "missing" | "invalid" | "conflict" };
|
|
2467
|
+
|
|
2468
|
+
/**
|
|
2469
|
+
* Result of the first-run config bootstrap.
|
|
2470
|
+
*
|
|
2471
|
+
* `loadConfig()` is deliberately read-only when `config.json` is absent. That
|
|
2472
|
+
* is the right property for status/diagnostic calls, but it left startup with
|
|
2473
|
+
* no persisted config to admit when Codex Desktop selected `codex-lb`. Keep
|
|
2474
|
+
* the write explicit and report whether it actually created anything so the
|
|
2475
|
+
* caller can distinguish a fresh install from an existing or malformed file.
|
|
2476
|
+
*/
|
|
2477
|
+
export type ConfigBootstrapResult =
|
|
2478
|
+
| { status: "created" }
|
|
2479
|
+
| { status: "existing" }
|
|
2480
|
+
| { status: "invalid" };
|
|
2481
|
+
|
|
2482
|
+
/** A filesystem error whose code means another writer won an exclusive create. */
|
|
2483
|
+
function isAlreadyExistsFsError(error: unknown): boolean {
|
|
2484
|
+
return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
/**
|
|
2488
|
+
* Publish the default config exactly once, and only when the destination is
|
|
2489
|
+
* still absent.
|
|
2490
|
+
*
|
|
2491
|
+
* This is intentionally separate from `mutatePersistedConfig`: that API must
|
|
2492
|
+
* continue refusing missing/malformed files, while startup needs one narrowly
|
|
2493
|
+
* scoped way to bootstrap a genuinely fresh home. A temporary file is written
|
|
2494
|
+
* and hard-linked into place, so a concurrent non-cooperating writer can win
|
|
2495
|
+
* without being overwritten and readers never observe a partially written
|
|
2496
|
+
* JSON document. Existing or malformed files are never replaced.
|
|
2497
|
+
*/
|
|
2498
|
+
export function ensureConfigFile(): ConfigBootstrapResult {
|
|
2499
|
+
// Keep the real-home guard ahead of lock/directory preparation, matching the
|
|
2500
|
+
// other explicit config write entry points.
|
|
2501
|
+
assertNotRealHomeUnderTest(getConfigDir());
|
|
2502
|
+
return withConfigMutationLockSync(() => {
|
|
2503
|
+
const path = getConfigPath();
|
|
2504
|
+
const current = readConfigFileSnapshot();
|
|
2505
|
+
if (current.diagnostics.source === "file") return { status: "existing" };
|
|
2506
|
+
if (current.diagnostics.source !== "default") return { status: "invalid" };
|
|
2507
|
+
|
|
2508
|
+
const target = resolveWriteTarget(path);
|
|
2509
|
+
const temp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
2510
|
+
const bytes = JSON.stringify(getDefaultConfig(), null, 2) + "\n";
|
|
2511
|
+
// The coordinator acquisition creates/hardens the config directory. Record
|
|
2512
|
+
// config.json before publishing so fresh homes remain uninstallable.
|
|
2513
|
+
recordOwnedConfigPath(getConfigDir(), path);
|
|
2514
|
+
let published = false;
|
|
2515
|
+
try {
|
|
2516
|
+
writeFileSync(temp, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
2517
|
+
hardenExistingSecret(temp);
|
|
2518
|
+
try {
|
|
2519
|
+
// A hard link is an atomic no-replace publish on the same filesystem.
|
|
2520
|
+
// `target` is resolved first so an existing symlink can never be
|
|
2521
|
+
// replaced accidentally.
|
|
2522
|
+
linkSync(temp, target);
|
|
2523
|
+
published = true;
|
|
2524
|
+
} catch (error) {
|
|
2525
|
+
if (!isAlreadyExistsFsError(error)) throw error;
|
|
2526
|
+
// Another writer appeared after the initial read. Preserve it and
|
|
2527
|
+
// classify what it wrote without attempting a repair.
|
|
2528
|
+
const winner = readConfigFileSnapshot();
|
|
2529
|
+
return { status: winner.diagnostics.source === "file" ? "existing" : "invalid" };
|
|
2530
|
+
}
|
|
2531
|
+
return { status: "created" };
|
|
2532
|
+
} finally {
|
|
2533
|
+
// The destination owns the inode after a successful hard-link publish;
|
|
2534
|
+
// on every path the staging name is disposable.
|
|
2535
|
+
try { unlinkSync(temp); } catch (error) {
|
|
2536
|
+
if (!isMissingPathError(error) && published) throw error;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
const CONFIG_MUTATION_MAX_REBASE_ATTEMPTS = 3;
|
|
2543
|
+
let persistedConfigMutationBeforeCommitForTests: (() => void) | null = null;
|
|
2544
|
+
|
|
2545
|
+
/** Test-only one-shot seam: inject a competing mutation after the first decision, before freshness revalidation. */
|
|
2546
|
+
export function setPersistedConfigMutationBeforeCommitForTests(hook: (() => void) | null): void {
|
|
2547
|
+
persistedConfigMutationBeforeCommitForTests = hook;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
function unavailableConfigMutationReason(snapshot: ConfigFileSnapshot): "missing" | "invalid" {
|
|
2551
|
+
return snapshot.diagnostics.source === "default" ? "missing" : "invalid";
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
/**
|
|
2555
|
+
* Patch a schema-valid on-disk config under the shared mutation lock. Cooperating writers are
|
|
2556
|
+
* serialized; the callback is rerun on the newest snapshot so observed direct byte changes rebase
|
|
2557
|
+
* and credential predicates are re-evaluated immediately before the atomic commit. A writer that
|
|
2558
|
+
* ignores the coordinator can still change bytes after the final check because the filesystem has
|
|
2559
|
+
* no portable conditional rename. Missing or malformed config always fails closed and is never
|
|
2560
|
+
* recreated from a prior snapshot.
|
|
2561
|
+
*/
|
|
2562
|
+
export function mutatePersistedConfig<T>(
|
|
2563
|
+
mutate: (config: OcxConfig) => PersistedConfigMutation<T>,
|
|
2564
|
+
): PersistedConfigMutationOutcome<T> {
|
|
2565
|
+
// Avoid creating/opening the coordinator database for a read-path update that already knows
|
|
2566
|
+
// there is no valid config. The same check runs again under the transaction for authority.
|
|
2567
|
+
const observed = readConfigFileSnapshot();
|
|
2568
|
+
if (observed.diagnostics.source !== "file" || observed.raw === undefined) {
|
|
2569
|
+
return { status: "unavailable", reason: unavailableConfigMutationReason(observed) };
|
|
2570
|
+
}
|
|
2571
|
+
return withConfigMutationLockSync(() => {
|
|
2572
|
+
let base = readConfigFileSnapshot();
|
|
2573
|
+
for (let attempt = 0; attempt < CONFIG_MUTATION_MAX_REBASE_ATTEMPTS; attempt += 1) {
|
|
2574
|
+
if (base.diagnostics.source !== "file" || base.raw === undefined) {
|
|
2575
|
+
return { status: "unavailable", reason: unavailableConfigMutationReason(base) };
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
const tentativeConfig = structuredClone(base.diagnostics.config);
|
|
2579
|
+
const tentative = mutate(tentativeConfig);
|
|
2580
|
+
if (!tentative.changed) return { status: "unchanged", value: tentative.value };
|
|
2581
|
+
|
|
2582
|
+
const hook = persistedConfigMutationBeforeCommitForTests;
|
|
2583
|
+
persistedConfigMutationBeforeCommitForTests = null;
|
|
2584
|
+
hook?.();
|
|
2585
|
+
|
|
2586
|
+
const latest = readConfigFileSnapshot();
|
|
2587
|
+
if (latest.diagnostics.source !== "file" || latest.raw === undefined) {
|
|
2588
|
+
return { status: "unavailable", reason: unavailableConfigMutationReason(latest) };
|
|
2589
|
+
}
|
|
2590
|
+
if (latest.raw !== base.raw) {
|
|
2591
|
+
base = latest;
|
|
2592
|
+
continue;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
// Re-run against a fresh clone even when config bytes are unchanged: a Codex credential
|
|
2596
|
+
// generation lives in a separate file and may have changed at the injected seam.
|
|
2597
|
+
const confirmedConfig = structuredClone(latest.diagnostics.config);
|
|
2598
|
+
const confirmed = mutate(confirmedConfig);
|
|
2599
|
+
if (!confirmed.changed) return { status: "unchanged", value: confirmed.value };
|
|
2600
|
+
|
|
2601
|
+
const commitBase = readConfigFileSnapshot();
|
|
2602
|
+
if (commitBase.diagnostics.source !== "file" || commitBase.raw === undefined) {
|
|
2603
|
+
return { status: "unavailable", reason: unavailableConfigMutationReason(commitBase) };
|
|
2604
|
+
}
|
|
2605
|
+
if (commitBase.raw !== latest.raw) {
|
|
2606
|
+
base = commitBase;
|
|
2607
|
+
continue;
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
const projected = projectCustomModelCatalogMigration(
|
|
2611
|
+
commitBase.diagnostics.config,
|
|
2612
|
+
confirmedConfig,
|
|
2613
|
+
);
|
|
2614
|
+
if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite();
|
|
2615
|
+
return { status: "committed", value: confirmed.value };
|
|
2616
|
+
}
|
|
2617
|
+
return { status: "unavailable", reason: "conflict" };
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
|
|
2622
|
+
return config.websockets === true;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// ---------------------------------------------------------------------------
|
|
2626
|
+
// Hand-edit protection for the `claudeCode` subtree (devlog 260726_claude_auth_auto/040 H1).
|
|
2627
|
+
//
|
|
2628
|
+
// `saveConfig` serializes the WHOLE config object, so ANY service-time save — a model
|
|
2629
|
+
// visibility toggle, a 429 key rotation on the request path — rewrites `claudeCode`
|
|
2630
|
+
// from whatever the long-lived server config happens to hold. A user who hand-edits
|
|
2631
|
+
// `config.json` while the proxy runs then watches their edit vanish for no visible
|
|
2632
|
+
// reason (issue #488). Enumerating `claudeCode` mutators cannot fix that; the guard has
|
|
2633
|
+
// to live in ONE save wrapper that every live-config writer goes through.
|
|
2634
|
+
// ---------------------------------------------------------------------------
|
|
2635
|
+
|
|
2636
|
+
/**
|
|
2637
|
+
* Baseline keyed on the CONFIG INSTANCE, never a module global: a second `loadConfig()`
|
|
2638
|
+
* elsewhere must not refresh the baseline the long-lived server config is judged
|
|
2639
|
+
* against, or a later stale save would masquerade as "our own change".
|
|
2640
|
+
*/
|
|
2641
|
+
const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
|
|
2642
|
+
|
|
2643
|
+
/**
|
|
2644
|
+
* The live config retains the address of the socket Bun actually opened, while
|
|
2645
|
+
* this map retains the operator's desired address for the next process start.
|
|
2646
|
+
* Keeping them separate prevents an unrelated live save from restoring a stale
|
|
2647
|
+
* externally exposed bind after OAuth adopted a newer loopback disk config.
|
|
2648
|
+
*/
|
|
2649
|
+
type PersistedServerBinding = Pick<OcxConfig, "port" | "hostname">;
|
|
2650
|
+
|
|
2651
|
+
const persistedLiveServerBinding = new WeakMap<OcxConfig, PersistedServerBinding>();
|
|
2652
|
+
|
|
2653
|
+
/**
|
|
2654
|
+
* Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on
|
|
2655
|
+
* first save — arming lazily would lose exactly the hand edit made before that first
|
|
2656
|
+
* save, which is the case the guard exists for.
|
|
2657
|
+
*/
|
|
2658
|
+
export function armClaudeCodeBaseline(config: OcxConfig): void {
|
|
2659
|
+
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
/** Test seam only: is this instance armed? */
|
|
2663
|
+
export function claudeCodeBaselineArmed(config: OcxConfig): boolean {
|
|
2664
|
+
return claudeCodeBaseline.has(config);
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
/**
|
|
2668
|
+
* Structural compare of parsed subtrees. NOT `JSON.stringify`: key order must not
|
|
2669
|
+
* decide whether a user's hand edit survives.
|
|
2670
|
+
*/
|
|
2671
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
2672
|
+
if (a === b) return true;
|
|
2673
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
2674
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
2675
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
2676
|
+
return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
|
|
2677
|
+
}
|
|
2678
|
+
const left = a as Record<string, unknown>;
|
|
2679
|
+
const right = b as Record<string, unknown>;
|
|
2680
|
+
// `undefined` values and absent keys are the same thing after a JSON round-trip.
|
|
2681
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
2682
|
+
for (const key of keys) {
|
|
2683
|
+
if (left[key] === undefined && right[key] === undefined) continue;
|
|
2684
|
+
if (!deepEqual(left[key], right[key])) return false;
|
|
2685
|
+
}
|
|
2686
|
+
return true;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
const MISSING_CONFIG_VALUE = Symbol("missing-config-value");
|
|
2690
|
+
type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE;
|
|
2691
|
+
|
|
2692
|
+
function isPlainConfigRecord(value: ConfigMergeValue): value is Record<string, unknown> {
|
|
2693
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
2694
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2695
|
+
return prototype === Object.prototype || prototype === null;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
function ownConfigValue(record: Record<string, unknown>, key: string): ConfigMergeValue {
|
|
2699
|
+
return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue {
|
|
2703
|
+
return value === MISSING_CONFIG_VALUE ? value : structuredClone(value);
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
function reconcileConfigRecord(
|
|
2707
|
+
live: Record<string, unknown>,
|
|
2708
|
+
baseline: Record<string, unknown>,
|
|
2709
|
+
persisted: Record<string, unknown>,
|
|
2710
|
+
skippedKeys?: ReadonlySet<string>,
|
|
2711
|
+
): void {
|
|
2712
|
+
const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]);
|
|
2713
|
+
for (const key of keys) {
|
|
2714
|
+
if (skippedKeys?.has(key)) continue;
|
|
2715
|
+
const merged = reconcileConfigValue(
|
|
2716
|
+
ownConfigValue(baseline, key),
|
|
2717
|
+
ownConfigValue(live, key),
|
|
2718
|
+
ownConfigValue(persisted, key),
|
|
2719
|
+
);
|
|
2720
|
+
if (merged === MISSING_CONFIG_VALUE) delete live[key];
|
|
2721
|
+
else live[key] = merged;
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
function reconcileConfigValue(
|
|
2726
|
+
baseline: ConfigMergeValue,
|
|
2727
|
+
live: ConfigMergeValue,
|
|
2728
|
+
persisted: ConfigMergeValue,
|
|
2729
|
+
): ConfigMergeValue {
|
|
2730
|
+
const liveChanged = !deepEqual(live, baseline);
|
|
2731
|
+
const persistedChanged = !deepEqual(persisted, baseline);
|
|
2732
|
+
|
|
2733
|
+
if (!liveChanged) {
|
|
2734
|
+
if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) {
|
|
2735
|
+
live.splice(0, live.length, ...structuredClone(persisted));
|
|
2736
|
+
return live;
|
|
2737
|
+
}
|
|
2738
|
+
if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) {
|
|
2739
|
+
reconcileConfigRecord(
|
|
2740
|
+
live,
|
|
2741
|
+
isPlainConfigRecord(baseline) ? baseline : {},
|
|
2742
|
+
persisted,
|
|
2743
|
+
);
|
|
2744
|
+
return live;
|
|
2745
|
+
}
|
|
2746
|
+
return cloneConfigValue(persisted);
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
if (!persistedChanged) return live;
|
|
2750
|
+
|
|
2751
|
+
if (isPlainConfigRecord(live)
|
|
2752
|
+
&& isPlainConfigRecord(persisted)
|
|
2753
|
+
&& (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) {
|
|
2754
|
+
reconcileConfigRecord(
|
|
2755
|
+
live,
|
|
2756
|
+
isPlainConfigRecord(baseline) ? baseline : {},
|
|
2757
|
+
persisted,
|
|
2758
|
+
);
|
|
2759
|
+
}
|
|
2760
|
+
// Same-leaf conflicts prefer the pending live management mutation.
|
|
2761
|
+
return live;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
/**
|
|
2765
|
+
* Reconcile an async OAuth disk commit into the shared live config without erasing
|
|
2766
|
+
* management mutations that have not saved yet. The baseline is a normalized disk
|
|
2767
|
+
* snapshot from immediately before login; disjoint object edits merge recursively,
|
|
2768
|
+
* while same-leaf conflicts prefer live state.
|
|
2769
|
+
*/
|
|
2770
|
+
export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void {
|
|
2771
|
+
const diagnostics = readConfigDiagnostics();
|
|
2772
|
+
if (diagnostics.source === "fallback") {
|
|
2773
|
+
throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`);
|
|
2774
|
+
}
|
|
2775
|
+
const persisted = diagnostics.config;
|
|
2776
|
+
const claudeGuardArmed = claudeCodeBaseline.has(config);
|
|
2777
|
+
const pendingLiveClaudeMutation = claudeGuardArmed
|
|
2778
|
+
&& !deepEqual(config.claudeCode, claudeCodeBaseline.get(config));
|
|
2779
|
+
|
|
2780
|
+
persistedLiveServerBinding.set(config, {
|
|
2781
|
+
port: persisted.port,
|
|
2782
|
+
...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}),
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
reconcileConfigRecord(
|
|
2786
|
+
config as unknown as Record<string, unknown>,
|
|
2787
|
+
persistedBaseline as unknown as Record<string, unknown>,
|
|
2788
|
+
persisted as unknown as Record<string, unknown>,
|
|
2789
|
+
new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]),
|
|
2790
|
+
);
|
|
2791
|
+
|
|
2792
|
+
if (claudeGuardArmed && !pendingLiveClaudeMutation) {
|
|
2793
|
+
if (persisted.claudeCode === undefined) delete config.claudeCode;
|
|
2794
|
+
else config.claudeCode = structuredClone(persisted.claudeCode);
|
|
2795
|
+
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
/** The literal file, with no schema merge or default injection. */
|
|
2800
|
+
function readRawConfigJson(): Record<string, unknown> | undefined {
|
|
2801
|
+
try {
|
|
2802
|
+
const configPath = getConfigPath();
|
|
2803
|
+
if (!existsSync(configPath)) return undefined;
|
|
2804
|
+
const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
|
|
2805
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
2806
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
2807
|
+
return parsed as Record<string, unknown>;
|
|
2808
|
+
} catch {
|
|
2809
|
+
// Unreadable or corrupt: behave exactly as before. Never fail a save over protection.
|
|
2810
|
+
return undefined;
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
/**
|
|
2815
|
+
* Read only schema-valid binding fields from the literal file. Missing fields mean
|
|
2816
|
+
* their schema defaults; malformed fields keep the last known persisted value.
|
|
2817
|
+
*/
|
|
2818
|
+
function readPersistedServerBinding(
|
|
2819
|
+
raw: Record<string, unknown>,
|
|
2820
|
+
baseline: PersistedServerBinding,
|
|
2821
|
+
): PersistedServerBinding {
|
|
2822
|
+
const port = raw.port === undefined
|
|
2823
|
+
? 10100
|
|
2824
|
+
: (typeof raw.port === "number"
|
|
2825
|
+
&& Number.isInteger(raw.port)
|
|
2826
|
+
&& raw.port >= 0
|
|
2827
|
+
&& raw.port <= 65535
|
|
2828
|
+
? raw.port
|
|
2829
|
+
: baseline.port);
|
|
2830
|
+
const hostname = raw.hostname === undefined
|
|
2831
|
+
? undefined
|
|
2832
|
+
: (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname);
|
|
2833
|
+
return { port, ...(hostname !== undefined ? { hostname } : {}) };
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/**
|
|
2837
|
+
* The save entry point for every writer holding a LIVE server config.
|
|
2838
|
+
*
|
|
2839
|
+
* Conflict policy, chosen deliberately:
|
|
2840
|
+
* - disk changed, we did not → their hand edit wins;
|
|
2841
|
+
* - disk changed AND we changed → our change wins and the baseline rebases, so the
|
|
2842
|
+
* user's next edit starts from the new value (a three-way merge is out of scope);
|
|
2843
|
+
* - file missing/unreadable → save what we have, no throw.
|
|
2844
|
+
*
|
|
2845
|
+
* Scope residual: only `claudeCode` is reconciled. A hand edit to `providers` is still
|
|
2846
|
+
* clobbered — recorded and asserted in tests so it cannot drift into an assumed
|
|
2847
|
+
* guarantee.
|
|
2848
|
+
*/
|
|
2849
|
+
export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
|
|
2850
|
+
withConfigMutationLockSync(() => {
|
|
2851
|
+
const bindingBaseline = persistedLiveServerBinding.get(config);
|
|
2852
|
+
// One authoritative pre-write read feeds both the live-config reconciliation and
|
|
2853
|
+
// custom-model deletion migration. A second read could observe different bytes.
|
|
2854
|
+
const onDisk = readRawConfigJson();
|
|
2855
|
+
if (claudeCodeBaseline.has(config)) {
|
|
2856
|
+
if (onDisk !== undefined) {
|
|
2857
|
+
const baseline = claudeCodeBaseline.get(config);
|
|
2858
|
+
const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode);
|
|
2859
|
+
const diskChanged = !deepEqual(persistedClaudeCode, baseline);
|
|
2860
|
+
const weChanged = !deepEqual(config.claudeCode, baseline);
|
|
2861
|
+
if (diskChanged && !weChanged) {
|
|
2862
|
+
config.claudeCode = persistedClaudeCode;
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
const projectedConfig = projectCustomModelCatalogMigration(
|
|
2867
|
+
onDisk,
|
|
2868
|
+
config,
|
|
2869
|
+
);
|
|
2870
|
+
const persistedBinding = bindingBaseline && onDisk
|
|
2871
|
+
? readPersistedServerBinding(onDisk, bindingBaseline)
|
|
2872
|
+
: bindingBaseline;
|
|
2873
|
+
if (persistedBinding) {
|
|
2874
|
+
const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port };
|
|
2875
|
+
if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
|
|
2876
|
+
else persistedConfig.hostname = persistedBinding.hostname;
|
|
2877
|
+
if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite();
|
|
2878
|
+
persistedLiveServerBinding.set(config, persistedBinding);
|
|
2879
|
+
} else {
|
|
2880
|
+
if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite();
|
|
2881
|
+
}
|
|
2882
|
+
adoptCustomModelCatalogMigration(config, projectedConfig);
|
|
2883
|
+
if (claudeCodeBaseline.has(config)) {
|
|
2884
|
+
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
2885
|
+
}
|
|
2886
|
+
});
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">): boolean {
|
|
2890
|
+
return config.codexAutoStart !== false;
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
export const CODEX_SHIM_AUTO_RESTORE_ENV = "OPENCODEX_CODEX_SHIM_AUTO_RESTORE";
|
|
2894
|
+
|
|
2895
|
+
export function codexShimAutoRestoreEnabled(
|
|
2896
|
+
config: Pick<OcxConfig, "codexShimAutoRestore">,
|
|
2897
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
2898
|
+
): boolean {
|
|
2899
|
+
return config.codexShimAutoRestore !== false && env[CODEX_SHIM_AUTO_RESTORE_ENV] !== "0";
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
export function multiAgentGuidanceEnabled(
|
|
2903
|
+
config: Pick<OcxConfig, "multiAgentGuidanceEnabled">,
|
|
2904
|
+
): boolean {
|
|
2905
|
+
return config.multiAgentGuidanceEnabled !== false;
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
export function getDefaultConfig(): OcxConfig {
|
|
2909
|
+
// Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key).
|
|
2910
|
+
// gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend.
|
|
2911
|
+
// Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
|
|
2912
|
+
return {
|
|
2913
|
+
port: 10100,
|
|
2914
|
+
managementUsageMaxReadBytes: 64 * 1024 * 1024,
|
|
2915
|
+
appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024),
|
|
2916
|
+
// Fresh/re-initialized configs are already written in the current three-tier
|
|
2917
|
+
// OpenAI shape. Mark them as such so startup does not mistake them for a
|
|
2918
|
+
// legacy config and collide with an immutable backup from an earlier setup.
|
|
2919
|
+
openaiProviderTierVersion: OPENAI_PROVIDER_TIER_VERSION,
|
|
2920
|
+
providers: {
|
|
2921
|
+
openai: {
|
|
2922
|
+
adapter: "openai-responses",
|
|
2923
|
+
baseUrl: "https://chatgpt.com/backend-api/codex",
|
|
2924
|
+
authMode: "forward",
|
|
2925
|
+
codexAccountMode: "pool",
|
|
2926
|
+
},
|
|
2927
|
+
},
|
|
2928
|
+
defaultProvider: "openai",
|
|
2929
|
+
subagentModels: [...DEFAULT_SUBAGENT_MODELS],
|
|
2930
|
+
multiAgentGuidanceEnabled: true,
|
|
2931
|
+
websockets: false,
|
|
2932
|
+
codexAutoStart: true,
|
|
2933
|
+
codexShimAutoRestore: true,
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
export function resolveEnvValue(value: string | undefined): string | undefined {
|
|
2938
|
+
if (!value) return undefined;
|
|
2939
|
+
const match = value.match(/^\$\{(\w+)\}$/);
|
|
2940
|
+
if (match) return process.env[match[1]];
|
|
2941
|
+
if (value.startsWith("$")) return process.env[value.slice(1)];
|
|
2942
|
+
return value;
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
/**
|
|
2946
|
+
* Mirror `config.proxy` into HTTP(S)_PROXY env vars so Bun's native fetch routes every outbound
|
|
2947
|
+
* provider call through the proxy — no per-callsite changes (verified: Bun honors these plus
|
|
2948
|
+
* NO_PROXY). User-set env vars always win; localhost/127.0.0.1 are appended to NO_PROXY so the
|
|
2949
|
+
* CLI's own health checks and running-proxy API calls stay direct. Call once per process entry
|
|
2950
|
+
* that makes outbound provider requests (server start, catalog sync).
|
|
2951
|
+
*/
|
|
2952
|
+
export function applyProxyEnv(config: OcxConfig): void {
|
|
2953
|
+
const proxy = resolveEnvValue(config.proxy);
|
|
2954
|
+
if (!proxy) return;
|
|
2955
|
+
if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy;
|
|
2956
|
+
if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy;
|
|
2957
|
+
const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
|
|
2958
|
+
const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
|
|
2959
|
+
const seen = new Set(entries.map(e => e.toLowerCase()));
|
|
2960
|
+
for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) {
|
|
2961
|
+
if (!seen.has(host)) {
|
|
2962
|
+
entries.push(host);
|
|
2963
|
+
seen.add(host);
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
process.env.NO_PROXY = entries.join(",");
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
export function writePid(pid: number): void {
|
|
2970
|
+
const dir = getConfigDir();
|
|
2971
|
+
// Guard before ANY directory mutation (mkdir or chmod), not just the write.
|
|
2972
|
+
assertNotRealHomeUnderTest(dir);
|
|
2973
|
+
if (!existsSync(dir)) {
|
|
2974
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
2975
|
+
} else {
|
|
2976
|
+
hardenConfigDir();
|
|
2977
|
+
}
|
|
2978
|
+
atomicWriteFile(getPidPath(), String(pid));
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
export type RuntimePortState = {
|
|
2982
|
+
pid: number;
|
|
2983
|
+
port: number;
|
|
2984
|
+
hostname?: string;
|
|
2985
|
+
/** Per-process proof key; protected by the config directory and never served. */
|
|
2986
|
+
attestationSecret?: string;
|
|
2987
|
+
};
|
|
2988
|
+
|
|
2989
|
+
function isValidRuntimePortState(value: unknown): value is RuntimePortState {
|
|
2990
|
+
if (!value || typeof value !== "object") return false;
|
|
2991
|
+
const state = value as Record<string, unknown>;
|
|
2992
|
+
const hostnameOk = state.hostname === undefined || typeof state.hostname === "string";
|
|
2993
|
+
const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret);
|
|
2994
|
+
return Number.isSafeInteger(state.pid)
|
|
2995
|
+
&& Number(state.pid) > 0
|
|
2996
|
+
&& Number.isInteger(state.port)
|
|
2997
|
+
&& Number(state.port) > 0
|
|
2998
|
+
&& Number(state.port) <= 65535
|
|
2999
|
+
&& hostnameOk
|
|
3000
|
+
&& attestationOk;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
export function writeRuntimePort(state: RuntimePortState): void {
|
|
3004
|
+
const dir = getConfigDir();
|
|
3005
|
+
// Guard before ANY directory mutation (mkdir or chmod), not just the write.
|
|
3006
|
+
assertNotRealHomeUnderTest(dir);
|
|
3007
|
+
if (!existsSync(dir)) {
|
|
3008
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
3009
|
+
} else {
|
|
3010
|
+
hardenConfigDir();
|
|
3011
|
+
}
|
|
3012
|
+
atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n");
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
export function readPid(): number | null {
|
|
3016
|
+
const pidPath = getPidPath();
|
|
3017
|
+
if (!existsSync(pidPath)) return null;
|
|
3018
|
+
try {
|
|
3019
|
+
const raw = readFileSync(pidPath, "utf-8").trim();
|
|
3020
|
+
const pid = parsePidFile(raw);
|
|
3021
|
+
if (pid === null) return null;
|
|
3022
|
+
try {
|
|
3023
|
+
process.kill(pid, 0);
|
|
3024
|
+
return isLikelyOcxStartProcess(pid) ? pid : null;
|
|
3025
|
+
} catch (e: unknown) {
|
|
3026
|
+
if ((e as NodeJS.ErrnoException).code === "EPERM") {
|
|
3027
|
+
return isLikelyOcxStartProcess(pid) ? pid : null;
|
|
3028
|
+
}
|
|
3029
|
+
return null;
|
|
3030
|
+
}
|
|
3031
|
+
} catch {
|
|
3032
|
+
return null;
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
export function readRuntimePort(expectedPid?: number): RuntimePortState | null {
|
|
3037
|
+
try {
|
|
3038
|
+
const parsed = JSON.parse(readFileSync(getRuntimePortPath(), "utf-8"));
|
|
3039
|
+
if (!isValidRuntimePortState(parsed)) return null;
|
|
3040
|
+
if (expectedPid !== undefined && parsed.pid !== expectedPid) return null;
|
|
3041
|
+
return parsed;
|
|
3042
|
+
} catch {
|
|
3043
|
+
return null;
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
export function removePid(expectedPid?: number): void {
|
|
3048
|
+
if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return;
|
|
3049
|
+
try {
|
|
3050
|
+
unlinkSync(getPidPath());
|
|
3051
|
+
} catch { /* ignore */ }
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
function warnConfigRepaired(configPath: string, error: z.ZodError): void {
|
|
3055
|
+
if (warnedConfigFallbacks.has(configPath)) return;
|
|
3056
|
+
warnedConfigFallbacks.add(configPath);
|
|
3057
|
+
const fields = error.issues.map(i => i.path.join(".") || "config").join(", ");
|
|
3058
|
+
console.error(`Remodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`);
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
export function readPidFileValue(): number | null {
|
|
3062
|
+
try {
|
|
3063
|
+
return parsePidFile(readFileSync(getPidPath(), "utf-8"));
|
|
3064
|
+
} catch {
|
|
3065
|
+
return null;
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
export function removeRuntimePort(expectedPid?: number): void {
|
|
3070
|
+
if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return;
|
|
3071
|
+
try {
|
|
3072
|
+
unlinkSync(getRuntimePortPath());
|
|
3073
|
+
} catch { /* ignore */ }
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
/**
|
|
3077
|
+
* Snapshot-guarded stale-state purge: remove the pid/runtime files only when their content
|
|
3078
|
+
* still matches what the caller saw BEFORE its liveness probe. A concurrent `rmx start` can
|
|
3079
|
+
* write fresh records mid-probe; an unconditional purge would erase the new proxy's state.
|
|
3080
|
+
*/
|
|
3081
|
+
export function removePidIfValueIs(snapshot: number | null): void {
|
|
3082
|
+
if (!existsSync(getPidPath())) return;
|
|
3083
|
+
if (readPidFileValue() !== snapshot) return;
|
|
3084
|
+
try {
|
|
3085
|
+
unlinkSync(getPidPath());
|
|
3086
|
+
} catch { /* ignore */ }
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
export function removeRuntimePortIfPidIs(snapshotPid: number | null): void {
|
|
3090
|
+
const current = readRuntimePort();
|
|
3091
|
+
if ((current?.pid ?? null) !== snapshotPid) return;
|
|
3092
|
+
try {
|
|
3093
|
+
unlinkSync(getRuntimePortPath());
|
|
3094
|
+
} catch { /* ignore */ }
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
export function parsePidFile(raw: string): number | null {
|
|
3098
|
+
const trimmed = raw.trim();
|
|
3099
|
+
if (!/^\d+$/.test(trimmed)) return null;
|
|
3100
|
+
const pid = Number.parseInt(trimmed, 10);
|
|
3101
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
export function isOcxStartCommandLine(commandLine: string): boolean {
|
|
3105
|
+
const normalized = commandLine.toLowerCase().replace(/\\/g, "/");
|
|
3106
|
+
// "src/cli.ts" matches pre-restructure installs still running; "src/cli/index.ts" is current.
|
|
3107
|
+
// npm uses an in-place rename during `npm install -g` — a Windows service
|
|
3108
|
+
// wrapper can respawn from either the current temp tree (`@remodex/.rmx-*`)
|
|
3109
|
+
// or the legacy one (`@bitkyc08/.opencodex-*`) mid-update, and must still
|
|
3110
|
+
// count as Remodex for port reclaim.
|
|
3111
|
+
const hasOcxEntrypoint = normalized.includes("src/cli.ts")
|
|
3112
|
+
|| normalized.includes("src/cli/index.ts")
|
|
3113
|
+
|| normalized.includes("@remodex/rmx")
|
|
3114
|
+
|| normalized.includes("@bitkyc08/opencodex")
|
|
3115
|
+
|| /@remodex\/\.rmx-/.test(normalized)
|
|
3116
|
+
|| /@bitkyc08\/\.opencodex-/.test(normalized)
|
|
3117
|
+
|| /(?:^|[\s/"'])(?:rmx|remodex|ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized);
|
|
3118
|
+
return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
/** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */
|
|
3122
|
+
const ocxStartProcessCache = new Map<number, boolean>();
|
|
3123
|
+
let ocxStartProcessSweepCursor = 0;
|
|
3124
|
+
let ocxStartProcessProbe: (pid: number) => void = pid => { process.kill(pid, 0); };
|
|
3125
|
+
|
|
3126
|
+
export function setOcxStartProcessProbeForTests(probe: ((pid: number) => void) | null): void {
|
|
3127
|
+
ocxStartProcessProbe = probe ?? (pid => { process.kill(pid, 0); });
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
export function setOcxStartProcessCacheForTests(entries: Iterable<readonly [number, boolean]>): void {
|
|
3131
|
+
ocxStartProcessCache.clear();
|
|
3132
|
+
for (const [pid, value] of entries) ocxStartProcessCache.set(pid, value);
|
|
3133
|
+
ocxStartProcessSweepCursor = 0;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
export function sweepDeadOcxStartProcessCache(maxProbes = 64): number {
|
|
3137
|
+
const pids: number[] = [];
|
|
3138
|
+
let removed = 0;
|
|
3139
|
+
for (const pid of ocxStartProcessCache.keys()) {
|
|
3140
|
+
if (Number.isSafeInteger(pid) && pid > 0) pids.push(pid);
|
|
3141
|
+
else if (ocxStartProcessCache.delete(pid)) removed += 1;
|
|
3142
|
+
}
|
|
3143
|
+
if (pids.length === 0 || maxProbes <= 0) {
|
|
3144
|
+
ocxStartProcessSweepCursor = 0;
|
|
3145
|
+
return removed;
|
|
3146
|
+
}
|
|
3147
|
+
const probeCount = Math.min(Math.floor(maxProbes), pids.length);
|
|
3148
|
+
const start = ocxStartProcessSweepCursor % pids.length;
|
|
3149
|
+
for (let offset = 0; offset < probeCount; offset += 1) {
|
|
3150
|
+
const pid = pids[(start + offset) % pids.length]!;
|
|
3151
|
+
try {
|
|
3152
|
+
ocxStartProcessProbe(pid);
|
|
3153
|
+
} catch (error) {
|
|
3154
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue;
|
|
3155
|
+
if (ocxStartProcessCache.delete(pid)) removed += 1;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
ocxStartProcessSweepCursor = (start + probeCount) % pids.length;
|
|
3159
|
+
return removed;
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
export function ocxStartProcessCacheSizeForTests(): number {
|
|
3163
|
+
return ocxStartProcessCache.size;
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
function isLikelyOcxStartProcess(pid: number): boolean {
|
|
3167
|
+
const cached = ocxStartProcessCache.get(pid);
|
|
3168
|
+
if (cached !== undefined) return cached;
|
|
3169
|
+
const commandLine = readProcessCommandLine(pid);
|
|
3170
|
+
if (commandLine === undefined) return false;
|
|
3171
|
+
const ok = isOcxStartCommandLine(commandLine);
|
|
3172
|
+
ocxStartProcessCache.set(pid, ok);
|
|
3173
|
+
return ok;
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
/**
|
|
3177
|
+
* Alive pid from the pid file without the expensive Windows command-line probe.
|
|
3178
|
+
* Safe for liveness polls: callers still identity-check /healthz before trusting the proxy.
|
|
3179
|
+
* Destructive stop/kill paths should keep using {@link readPid}, which verifies the cmdline.
|
|
3180
|
+
*/
|
|
3181
|
+
export function readAlivePid(): number | null {
|
|
3182
|
+
const pid = readPidFileValue();
|
|
3183
|
+
if (pid === null) return null;
|
|
3184
|
+
try {
|
|
3185
|
+
process.kill(pid, 0);
|
|
3186
|
+
return pid;
|
|
3187
|
+
} catch (e: unknown) {
|
|
3188
|
+
if ((e as NodeJS.ErrnoException).code === "EPERM") return pid;
|
|
3189
|
+
return null;
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
/**
|
|
3194
|
+
* Full identity check of a KNOWN candidate pid (alive + ocx-start command line).
|
|
3195
|
+
* Companion to {@link readAlivePid}: liveness discovery may be cheap, but any pid
|
|
3196
|
+
* handed to a destructive caller must pass this check — and must equal the candidate
|
|
3197
|
+
* it was asked about, so a pidfile rewrite between discovery and verification can
|
|
3198
|
+
* never swap in a different process (TOCTOU guard).
|
|
3199
|
+
*/
|
|
3200
|
+
export function verifyPidIdentity(candidatePid: number): number | null {
|
|
3201
|
+
try {
|
|
3202
|
+
process.kill(candidatePid, 0);
|
|
3203
|
+
} catch (e: unknown) {
|
|
3204
|
+
if ((e as NodeJS.ErrnoException).code !== "EPERM") return null;
|
|
3205
|
+
}
|
|
3206
|
+
return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null;
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
function readProcessCommandLine(pid: number): string | undefined {
|
|
3210
|
+
try {
|
|
3211
|
+
if (process.platform === "win32") {
|
|
3212
|
+
// Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash.
|
|
3213
|
+
// Fall back to PowerShell when WMIC is absent (newer Windows images).
|
|
3214
|
+
const wmic = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\wbem\\WMIC.exe`;
|
|
3215
|
+
try {
|
|
3216
|
+
const output = execFileSync(wmic, [
|
|
3217
|
+
"process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE",
|
|
3218
|
+
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
|
|
3219
|
+
const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, ""));
|
|
3220
|
+
const value = match?.[1]?.trim();
|
|
3221
|
+
if (value) return value;
|
|
3222
|
+
} catch {
|
|
3223
|
+
/* WMIC missing or failed — fall through */
|
|
3224
|
+
}
|
|
3225
|
+
const output = execFileSync("powershell.exe", [
|
|
3226
|
+
"-NoProfile",
|
|
3227
|
+
"-NoLogo",
|
|
3228
|
+
"-NonInteractive",
|
|
3229
|
+
"-WindowStyle",
|
|
3230
|
+
"Hidden",
|
|
3231
|
+
"-Command",
|
|
3232
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
|
|
3233
|
+
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
|
|
3234
|
+
return output.trim() || undefined;
|
|
3235
|
+
}
|
|
3236
|
+
const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
3237
|
+
encoding: "utf-8",
|
|
3238
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
3239
|
+
timeout: 1000,
|
|
3240
|
+
windowsHide: true,
|
|
3241
|
+
});
|
|
3242
|
+
return output.trim() || undefined;
|
|
3243
|
+
} catch {
|
|
3244
|
+
return undefined;
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
|
|
3249
|
+
if (warnedConfigFallbacks.has(configPath)) return;
|
|
3250
|
+
warnedConfigFallbacks.add(configPath);
|
|
3251
|
+
|
|
3252
|
+
const backupPath = backupInvalidConfig(configPath);
|
|
3253
|
+
const reason = error instanceof z.ZodError
|
|
3254
|
+
? error.issues.map(issue => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ")
|
|
3255
|
+
: error instanceof Error ? error.message : String(error);
|
|
3256
|
+
const backupNote = backupPath ? ` A backup was written to ${backupPath}.` : "";
|
|
3257
|
+
console.error(`Could not load Remodex config at ${configPath}: ${reason}. Using default config.${backupNote}`);
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
export function backupInvalidConfig(configPath: string): string | null {
|
|
3261
|
+
if (!existsSync(configPath)) return null;
|
|
3262
|
+
const backupPath = `${configPath}.invalid-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
3263
|
+
try {
|
|
3264
|
+
copyFileSync(configPath, backupPath);
|
|
3265
|
+
try { chmodSync(backupPath, 0o600); } catch { /* best-effort */ }
|
|
3266
|
+
return backupPath;
|
|
3267
|
+
} catch {
|
|
3268
|
+
return null;
|
|
3269
|
+
}
|
|
3270
|
+
}
|