@iislee/opencodex 2.11.0 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +109 -0
- package/README.md +114 -19
- package/bin/ocx.mjs +164 -36
- package/bin/package-main.mjs +1 -1
- package/gui/dist/assets/index-BF38heuV.js +104 -0
- package/gui/dist/assets/index-DMiI18Kv.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +19 -10
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +376 -52
- package/src/adapters/base.ts +54 -7
- package/src/adapters/client-fingerprint.ts +18 -12
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +601 -0
- package/src/adapters/cursor/checkpoint-store.ts +303 -0
- package/src/adapters/cursor/cursor-errors.ts +129 -6
- package/src/adapters/cursor/discovery.ts +61 -4
- package/src/adapters/cursor/effort-map.ts +27 -3
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/http1-bidi.ts +361 -0
- package/src/adapters/cursor/images.ts +704 -0
- package/src/adapters/cursor/live-models.ts +174 -58
- package/src/adapters/cursor/live-transport.ts +609 -170
- package/src/adapters/cursor/native-exec-common.ts +23 -2
- package/src/adapters/cursor/native-exec-fs.ts +10 -7
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +0 -1
- package/src/adapters/cursor/native-exec.ts +101 -14
- package/src/adapters/cursor/protobuf-events.ts +829 -11
- package/src/adapters/cursor/protobuf-request.ts +383 -65
- package/src/adapters/cursor/request-builder.ts +237 -31
- package/src/adapters/cursor/tool-definitions.ts +242 -12
- package/src/adapters/cursor/tool-result-normalize.ts +92 -0
- package/src/adapters/cursor/transport.ts +22 -0
- package/src/adapters/cursor/types.ts +28 -1
- package/src/adapters/cursor.ts +133 -7
- package/src/adapters/google-antigravity-replay.ts +558 -34
- package/src/adapters/google-antigravity-wire.ts +43 -10
- package/src/adapters/google-http.ts +38 -10
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +618 -74
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +18 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +92 -54
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1311 -336
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +830 -56
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +104 -21
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/bridge.ts +428 -61
- package/src/chat/inbound.ts +43 -19
- package/src/chat/outbound.ts +82 -26
- package/src/claude/agents-inject.ts +32 -9
- package/src/claude/context-windows.ts +21 -5
- package/src/claude/desktop-3p.ts +243 -9
- package/src/claude/gateway-cache.ts +41 -4
- package/src/claude/inbound.ts +72 -3
- package/src/claude/model-info.ts +38 -15
- package/src/claude/outbound.ts +70 -16
- package/src/cli/account-api.ts +35 -1
- package/src/cli/account-auth.ts +33 -6
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +389 -2
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +30 -6
- package/src/cli/agent.ts +45 -1
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +29 -4
- package/src/cli/claude.ts +103 -34
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +76 -0
- package/src/cli/combo.ts +8 -0
- package/src/cli/config-command.ts +74 -10
- package/src/cli/dispatch.ts +593 -0
- package/src/cli/doctor.ts +315 -43
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +46 -20
- package/src/cli/help.ts +20 -271
- package/src/cli/index.ts +378 -519
- package/src/cli/init.ts +4 -17
- package/src/cli/integrations.ts +120 -2
- package/src/cli/lab.ts +607 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/minimax.ts +497 -0
- package/src/cli/models-runtime.ts +35 -2
- package/src/cli/models.ts +100 -14
- package/src/cli/observe.ts +92 -3
- package/src/cli/opencode.ts +4 -2
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/provider.ts +24 -3
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +437 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +10 -3
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/cli/v2.ts +105 -10
- package/src/cli.ts +1 -1
- package/src/clients/config-export.ts +1358 -21
- package/src/codex/account-label.ts +14 -1
- package/src/codex/account-lifecycle.ts +130 -13
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/account-store.ts +29 -2
- package/src/codex/account-usability.ts +25 -2
- package/src/codex/admission.ts +256 -0
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/app-server-processes.ts +493 -106
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +849 -242
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/auth-context.ts +345 -32
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +68 -10
- package/src/codex/catalog/bundled.ts +331 -33
- package/src/codex/catalog/effort.ts +121 -30
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +529 -45
- package/src/codex/catalog/native-models.ts +72 -0
- package/src/codex/catalog/parsing.ts +224 -30
- package/src/codex/catalog/provider-fetch.ts +1460 -134
- package/src/codex/catalog/sync.ts +1449 -186
- package/src/codex/catalog-admission.ts +199 -0
- package/src/codex/catalog-refresh-status.ts +105 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +6 -3
- package/src/codex/codex-write-lock.ts +384 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +651 -0
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/features.ts +636 -39
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +407 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-migration-guardian.ts +26 -20
- package/src/codex/history-provider.ts +231 -28
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +220 -0
- package/src/codex/inject-coordination.ts +290 -0
- package/src/codex/inject.ts +1073 -152
- package/src/codex/injected-marker.ts +37 -3
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +66 -4
- package/src/codex/log-guard/inspect.ts +506 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +88 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/main-account.ts +29 -1
- package/src/codex/management-convergence.ts +167 -0
- package/src/codex/model-cache.ts +56 -10
- package/src/codex/model-entitlements.ts +353 -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 +178 -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 +492 -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 +682 -0
- package/src/codex/paths.ts +80 -1
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +40 -0
- package/src/codex/plugins-doctor.ts +1 -1
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/project-config-warnings.ts +20 -6
- package/src/codex/prompt-journal.ts +352 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +175 -13
- package/src/codex/refresh.ts +11 -2
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +505 -94
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +1009 -28
- package/src/codex/subagent-model-fallback.ts +350 -35
- package/src/codex/sync.ts +191 -2
- package/src/codex/transition-state.ts +612 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +557 -0
- package/src/codex/warmup.ts +187 -81
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +20 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/request.ts +32 -0
- package/src/combos/types.ts +81 -9
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +1762 -140
- package/src/generated/compatibility-version.json +3164 -0
- package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
- package/src/grok/inject.ts +16 -5
- package/src/grok/inspect.ts +45 -0
- package/src/grok/sync.ts +2 -2
- package/src/images/loop.ts +152 -29
- package/src/images/plan.ts +23 -13
- package/src/integrations/config-io.ts +269 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/mutation-flight.ts +71 -0
- package/src/integrations/native/ownership-preflight.ts +202 -0
- package/src/integrations/omp-yaml-source.ts +358 -0
- package/src/integrations/owned-refresh.ts +74 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +159 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +361 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +691 -0
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/automation/budgets.ts +78 -0
- package/src/lab/automation/config-persistence.ts +256 -0
- package/src/lab/automation/constants.ts +39 -0
- package/src/lab/automation/cooldown.ts +103 -0
- package/src/lab/automation/dispatch.ts +211 -0
- package/src/lab/automation/index.ts +13 -0
- package/src/lab/automation/orchestrator.ts +499 -0
- package/src/lab/automation/persistence.ts +512 -0
- package/src/lab/automation/planner.ts +371 -0
- package/src/lab/automation/policy.ts +136 -0
- package/src/lab/automation/queue.ts +191 -0
- package/src/lab/automation/recovery.ts +24 -0
- package/src/lab/automation/route-context.ts +21 -0
- package/src/lab/automation/run-key.ts +44 -0
- package/src/lab/automation/runs-query.ts +34 -0
- package/src/lab/automation/types.ts +160 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +64 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +164 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +68 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +214 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +229 -0
- package/src/lab/events/validate.ts +781 -0
- package/src/lab/fabric/constants.ts +40 -0
- package/src/lab/fabric/executor.ts +492 -0
- package/src/lab/fabric/index.ts +80 -0
- package/src/lab/fabric/manifest.ts +222 -0
- package/src/lab/fabric/observe.ts +489 -0
- package/src/lab/fabric/patch.ts +79 -0
- package/src/lab/fabric/producer-child.ts +139 -0
- package/src/lab/fabric/producer-isolate.ts +276 -0
- package/src/lab/fabric/producer-protocol.ts +61 -0
- package/src/lab/fabric/scratch.ts +439 -0
- package/src/lab/fabric/subject.ts +106 -0
- package/src/lab/fabric/types.ts +134 -0
- package/src/lab/fabric/verifier.ts +98 -0
- package/src/lab/index.ts +54 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +310 -0
- package/src/lab/ledger/store.ts +532 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +336 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +118 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +301 -0
- package/src/lab/observe/from-live.ts +117 -0
- package/src/lab/paths.ts +153 -0
- package/src/lab/projection/rebuild.ts +495 -0
- package/src/lab/projection/schema.ts +135 -0
- package/src/lab/projection/verdicts.ts +474 -0
- package/src/lab/projection/verification.ts +412 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +243 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lab/query/catalog.ts +101 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/freshness.ts +53 -0
- package/src/lab/query/index.ts +45 -0
- package/src/lab/query/latest-observation.ts +59 -0
- package/src/lab/query/passive-production.ts +159 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +112 -0
- package/src/lab/subject/protocol-subject.ts +80 -0
- package/src/lab/subject/route-subject.ts +74 -0
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/bounded-body.ts +153 -9
- package/src/lib/bun-runtime.ts +125 -12
- package/src/lib/bun-stream-caps.ts +13 -9
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/config-ownership.ts +6 -2
- package/src/lib/destination-policy.ts +65 -1
- package/src/lib/errors.ts +44 -2
- package/src/lib/fabric-task-execution-authority.ts +7 -0
- package/src/lib/fabric-task-host.ts +29 -0
- package/src/lib/lab-activation.ts +223 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +56 -0
- package/src/lib/lab-live-route-production.ts +130 -0
- package/src/lib/lab-passive-linker-registration.ts +26 -0
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/local-management-capability.ts +100 -0
- package/src/lib/local-provider-reload-contract.ts +100 -0
- package/src/lib/optional-shutdown-hooks.ts +57 -0
- package/src/lib/pinned-http.ts +145 -26
- package/src/lib/process-control.ts +4 -1
- package/src/lib/provider-outbound.ts +49 -9
- package/src/lib/redact.ts +419 -3
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/server-resource-ownership.ts +71 -0
- package/src/lib/shadow-call.ts +35 -4
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/state-store-registrations.ts +10 -2
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/token-estimate.ts +19 -2
- package/src/lib/tool-argument-integers.ts +202 -0
- package/src/lib/translator-budget.ts +44 -0
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-reachability.ts +95 -0
- package/src/lib/upstream-retry.ts +156 -3
- package/src/lib/windows-atomic-replace.ts +155 -0
- package/src/lib/windows-elevation.ts +70 -2
- package/src/lib/windows-secret-acl.ts +409 -69
- package/src/lib/windows-service-wrappers.ts +72 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +341 -0
- package/src/lib/winsw.ts +33 -5
- package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
- package/src/oauth/account-import/index.ts +15 -0
- package/src/oauth/account-import/parser.ts +83 -0
- package/src/oauth/account-import/registry.ts +18 -0
- package/src/oauth/account-import/service.ts +75 -0
- package/src/oauth/account-import/types.ts +91 -0
- package/src/oauth/anthropic.ts +12 -1
- package/src/oauth/callback-server.ts +8 -2
- package/src/oauth/chatgpt.ts +12 -1
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +46 -5
- package/src/oauth/google-antigravity.ts +35 -3
- package/src/oauth/health.ts +20 -12
- package/src/oauth/index.ts +398 -66
- package/src/oauth/key-providers.ts +16 -0
- package/src/oauth/kimi.ts +16 -2
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/local-token-detect.ts +11 -2
- package/src/oauth/log.ts +3 -1
- package/src/oauth/login-cli.ts +88 -28
- package/src/oauth/nous.ts +798 -0
- package/src/oauth/store.ts +119 -21
- package/src/oauth/token-guardian.ts +9 -3
- package/src/pi/models.ts +2 -2
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/antigravity-models.ts +521 -31
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +144 -0
- package/src/providers/context-cap.ts +22 -5
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +253 -6
- package/src/providers/fastwire.ts +501 -0
- package/src/providers/free-directory.ts +10 -7
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +115 -22
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/openai-sidecar.ts +72 -4
- package/src/providers/openai-tier-startup.ts +31 -2
- package/src/providers/openai-tiers.ts +119 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +29 -0
- package/src/providers/quota.ts +1319 -38
- package/src/providers/registry.ts +1429 -111
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +42 -6
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/providers/xai-responses-opt-in.ts +15 -0
- package/src/providers/xai-transport.ts +11 -4
- package/src/reasoning-effort.ts +49 -1
- package/src/responses/compaction.ts +26 -1
- package/src/responses/custom-tool-compat.ts +266 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +355 -0
- package/src/responses/parser.ts +220 -38
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +426 -0
- package/src/responses/schema.ts +7 -1
- package/src/responses/spill-store.ts +75 -10
- package/src/responses/state.ts +565 -27
- package/src/responses/thought-signature-replay.ts +347 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/router.ts +366 -30
- package/src/routing/analytics.ts +378 -0
- package/src/routing/capability.ts +244 -0
- package/src/routing/compatibility/assemble.ts +73 -0
- package/src/routing/compatibility/behavior.ts +278 -0
- package/src/routing/compatibility/catalog.ts +99 -0
- package/src/routing/compatibility/endpoint.ts +52 -0
- package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
- package/src/routing/compatibility/policy.ts +181 -0
- package/src/routing/compatibility/provider-slot.ts +56 -0
- package/src/routing/compatibility/reader.ts +110 -0
- package/src/routing/compatibility/subject.ts +191 -0
- package/src/routing/compatibility/types.ts +64 -0
- package/src/routing/compatibility/version.ts +104 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +495 -0
- package/src/routing/health.ts +412 -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 +547 -0
- package/src/routing/quota.ts +145 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +776 -0
- package/src/server/adapter-resolve.ts +2 -29
- package/src/server/auth-cors.ts +267 -46
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +130 -56
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +426 -0
- package/src/server/claude-messages.ts +159 -43
- package/src/server/direct-local-http.ts +347 -0
- package/src/server/effort-policy.ts +18 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +94 -12
- package/src/server/index.ts +865 -181
- package/src/server/lifecycle.ts +284 -13
- package/src/server/live.ts +136 -17
- package/src/server/local-management-read-client.ts +90 -0
- package/src/server/local-provider-reload-client.ts +137 -0
- package/src/server/management/agent-settings-routes.ts +398 -103
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/body.ts +6 -0
- package/src/server/management/combo-routes.ts +62 -24
- package/src/server/management/config-routes.ts +464 -51
- package/src/server/management/context.ts +80 -2
- package/src/server/management/integration-routes.ts +498 -0
- package/src/server/management/lab-automation-routes.ts +206 -0
- package/src/server/management/lab-routes.ts +563 -0
- package/src/server/management/logs-usage-routes.ts +101 -32
- package/src/server/management/model-routes.ts +189 -131
- package/src/server/management/model-rows.ts +163 -0
- package/src/server/management/native-integration-routes.ts +769 -0
- package/src/server/management/oauth-account-routes.ts +80 -4
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +764 -157
- 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 +380 -0
- package/src/server/management/shared.ts +27 -11
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/storage-log-guard-routes.ts +186 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +288 -32
- package/src/server/management/system-routes.ts +77 -0
- package/src/server/management/usage-summary-cache.ts +9 -1
- package/src/server/management/vision-sidecar-options.ts +167 -0
- package/src/server/management/web-search-sidecar-options.ts +120 -0
- package/src/server/management-api.ts +115 -14
- package/src/server/management-auth.ts +220 -5
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +132 -5
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +236 -76
- package/src/server/request-decompress.ts +113 -6
- package/src/server/request-log.ts +235 -22
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +465 -0
- package/src/server/responses/collaboration.ts +204 -35
- package/src/server/responses/compact.ts +442 -55
- package/src/server/responses/core.ts +2872 -331
- package/src/server/responses/empty-completion-guard.ts +276 -0
- package/src/server/responses/encrypted-payload.ts +62 -39
- package/src/server/responses/fetch-helpers.ts +79 -4
- package/src/server/responses/input-admission.ts +185 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +251 -0
- package/src/server/responses/terminal-guard.ts +26 -5
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses/ws-upstream.ts +308 -0
- package/src/server/responses-custom-tool-repair.ts +282 -0
- package/src/server/responses-item-id-repair.ts +54 -6
- package/src/server/responses-json-events.ts +90 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses-terminal-repair.ts +342 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/responses.ts +18 -2
- package/src/server/search.ts +78 -13
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +110 -22
- package/src/server/startup-action-control.ts +8 -1
- package/src/server/startup-health-cache.ts +19 -1
- package/src/server/system-env.ts +80 -9
- package/src/server/ws-bridge.ts +39 -38
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +1111 -90
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/storage/cleanup.ts +2 -2
- package/src/storage/scanner.ts +1 -1
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +83 -9
- package/src/tray/windows.ts +43 -16
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +845 -0
- package/src/types/provider.ts +545 -0
- package/src/types/request.ts +384 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +104 -1236
- package/src/update/index.ts +32 -19
- package/src/update/job.ts +442 -67
- package/src/update/notify.ts +12 -6
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +259 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +268 -16
- package/src/usage/log.ts +606 -41
- package/src/usage/summary.ts +177 -9
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/anthropic-describe.ts +10 -6
- package/src/vision/backends.ts +97 -0
- package/src/vision/describe.ts +9 -3
- package/src/vision/eligibility.ts +250 -0
- package/src/vision/index.ts +238 -24
- package/src/vision/reasoning.ts +55 -0
- package/src/vision/routed-describe.ts +175 -0
- package/src/vision/timeout-bounds.ts +9 -0
- package/src/web-search/anthropic-executor.ts +13 -7
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +88 -0
- package/src/web-search/executor.ts +11 -3
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +150 -15
- package/src/web-search/loop.ts +279 -50
- package/src/web-search/parse.ts +125 -30
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/xai-executor.ts +219 -0
- package/gui/dist/assets/index-DTpMHS4F.js +0 -67
- package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
- package/src/cli/internal-dispatch.ts +0 -20
package/src/config.ts
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync,
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import { Database } from "bun:sqlite";
|
|
7
7
|
import * as z from "zod/v4";
|
|
8
|
+
import { isValidProviderName, hasOwnProvider } from "./config/provider-name";
|
|
9
|
+
import {
|
|
10
|
+
bumpConfigGenerationAtPath,
|
|
11
|
+
bumpCurrentConfigGeneration,
|
|
12
|
+
initializeConfigGeneration,
|
|
13
|
+
observeConfigGenerationAtPath,
|
|
14
|
+
readConfigGenerationAtPath,
|
|
15
|
+
readConfigGenerationInTransaction,
|
|
16
|
+
type ConfigGenerationObservation,
|
|
17
|
+
} from "./codex/generation";
|
|
18
|
+
import type {
|
|
19
|
+
BumpConfigGeneration,
|
|
20
|
+
ConfigGeneration,
|
|
21
|
+
ReadConfigGeneration,
|
|
22
|
+
WithExpectedConfigGenerationSync,
|
|
23
|
+
} from "./codex/convergence-types";
|
|
8
24
|
import {
|
|
9
25
|
CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
|
|
10
26
|
codexAccountNamespaceForModel,
|
|
@@ -12,9 +28,18 @@ import {
|
|
|
12
28
|
isValidCodexAccountNamespaceTarget,
|
|
13
29
|
MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
|
|
14
30
|
} from "./codex/account-namespace-match";
|
|
31
|
+
import { isCodexAccountPriorityKey } from "./codex/account-priority";
|
|
32
|
+
import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health";
|
|
33
|
+
import {
|
|
34
|
+
adoptCustomModelCatalogMigration,
|
|
35
|
+
projectCustomModelCatalogMigration,
|
|
36
|
+
} from "./codex/custom-model-catalog-migration";
|
|
37
|
+
import { parseAccountPriority } from "./codex/pool-rotation";
|
|
15
38
|
import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
|
|
39
|
+
import { routingProfileIssues } from "./routing/profile";
|
|
40
|
+
import { POLICY_NAMESPACE } from "./routing/profile-namespace";
|
|
16
41
|
import {
|
|
17
|
-
|
|
42
|
+
forgetEphemeralSecretPath,
|
|
18
43
|
hardenSecretDir,
|
|
19
44
|
hardenSecretPath,
|
|
20
45
|
hardenSecretPathAsync,
|
|
@@ -22,57 +47,63 @@ import {
|
|
|
22
47
|
} from "./lib/windows-secret-acl";
|
|
23
48
|
import { recordOwnedConfigPath } from "./lib/config-ownership";
|
|
24
49
|
import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
|
|
50
|
+
import { isLocalAttestationSecret } from "./lib/local-management-attestation";
|
|
25
51
|
import { providerDestinationConfigError } from "./lib/destination-policy";
|
|
52
|
+
import { redactSecretString } from "./lib/redact";
|
|
53
|
+
import {
|
|
54
|
+
resolveTrustedWindowsPowerShellExe,
|
|
55
|
+
resolveTrustedWindowsSystemDirectory,
|
|
56
|
+
} from "./lib/windows-elevation";
|
|
26
57
|
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
|
|
27
58
|
import {
|
|
28
59
|
isWirePinnedModel,
|
|
29
60
|
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
30
61
|
OPENAI_PROVIDER_TIER_VERSION,
|
|
62
|
+
pinnedWireAdapter,
|
|
31
63
|
REASONING_SUMMARY_DELIVERY_VALUES,
|
|
64
|
+
UPSTREAM_HTTP_VERSION_VALUES,
|
|
32
65
|
type OcxClaudeCodeConfig,
|
|
33
66
|
type OcxConfig,
|
|
34
67
|
type OcxApiKeyEntry,
|
|
35
68
|
type OcxProviderConfig,
|
|
69
|
+
type FastWire,
|
|
70
|
+
type ProviderCostOverlay,
|
|
36
71
|
} from "./types";
|
|
37
72
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
|
|
73
|
+
import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire";
|
|
74
|
+
import {
|
|
75
|
+
getProviderRegistryEntry,
|
|
76
|
+
providerMatchesRegistryTransport,
|
|
77
|
+
providerModelWireDefault,
|
|
78
|
+
registryModelServiceTierCapabilityApplies,
|
|
79
|
+
} from "./providers/registry";
|
|
80
|
+
import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models";
|
|
38
81
|
import { parseDesktopProfile } from "./claude/desktop-profile";
|
|
39
82
|
import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
|
|
83
|
+
import {
|
|
84
|
+
COST4_RATE_KEYS,
|
|
85
|
+
isValidCost4Rate,
|
|
86
|
+
refreshPreservedProviderOwner,
|
|
87
|
+
refreshUserCostOverlays,
|
|
88
|
+
withPreservedDiskOnlyProviders,
|
|
89
|
+
} from "./usage/user-cost-overlays";
|
|
90
|
+
import { MAX_COST4_RATE } from "./usage/expected-prices";
|
|
40
91
|
import {
|
|
41
92
|
DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES,
|
|
42
93
|
MAX_APP_OWNED_MEMORY_BUDGET_MB,
|
|
43
94
|
MIN_APP_OWNED_MEMORY_BUDGET_MB,
|
|
44
95
|
} from "./lib/app-owned-memory";
|
|
96
|
+
import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy";
|
|
45
97
|
|
|
46
98
|
let _atomicSeq = 0;
|
|
47
99
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
source: string,
|
|
56
|
-
destination: string,
|
|
57
|
-
io: AtomicRenameIO = {
|
|
58
|
-
platform: process.platform,
|
|
59
|
-
rename: renameSync,
|
|
60
|
-
sleep: Bun.sleepSync,
|
|
61
|
-
},
|
|
62
|
-
): void {
|
|
63
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
64
|
-
try {
|
|
65
|
-
io.rename(source, destination);
|
|
66
|
-
return;
|
|
67
|
-
} catch (error) {
|
|
68
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
69
|
-
const transientWindowsError = io.platform === "win32"
|
|
70
|
-
&& (code === "EBUSY" || code === "EPERM" || code === "EACCES");
|
|
71
|
-
if (!transientWindowsError || attempt >= 2) throw error;
|
|
72
|
-
io.sleep(25 * (attempt + 1));
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
100
|
+
// The Windows-tolerant replace lives in lib/windows-atomic-replace: config-ownership
|
|
101
|
+
// is one of its callers and this module already imports config-ownership, so
|
|
102
|
+
// exporting it from here would close an import cycle. Re-exported because these
|
|
103
|
+
// names are part of this module's public surface and its callers.
|
|
104
|
+
export type { AtomicRenameIO } from "./lib/windows-atomic-replace";
|
|
105
|
+
export { renameAtomicFile } from "./lib/windows-atomic-replace";
|
|
106
|
+
import { renameAtomicFile, renameAtomicFileAsync } from "./lib/windows-atomic-replace";
|
|
76
107
|
|
|
77
108
|
/**
|
|
78
109
|
* Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the
|
|
@@ -104,25 +135,92 @@ function isMissingPathError(error: unknown): boolean {
|
|
|
104
135
|
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
105
136
|
}
|
|
106
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Resolve a write target through any symlink before the temp+rename dance.
|
|
140
|
+
*
|
|
141
|
+
* rename(2) replaces a directory ENTRY. When the entry is itself a symlink
|
|
142
|
+
* (a dotfiles-managed `~/.codex/config.toml` -> `~/dotfiles/.codex/config.toml`,
|
|
143
|
+
* say), renaming a sibling temp file over it destroys the link and leaves a plain
|
|
144
|
+
* file behind — the repo silently stops receiving writes. Resolving first puts both
|
|
145
|
+
* the temp file and the rename target inside the link's real directory, so the entry
|
|
146
|
+
* being replaced is the real file and the symlink survives.
|
|
147
|
+
*
|
|
148
|
+
* Same-filesystem atomicity is preserved because the temp file stays beside its
|
|
149
|
+
* resolved target. A genuinely absent destination (not yet created) falls back to
|
|
150
|
+
* the literal path, which is the correct target for a first write.
|
|
151
|
+
*
|
|
152
|
+
* An EXISTING symlink that cannot be resolved — dangling because its target volume
|
|
153
|
+
* is unmounted, an ELOOP chain, an EACCES parent — is refused instead. Falling back
|
|
154
|
+
* to the literal path there would let the rename replace the link, recreating the
|
|
155
|
+
* exact dotfiles-divergence failure this helper exists to prevent (audit: wt4 wp2).
|
|
156
|
+
*/
|
|
157
|
+
export function resolveWriteTarget(path: string): string {
|
|
158
|
+
try {
|
|
159
|
+
return realpathSync(path);
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
let entry;
|
|
162
|
+
try {
|
|
163
|
+
entry = lstatSync(path);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (isMissingPathError(error)) return path; // no entry at all — first write
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
if (entry.isSymbolicLink()) {
|
|
169
|
+
throw new Error(`refusing to replace unresolvable symlinked write target: ${path}`, { cause });
|
|
170
|
+
}
|
|
171
|
+
return path;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Re-apply the real-home guard to a RESOLVED write target.
|
|
177
|
+
*
|
|
178
|
+
* Callers such as saveConfig check only their logical config dir, which passes when
|
|
179
|
+
* OPENCODEX_HOME points at a temp fixture. Following a symlink out of that fixture
|
|
180
|
+
* would land on the protected home the caller's own check just cleared, so the guard
|
|
181
|
+
* has to run again on wherever the write actually terminates. Inert in production,
|
|
182
|
+
* where the guard is disarmed.
|
|
183
|
+
*/
|
|
184
|
+
function assertResolvedTargetAllowed(path: string, target: string): void {
|
|
185
|
+
// The file itself may resolve literally while its PARENT is a symlink out
|
|
186
|
+
// of the fixture (a first write beneath a symlinked config dir). Guard the
|
|
187
|
+
// directory the write actually lands in either way.
|
|
188
|
+
if (target === path) {
|
|
189
|
+
let realParent: string;
|
|
190
|
+
try {
|
|
191
|
+
realParent = realpathSync(dirname(target));
|
|
192
|
+
} catch {
|
|
193
|
+
return; // unresolvable parent: resolveWriteTarget already owns that refusal
|
|
194
|
+
}
|
|
195
|
+
if (realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
assertNotRealHomeUnderTest(dirname(target));
|
|
199
|
+
}
|
|
200
|
+
|
|
107
201
|
export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO = {
|
|
108
202
|
write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
|
|
109
203
|
harden: target => {
|
|
110
204
|
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
111
|
-
|
|
205
|
+
// Timeout memo keyed by the stable destination (matches the async writer):
|
|
206
|
+
// a failed temp harden must not mint a new unique-temp key on every write.
|
|
207
|
+
if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: path });
|
|
112
208
|
},
|
|
113
209
|
rename: renameAtomicFile,
|
|
114
210
|
truncate: target => truncateSync(target, 0),
|
|
115
211
|
unlink: unlinkSync,
|
|
116
212
|
}): void {
|
|
117
213
|
recordOwnedConfigPath(resolveConfigDir(), path);
|
|
118
|
-
const
|
|
214
|
+
const target = resolveWriteTarget(path);
|
|
215
|
+
assertResolvedTargetAllowed(path, target);
|
|
216
|
+
const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
119
217
|
let hardened = false;
|
|
120
218
|
try {
|
|
121
219
|
io.write(tmp, content);
|
|
122
220
|
io.harden(tmp);
|
|
123
221
|
hardened = true;
|
|
124
|
-
io.rename(tmp,
|
|
125
|
-
|
|
222
|
+
io.rename(tmp, target);
|
|
223
|
+
forgetEphemeralSecretPath(tmp);
|
|
126
224
|
} catch (cause) {
|
|
127
225
|
let scrubbed = false;
|
|
128
226
|
try {
|
|
@@ -149,7 +247,7 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
|
|
|
149
247
|
if (!removed && !hardened) {
|
|
150
248
|
try { io.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
|
|
151
249
|
}
|
|
152
|
-
if (removed)
|
|
250
|
+
if (removed) forgetEphemeralSecretPath(tmp);
|
|
153
251
|
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
|
|
154
252
|
throw cause;
|
|
155
253
|
}
|
|
@@ -164,19 +262,9 @@ export interface AtomicWriteAsyncIO {
|
|
|
164
262
|
unlink: (path: string) => void | Promise<void>;
|
|
165
263
|
}
|
|
166
264
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
renameSync(source, destination);
|
|
171
|
-
return;
|
|
172
|
-
} catch (error) {
|
|
173
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
174
|
-
const transientWindowsError = process.platform === "win32"
|
|
175
|
-
&& (code === "EBUSY" || code === "EPERM" || code === "EACCES");
|
|
176
|
-
if (!transientWindowsError || attempt >= 2) throw error;
|
|
177
|
-
await Bun.sleep(25 * (attempt + 1));
|
|
178
|
-
}
|
|
179
|
-
}
|
|
265
|
+
/** Test-only crash seam. Production callers leave this undefined. */
|
|
266
|
+
export interface AtomicWriteAsyncTestSeam {
|
|
267
|
+
afterTempWrite?: (tempPath: string) => void | Promise<void>;
|
|
180
268
|
}
|
|
181
269
|
|
|
182
270
|
/**
|
|
@@ -188,6 +276,7 @@ export async function atomicWriteFileAsync(
|
|
|
188
276
|
path: string,
|
|
189
277
|
content: string,
|
|
190
278
|
io?: AtomicWriteAsyncIO,
|
|
279
|
+
testSeam?: AtomicWriteAsyncTestSeam,
|
|
191
280
|
): Promise<void> {
|
|
192
281
|
const effective: AtomicWriteAsyncIO = io ?? {
|
|
193
282
|
write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }),
|
|
@@ -201,14 +290,17 @@ export async function atomicWriteFileAsync(
|
|
|
201
290
|
truncate: target => truncateSync(target, 0),
|
|
202
291
|
unlink: unlinkSync,
|
|
203
292
|
};
|
|
204
|
-
const
|
|
293
|
+
const target = resolveWriteTarget(path);
|
|
294
|
+
assertResolvedTargetAllowed(path, target);
|
|
295
|
+
const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
|
|
205
296
|
let hardened = false;
|
|
206
297
|
try {
|
|
207
298
|
await effective.write(tmp, content);
|
|
299
|
+
await testSeam?.afterTempWrite?.(tmp);
|
|
208
300
|
await effective.harden(tmp);
|
|
209
301
|
hardened = true;
|
|
210
|
-
await effective.rename(tmp,
|
|
211
|
-
|
|
302
|
+
await effective.rename(tmp, target);
|
|
303
|
+
forgetEphemeralSecretPath(tmp);
|
|
212
304
|
} catch (cause) {
|
|
213
305
|
let scrubbed = false;
|
|
214
306
|
try {
|
|
@@ -235,7 +327,7 @@ export async function atomicWriteFileAsync(
|
|
|
235
327
|
if (!removed && !hardened) {
|
|
236
328
|
try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
|
|
237
329
|
}
|
|
238
|
-
if (removed)
|
|
330
|
+
if (removed) forgetEphemeralSecretPath(tmp);
|
|
239
331
|
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
|
|
240
332
|
throw cause;
|
|
241
333
|
}
|
|
@@ -250,7 +342,21 @@ export class OpenAiTierBackupRollbackError extends Error {
|
|
|
250
342
|
}
|
|
251
343
|
|
|
252
344
|
export class OpenAiTierBackupCollisionError extends Error {
|
|
253
|
-
|
|
345
|
+
readonly configPath?: string;
|
|
346
|
+
constructor(configPath?: string) {
|
|
347
|
+
super("Existing OpenAI tier backup differs from the current config");
|
|
348
|
+
this.name = "OpenAiTierBackupCollisionError";
|
|
349
|
+
this.configPath = configPath;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export class OpenAiTierRollbackPreserveError extends Error {
|
|
354
|
+
readonly code?: "missing" | "not-rollback" | "mismatch" | "exhausted";
|
|
355
|
+
constructor(message: string, options?: ErrorOptions & { code?: OpenAiTierRollbackPreserveError["code"] }) {
|
|
356
|
+
super(message, options);
|
|
357
|
+
this.name = "OpenAiTierRollbackPreserveError";
|
|
358
|
+
this.code = options?.code;
|
|
359
|
+
}
|
|
254
360
|
}
|
|
255
361
|
|
|
256
362
|
export class OpenAiTierBackupSecretResidualError extends Error {
|
|
@@ -338,7 +444,7 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
338
444
|
// point would be surprising and potentially destructive.
|
|
339
445
|
const backupBytes = io.read(backup);
|
|
340
446
|
if (classifyOpenAiTierBackup(backupBytes) === "rollback") {
|
|
341
|
-
throw new OpenAiTierBackupCollisionError();
|
|
447
|
+
throw new OpenAiTierBackupCollisionError(source);
|
|
342
448
|
}
|
|
343
449
|
console.warn("[openai-provider-migration] Replacing stale pre-migration backup (post-migration config was rewritten since last migration).");
|
|
344
450
|
io.unlink(backup);
|
|
@@ -379,7 +485,7 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
379
485
|
}
|
|
380
486
|
}
|
|
381
487
|
}
|
|
382
|
-
if (removed)
|
|
488
|
+
if (removed) forgetEphemeralSecretPath(temp);
|
|
383
489
|
if (!removed && !scrubbed) throw new OpenAiTierBackupSecretResidualError(temp);
|
|
384
490
|
if (!removed) throw new OpenAiTierBackupCleanupError();
|
|
385
491
|
};
|
|
@@ -393,23 +499,23 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
393
499
|
} catch (cause) {
|
|
394
500
|
if (!isAlreadyExistsError(cause)) throw cause;
|
|
395
501
|
const winner = io.read(backup);
|
|
396
|
-
if (!sameBytes(original, winner)) throw new OpenAiTierBackupCollisionError();
|
|
502
|
+
if (!sameBytes(original, winner)) throw new OpenAiTierBackupCollisionError(source);
|
|
397
503
|
scrubUnpublishedTemp();
|
|
398
504
|
return "reused";
|
|
399
505
|
}
|
|
400
506
|
published = true;
|
|
401
507
|
try {
|
|
402
508
|
io.unlink(temp);
|
|
403
|
-
|
|
509
|
+
forgetEphemeralSecretPath(temp);
|
|
404
510
|
} catch (firstError) {
|
|
405
511
|
if (isMissingPathError(firstError)) {
|
|
406
|
-
|
|
512
|
+
forgetEphemeralSecretPath(temp);
|
|
407
513
|
} else try {
|
|
408
514
|
io.unlink(temp);
|
|
409
|
-
|
|
515
|
+
forgetEphemeralSecretPath(temp);
|
|
410
516
|
} catch (secondError) {
|
|
411
517
|
if (isMissingPathError(secondError)) {
|
|
412
|
-
|
|
518
|
+
forgetEphemeralSecretPath(temp);
|
|
413
519
|
return "created";
|
|
414
520
|
}
|
|
415
521
|
// temp and backup are hard links to the same inode. Roll back the backup
|
|
@@ -429,6 +535,67 @@ export function backupConfigBeforeOpenAiTierMigration(
|
|
|
429
535
|
}
|
|
430
536
|
}
|
|
431
537
|
|
|
538
|
+
export interface OpenAiTierRollbackPreserveIO {
|
|
539
|
+
exists(path: string): boolean;
|
|
540
|
+
read(path: string): Uint8Array;
|
|
541
|
+
copyExclusive(source: string, destination: string): void;
|
|
542
|
+
unlink(path: string): void;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = {
|
|
546
|
+
exists: existsSync,
|
|
547
|
+
read: target => readFileSync(target),
|
|
548
|
+
copyExclusive: (source, destination) => {
|
|
549
|
+
copyFileSync(source, destination, fsConstants.COPYFILE_EXCL);
|
|
550
|
+
},
|
|
551
|
+
unlink: unlinkSync,
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS = 16;
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Copy a rollback-classified `.pre-openai-tiers-v2.bak` to a unique
|
|
558
|
+
* `.pre-openai-tiers-v1-rollback.<timestamp>[suffix].bak` path, then unlink the
|
|
559
|
+
* blocking v2 name. The original bytes are copied with no-replace publication;
|
|
560
|
+
* the v2 path is removed only after the copy is verified. Shared by startup
|
|
561
|
+
* migration recovery and `ocx init` cleanup so the two paths cannot drift.
|
|
562
|
+
*/
|
|
563
|
+
export function preserveOpenAiTierRollbackSnapshot(
|
|
564
|
+
configPath = getConfigPath(),
|
|
565
|
+
io: OpenAiTierRollbackPreserveIO = DEFAULT_ROLLBACK_PRESERVE_IO,
|
|
566
|
+
): string {
|
|
567
|
+
const backup = `${configPath}.pre-openai-tiers-v2.bak`;
|
|
568
|
+
if (!io.exists(backup)) {
|
|
569
|
+
throw new OpenAiTierRollbackPreserveError("OpenAI tier rollback backup is missing", { code: "missing" });
|
|
570
|
+
}
|
|
571
|
+
const original = io.read(backup);
|
|
572
|
+
if (classifyOpenAiTierBackup(original) !== "rollback") {
|
|
573
|
+
throw new OpenAiTierRollbackPreserveError("OpenAI tier backup is not a rollback snapshot", { code: "not-rollback" });
|
|
574
|
+
}
|
|
575
|
+
for (let attempt = 0; attempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; attempt++) {
|
|
576
|
+
const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`;
|
|
577
|
+
try {
|
|
578
|
+
io.copyExclusive(backup, preserved);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (isAlreadyExistsError(error)) continue;
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
let copied: Uint8Array;
|
|
584
|
+
try {
|
|
585
|
+
copied = io.read(preserved);
|
|
586
|
+
} catch (error) {
|
|
587
|
+
throw new OpenAiTierRollbackPreserveError("Failed to read preserved rollback snapshot", { cause: error, code: "mismatch" });
|
|
588
|
+
}
|
|
589
|
+
if (!sameBytes(original, copied)) {
|
|
590
|
+
try { io.unlink(preserved); } catch { /* keep the original backup; incomplete copy is best-effort */ }
|
|
591
|
+
throw new OpenAiTierRollbackPreserveError("Preserved rollback snapshot does not match source bytes", { code: "mismatch" });
|
|
592
|
+
}
|
|
593
|
+
io.unlink(backup);
|
|
594
|
+
return preserved;
|
|
595
|
+
}
|
|
596
|
+
throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" });
|
|
597
|
+
}
|
|
598
|
+
|
|
432
599
|
/**
|
|
433
600
|
* Expand a leading `~` to the home directory in user-supplied paths
|
|
434
601
|
* (OPENCODEX_HOME/CODEX_HOME set from GUIs/service files where no shell expanded it).
|
|
@@ -463,36 +630,119 @@ function resolveRuntimePortPath(): string {
|
|
|
463
630
|
}
|
|
464
631
|
|
|
465
632
|
const warnedConfigFallbacks = new Set<string>();
|
|
633
|
+
const warnedInheritedFastWireConflicts = new Set<string>();
|
|
466
634
|
let lastWarningReconciledGeneration = 0;
|
|
467
635
|
|
|
468
636
|
export function reconcileConfigWarningMemos(generation: number): number {
|
|
469
637
|
if (generation <= lastWarningReconciledGeneration) return 0;
|
|
470
|
-
const removed = warnedConfigFallbacks.size;
|
|
638
|
+
const removed = warnedConfigFallbacks.size + warnedInheritedFastWireConflicts.size;
|
|
471
639
|
warnedConfigFallbacks.clear();
|
|
640
|
+
warnedInheritedFastWireConflicts.clear();
|
|
472
641
|
lastWarningReconciledGeneration = generation;
|
|
473
642
|
return removed;
|
|
474
643
|
}
|
|
475
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth
|
|
647
|
+
* shared by the config schema, the load-time sanitizer, and the management write
|
|
648
|
+
* boundary. Strict, so an unknown key is rejected at every validation boundary instead
|
|
649
|
+
* of being silently ignored (the load-time sanitizer still degrades unknown keys with a
|
|
650
|
+
* warning before schema validation, so hand-edited configs keep loading).
|
|
651
|
+
*/
|
|
652
|
+
const retryOn429PolicySchema = z.object({
|
|
653
|
+
enabled: z.boolean().optional(),
|
|
654
|
+
attempts: z.number().int().min(1).max(20).optional(),
|
|
655
|
+
intervalMs: z.number().int().min(100).max(600_000).optional(),
|
|
656
|
+
// The effective cap for a single wait is MAX_COOLDOWN_MS (10 min) in key-failover.ts;
|
|
657
|
+
// larger configured values would be dead config.
|
|
658
|
+
maxIntervalMs: z.number().int().min(100).max(600_000).optional(),
|
|
659
|
+
respectRetryAfter: z.boolean().optional(),
|
|
660
|
+
}).strict();
|
|
661
|
+
|
|
662
|
+
const requestPacingRuleSchema = z.object({
|
|
663
|
+
// Keep the RPM-derived timer within the same one-hour bound as minIntervalMs.
|
|
664
|
+
requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
|
|
665
|
+
minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
|
|
666
|
+
}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, {
|
|
667
|
+
message: "request pacing rules need requestsPerMinute or minIntervalMs",
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const requestPacingSchema = z.object({
|
|
671
|
+
enabled: z.boolean(),
|
|
672
|
+
requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
|
|
673
|
+
minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
|
|
674
|
+
models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(),
|
|
675
|
+
}).strict().refine(value => value.enabled === false
|
|
676
|
+
|| value.requestsPerMinute !== undefined
|
|
677
|
+
|| value.minIntervalMs !== undefined
|
|
678
|
+
|| (value.models !== undefined && Object.keys(value.models).length > 0), {
|
|
679
|
+
message: "enabled request pacing needs a provider rule or model override",
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
export function requestPacingConfigError(value: unknown): string | null {
|
|
683
|
+
if (value === undefined) return null;
|
|
684
|
+
const parsed = requestPacingSchema.safeParse(value);
|
|
685
|
+
if (parsed.success) return null;
|
|
686
|
+
return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides";
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const fastWireSchema = z.object({
|
|
690
|
+
kind: z.string(),
|
|
691
|
+
canonicalToWire: z.record(z.string().trim(), z.string().trim()),
|
|
692
|
+
foreignCallerTiers: z.string(),
|
|
693
|
+
betas: z.array(z.string().trim()).optional(),
|
|
694
|
+
}).strict().superRefine((fastWire, ctx) => {
|
|
695
|
+
const error = fastWireDeclarationError({ fastWire });
|
|
696
|
+
if (error) ctx.addIssue({ code: "custom", message: error });
|
|
697
|
+
}).transform(fastWire => fastWire as FastWire);
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Zod schema for one provider entry: known fields are validated strictly while unknown
|
|
701
|
+
* fields pass through (preserved for runtime extensions).
|
|
702
|
+
*/
|
|
476
703
|
const providerConfigSchema = z.object({
|
|
477
704
|
adapter: z.string().min(1),
|
|
478
705
|
baseUrl: z.string().min(1),
|
|
706
|
+
requestPacing: requestPacingSchema.optional().catch(undefined),
|
|
479
707
|
mcpMaxTools: z.number().int().positive().optional(),
|
|
480
708
|
mcpMaxSchemaBytes: z.number().int().positive().optional(),
|
|
481
709
|
mcpMaxResultBytes: z.number().int().positive().optional(),
|
|
482
710
|
apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
|
|
483
711
|
responsesPath: z.string().min(1).optional(),
|
|
484
712
|
statelessResponses: z.boolean().optional(),
|
|
713
|
+
requiresAdjacentResponsesToolResults: z.boolean().optional(),
|
|
714
|
+
fastWire: fastWireSchema.nullable().optional(),
|
|
715
|
+
supportsServiceTier: z.boolean().optional(),
|
|
716
|
+
modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
|
|
717
|
+
preserveResponsesReasoningContent: z.boolean().optional(),
|
|
718
|
+
decodesNativeCompactionBlobs: z.boolean().optional(),
|
|
485
719
|
allowPrivateNetwork: z.boolean().optional(),
|
|
720
|
+
// The management API accepts `null` as "clear this", so a config written before the POST
|
|
721
|
+
// canonicalization below can hold one on disk. Rejecting it here would send the operator
|
|
722
|
+
// through invalid-config recovery for a value the API told them was fine.
|
|
723
|
+
upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES)
|
|
724
|
+
.nullish()
|
|
725
|
+
.transform(value => value ?? undefined),
|
|
726
|
+
directGeminiWireRenames: z.boolean().optional(),
|
|
727
|
+
noStructuredOutputModels: z.array(z.string().min(1))
|
|
728
|
+
.transform(normalizeNonBlankStringArray)
|
|
729
|
+
.optional(),
|
|
730
|
+
retryOn429: retryOn429PolicySchema.optional(),
|
|
486
731
|
codexAccountMode: z.enum(["pool", "direct"]).optional(),
|
|
732
|
+
// Validated rather than passed through: this schema ends in `.passthrough()`, so an
|
|
733
|
+
// undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be
|
|
734
|
+
// accepted, persisted, and then silently resolved to the `code_mode_only` default — the
|
|
735
|
+
// operator asked for shell mode, got code mode, and was told nothing (#2106).
|
|
736
|
+
codexToolMode: z.enum(["code_mode_only", "shell"]).optional(),
|
|
487
737
|
responsesItemIdRepair: z.object({
|
|
488
738
|
message: z.array(z.string().min(1)).optional(),
|
|
489
739
|
reasoning: z.array(z.string().min(1)).optional(),
|
|
490
740
|
repairMissingTerminalIds: z.boolean().optional(),
|
|
741
|
+
repairInvalidIds: z.boolean().optional(),
|
|
491
742
|
}).strict().optional(),
|
|
743
|
+
responsesSnapshotRepair: z.boolean().optional(),
|
|
492
744
|
}).passthrough();
|
|
493
745
|
|
|
494
|
-
const RESERVED_PROVIDER_NAMES = new Set(["__proto__", "prototype", "constructor"]);
|
|
495
|
-
const PROVIDER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;
|
|
496
746
|
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
497
747
|
const SENSITIVE_PROVIDER_HEADERS = new Set([
|
|
498
748
|
"authorization",
|
|
@@ -504,16 +754,7 @@ const SENSITIVE_PROVIDER_HEADERS = new Set([
|
|
|
504
754
|
"x-amz-security-token",
|
|
505
755
|
]);
|
|
506
756
|
|
|
507
|
-
export
|
|
508
|
-
const trimmed = name.trim();
|
|
509
|
-
return trimmed === name
|
|
510
|
-
&& PROVIDER_NAME_PATTERN.test(name)
|
|
511
|
-
&& !RESERVED_PROVIDER_NAMES.has(name.toLowerCase());
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
export function hasOwnProvider(providers: Record<string, unknown>, name: string): boolean {
|
|
515
|
-
return Object.prototype.hasOwnProperty.call(providers, name);
|
|
516
|
-
}
|
|
757
|
+
export { isValidProviderName, hasOwnProvider } from "./config/provider-name";
|
|
517
758
|
|
|
518
759
|
export function providerBaseUrlConfigError(baseUrl: string): string | null {
|
|
519
760
|
try {
|
|
@@ -552,6 +793,75 @@ export function providerHeadersConfigError(headers: unknown): string | null {
|
|
|
552
793
|
return null;
|
|
553
794
|
}
|
|
554
795
|
|
|
796
|
+
/**
|
|
797
|
+
* Validate `providers.<name>.modelCosts`: a plain object keyed by exact model
|
|
798
|
+
* id, each value a 4-tuple of non-negative finite USD-per-1M-token rates.
|
|
799
|
+
* Returns null when valid/absent, else a human-readable error.
|
|
800
|
+
*/
|
|
801
|
+
export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null {
|
|
802
|
+
if (value === undefined) return null;
|
|
803
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
804
|
+
return `${field} must be a plain object keyed by model id`;
|
|
805
|
+
}
|
|
806
|
+
for (const [modelId, entry] of Object.entries(value)) {
|
|
807
|
+
if (!modelId.trim()) return `${field} keys must be nonblank model ids`;
|
|
808
|
+
// Redact secret-shaped model ids and JSON-escape control characters so a
|
|
809
|
+
// malformed write cannot echo a pasted key/secret back through the
|
|
810
|
+
// management API response.
|
|
811
|
+
const safeModelId = JSON.stringify(redactSecretString(modelId));
|
|
812
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
813
|
+
return `${field}.${safeModelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`;
|
|
814
|
+
}
|
|
815
|
+
const rates = entry as Record<string, unknown>;
|
|
816
|
+
for (const key of COST4_RATE_KEYS) {
|
|
817
|
+
const rate = rates[key];
|
|
818
|
+
if (!isValidCost4Rate(rate)) {
|
|
819
|
+
return `${field}.${safeModelId}.${key} must be a non-negative finite number at most ${MAX_COST4_RATE} (USD per 1M tokens)`;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
// Reject unknown fields: a misplaced apiKey/apiKeyPool under a cost row
|
|
823
|
+
// would otherwise be persisted and echoed verbatim by display paths that
|
|
824
|
+
// mask only top-level provider secrets.
|
|
825
|
+
const extraKeys = Object.keys(rates)
|
|
826
|
+
.filter((key) => !(COST4_RATE_KEYS as readonly string[]).includes(key));
|
|
827
|
+
if (extraKeys.length > 0) {
|
|
828
|
+
return `${field}.${safeModelId} has unexpected fields ${JSON.stringify(extraKeys.map(redactSecretString).join(", "))} — only input, output, cacheRead, and cacheWrite are allowed (USD per 1M tokens)`;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Serialize `providers.<name>.modelCosts` for display: copy ONLY the four
|
|
836
|
+
* numeric rate fields per model and DROP secret-shaped model ids, so a pasted
|
|
837
|
+
* API key in a key position cannot be echoed back by CLI/DTO display paths.
|
|
838
|
+
* The result uses a null prototype so "__proto__" remains an own row.
|
|
839
|
+
*/
|
|
840
|
+
export function sanitizeModelCostsForDisplay(costs: unknown): Record<string, ProviderCostOverlay> | undefined {
|
|
841
|
+
if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined;
|
|
842
|
+
const out = Object.create(null) as Record<string, ProviderCostOverlay>;
|
|
843
|
+
for (const [modelId, entry] of Object.entries(costs)) {
|
|
844
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
845
|
+
const rates = entry as Record<string, unknown>;
|
|
846
|
+
const input = rates.input;
|
|
847
|
+
const output = rates.output;
|
|
848
|
+
const cacheRead = rates.cacheRead;
|
|
849
|
+
const cacheWrite = rates.cacheWrite;
|
|
850
|
+
if (
|
|
851
|
+
isValidCost4Rate(input)
|
|
852
|
+
&& isValidCost4Rate(output)
|
|
853
|
+
&& isValidCost4Rate(cacheRead)
|
|
854
|
+
&& isValidCost4Rate(cacheWrite)
|
|
855
|
+
) {
|
|
856
|
+
// Secret-shaped ids are DROPPED rather than mapped to "[REDACTED]" so
|
|
857
|
+
// distinct rows cannot collapse into one placeholder key.
|
|
858
|
+
if (redactSecretString(modelId) !== modelId) continue;
|
|
859
|
+
out[modelId] = { input, output, cacheRead, cacheWrite };
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
863
|
+
}
|
|
864
|
+
|
|
555
865
|
/** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
|
|
556
866
|
export function apiKeyTransportConfigError(
|
|
557
867
|
provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
|
|
@@ -569,6 +879,20 @@ export function apiKeyTransportConfigError(
|
|
|
569
879
|
return null;
|
|
570
880
|
}
|
|
571
881
|
|
|
882
|
+
/**
|
|
883
|
+
* Shared runtime boundary for the per-provider upstream HTTP-version pin (#1668). Used by
|
|
884
|
+
* the management write path (providerManagementConfigError / PATCH) so it can never disagree
|
|
885
|
+
* with the strict zod load schema: a value that survives POST/PATCH is always loadable, and
|
|
886
|
+
* a value the loader rejects is rejected at write time too.
|
|
887
|
+
*/
|
|
888
|
+
export function upstreamHttpVersionConfigError(value: unknown): string | null {
|
|
889
|
+
if (value === undefined || value === null) return null;
|
|
890
|
+
if (typeof value !== "string" || !(UPSTREAM_HTTP_VERSION_VALUES as readonly string[]).includes(value)) {
|
|
891
|
+
return 'upstreamHttpVersion must be one of "auto", "http1.1", "h1", "http2", "h2", or null to clear';
|
|
892
|
+
}
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
|
|
572
896
|
export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null {
|
|
573
897
|
if (value === undefined) return null;
|
|
574
898
|
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
@@ -591,6 +915,26 @@ export function positiveIntegerConfigError(value: unknown, field: string): strin
|
|
|
591
915
|
return null;
|
|
592
916
|
}
|
|
593
917
|
|
|
918
|
+
export function nonBlankStringArrayConfigError(value: unknown, field: string): string | null {
|
|
919
|
+
if (value === undefined) return null;
|
|
920
|
+
if (!Array.isArray(value)) return `${field} must be an array`;
|
|
921
|
+
for (const [index, entry] of value.entries()) {
|
|
922
|
+
if (typeof entry !== "string" || !entry.trim()) {
|
|
923
|
+
return `${field}.${index} must be a nonblank model id`;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Keep hand-edited config and management writes on one canonical model-id list.
|
|
931
|
+
* Validation happens separately so an all-whitespace value is rejected rather than
|
|
932
|
+
* normalized into a model id that can never match at runtime.
|
|
933
|
+
*/
|
|
934
|
+
export function normalizeNonBlankStringArray(value: readonly string[]): string[] {
|
|
935
|
+
return [...new Set(value.map(entry => entry.trim()))];
|
|
936
|
+
}
|
|
937
|
+
|
|
594
938
|
export function booleanRecordConfigError(value: unknown, field: string): string | null {
|
|
595
939
|
if (value === undefined) return null;
|
|
596
940
|
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
@@ -631,6 +975,100 @@ export function reasoningSummaryDeliveryRecordConfigError(
|
|
|
631
975
|
return null;
|
|
632
976
|
}
|
|
633
977
|
|
|
978
|
+
const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]);
|
|
979
|
+
|
|
980
|
+
export function modelPreferHostedToolsConfigError(
|
|
981
|
+
value: unknown,
|
|
982
|
+
field: string,
|
|
983
|
+
providerName: string,
|
|
984
|
+
provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown },
|
|
985
|
+
): string | null {
|
|
986
|
+
if (value === undefined) return null;
|
|
987
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
988
|
+
const prototype = Object.getPrototypeOf(value);
|
|
989
|
+
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
990
|
+
const entries = Object.entries(value);
|
|
991
|
+
const registry = getProviderRegistryEntry(providerName);
|
|
992
|
+
// Effective transport: a `preserveCustomDestination` registry row reused under a
|
|
993
|
+
// different endpoint keeps its own adapter AND its own auth at runtime, because
|
|
994
|
+
// `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the
|
|
995
|
+
// wire check below and the forward-auth check here have to start from the same
|
|
996
|
+
// decision, or validation accepts a preference the adapter never applies —
|
|
997
|
+
// `preferConfiguredHostedTools()` runs only on the non-forward branch.
|
|
998
|
+
const registryTransportMatches = typeof provider.baseUrl === "string"
|
|
999
|
+
&& providerMatchesRegistryTransport(providerName, {
|
|
1000
|
+
baseUrl: provider.baseUrl,
|
|
1001
|
+
adapter: provider.adapter as OcxProviderConfig["adapter"],
|
|
1002
|
+
...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
|
|
1003
|
+
});
|
|
1004
|
+
const effectiveForwardAuth = registryTransportMatches
|
|
1005
|
+
? registry?.authKind === "forward"
|
|
1006
|
+
: provider.authMode === "forward";
|
|
1007
|
+
if (entries.length > 0 && effectiveForwardAuth) {
|
|
1008
|
+
return `${field} is not supported on forward-auth Responses providers`;
|
|
1009
|
+
}
|
|
1010
|
+
const requestedWireFor = (modelId: string): unknown => provider.modelAdapters
|
|
1011
|
+
&& typeof provider.modelAdapters === "object"
|
|
1012
|
+
&& !Array.isArray(provider.modelAdapters)
|
|
1013
|
+
? (provider.modelAdapters as Record<string, unknown>)[modelId]
|
|
1014
|
+
: undefined;
|
|
1015
|
+
const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => {
|
|
1016
|
+
const pinned = pinnedWireAdapter(providerName, modelId);
|
|
1017
|
+
if (pinned) return pinned;
|
|
1018
|
+
const requestedWire = requestedWireFor(modelId);
|
|
1019
|
+
if (typeof requestedWire === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requestedWire)) {
|
|
1020
|
+
return requestedWire;
|
|
1021
|
+
}
|
|
1022
|
+
// No explicit override: fall back to the registry's per-model wire default before
|
|
1023
|
+
// the provider-wide adapter, because that is the order `resolveModelAdapter()`
|
|
1024
|
+
// uses at request time (src/server/adapter-resolve.ts:38-48). Skipping it rejected
|
|
1025
|
+
// preferences the runtime would have honored — DeepSeek routes `deepseek-v4-flash`
|
|
1026
|
+
// over native Responses for a Responses inbound while the provider-wide wire stays
|
|
1027
|
+
// openai-chat. Hosted-tool preferences only apply to Responses traffic, so the
|
|
1028
|
+
// inbound to ask about is "responses".
|
|
1029
|
+
const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string"
|
|
1030
|
+
? providerModelWireDefault(
|
|
1031
|
+
providerName,
|
|
1032
|
+
{
|
|
1033
|
+
baseUrl: provider.baseUrl,
|
|
1034
|
+
adapter: currentWire,
|
|
1035
|
+
...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
|
|
1036
|
+
},
|
|
1037
|
+
modelId,
|
|
1038
|
+
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
1039
|
+
"responses",
|
|
1040
|
+
)
|
|
1041
|
+
: undefined;
|
|
1042
|
+
return registryDefault ?? currentWire;
|
|
1043
|
+
};
|
|
1044
|
+
for (const [key, entry] of entries) {
|
|
1045
|
+
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
1046
|
+
if (!Array.isArray(entry)) return `${field}.${key} must be an array`;
|
|
1047
|
+
if (entry.length === 0) return `${field}.${key} must include image_generation`;
|
|
1048
|
+
for (const tool of entry) {
|
|
1049
|
+
if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) {
|
|
1050
|
+
return `${field}.${key} supports only image_generation`;
|
|
1051
|
+
}
|
|
1052
|
+
if (isHostedToolUnsupportedForModel(key, tool)) {
|
|
1053
|
+
return `${field}.${key} cannot prefer ${tool}: the model does not support it`;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// Same `registryTransportMatches` decision the forward-auth check above uses:
|
|
1057
|
+
// start from the registry adapter only when this config still points at the
|
|
1058
|
+
// registry's documented transport.
|
|
1059
|
+
const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter;
|
|
1060
|
+
let effectiveWire = resolveEffectiveWire(key, baseWire);
|
|
1061
|
+
const virtualWireModel = resolveOpenAiVirtualModel(providerName, key)?.wireModelId;
|
|
1062
|
+
if (virtualWireModel && virtualWireModel !== key) {
|
|
1063
|
+
effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire);
|
|
1064
|
+
}
|
|
1065
|
+
if (effectiveWire !== "openai-responses") {
|
|
1066
|
+
return `${field}.${key} requires the openai-responses wire`;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
634
1072
|
/**
|
|
635
1073
|
* Validate a provider's per-model wire override map (#404).
|
|
636
1074
|
*
|
|
@@ -712,6 +1150,33 @@ const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
|
|
|
712
1150
|
}
|
|
713
1151
|
}).pipe(z.record(z.string(), z.string()));
|
|
714
1152
|
|
|
1153
|
+
const CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR =
|
|
1154
|
+
"codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers";
|
|
1155
|
+
const CODEX_ACCOUNT_PRIORITY_KEY_ERROR =
|
|
1156
|
+
"selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys";
|
|
1157
|
+
const CODEX_ACCOUNT_PRIORITY_VALUE_ERROR =
|
|
1158
|
+
"selection order must be an integer between -100 and 100";
|
|
1159
|
+
|
|
1160
|
+
const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
1161
|
+
|
|
1162
|
+
const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
|
|
1163
|
+
(value): value is Record<string, unknown> => !!value
|
|
1164
|
+
&& typeof value === "object"
|
|
1165
|
+
&& !Array.isArray(value)
|
|
1166
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
|
|
1167
|
+
{ error: CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR },
|
|
1168
|
+
).superRefine((priorities, ctx) => {
|
|
1169
|
+
// Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
|
|
1170
|
+
for (const [accountId, priority] of Object.entries(priorities)) {
|
|
1171
|
+
if (!isCodexAccountPriorityKey(accountId)) {
|
|
1172
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_KEY_ERROR });
|
|
1173
|
+
}
|
|
1174
|
+
if (parseAccountPriority(priority) === null) {
|
|
1175
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_VALUE_ERROR });
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}).pipe(z.record(z.string(), z.number().int()));
|
|
1179
|
+
|
|
715
1180
|
/**
|
|
716
1181
|
* Deliberately permissive. A user's config is not ours to invalidate: a strict
|
|
717
1182
|
* entry fails the whole parse, and loadConfig's fallback then backs the file up
|
|
@@ -742,9 +1207,37 @@ const apiKeyEntrySchema = z.object({
|
|
|
742
1207
|
createdAt: z.string().catch(""),
|
|
743
1208
|
}).passthrough();
|
|
744
1209
|
|
|
1210
|
+
/**
|
|
1211
|
+
* Durable per-client intent.
|
|
1212
|
+
*
|
|
1213
|
+
* `.passthrough()` is load-bearing: a binary that only knows `codex` must not
|
|
1214
|
+
* erase a key a later version wrote during a field-scoped mutation. And each key
|
|
1215
|
+
* degrades on its own — a hand edit of `{"codex": "false", "future": false}`
|
|
1216
|
+
* drops `codex` to absent (which reads as ON) and keeps `future`, rather than
|
|
1217
|
+
* invalidating the object or, worse, the whole config.
|
|
1218
|
+
*/
|
|
1219
|
+
const clientIntegrationsSchema = z.object({
|
|
1220
|
+
codex: z.boolean().optional().catch(undefined),
|
|
1221
|
+
grok: z.boolean().optional().catch(undefined),
|
|
1222
|
+
"claude-desktop": z.boolean().optional().catch(undefined),
|
|
1223
|
+
}).passthrough();
|
|
1224
|
+
|
|
1225
|
+
const agentTaskRecoverySchema = z.object({
|
|
1226
|
+
enabled: z.boolean().optional(),
|
|
1227
|
+
model: z.string().trim().min(1).optional(),
|
|
1228
|
+
timeoutMs: z.number().int().min(1_000).max(120_000).optional(),
|
|
1229
|
+
cacheEntries: z.number().int().min(1).max(512).optional(),
|
|
1230
|
+
}).strict();
|
|
1231
|
+
|
|
745
1232
|
const configSchema = z.object({
|
|
746
1233
|
port: z.number().int().min(0).max(65535).default(10100),
|
|
747
1234
|
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
|
|
1235
|
+
// Invalid hand edits disable only this opt-in circuit. Live writes remain strict.
|
|
1236
|
+
upstreamHostCircuitThreshold: z.number().int()
|
|
1237
|
+
.min(0)
|
|
1238
|
+
.max(UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD)
|
|
1239
|
+
.optional()
|
|
1240
|
+
.catch(undefined),
|
|
748
1241
|
appOwnedMemoryBudgetMb: z.number().int()
|
|
749
1242
|
.min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
|
|
750
1243
|
.max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
|
|
@@ -757,15 +1250,27 @@ const configSchema = z.object({
|
|
|
757
1250
|
// is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time
|
|
758
1251
|
// rejection lives in validateConfigCandidate() so bad values still surface to the caller.
|
|
759
1252
|
hostname: z.string().trim().min(1).optional().catch(undefined),
|
|
1253
|
+
// Discriminated on `enabled` so a disabled entry cannot be forced to carry a port, and an
|
|
1254
|
+
// enabled one cannot omit it (#1102). A malformed value degrades to undefined rather than
|
|
1255
|
+
// failing the whole parse: this is an opt-in convenience surface, and a hand-edit typo here
|
|
1256
|
+
// must never reset providers/apiKeys through the backup-and-defaults repair path.
|
|
1257
|
+
unauthenticatedLoopbackListener: z.union([
|
|
1258
|
+
z.object({ enabled: z.literal(false) }),
|
|
1259
|
+
z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }),
|
|
1260
|
+
]).optional().catch(undefined),
|
|
760
1261
|
providers: z.record(z.string(), providerConfigSchema),
|
|
761
1262
|
defaultProvider: z.string().min(1).default("openai"),
|
|
1263
|
+
// A retry can be billable, so absence and malformed hand edits both stay off.
|
|
1264
|
+
emptyCompletionRetry: z.boolean().optional().catch(false),
|
|
762
1265
|
openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
|
|
763
|
-
// Invalid hand edits must not discard an otherwise usable config.
|
|
764
|
-
|
|
765
|
-
|
|
1266
|
+
// Invalid hand edits must not discard an otherwise usable config.
|
|
1267
|
+
googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined),
|
|
1268
|
+
clientIntegrations: clientIntegrationsSchema.optional().catch(undefined),
|
|
766
1269
|
providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
|
|
767
1270
|
contextCapValue: z.number().int().positive().optional(),
|
|
768
1271
|
multiAgentGuidanceEnabled: z.boolean().optional(),
|
|
1272
|
+
// Invalid optional recovery config must not discard unrelated provider/account state.
|
|
1273
|
+
agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined),
|
|
769
1274
|
// These selections pre-date schema validation and used to pass through as
|
|
770
1275
|
// unknown fields. Invalid hand edits must disable only the optional
|
|
771
1276
|
// delegation/native-default feature, not reject the whole config and hide
|
|
@@ -773,9 +1278,24 @@ const configSchema = z.object({
|
|
|
773
1278
|
injectionModel: z.string().optional().catch(undefined),
|
|
774
1279
|
injectionEffort: z.string().optional().catch(undefined),
|
|
775
1280
|
syncCodexSubagentDefaults: z.boolean().optional().catch(undefined),
|
|
1281
|
+
// Per-primary-model fallback chains. Values must be non-empty string arrays;
|
|
1282
|
+
// malformed entries degrade to undefined rather than rejecting the whole config.
|
|
1283
|
+
subagentModelFallbackByModel: z.record(
|
|
1284
|
+
z.string(),
|
|
1285
|
+
z.array(z.string().trim().min(1)).min(1),
|
|
1286
|
+
).optional().catch(undefined),
|
|
776
1287
|
codexShimAutoRestore: z.boolean().optional(),
|
|
777
1288
|
pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
|
|
778
1289
|
codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
|
|
1290
|
+
// Selection order is a preference, not a safety control like pause: a malformed
|
|
1291
|
+
// map degrades to "no ordering" rather than failing the parse, so a hand-edited
|
|
1292
|
+
// typo cannot trip the backup-and-defaults repair path and wipe providers or
|
|
1293
|
+
// pool accounts. Warning emitted in loadConfig.
|
|
1294
|
+
codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined),
|
|
1295
|
+
activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined),
|
|
1296
|
+
// A malformed hand edit must degrade to false without discarding providers, accounts,
|
|
1297
|
+
// or the exact selector map. Live writes remain strict.
|
|
1298
|
+
codexAccountPickerEnabled: z.boolean().optional().catch(false),
|
|
779
1299
|
// Model ids excluded from the Grok Build managed block (dashboard switches).
|
|
780
1300
|
grokExcludedModels: z.array(z.string()).optional(),
|
|
781
1301
|
// Invalid values degrade to undefined ("auto") instead of failing the whole
|
|
@@ -828,6 +1348,7 @@ const configSchema = z.object({
|
|
|
828
1348
|
const configuredProviderNamespaces = new Set([
|
|
829
1349
|
COMBO_NAMESPACE,
|
|
830
1350
|
OPENAI_CODEX_PROVIDER_ID,
|
|
1351
|
+
POLICY_NAMESPACE,
|
|
831
1352
|
...Object.keys(config.providers),
|
|
832
1353
|
].map(codexProviderNamespaceKey));
|
|
833
1354
|
const namespaceTargets = new Set(
|
|
@@ -839,7 +1360,7 @@ const configSchema = z.object({
|
|
|
839
1360
|
ctx.addIssue({
|
|
840
1361
|
code: "custom",
|
|
841
1362
|
path: ["codexAccountNamespaces", namespace],
|
|
842
|
-
message: "account selectors must not collide with configured provider or
|
|
1363
|
+
message: "account selectors must not collide with configured provider, combo, or routing policy namespaces",
|
|
843
1364
|
});
|
|
844
1365
|
}
|
|
845
1366
|
if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) {
|
|
@@ -855,18 +1376,25 @@ const configSchema = z.object({
|
|
|
855
1376
|
if (!isValidProviderName(name)) {
|
|
856
1377
|
ctx.addIssue({
|
|
857
1378
|
code: "custom",
|
|
858
|
-
path: ["providers", name],
|
|
859
|
-
message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys",
|
|
1379
|
+
path: ["providers", redactSecretString(name)],
|
|
1380
|
+
message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys or routing namespaces (policy)",
|
|
860
1381
|
});
|
|
861
1382
|
}
|
|
862
1383
|
const provider = config.providers[name];
|
|
1384
|
+
if (hasFastWireCapabilityConflict(provider)) {
|
|
1385
|
+
ctx.addIssue({
|
|
1386
|
+
code: "custom",
|
|
1387
|
+
path: ["providers", redactSecretString(name), "fastWire"],
|
|
1388
|
+
message: "fastWire=null conflicts with supportsServiceTier=true",
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
863
1391
|
const openRouterRoutingError = openRouterRoutingConfigError(provider);
|
|
864
1392
|
if (openRouterRoutingError) {
|
|
865
1393
|
ctx.addIssue({
|
|
866
1394
|
code: "custom",
|
|
867
1395
|
path: [
|
|
868
1396
|
"providers",
|
|
869
|
-
name,
|
|
1397
|
+
redactSecretString(name),
|
|
870
1398
|
openRouterRoutingError.startsWith("modelOpenRouterRouting")
|
|
871
1399
|
? "modelOpenRouterRouting"
|
|
872
1400
|
: "openRouterRouting",
|
|
@@ -877,7 +1405,7 @@ const configSchema = z.object({
|
|
|
877
1405
|
if (Object.hasOwn(provider, "virtualModels")) {
|
|
878
1406
|
ctx.addIssue({
|
|
879
1407
|
code: "custom",
|
|
880
|
-
path: ["providers", name, "virtualModels"],
|
|
1408
|
+
path: ["providers", redactSecretString(name), "virtualModels"],
|
|
881
1409
|
message: "virtualModels is registry-only and must not be persisted",
|
|
882
1410
|
});
|
|
883
1411
|
}
|
|
@@ -885,7 +1413,7 @@ const configSchema = z.object({
|
|
|
885
1413
|
if (baseUrlError) {
|
|
886
1414
|
ctx.addIssue({
|
|
887
1415
|
code: "custom",
|
|
888
|
-
path: ["providers", name, "baseUrl"],
|
|
1416
|
+
path: ["providers", redactSecretString(name), "baseUrl"],
|
|
889
1417
|
message: baseUrlError,
|
|
890
1418
|
});
|
|
891
1419
|
} else {
|
|
@@ -893,7 +1421,7 @@ const configSchema = z.object({
|
|
|
893
1421
|
if (destinationError) {
|
|
894
1422
|
ctx.addIssue({
|
|
895
1423
|
code: "custom",
|
|
896
|
-
path: ["providers", name, "baseUrl"],
|
|
1424
|
+
path: ["providers", redactSecretString(name), "baseUrl"],
|
|
897
1425
|
message: destinationError,
|
|
898
1426
|
});
|
|
899
1427
|
}
|
|
@@ -902,7 +1430,7 @@ const configSchema = z.object({
|
|
|
902
1430
|
if (responsesPathError) {
|
|
903
1431
|
ctx.addIssue({
|
|
904
1432
|
code: "custom",
|
|
905
|
-
path: ["providers", name, "responsesPath"],
|
|
1433
|
+
path: ["providers", redactSecretString(name), "responsesPath"],
|
|
906
1434
|
message: responsesPathError,
|
|
907
1435
|
});
|
|
908
1436
|
}
|
|
@@ -910,15 +1438,25 @@ const configSchema = z.object({
|
|
|
910
1438
|
if (headersError) {
|
|
911
1439
|
ctx.addIssue({
|
|
912
1440
|
code: "custom",
|
|
913
|
-
path: ["providers", name, "headers"],
|
|
1441
|
+
path: ["providers", redactSecretString(name), "headers"],
|
|
914
1442
|
message: headersError,
|
|
915
1443
|
});
|
|
916
1444
|
}
|
|
1445
|
+
const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts);
|
|
1446
|
+
if (modelCostsError) {
|
|
1447
|
+
ctx.addIssue({
|
|
1448
|
+
code: "custom",
|
|
1449
|
+
// The provider key is caller-controlled and can be token-shaped; redact it
|
|
1450
|
+
// before schemaDiagnosticsError serializes the path (ocx config validate/import).
|
|
1451
|
+
path: ["providers", redactSecretString(name), "modelCosts"],
|
|
1452
|
+
message: modelCostsError,
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
917
1455
|
const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig);
|
|
918
1456
|
if (apiKeyTransportError) {
|
|
919
1457
|
ctx.addIssue({
|
|
920
1458
|
code: "custom",
|
|
921
|
-
path: ["providers", name, "apiKeyTransport"],
|
|
1459
|
+
path: ["providers", redactSecretString(name), "apiKeyTransport"],
|
|
922
1460
|
message: apiKeyTransportError,
|
|
923
1461
|
});
|
|
924
1462
|
}
|
|
@@ -931,10 +1469,23 @@ const configSchema = z.object({
|
|
|
931
1469
|
if (modelAdaptersError) {
|
|
932
1470
|
ctx.addIssue({
|
|
933
1471
|
code: "custom",
|
|
934
|
-
path: ["providers", name, "modelAdapters"],
|
|
1472
|
+
path: ["providers", redactSecretString(name), "modelAdapters"],
|
|
935
1473
|
message: modelAdaptersError,
|
|
936
1474
|
});
|
|
937
1475
|
}
|
|
1476
|
+
const preferHostedToolsError = modelPreferHostedToolsConfigError(
|
|
1477
|
+
(provider as { modelPreferHostedTools?: unknown }).modelPreferHostedTools,
|
|
1478
|
+
"modelPreferHostedTools",
|
|
1479
|
+
name,
|
|
1480
|
+
provider,
|
|
1481
|
+
);
|
|
1482
|
+
if (preferHostedToolsError) {
|
|
1483
|
+
ctx.addIssue({
|
|
1484
|
+
code: "custom",
|
|
1485
|
+
path: ["providers", redactSecretString(name), "modelPreferHostedTools"],
|
|
1486
|
+
message: preferHostedToolsError,
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
938
1489
|
const maxInputError = positiveIntegerRecordConfigError(
|
|
939
1490
|
(provider as { modelMaxInputTokens?: unknown }).modelMaxInputTokens,
|
|
940
1491
|
"modelMaxInputTokens",
|
|
@@ -942,7 +1493,7 @@ const configSchema = z.object({
|
|
|
942
1493
|
if (maxInputError) {
|
|
943
1494
|
ctx.addIssue({
|
|
944
1495
|
code: "custom",
|
|
945
|
-
path: ["providers", name, "modelMaxInputTokens"],
|
|
1496
|
+
path: ["providers", redactSecretString(name), "modelMaxInputTokens"],
|
|
946
1497
|
message: maxInputError,
|
|
947
1498
|
});
|
|
948
1499
|
}
|
|
@@ -953,10 +1504,21 @@ const configSchema = z.object({
|
|
|
953
1504
|
if (reasoningSummariesError) {
|
|
954
1505
|
ctx.addIssue({
|
|
955
1506
|
code: "custom",
|
|
956
|
-
path: ["providers", name, "modelSupportsReasoningSummaries"],
|
|
1507
|
+
path: ["providers", redactSecretString(name), "modelSupportsReasoningSummaries"],
|
|
957
1508
|
message: reasoningSummariesError,
|
|
958
1509
|
});
|
|
959
1510
|
}
|
|
1511
|
+
const serviceTierModelsError = booleanRecordConfigError(
|
|
1512
|
+
(provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier,
|
|
1513
|
+
"modelSupportsServiceTier",
|
|
1514
|
+
);
|
|
1515
|
+
if (serviceTierModelsError) {
|
|
1516
|
+
ctx.addIssue({
|
|
1517
|
+
code: "custom",
|
|
1518
|
+
path: ["providers", redactSecretString(name), "modelSupportsServiceTier"],
|
|
1519
|
+
message: serviceTierModelsError,
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
960
1522
|
const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError(
|
|
961
1523
|
(provider as { modelReasoningSummaryDelivery?: unknown }).modelReasoningSummaryDelivery,
|
|
962
1524
|
(provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
|
|
@@ -964,7 +1526,7 @@ const configSchema = z.object({
|
|
|
964
1526
|
if (reasoningSummaryDeliveryError) {
|
|
965
1527
|
ctx.addIssue({
|
|
966
1528
|
code: "custom",
|
|
967
|
-
path: ["providers", name, "modelReasoningSummaryDelivery"],
|
|
1529
|
+
path: ["providers", redactSecretString(name), "modelReasoningSummaryDelivery"],
|
|
968
1530
|
message: reasoningSummaryDeliveryError,
|
|
969
1531
|
});
|
|
970
1532
|
}
|
|
@@ -975,7 +1537,7 @@ const configSchema = z.object({
|
|
|
975
1537
|
if (defaultMaxOutputError) {
|
|
976
1538
|
ctx.addIssue({
|
|
977
1539
|
code: "custom",
|
|
978
|
-
path: ["providers", name, "defaultMaxOutputTokens"],
|
|
1540
|
+
path: ["providers", redactSecretString(name), "defaultMaxOutputTokens"],
|
|
979
1541
|
message: defaultMaxOutputError,
|
|
980
1542
|
});
|
|
981
1543
|
}
|
|
@@ -986,10 +1548,21 @@ const configSchema = z.object({
|
|
|
986
1548
|
if (maxOutputError) {
|
|
987
1549
|
ctx.addIssue({
|
|
988
1550
|
code: "custom",
|
|
989
|
-
path: ["providers", name, "modelMaxOutputTokens"],
|
|
1551
|
+
path: ["providers", redactSecretString(name), "modelMaxOutputTokens"],
|
|
990
1552
|
message: maxOutputError,
|
|
991
1553
|
});
|
|
992
1554
|
}
|
|
1555
|
+
const structuredOutputOptOutError = nonBlankStringArrayConfigError(
|
|
1556
|
+
(provider as { noStructuredOutputModels?: unknown }).noStructuredOutputModels,
|
|
1557
|
+
"noStructuredOutputModels",
|
|
1558
|
+
);
|
|
1559
|
+
if (structuredOutputOptOutError) {
|
|
1560
|
+
ctx.addIssue({
|
|
1561
|
+
code: "custom",
|
|
1562
|
+
path: ["providers", redactSecretString(name), "noStructuredOutputModels"],
|
|
1563
|
+
message: structuredOutputOptOutError,
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
993
1566
|
if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) {
|
|
994
1567
|
// Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider.
|
|
995
1568
|
// Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them.
|
|
@@ -1001,7 +1574,7 @@ const configSchema = z.object({
|
|
|
1001
1574
|
if (!canonicalOpenAiShape) {
|
|
1002
1575
|
ctx.addIssue({
|
|
1003
1576
|
code: "custom",
|
|
1004
|
-
path: ["providers", name, "codexAccountMode"],
|
|
1577
|
+
path: ["providers", redactSecretString(name), "codexAccountMode"],
|
|
1005
1578
|
message: "codexAccountMode is valid only on the canonical built-in openai provider",
|
|
1006
1579
|
});
|
|
1007
1580
|
}
|
|
@@ -1045,6 +1618,27 @@ const configSchema = z.object({
|
|
|
1045
1618
|
}
|
|
1046
1619
|
}
|
|
1047
1620
|
}
|
|
1621
|
+
const routingProfiles = (config as { routingProfiles?: unknown }).routingProfiles;
|
|
1622
|
+
if (routingProfiles !== undefined) {
|
|
1623
|
+
if (!routingProfiles || typeof routingProfiles !== "object" || Array.isArray(routingProfiles)) {
|
|
1624
|
+
ctx.addIssue({ code: "custom", path: ["routingProfiles"], message: "routingProfiles must be an object" });
|
|
1625
|
+
} else {
|
|
1626
|
+
for (const [id, raw] of Object.entries(routingProfiles as Record<string, unknown>)) {
|
|
1627
|
+
for (const issue of routingProfileIssues(id, raw, {
|
|
1628
|
+
providers: config.providers,
|
|
1629
|
+
combos: combos as Record<string, import("./types").OcxComboConfig> | undefined,
|
|
1630
|
+
routingProfiles: routingProfiles as Record<string, import("./types").OcxRoutingProfileConfig>,
|
|
1631
|
+
codexAccountNamespaces: accountNamespaces,
|
|
1632
|
+
}, { excludeProfileId: id })) {
|
|
1633
|
+
ctx.addIssue({
|
|
1634
|
+
code: "custom",
|
|
1635
|
+
path: ["routingProfiles", id, ...issue.path],
|
|
1636
|
+
message: issue.message,
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1048
1642
|
});
|
|
1049
1643
|
|
|
1050
1644
|
/**
|
|
@@ -1076,6 +1670,9 @@ export function getRuntimePortPath(): string {
|
|
|
1076
1670
|
|
|
1077
1671
|
export function hardenConfigDir(): void {
|
|
1078
1672
|
const dir = getConfigDir();
|
|
1673
|
+
// The guard runs BEFORE any mutation: refusing the write after chmod/ACL
|
|
1674
|
+
// would already have changed the protected directory (review round 2).
|
|
1675
|
+
assertNotRealHomeUnderTest(dir);
|
|
1079
1676
|
if (existsSync(dir)) {
|
|
1080
1677
|
try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
|
|
1081
1678
|
if (process.platform === "win32") {
|
|
@@ -1105,6 +1702,144 @@ function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void
|
|
|
1105
1702
|
}
|
|
1106
1703
|
}
|
|
1107
1704
|
|
|
1705
|
+
/**
|
|
1706
|
+
* Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional
|
|
1707
|
+
* field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every
|
|
1708
|
+
* provider/key behind a default config. Invalid fields are dropped with a warning; the management
|
|
1709
|
+
* write boundary still rejects invalid policies explicitly.
|
|
1710
|
+
*/
|
|
1711
|
+
function sanitizeRetryOn429ForLoad(parsed: unknown): void {
|
|
1712
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
1713
|
+
const root = parsed as Record<string, unknown>;
|
|
1714
|
+
const providers = root.providers;
|
|
1715
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
|
|
1716
|
+
for (const [name, provider] of Object.entries(providers as Record<string, unknown>)) {
|
|
1717
|
+
// This sanitizer runs BEFORE schema validation, so the provider name is untrusted: redact
|
|
1718
|
+
// secret-shaped names and JSON-escape control characters before it reaches any warning.
|
|
1719
|
+
const safeProviderName = JSON.stringify(redactSecretString(name));
|
|
1720
|
+
if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
|
|
1721
|
+
const p = provider as Record<string, unknown>;
|
|
1722
|
+
const policy = p.retryOn429;
|
|
1723
|
+
if (policy === undefined) continue;
|
|
1724
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
1725
|
+
delete p.retryOn429;
|
|
1726
|
+
// Never serialize the value: an accidental `retryOn429: "sk-..."` would leak the secret.
|
|
1727
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 (${typeof policy}) is invalid — ignoring the policy`);
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
const policyRecord = policy as Record<string, unknown>;
|
|
1731
|
+
// An explicitly present but invalid master switch must not silently default to ENABLED:
|
|
1732
|
+
// drop the whole policy so a hand-edit that tried to disable retries stays disabled.
|
|
1733
|
+
if ("enabled" in policyRecord && typeof policyRecord.enabled !== "boolean") {
|
|
1734
|
+
delete p.retryOn429;
|
|
1735
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.enabled (${typeof policyRecord.enabled}) is invalid — ignoring the whole policy`);
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
// Field checks derive from the shared policy schema so the bounds cannot drift
|
|
1739
|
+
// between the load-time sanitizer, the config schema, and the write boundary.
|
|
1740
|
+
const policyShape = retryOn429PolicySchema.shape;
|
|
1741
|
+
const hadPolicyEntries = Object.keys(policyRecord).length > 0;
|
|
1742
|
+
const cleaned: Record<string, unknown> = {};
|
|
1743
|
+
for (const [key, fieldSchema] of Object.entries(policyShape)) {
|
|
1744
|
+
const value = policyRecord[key];
|
|
1745
|
+
if (value === undefined) continue;
|
|
1746
|
+
if (fieldSchema.safeParse(value).success) cleaned[key] = value;
|
|
1747
|
+
// Log only the received type, never the value (provider config can hold secrets).
|
|
1748
|
+
else console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${key} (${typeof value}) is invalid — ignoring the field`);
|
|
1749
|
+
}
|
|
1750
|
+
const knownKeys = new Set(Object.keys(policyShape));
|
|
1751
|
+
for (const key of Object.keys(policyRecord)) {
|
|
1752
|
+
if (!knownKeys.has(key)) {
|
|
1753
|
+
// Redact the field NAME before logging: a malformed hand-edit can place a secret in a
|
|
1754
|
+
// property name (`retryOn429: { "sk-...": true }`). Ordinary typos (e.g. `attempt`)
|
|
1755
|
+
// stay readable, secret-shaped names become [REDACTED]. JSON-escape afterwards so a
|
|
1756
|
+
// control-character property name (newline/ANSI) can never forge a log line.
|
|
1757
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${JSON.stringify(redactSecretString(key))} is not a recognized field — ignoring it`);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
if (hadPolicyEntries && Object.keys(cleaned).length === 0) {
|
|
1761
|
+
// Every supplied field was invalid: drop the whole policy. Persisting `{}` here would
|
|
1762
|
+
// opt IN to retries with defaults, which is the opposite of what a malformed
|
|
1763
|
+
// disable-oriented edit (`retryOn429: { enabled: "false" }`, `attempts: 0`) asked for.
|
|
1764
|
+
delete p.retryOn429;
|
|
1765
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 has no valid fields left — removing the policy (an empty policy would enable retries with defaults)`);
|
|
1766
|
+
} else {
|
|
1767
|
+
// Preserve an intentionally empty `retryOn429: {}` (presence = opt-in with defaults).
|
|
1768
|
+
p.retryOn429 = cleaned;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Management write-boundary validation for `retryOn429` (fail closed). Unlike the
|
|
1775
|
+
* lenient load-time sanitizer, invalid values and unknown keys are rejected outright so
|
|
1776
|
+
* a POST/PATCH cannot persist a policy the proxy would then silently degrade. Reuses the
|
|
1777
|
+
* shared policy schema. Never echoes values, and secret-shaped unknown field names are
|
|
1778
|
+
* redacted (a malformed write can place a secret in a property name).
|
|
1779
|
+
*/
|
|
1780
|
+
export function retryOn429PolicyConfigError(policy: unknown): string | null {
|
|
1781
|
+
if (policy === undefined) return null;
|
|
1782
|
+
const result = retryOn429PolicySchema.safeParse(policy);
|
|
1783
|
+
if (result.success) return null;
|
|
1784
|
+
const first = result.error.issues[0];
|
|
1785
|
+
if (!first) return "retryOn429 is invalid";
|
|
1786
|
+
if (first.code === "unrecognized_keys") {
|
|
1787
|
+
const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", ");
|
|
1788
|
+
return `retryOn429 has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`;
|
|
1789
|
+
}
|
|
1790
|
+
if (first.path.length === 0) return `retryOn429 is invalid (${first.message})`;
|
|
1791
|
+
const field = String(first.path[first.path.length - 1]);
|
|
1792
|
+
return `retryOn429.${field} is invalid (${first.message})`;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Load-time degradation for `providers.<name>.modelCosts`, mirroring
|
|
1797
|
+
* {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row
|
|
1798
|
+
* must not fail the whole config parse — that would back up config.json and
|
|
1799
|
+
* fall back to defaults, dropping otherwise valid providers and the default
|
|
1800
|
+
* route for a typo in a non-runtime display field. Invalid rows are dropped
|
|
1801
|
+
* with a warning; strict rejection stays at the management/write boundary
|
|
1802
|
+
* (providerManagementConfigError).
|
|
1803
|
+
*/
|
|
1804
|
+
function sanitizeModelCostsForLoad(parsed: unknown): void {
|
|
1805
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
1806
|
+
const root = parsed as Record<string, unknown>;
|
|
1807
|
+
const providers = root.providers;
|
|
1808
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
|
|
1809
|
+
for (const [name, provider] of Object.entries(providers as Record<string, unknown>)) {
|
|
1810
|
+
// Runs before schema validation, so the provider name is untrusted: redact
|
|
1811
|
+
// secret-shaped names and JSON-escape control characters for the warning.
|
|
1812
|
+
const safeProviderName = JSON.stringify(redactSecretString(name));
|
|
1813
|
+
if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
|
|
1814
|
+
const p = provider as Record<string, unknown>;
|
|
1815
|
+
const costs = p.modelCosts;
|
|
1816
|
+
if (costs === undefined) continue;
|
|
1817
|
+
if (!costs || typeof costs !== "object" || Array.isArray(costs)) {
|
|
1818
|
+
delete p.modelCosts;
|
|
1819
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts (${typeof costs}) is invalid — ignoring the overlay`);
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
const costsRecord = costs as Record<string, unknown>;
|
|
1823
|
+
const hadEntries = Object.keys(costsRecord).length > 0;
|
|
1824
|
+
let kept = 0;
|
|
1825
|
+
for (const [modelId, entry] of Object.entries(costsRecord)) {
|
|
1826
|
+
// Reuse the shared per-row shape contract so the load-time sanitizer
|
|
1827
|
+
// cannot drift from the schema and the write boundary.
|
|
1828
|
+
if (providerModelCostsConfigError({ [modelId]: entry }) === null) {
|
|
1829
|
+
kept++;
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
delete costsRecord[modelId];
|
|
1833
|
+
// Redact the model id: a hand-edit can place a secret in a key name.
|
|
1834
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts.${JSON.stringify(redactSecretString(modelId))} is invalid — ignoring the row`);
|
|
1835
|
+
}
|
|
1836
|
+
if (hadEntries && kept === 0) {
|
|
1837
|
+
delete p.modelCosts;
|
|
1838
|
+
console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts has no valid rows left — removing the overlay`);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1108
1843
|
/**
|
|
1109
1844
|
* Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind
|
|
1110
1845
|
* falls back to loopback, which is the safe direction but not what the file asked for —
|
|
@@ -1118,6 +1853,32 @@ function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void {
|
|
|
1118
1853
|
}
|
|
1119
1854
|
}
|
|
1120
1855
|
|
|
1856
|
+
/**
|
|
1857
|
+
* Companion to {@link warnDegradedStreamMode} for a malformed selection-order map.
|
|
1858
|
+
* Priority is a preference, so the schema drops the whole map rather than failing
|
|
1859
|
+
* the parse — say so once, otherwise the pool silently reverts to flat ordering.
|
|
1860
|
+
*/
|
|
1861
|
+
function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validated: OcxConfig): string[] {
|
|
1862
|
+
const record = rawConfigRecord(rawParsed);
|
|
1863
|
+
const warnings: string[] = [];
|
|
1864
|
+
// The pin degrades silently otherwise, which reads as the manual selection simply
|
|
1865
|
+
// not having survived the restart.
|
|
1866
|
+
if (record?.activeCodexAccountPinned !== undefined && validated.activeCodexAccountPinned === undefined) {
|
|
1867
|
+
warnings.push("activeCodexAccountPinned is not a valid account id — the manually selected account is no longer pinned");
|
|
1868
|
+
}
|
|
1869
|
+
const raw = record?.codexAccountPriorities;
|
|
1870
|
+
if (raw !== undefined && validated.codexAccountPriorities === undefined) {
|
|
1871
|
+
warnings.push("codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled");
|
|
1872
|
+
}
|
|
1873
|
+
return warnings;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function warnDegradedCodexAccountPriorities(rawParsed: unknown, validated: OcxConfig): void {
|
|
1877
|
+
for (const warning of degradedCodexAccountPriorityWarnings(rawParsed, validated)) {
|
|
1878
|
+
console.warn(`⚠️ config.json ${warning}`);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1121
1882
|
/**
|
|
1122
1883
|
* The apiKeys schema salvages entry by entry rather than failing the parse, so a
|
|
1123
1884
|
* dropped key is otherwise invisible — and it will not be re-saved by the next
|
|
@@ -1230,12 +1991,30 @@ function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCod
|
|
|
1230
1991
|
if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) {
|
|
1231
1992
|
delete normalized.subagentEffort;
|
|
1232
1993
|
}
|
|
1994
|
+
// A hand-authored config never passes through the management validator, so coerce here too.
|
|
1995
|
+
// A malformed classifierFallbacks (a bare string, or an array with non-string entries) would
|
|
1996
|
+
// otherwise reach the resolver unchecked.
|
|
1997
|
+
if (Object.hasOwn(normalized, "classifierModel")) {
|
|
1998
|
+
const value = typeof normalized.classifierModel === "string" ? normalized.classifierModel.trim() : "";
|
|
1999
|
+
if (value.length > 0) normalized.classifierModel = value;
|
|
2000
|
+
else delete normalized.classifierModel;
|
|
2001
|
+
}
|
|
2002
|
+
if (Object.hasOwn(normalized, "classifierFallbacks")) {
|
|
2003
|
+
const raw = normalized.classifierFallbacks;
|
|
2004
|
+
const kept = Array.isArray(raw)
|
|
2005
|
+
? raw.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map(entry => entry.trim())
|
|
2006
|
+
: [];
|
|
2007
|
+
if (kept.length > 0) normalized.classifierFallbacks = kept;
|
|
2008
|
+
else delete normalized.classifierFallbacks;
|
|
2009
|
+
}
|
|
1233
2010
|
return normalized as OcxConfig["claudeCode"];
|
|
1234
2011
|
}
|
|
1235
2012
|
|
|
1236
|
-
function normalizeClaudeSubagentEffort(config: OcxConfig,
|
|
1237
|
-
|
|
1238
|
-
|
|
2013
|
+
function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unknown): OcxConfig {
|
|
2014
|
+
// Unconditional. This used to short-circuit when `subagentEffort` was absent or already valid,
|
|
2015
|
+
// which meant a config whose ONLY defect was elsewhere in `claudeCode` was never normalized.
|
|
2016
|
+
// The specialized subagentEffort WARNING is a separate concern and stays exactly as it is.
|
|
2017
|
+
if (!config.claudeCode) return config;
|
|
1239
2018
|
return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) };
|
|
1240
2019
|
}
|
|
1241
2020
|
|
|
@@ -1246,6 +2025,37 @@ function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
|
|
|
1246
2025
|
}
|
|
1247
2026
|
}
|
|
1248
2027
|
|
|
2028
|
+
function malformedUpstreamHostCircuitThresholdWarning(rawParsed: unknown): string | null {
|
|
2029
|
+
const raw = rawConfigRecord(rawParsed);
|
|
2030
|
+
if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
|
|
2031
|
+
const threshold = raw.upstreamHostCircuitThreshold;
|
|
2032
|
+
if (threshold === undefined) return null;
|
|
2033
|
+
if (typeof threshold === "number"
|
|
2034
|
+
&& Number.isInteger(threshold)
|
|
2035
|
+
&& threshold >= 0
|
|
2036
|
+
&& threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
|
|
2037
|
+
return `upstreamHostCircuitThreshold ignored: expected an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
function warnDegradedUpstreamHostCircuitThreshold(rawParsed: unknown): void {
|
|
2041
|
+
const warning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
|
|
2042
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function malformedAgentTaskRecoveryWarning(rawParsed: unknown): string | null {
|
|
2046
|
+
const raw = rawConfigRecord(rawParsed);
|
|
2047
|
+
if (!raw || !Object.hasOwn(raw, "agentTaskRecovery")) return null;
|
|
2048
|
+
const result = agentTaskRecoverySchema.safeParse(raw.agentTaskRecovery);
|
|
2049
|
+
if (result.success) return null;
|
|
2050
|
+
const field = result.error.issues[0]?.path.join(".");
|
|
2051
|
+
return `agentTaskRecovery${field ? `.${field}` : ""} ignored: invalid experimental recovery configuration`;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function warnDegradedAgentTaskRecovery(rawParsed: unknown): void {
|
|
2055
|
+
const warning = malformedAgentTaskRecoveryWarning(rawParsed);
|
|
2056
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
1249
2059
|
type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
|
|
1250
2060
|
|
|
1251
2061
|
function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
|
|
@@ -1275,6 +2085,18 @@ function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField
|
|
|
1275
2085
|
return `${field} ignored: expected ${expected}`;
|
|
1276
2086
|
}
|
|
1277
2087
|
|
|
2088
|
+
function malformedCodexAccountPickerWarning(rawParsed: unknown): string | null {
|
|
2089
|
+
const raw = rawConfigRecord(rawParsed);
|
|
2090
|
+
if (!raw || !Object.hasOwn(raw, "codexAccountPickerEnabled")) return null;
|
|
2091
|
+
if (typeof raw.codexAccountPickerEnabled === "boolean") return null;
|
|
2092
|
+
return "codexAccountPickerEnabled ignored: expected a boolean";
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function warnDegradedCodexAccountPicker(rawParsed: unknown): void {
|
|
2096
|
+
const warning = malformedCodexAccountPickerWarning(rawParsed);
|
|
2097
|
+
if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
1278
2100
|
function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null {
|
|
1279
2101
|
if (config.syncCodexSubagentDefaults !== true) return null;
|
|
1280
2102
|
const malformed = malformedNativeSubagentFields(rawParsed);
|
|
@@ -1304,6 +2126,61 @@ function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig)
|
|
|
1304
2126
|
}
|
|
1305
2127
|
}
|
|
1306
2128
|
|
|
2129
|
+
/**
|
|
2130
|
+
* Registry metadata can gain service-tier capability after a config was written. An explicit
|
|
2131
|
+
* `fastWire: null` remains authoritative on load and on whole-document writes; rejecting either
|
|
2132
|
+
* would discard or lock access to unrelated providers and API keys. Direct contradictions within
|
|
2133
|
+
* one provider row remain schema errors through the outer config refinement, where the dynamic
|
|
2134
|
+
* provider name can be redacted before it reaches diagnostics.
|
|
2135
|
+
*/
|
|
2136
|
+
function inheritedFastWireConflictProviderNames(
|
|
2137
|
+
config: Pick<OcxConfig, "providers">,
|
|
2138
|
+
): string[] {
|
|
2139
|
+
const conflicts: string[] = [];
|
|
2140
|
+
for (const [name, provider] of Object.entries(config.providers)) {
|
|
2141
|
+
if (provider.fastWire !== null || provider.supportsServiceTier === false) continue;
|
|
2142
|
+
const registry = providerMatchesRegistryTransport(name, provider)
|
|
2143
|
+
? getProviderRegistryEntry(name)
|
|
2144
|
+
: undefined;
|
|
2145
|
+
if (!registry) continue;
|
|
2146
|
+
const effectiveProviderCapability = provider.supportsServiceTier ?? registry.supportsServiceTier;
|
|
2147
|
+
const effectiveModelCapabilities = {
|
|
2148
|
+
...(registryModelServiceTierCapabilityApplies(registry, provider)
|
|
2149
|
+
? registry.modelSupportsServiceTier ?? {}
|
|
2150
|
+
: {}),
|
|
2151
|
+
...(provider.modelSupportsServiceTier ?? {}),
|
|
2152
|
+
};
|
|
2153
|
+
if (
|
|
2154
|
+
effectiveProviderCapability === true
|
|
2155
|
+
|| Object.values(effectiveModelCapabilities).some(value => value === true)
|
|
2156
|
+
) {
|
|
2157
|
+
conflicts.push(name);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
return conflicts;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
function inheritedFastWireConflictWarning(name: string): string {
|
|
2164
|
+
return `providers.${redactSecretString(name)}.fastWire=null overrides service-tier capability inherited from the matching registry entry`;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
function warnInheritedFastWireConflicts(configPath: string, config: OcxConfig): void {
|
|
2168
|
+
const names = inheritedFastWireConflictProviderNames(config);
|
|
2169
|
+
if (names.length === 0 || warnedInheritedFastWireConflicts.has(configPath)) return;
|
|
2170
|
+
warnedInheritedFastWireConflicts.add(configPath);
|
|
2171
|
+
console.warn(
|
|
2172
|
+
`⚠️ config.json ${names.map(inheritedFastWireConflictWarning).join("; ")}. `
|
|
2173
|
+
+ "The persisted providers and API keys were preserved.",
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* Load and validate config.json into an OcxConfig. Missing files reset to
|
|
2179
|
+
* defaults and clear stale overlays. Broken existing files also fall back to
|
|
2180
|
+
* default routing (after backup), but keep the last-good cost-overlay registry
|
|
2181
|
+
* until a valid config or a genuinely missing file is observed. A partially-
|
|
2182
|
+
* invalid config is merged with defaults so providers and pool accounts survive.
|
|
2183
|
+
*/
|
|
1307
2184
|
export function loadConfig(): OcxConfig {
|
|
1308
2185
|
const dir = getConfigDir();
|
|
1309
2186
|
const configPath = getConfigPath();
|
|
@@ -1311,20 +2188,27 @@ export function loadConfig(): OcxConfig {
|
|
|
1311
2188
|
hardenExistingSecret(configPath);
|
|
1312
2189
|
hardenExistingSecret(join(dir, "auth.json"));
|
|
1313
2190
|
if (!existsSync(configPath)) {
|
|
1314
|
-
return getDefaultConfig();
|
|
2191
|
+
return withRefreshedCostOverlays(getDefaultConfig());
|
|
1315
2192
|
}
|
|
1316
2193
|
try {
|
|
1317
2194
|
const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
|
|
1318
2195
|
const parsed = JSON.parse(raw);
|
|
2196
|
+
sanitizeRetryOn429ForLoad(parsed);
|
|
2197
|
+
sanitizeModelCostsForLoad(parsed);
|
|
1319
2198
|
const result = configSchema.safeParse(parsed);
|
|
1320
2199
|
if (result.success) {
|
|
1321
2200
|
const config = normalizeApiKeyIds(result.data as OcxConfig);
|
|
2201
|
+
warnInheritedFastWireConflicts(configPath, config);
|
|
1322
2202
|
warnDegradedStreamMode(parsed, config);
|
|
1323
2203
|
warnDegradedHostname(parsed, config);
|
|
1324
2204
|
warnDegradedApiKeys(parsed, config);
|
|
2205
|
+
warnDegradedCodexAccountPriorities(parsed, config);
|
|
1325
2206
|
warnDegradedClaudeSubagentEffort(parsed);
|
|
1326
2207
|
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1327
|
-
|
|
2208
|
+
warnDegradedCodexAccountPicker(parsed);
|
|
2209
|
+
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
2210
|
+
warnDegradedAgentTaskRecovery(parsed);
|
|
2211
|
+
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
|
|
1328
2212
|
}
|
|
1329
2213
|
// Schema validation failed — merge defaults into the raw object instead of
|
|
1330
2214
|
// discarding it entirely, so pool accounts and providers survive a missing
|
|
@@ -1339,11 +2223,37 @@ export function loadConfig(): OcxConfig {
|
|
|
1339
2223
|
if (retryResult.success) {
|
|
1340
2224
|
warnConfigRepaired(configPath, result.error);
|
|
1341
2225
|
const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
|
|
2226
|
+
warnInheritedFastWireConflicts(configPath, config);
|
|
1342
2227
|
warnDegradedHostname(parsed, config);
|
|
1343
2228
|
warnDegradedApiKeys(parsed, config);
|
|
2229
|
+
warnDegradedCodexAccountPriorities(parsed, config);
|
|
1344
2230
|
warnDegradedClaudeSubagentEffort(parsed);
|
|
1345
2231
|
warnDegradedNativeSubagentConfig(parsed, config);
|
|
1346
|
-
|
|
2232
|
+
warnDegradedCodexAccountPicker(parsed);
|
|
2233
|
+
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
2234
|
+
warnDegradedAgentTaskRecovery(parsed);
|
|
2235
|
+
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
|
|
2236
|
+
}
|
|
2237
|
+
// Still failing, but if every complaint is about one or more named entries
|
|
2238
|
+
// in an independent section, drop exactly those and keep the rest. Falling
|
|
2239
|
+
// back to defaults here would silently retire the operator's providers,
|
|
2240
|
+
// keys and prices over a mistake in one routing profile.
|
|
2241
|
+
const salvaged = salvageConfigCandidate(merged, retryResult.error);
|
|
2242
|
+
if (salvaged) {
|
|
2243
|
+
{
|
|
2244
|
+
warnDroppedConfigSections(configPath, salvaged.dropped, salvaged.issues);
|
|
2245
|
+
const config = normalizeApiKeyIds(salvaged.parsed);
|
|
2246
|
+
warnInheritedFastWireConflicts(configPath, config);
|
|
2247
|
+
warnDegradedHostname(parsed, config);
|
|
2248
|
+
warnDegradedApiKeys(parsed, config);
|
|
2249
|
+
warnDegradedCodexAccountPriorities(parsed, config);
|
|
2250
|
+
warnDegradedClaudeSubagentEffort(parsed);
|
|
2251
|
+
warnDegradedNativeSubagentConfig(parsed, config);
|
|
2252
|
+
warnDegradedCodexAccountPicker(parsed);
|
|
2253
|
+
warnDegradedUpstreamHostCircuitThreshold(parsed);
|
|
2254
|
+
warnDegradedAgentTaskRecovery(parsed);
|
|
2255
|
+
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
|
|
2256
|
+
}
|
|
1347
2257
|
}
|
|
1348
2258
|
// Merge couldn't fix it — truly broken config
|
|
1349
2259
|
warnAndBackupInvalidConfig(configPath, result.error);
|
|
@@ -1354,6 +2264,12 @@ export function loadConfig(): OcxConfig {
|
|
|
1354
2264
|
}
|
|
1355
2265
|
}
|
|
1356
2266
|
|
|
2267
|
+
/** Refresh the user cost-overlay registry from `config` and return it unchanged. */
|
|
2268
|
+
function withRefreshedCostOverlays(config: OcxConfig): OcxConfig {
|
|
2269
|
+
refreshUserCostOverlays(config);
|
|
2270
|
+
return config;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
1357
2273
|
export type ConfigDiagnostics = {
|
|
1358
2274
|
config: OcxConfig;
|
|
1359
2275
|
source: "default" | "file" | "fallback";
|
|
@@ -1387,10 +2303,18 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
|
|
|
1387
2303
|
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1388
2304
|
const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
|
|
1389
2305
|
const warnings = configPlaceholderWarnings(normalized);
|
|
2306
|
+
warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning));
|
|
2307
|
+
warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
|
|
1390
2308
|
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
1391
2309
|
warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`);
|
|
1392
2310
|
}
|
|
1393
2311
|
warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning));
|
|
2312
|
+
const pickerWarning = malformedCodexAccountPickerWarning(rawParsed);
|
|
2313
|
+
if (pickerWarning) warnings.push(pickerWarning);
|
|
2314
|
+
const hostCircuitWarning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
|
|
2315
|
+
if (hostCircuitWarning) warnings.push(hostCircuitWarning);
|
|
2316
|
+
const recoveryWarning = malformedAgentTaskRecoveryWarning(rawParsed);
|
|
2317
|
+
if (recoveryWarning) warnings.push(recoveryWarning);
|
|
1394
2318
|
if (syncDisabledReason) {
|
|
1395
2319
|
warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
|
|
1396
2320
|
}
|
|
@@ -1460,39 +2384,184 @@ function appOwnedMemoryBudgetError(value: unknown): string | null {
|
|
|
1460
2384
|
return null;
|
|
1461
2385
|
}
|
|
1462
2386
|
|
|
2387
|
+
function upstreamHostCircuitThresholdError(value: unknown): string | null {
|
|
2388
|
+
const raw = rawConfigRecord(value);
|
|
2389
|
+
if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
|
|
2390
|
+
const threshold = raw.upstreamHostCircuitThreshold;
|
|
2391
|
+
if (threshold === undefined) return null;
|
|
2392
|
+
if (typeof threshold === "number"
|
|
2393
|
+
&& Number.isInteger(threshold)
|
|
2394
|
+
&& threshold >= 0
|
|
2395
|
+
&& threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
|
|
2396
|
+
return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
function agentTaskRecoveryError(value: unknown): string | null {
|
|
2400
|
+
const raw = rawConfigRecord(value);
|
|
2401
|
+
if (!raw || !Object.hasOwn(raw, "agentTaskRecovery") || raw.agentTaskRecovery === undefined) return null;
|
|
2402
|
+
const result = agentTaskRecoverySchema.safeParse(raw.agentTaskRecovery);
|
|
2403
|
+
if (result.success) return null;
|
|
2404
|
+
const issue = result.error.issues[0];
|
|
2405
|
+
const field = issue?.path.join(".");
|
|
2406
|
+
return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
/**
|
|
2410
|
+
* Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
|
|
2411
|
+
* malformed selection-order map to undefined, which on a write would drop every entry the
|
|
2412
|
+
* user had accumulated and still report success. A load-time degrade leaves the raw map in
|
|
2413
|
+
* the file to be repaired by hand; a degraded write erases it. One bad `ocx config set`
|
|
2414
|
+
* must not cost the whole map, so a live caller is told instead.
|
|
2415
|
+
*/
|
|
2416
|
+
function codexAccountPrioritiesError(value: unknown): string | null {
|
|
2417
|
+
const raw = rawConfigRecord(value);
|
|
2418
|
+
if (!raw) return null;
|
|
2419
|
+
if (raw.codexAccountPriorities !== undefined) {
|
|
2420
|
+
const parsed = codexAccountPrioritiesSchema.safeParse(raw.codexAccountPriorities);
|
|
2421
|
+
if (!parsed.success) {
|
|
2422
|
+
return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities.");
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
// Tested as a string rather than coerced: `String(123)` matches the id pattern, so a
|
|
2426
|
+
// coercing guard waves a non-string pin through to the schema, where `.catch(undefined)`
|
|
2427
|
+
// drops it and reports the write as a success — the exact silent-degrade this guards.
|
|
2428
|
+
const pin = raw.activeCodexAccountPinned;
|
|
2429
|
+
if (pin !== undefined && (typeof pin !== "string" || !CODEX_ACCOUNT_PIN_PATTERN.test(pin))) {
|
|
2430
|
+
return "schema_invalid: activeCodexAccountPinned: must be an account id";
|
|
2431
|
+
}
|
|
2432
|
+
return null;
|
|
2433
|
+
}
|
|
2434
|
+
|
|
1463
2435
|
function googleAntigravityStaticCatalogVersionError(value: unknown): string | null {
|
|
1464
2436
|
const raw = rawConfigRecord(value);
|
|
1465
2437
|
if (!raw || !Object.hasOwn(raw, "googleAntigravityStaticCatalogVersion")) return null;
|
|
1466
2438
|
const version = raw.googleAntigravityStaticCatalogVersion;
|
|
1467
|
-
if (version === undefined || version === 1) return null;
|
|
1468
|
-
return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1 or omitted";
|
|
2439
|
+
if (version === undefined || version === 1 || version === 2) return null;
|
|
2440
|
+
return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1, 2, or omitted";
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
function codexAccountPickerEnabledError(value: unknown): string | null {
|
|
2444
|
+
const raw = rawConfigRecord(value);
|
|
2445
|
+
if (!raw) return null;
|
|
2446
|
+
const descriptor = Object.getOwnPropertyDescriptor(raw, "codexAccountPickerEnabled");
|
|
2447
|
+
if (!descriptor) {
|
|
2448
|
+
return "codexAccountPickerEnabled" in raw
|
|
2449
|
+
? "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted"
|
|
2450
|
+
: null;
|
|
2451
|
+
}
|
|
2452
|
+
if (!("value" in descriptor)) {
|
|
2453
|
+
return "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted";
|
|
2454
|
+
}
|
|
2455
|
+
const enabled = descriptor.value;
|
|
2456
|
+
if (enabled === undefined || typeof enabled === "boolean") return null;
|
|
2457
|
+
return "schema_invalid: codexAccountPickerEnabled: must be a boolean or omitted";
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
function emptyCompletionRetryError(value: unknown): string | null {
|
|
2461
|
+
const raw = rawConfigRecord(value);
|
|
2462
|
+
if (!raw || !Object.hasOwn(raw, "emptyCompletionRetry")) return null;
|
|
2463
|
+
const enabled = raw.emptyCompletionRetry;
|
|
2464
|
+
if (enabled === undefined || typeof enabled === "boolean") return null;
|
|
2465
|
+
return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted";
|
|
1469
2466
|
}
|
|
1470
2467
|
|
|
1471
2468
|
/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
|
|
2469
|
+
/**
|
|
2470
|
+
* Reject a loopback-listener port that collides with the proxy port (#1102).
|
|
2471
|
+
*
|
|
2472
|
+
* The schema can only check the shape of each field on its own; the two ports being distinct
|
|
2473
|
+
* is a relationship between them. Letting the pair through would surface as a startup failure
|
|
2474
|
+
* after the public listener already bound, which reads like an unrelated port conflict.
|
|
2475
|
+
*
|
|
2476
|
+
* This is write-time only, matching `blankHostnameError`: a live caller can be told the value
|
|
2477
|
+
* is wrong, whereas a hand-edited config on the read path degrades to undefined rather than
|
|
2478
|
+
* resetting the whole file.
|
|
2479
|
+
*/
|
|
2480
|
+
function loopbackListenerPortError(value: unknown): string | null {
|
|
2481
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2482
|
+
const listener = (value as Record<string, unknown>).unauthenticatedLoopbackListener;
|
|
2483
|
+
if (listener === undefined) return null;
|
|
2484
|
+
if (!listener || typeof listener !== "object" || Array.isArray(listener)) {
|
|
2485
|
+
return "schema_invalid: unauthenticatedLoopbackListener: must be an object or omitted";
|
|
2486
|
+
}
|
|
2487
|
+
const entry = listener as Record<string, unknown>;
|
|
2488
|
+
// `enabled` must be a real boolean. The schema's `.catch(undefined)` would otherwise DELETE
|
|
2489
|
+
// a `"true"` string entry and report success, leaving an operator convinced they enabled an
|
|
2490
|
+
// unauthenticated listener that is in fact off. Load-time still degrades quietly — a hand
|
|
2491
|
+
// edit must not reset the file — but a live caller gets told.
|
|
2492
|
+
if (typeof entry.enabled !== "boolean") {
|
|
2493
|
+
return "schema_invalid: unauthenticatedLoopbackListener.enabled: must be a boolean";
|
|
2494
|
+
}
|
|
2495
|
+
if (entry.enabled !== true) return null;
|
|
2496
|
+
const listenerPort = entry.port;
|
|
2497
|
+
if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) {
|
|
2498
|
+
return "schema_invalid: unauthenticatedLoopbackListener.port: must be an integer port when enabled";
|
|
2499
|
+
}
|
|
2500
|
+
const proxyPort = (value as Record<string, unknown>).port;
|
|
2501
|
+
if (typeof proxyPort === "number" && proxyPort === listenerPort) {
|
|
2502
|
+
return "schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port";
|
|
2503
|
+
}
|
|
2504
|
+
return null;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
1472
2507
|
export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
|
|
1473
2508
|
const boundaryError = blankHostnameError(value)
|
|
1474
2509
|
?? claudeSubagentEffortError(value)
|
|
1475
2510
|
?? appOwnedMemoryBudgetError(value)
|
|
1476
|
-
??
|
|
2511
|
+
?? upstreamHostCircuitThresholdError(value)
|
|
2512
|
+
?? agentTaskRecoveryError(value)
|
|
2513
|
+
?? googleAntigravityStaticCatalogVersionError(value)
|
|
2514
|
+
?? codexAccountPrioritiesError(value)
|
|
2515
|
+
?? codexAccountPickerEnabledError(value)
|
|
2516
|
+
?? emptyCompletionRetryError(value)
|
|
2517
|
+
?? loopbackListenerPortError(value);
|
|
1477
2518
|
if (boundaryError) return { ok: false, error: boundaryError };
|
|
1478
2519
|
const result = configSchema.safeParse(value);
|
|
1479
|
-
if (result.success)
|
|
2520
|
+
if (result.success) {
|
|
2521
|
+
const config = normalizeApiKeyIds(result.data as OcxConfig);
|
|
2522
|
+
return { ok: true, config };
|
|
2523
|
+
}
|
|
1480
2524
|
return { ok: false, error: schemaDiagnosticsError(result.error) };
|
|
1481
2525
|
}
|
|
1482
2526
|
|
|
1483
2527
|
function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
|
|
1484
2528
|
try {
|
|
1485
2529
|
const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
|
|
2530
|
+
// Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the
|
|
2531
|
+
// schema and send the caller a default-config fallback (the config command could then
|
|
2532
|
+
// persist that fallback over the user's providers/keys).
|
|
2533
|
+
sanitizeRetryOn429ForLoad(parsed);
|
|
2534
|
+
sanitizeModelCostsForLoad(parsed);
|
|
1486
2535
|
const result = configSchema.safeParse(parsed);
|
|
1487
2536
|
if (result.success) {
|
|
1488
2537
|
return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed);
|
|
1489
2538
|
}
|
|
1490
2539
|
|
|
1491
|
-
const
|
|
2540
|
+
const merged = mergeConfigDefaults(parsed);
|
|
2541
|
+
const retryResult = configSchema.safeParse(merged);
|
|
1492
2542
|
if (retryResult.success) {
|
|
1493
2543
|
return validFileConfigDiagnostics(normalizeApiKeyIds(retryResult.data as OcxConfig), parsed);
|
|
1494
2544
|
}
|
|
1495
2545
|
|
|
2546
|
+
// #1785: one invalid routing profile must not make diagnostics report the built-in
|
|
2547
|
+
// defaults AS the config, because a later config write persists those defaults over the
|
|
2548
|
+
// operator's providers, keys and prices.
|
|
2549
|
+
//
|
|
2550
|
+
// The failure is still reported. `source` stays "fallback" and `error` keeps the real
|
|
2551
|
+
// schema message -- diagnostics is the surface that tells callers the file is invalid,
|
|
2552
|
+
// and every consumer that must refuse an invalid config (provider reload, catalog sync,
|
|
2553
|
+
// cost reconcile, codex admission) gates on exactly those two fields. Only `config`
|
|
2554
|
+
// changes: it carries the salvaged document instead of factory defaults, so a caller
|
|
2555
|
+
// that ignores the error and writes it back preserves what the operator configured.
|
|
2556
|
+
const salvaged = salvageConfigCandidate(merged, retryResult.error);
|
|
2557
|
+
if (salvaged) {
|
|
2558
|
+
return {
|
|
2559
|
+
config: normalizeApiKeyIds(salvaged.parsed),
|
|
2560
|
+
source: "fallback",
|
|
2561
|
+
error: schemaDiagnosticsError(result.error),
|
|
2562
|
+
};
|
|
2563
|
+
}
|
|
2564
|
+
|
|
1496
2565
|
return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
|
|
1497
2566
|
} catch {
|
|
1498
2567
|
return { config: getDefaultConfig(), source: "fallback", error: "invalid_json" };
|
|
@@ -1519,6 +2588,54 @@ export function readConfigDiagnostics(): ConfigDiagnostics {
|
|
|
1519
2588
|
return readConfigFileSnapshot().diagnostics;
|
|
1520
2589
|
}
|
|
1521
2590
|
|
|
2591
|
+
/**
|
|
2592
|
+
* The persisted config, plus a digest of the EXACT bytes it was parsed from.
|
|
2593
|
+
*
|
|
2594
|
+
* A union rather than a nullable digest, because `{ kind: "read" }` with no
|
|
2595
|
+
* digest is a state that cannot occur — and a state that cannot occur should
|
|
2596
|
+
* not be a state that can be written down. Refusing it at runtime is a check
|
|
2597
|
+
* somebody eventually forgets; making it unrepresentable is not.
|
|
2598
|
+
*
|
|
2599
|
+
* Why a byte digest at all: the Codex write lock compares an authority snapshot
|
|
2600
|
+
* taken before the lock against one taken while holding it, and its config
|
|
2601
|
+
* component used to hash the PARSED object. Two files that differ only in
|
|
2602
|
+
* whitespace or key order parse identically, so a non-cooperating writer could
|
|
2603
|
+
* rewrite the file between admission and commit and the comparison would see
|
|
2604
|
+
* nothing. Hashing what was actually read closes that.
|
|
2605
|
+
*
|
|
2606
|
+
* `readConfigFileSnapshot` stays private on purpose. Its `raw` carries provider
|
|
2607
|
+
* API keys and admission tokens, and `privacy:scan` reads tracked source text,
|
|
2608
|
+
* not runtime values — so it would not catch a caller that logged or serialized
|
|
2609
|
+
* that string. The digest travels; the bytes do not.
|
|
2610
|
+
*/
|
|
2611
|
+
export type ConfigAdmissionSnapshot =
|
|
2612
|
+
| Readonly<{ kind: "read"; diagnostics: ConfigDiagnostics; contentSha256: string }>
|
|
2613
|
+
| Readonly<{ kind: "unreadable"; diagnostics: ConfigDiagnostics; contentSha256: null }>;
|
|
2614
|
+
|
|
2615
|
+
export function readConfigAdmissionSnapshot(): ConfigAdmissionSnapshot {
|
|
2616
|
+
let bytes: Buffer;
|
|
2617
|
+
try {
|
|
2618
|
+
// ONE read. Hashing the file and then reading it again to parse would leave
|
|
2619
|
+
// a window for the two to disagree, which is the exact hazard this exists
|
|
2620
|
+
// to detect — the check would become a second chance to be wrong.
|
|
2621
|
+
bytes = readFileSync(getConfigPath());
|
|
2622
|
+
} catch (error) {
|
|
2623
|
+
return {
|
|
2624
|
+
kind: "unreadable",
|
|
2625
|
+
diagnostics: isMissingPathError(error)
|
|
2626
|
+
? { config: getDefaultConfig(), source: "default", error: null }
|
|
2627
|
+
: { config: getDefaultConfig(), source: "fallback", error: "invalid_json" },
|
|
2628
|
+
contentSha256: null,
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
return {
|
|
2632
|
+
kind: "read",
|
|
2633
|
+
// Decoded from the same buffer that was hashed, not re-read from disk.
|
|
2634
|
+
diagnostics: configDiagnosticsFromRaw(bytes.toString("utf-8")),
|
|
2635
|
+
contentSha256: createHash("sha256").update(bytes).digest("hex"),
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
|
|
1522
2639
|
const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite";
|
|
1523
2640
|
const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const;
|
|
1524
2641
|
let warnedConfigMutationDirectoryAcl = false;
|
|
@@ -1567,6 +2684,7 @@ function configMutationDatabasePath(): string {
|
|
|
1567
2684
|
}
|
|
1568
2685
|
|
|
1569
2686
|
let configMutationLockDepth = 0;
|
|
2687
|
+
let configMutationDatabase: Database | null = null;
|
|
1570
2688
|
|
|
1571
2689
|
/**
|
|
1572
2690
|
* Serialize synchronous config and Codex credential-generation commits across processes with an
|
|
@@ -1593,7 +2711,11 @@ export function withConfigMutationLockSync<T>(fn: () => T): T {
|
|
|
1593
2711
|
try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ }
|
|
1594
2712
|
database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
|
|
1595
2713
|
transactionOpen = true;
|
|
2714
|
+
initializeConfigGeneration(database);
|
|
1596
2715
|
} catch (cause) {
|
|
2716
|
+
if (transactionOpen) {
|
|
2717
|
+
try { database?.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
|
|
2718
|
+
}
|
|
1597
2719
|
try { database?.close(); } catch { /* acquisition already failed */ }
|
|
1598
2720
|
const code = cause && typeof cause === "object" && "code" in cause
|
|
1599
2721
|
? String((cause as { code?: unknown }).code)
|
|
@@ -1605,6 +2727,7 @@ export function withConfigMutationLockSync<T>(fn: () => T): T {
|
|
|
1605
2727
|
}
|
|
1606
2728
|
|
|
1607
2729
|
configMutationLockDepth = 1;
|
|
2730
|
+
configMutationDatabase = database;
|
|
1608
2731
|
try {
|
|
1609
2732
|
const value = fn();
|
|
1610
2733
|
database.exec("COMMIT");
|
|
@@ -1618,19 +2741,143 @@ export function withConfigMutationLockSync<T>(fn: () => T): T {
|
|
|
1618
2741
|
throw error;
|
|
1619
2742
|
} finally {
|
|
1620
2743
|
configMutationLockDepth = 0;
|
|
2744
|
+
configMutationDatabase = null;
|
|
1621
2745
|
try { database.close(); } catch { /* the OS lock is released with the handle */ }
|
|
1622
2746
|
}
|
|
1623
2747
|
}
|
|
1624
2748
|
|
|
1625
|
-
function
|
|
2749
|
+
function bumpGenerationForCooperatingConfigWrite(): void {
|
|
2750
|
+
if (!configMutationDatabase) {
|
|
2751
|
+
throw new Error("A cooperating config write requires the config mutation transaction.");
|
|
2752
|
+
}
|
|
2753
|
+
bumpCurrentConfigGeneration(configMutationDatabase);
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
export const readConfigGeneration: ReadConfigGeneration = () => {
|
|
2757
|
+
try {
|
|
2758
|
+
return readConfigGenerationAtPath(configMutationDatabasePath());
|
|
2759
|
+
} catch {
|
|
2760
|
+
return { kind: "unavailable", reason: "database" };
|
|
2761
|
+
}
|
|
2762
|
+
};
|
|
2763
|
+
|
|
2764
|
+
export function observeConfigGeneration(): ConfigGenerationObservation {
|
|
2765
|
+
return observeConfigGenerationAtPath(join(getConfigDir(), CONFIG_MUTATION_DB_FILENAME));
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
/**
|
|
2769
|
+
* Read the generation from the transaction that is open RIGHT NOW.
|
|
2770
|
+
*
|
|
2771
|
+
* The observer cannot do this job. On the very first acquisition the
|
|
2772
|
+
* `BEGIN IMMEDIATE` that creates the table has not committed yet, so a separate
|
|
2773
|
+
* read-only connection cannot read a generation from it — measured, not
|
|
2774
|
+
* assumed. A caller that compared a pre-lock observation against an observer
|
|
2775
|
+
* re-read would therefore refuse every first write as stale.
|
|
2776
|
+
*
|
|
2777
|
+
* Throwing when no transaction is open is deliberate. Being called outside the
|
|
2778
|
+
* lock is broken plumbing, and returning a typed "unavailable" would let that
|
|
2779
|
+
* bug arrive disguised as an environmental failure — retried forever, on a
|
|
2780
|
+
* machine where nothing is wrong.
|
|
2781
|
+
*/
|
|
2782
|
+
export function readConfigGenerationInCurrentMutationTransaction(): ConfigGeneration {
|
|
2783
|
+
if (configMutationLockDepth < 1 || !configMutationDatabase) {
|
|
2784
|
+
throw new Error(
|
|
2785
|
+
"readConfigGenerationInCurrentMutationTransaction requires an open config mutation transaction.",
|
|
2786
|
+
);
|
|
2787
|
+
}
|
|
2788
|
+
return readConfigGenerationInTransaction(configMutationDatabase);
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
export const bumpConfigGeneration: BumpConfigGeneration = expected => {
|
|
2792
|
+
try {
|
|
2793
|
+
return bumpConfigGenerationAtPath(configMutationDatabasePath(), expected);
|
|
2794
|
+
} catch {
|
|
2795
|
+
return { kind: "unavailable", reason: "database" };
|
|
2796
|
+
}
|
|
2797
|
+
};
|
|
2798
|
+
|
|
2799
|
+
function configGenerationFailureReason(error: unknown): "busy" | "database" {
|
|
2800
|
+
const cause = error instanceof ConfigMutationLockError ? error.cause : error;
|
|
2801
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
2802
|
+
? String((cause as { code?: unknown }).code)
|
|
2803
|
+
: "";
|
|
2804
|
+
const message = cause instanceof Error ? cause.message : "";
|
|
2805
|
+
return code === "SQLITE_BUSY"
|
|
2806
|
+
|| code === "SQLITE_LOCKED"
|
|
2807
|
+
|| /database (?:is|table is) locked/i.test(message)
|
|
2808
|
+
? "busy"
|
|
2809
|
+
: "database";
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync = (
|
|
2813
|
+
expected,
|
|
2814
|
+
commit,
|
|
2815
|
+
) => {
|
|
2816
|
+
let callbackThrew = false;
|
|
2817
|
+
let callbackError: unknown;
|
|
2818
|
+
try {
|
|
2819
|
+
return withConfigMutationLockSync(() => {
|
|
2820
|
+
const database = configMutationDatabase;
|
|
2821
|
+
if (!database) throw new Error("Config mutation transaction database is unavailable.");
|
|
2822
|
+
const current = readConfigGenerationInTransaction(database);
|
|
2823
|
+
if (current.value !== expected.value) return { kind: "conflict", current };
|
|
2824
|
+
try {
|
|
2825
|
+
return { kind: "matched", generation: current, value: commit() };
|
|
2826
|
+
} catch (error) {
|
|
2827
|
+
callbackThrew = true;
|
|
2828
|
+
callbackError = error;
|
|
2829
|
+
throw error;
|
|
2830
|
+
}
|
|
2831
|
+
});
|
|
2832
|
+
} catch (error) {
|
|
2833
|
+
if (callbackThrew && error === callbackError) throw error;
|
|
2834
|
+
return { kind: "unavailable", reason: configGenerationFailureReason(error) };
|
|
2835
|
+
}
|
|
2836
|
+
};
|
|
2837
|
+
|
|
2838
|
+
/**
|
|
2839
|
+
* Atomic config.json write WITHOUT the mutation lock; callers must hold
|
|
2840
|
+
* `withConfigMutationLockSync`. Returns true when bytes changed. Refreshes the
|
|
2841
|
+
* cost-overlay registry from the persisted config so runtime estimates follow
|
|
2842
|
+
* every save path.
|
|
2843
|
+
*/
|
|
2844
|
+
function persistConfigUnlocked(config: OcxConfig): boolean {
|
|
1626
2845
|
const configPath = getConfigPath();
|
|
1627
|
-
|
|
2846
|
+
// External editors can add provider rows the live config deliberately does
|
|
2847
|
+
// not route with yet; merge them at the serialization boundary so an
|
|
2848
|
+
// unrelated in-process save cannot erase the provider or its overlay.
|
|
2849
|
+
const persisted = withPreservedDiskOnlyProviders(config);
|
|
2850
|
+
const bytes = JSON.stringify(persisted, null, 2) + "\n";
|
|
2851
|
+
let unchanged = false;
|
|
2852
|
+
try {
|
|
2853
|
+
unchanged = readFileSync(configPath, "utf8") === bytes;
|
|
2854
|
+
} catch (error) {
|
|
2855
|
+
if (!isMissingPathError(error)) throw error;
|
|
2856
|
+
}
|
|
2857
|
+
// Keep the runtime overlay registry in sync with EVERY persist path,
|
|
2858
|
+
// including byte-identical saves: a cooperating CLI process may have written
|
|
2859
|
+
// the same bytes (e.g. before a proxy notification), and Logs/Usage must
|
|
2860
|
+
// adopt the overlay without waiting for a changed save or restart.
|
|
2861
|
+
if (unchanged) {
|
|
2862
|
+
refreshUserCostOverlays(persisted);
|
|
2863
|
+
return false;
|
|
2864
|
+
}
|
|
2865
|
+
atomicWriteFile(configPath, bytes);
|
|
2866
|
+
// For changed saves, refresh only AFTER the write succeeded so a failed
|
|
2867
|
+
// write cannot leave estimates reflecting configuration never persisted.
|
|
2868
|
+
refreshUserCostOverlays(persisted);
|
|
2869
|
+
return true;
|
|
1628
2870
|
}
|
|
1629
2871
|
|
|
2872
|
+
/** Persist `config` to config.json under the config-mutation lock. */
|
|
1630
2873
|
export function saveConfig(config: OcxConfig): void {
|
|
1631
2874
|
// Keep the real-home assertion ahead of even lock-directory preparation.
|
|
1632
2875
|
assertNotRealHomeUnderTest(getConfigDir());
|
|
1633
|
-
withConfigMutationLockSync(() =>
|
|
2876
|
+
withConfigMutationLockSync(() => {
|
|
2877
|
+
const projected = projectCustomModelCatalogMigration(readRawConfigJson(), config);
|
|
2878
|
+
if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite();
|
|
2879
|
+
adoptCustomModelCatalogMigration(config, projected);
|
|
2880
|
+
});
|
|
1634
2881
|
}
|
|
1635
2882
|
|
|
1636
2883
|
export type PersistedConfigMutation<T> = {
|
|
@@ -1710,7 +2957,11 @@ export function mutatePersistedConfig<T>(
|
|
|
1710
2957
|
continue;
|
|
1711
2958
|
}
|
|
1712
2959
|
|
|
1713
|
-
|
|
2960
|
+
const projected = projectCustomModelCatalogMigration(
|
|
2961
|
+
commitBase.diagnostics.config,
|
|
2962
|
+
confirmedConfig,
|
|
2963
|
+
);
|
|
2964
|
+
if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite();
|
|
1714
2965
|
return { status: "committed", value: confirmed.value };
|
|
1715
2966
|
}
|
|
1716
2967
|
return { status: "unavailable", reason: "conflict" };
|
|
@@ -1738,6 +2989,12 @@ export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolea
|
|
|
1738
2989
|
* against, or a later stale save would masquerade as "our own change".
|
|
1739
2990
|
*/
|
|
1740
2991
|
const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
|
|
2992
|
+
/**
|
|
2993
|
+
* Full live-config baseline used to rebase unrelated cooperating writes. The
|
|
2994
|
+
* Claude subtree and the bound listener fields remain on their dedicated
|
|
2995
|
+
* reconciliation paths below.
|
|
2996
|
+
*/
|
|
2997
|
+
const liveConfigBaseline = new WeakMap<OcxConfig, OcxConfig>();
|
|
1741
2998
|
|
|
1742
2999
|
/**
|
|
1743
3000
|
* The live config retains the address of the socket Bun actually opened, while
|
|
@@ -1755,9 +3012,28 @@ const persistedLiveServerBinding = new WeakMap<OcxConfig, PersistedServerBinding
|
|
|
1755
3012
|
* save, which is the case the guard exists for.
|
|
1756
3013
|
*/
|
|
1757
3014
|
export function armClaudeCodeBaseline(config: OcxConfig): void {
|
|
3015
|
+
liveConfigBaseline.set(config, structuredClone(config));
|
|
1758
3016
|
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
1759
3017
|
}
|
|
1760
3018
|
|
|
3019
|
+
/**
|
|
3020
|
+
* Adopt one schema-validated provider that was read from the authoritative disk
|
|
3021
|
+
* config into a long-lived server config without rebasing any unrelated field.
|
|
3022
|
+
* Updating the matching baseline row keeps a later guarded save from treating the
|
|
3023
|
+
* adopted provider as an unsaved live edit that should defeat a newer disk change.
|
|
3024
|
+
*/
|
|
3025
|
+
export function adoptPersistedProviderIntoLiveConfig(
|
|
3026
|
+
config: OcxConfig,
|
|
3027
|
+
name: string,
|
|
3028
|
+
provider: OcxProviderConfig,
|
|
3029
|
+
persistedConfig?: OcxConfig,
|
|
3030
|
+
): void {
|
|
3031
|
+
config.providers[name] = structuredClone(provider);
|
|
3032
|
+
const baseline = liveConfigBaseline.get(config);
|
|
3033
|
+
if (baseline) baseline.providers[name] = structuredClone(provider);
|
|
3034
|
+
if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig);
|
|
3035
|
+
}
|
|
3036
|
+
|
|
1761
3037
|
/** Test seam only: is this instance armed? */
|
|
1762
3038
|
export function claudeCodeBaselineArmed(config: OcxConfig): boolean {
|
|
1763
3039
|
return claudeCodeBaseline.has(config);
|
|
@@ -1802,20 +3078,80 @@ function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue {
|
|
|
1802
3078
|
return value === MISSING_CONFIG_VALUE ? value : structuredClone(value);
|
|
1803
3079
|
}
|
|
1804
3080
|
|
|
3081
|
+
type IndexedCustomModels = {
|
|
3082
|
+
order: string[];
|
|
3083
|
+
byId: Map<string, Record<string, unknown>>;
|
|
3084
|
+
};
|
|
3085
|
+
|
|
3086
|
+
function indexCustomModels(value: ConfigMergeValue): IndexedCustomModels | null {
|
|
3087
|
+
if (!Array.isArray(value)) return null;
|
|
3088
|
+
const order: string[] = [];
|
|
3089
|
+
const byId = new Map<string, Record<string, unknown>>();
|
|
3090
|
+
for (const item of value) {
|
|
3091
|
+
if (!isPlainConfigRecord(item) || typeof item.id !== "string" || item.id.length === 0 || byId.has(item.id)) {
|
|
3092
|
+
return null;
|
|
3093
|
+
}
|
|
3094
|
+
order.push(item.id);
|
|
3095
|
+
byId.set(item.id, item);
|
|
3096
|
+
}
|
|
3097
|
+
return { order, byId };
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
/**
|
|
3101
|
+
* Merge custom-model rows by their stable id instead of treating the array as
|
|
3102
|
+
* one opaque value. A row changed only on disk is adopted, a row changed only
|
|
3103
|
+
* in the live config is retained, and disjoint edits to the same row recurse
|
|
3104
|
+
* through the normal three-way object merge. A newer persisted row deletion
|
|
3105
|
+
* wins over a stale live edit to that row.
|
|
3106
|
+
*/
|
|
3107
|
+
function reconcileCustomModels(
|
|
3108
|
+
baseline: ConfigMergeValue,
|
|
3109
|
+
live: ConfigMergeValue,
|
|
3110
|
+
persisted: ConfigMergeValue,
|
|
3111
|
+
): ConfigMergeValue | null {
|
|
3112
|
+
const baselineRows = indexCustomModels(baseline);
|
|
3113
|
+
const liveRows = indexCustomModels(live);
|
|
3114
|
+
const persistedRows = indexCustomModels(persisted);
|
|
3115
|
+
if (!baselineRows || !liveRows || !persistedRows) return null;
|
|
3116
|
+
|
|
3117
|
+
const order = [...liveRows.order, ...persistedRows.order.filter(id => !liveRows.byId.has(id))];
|
|
3118
|
+
const merged: Array<Record<string, unknown>> = [];
|
|
3119
|
+
for (const id of order) {
|
|
3120
|
+
const baselineRow = baselineRows.byId.get(id) ?? MISSING_CONFIG_VALUE;
|
|
3121
|
+
const persistedRow = persistedRows.byId.get(id) ?? MISSING_CONFIG_VALUE;
|
|
3122
|
+
const row = baselineRow !== MISSING_CONFIG_VALUE && persistedRow === MISSING_CONFIG_VALUE
|
|
3123
|
+
? MISSING_CONFIG_VALUE
|
|
3124
|
+
: reconcileConfigValue(
|
|
3125
|
+
baselineRow,
|
|
3126
|
+
liveRows.byId.get(id) ?? MISSING_CONFIG_VALUE,
|
|
3127
|
+
persistedRow,
|
|
3128
|
+
);
|
|
3129
|
+
if (row !== MISSING_CONFIG_VALUE) merged.push(row as Record<string, unknown>);
|
|
3130
|
+
}
|
|
3131
|
+
return merged;
|
|
3132
|
+
}
|
|
3133
|
+
|
|
1805
3134
|
function reconcileConfigRecord(
|
|
1806
3135
|
live: Record<string, unknown>,
|
|
1807
3136
|
baseline: Record<string, unknown>,
|
|
1808
3137
|
persisted: Record<string, unknown>,
|
|
1809
3138
|
skippedKeys?: ReadonlySet<string>,
|
|
3139
|
+
persistedDeletionsWin = false,
|
|
1810
3140
|
): void {
|
|
1811
3141
|
const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]);
|
|
1812
3142
|
for (const key of keys) {
|
|
1813
3143
|
if (skippedKeys?.has(key)) continue;
|
|
1814
|
-
const
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
3144
|
+
const baselineValue = ownConfigValue(baseline, key);
|
|
3145
|
+
const liveValue = ownConfigValue(live, key);
|
|
3146
|
+
const persistedValue = ownConfigValue(persisted, key);
|
|
3147
|
+
const merged = persistedDeletionsWin
|
|
3148
|
+
&& baselineValue !== MISSING_CONFIG_VALUE
|
|
3149
|
+
&& persistedValue === MISSING_CONFIG_VALUE
|
|
3150
|
+
? MISSING_CONFIG_VALUE
|
|
3151
|
+
: key === "customModels"
|
|
3152
|
+
? reconcileCustomModels(baselineValue, liveValue, persistedValue)
|
|
3153
|
+
?? reconcileConfigValue(baselineValue, liveValue, persistedValue)
|
|
3154
|
+
: reconcileConfigValue(baselineValue, liveValue, persistedValue, key === "providers");
|
|
1819
3155
|
if (merged === MISSING_CONFIG_VALUE) delete live[key];
|
|
1820
3156
|
else live[key] = merged;
|
|
1821
3157
|
}
|
|
@@ -1825,6 +3161,7 @@ function reconcileConfigValue(
|
|
|
1825
3161
|
baseline: ConfigMergeValue,
|
|
1826
3162
|
live: ConfigMergeValue,
|
|
1827
3163
|
persisted: ConfigMergeValue,
|
|
3164
|
+
persistedChildDeletionsWin = false,
|
|
1828
3165
|
): ConfigMergeValue {
|
|
1829
3166
|
const liveChanged = !deepEqual(live, baseline);
|
|
1830
3167
|
const persistedChanged = !deepEqual(persisted, baseline);
|
|
@@ -1854,6 +3191,8 @@ function reconcileConfigValue(
|
|
|
1854
3191
|
live,
|
|
1855
3192
|
isPlainConfigRecord(baseline) ? baseline : {},
|
|
1856
3193
|
persisted,
|
|
3194
|
+
undefined,
|
|
3195
|
+
persistedChildDeletionsWin,
|
|
1857
3196
|
);
|
|
1858
3197
|
}
|
|
1859
3198
|
// Same-leaf conflicts prefer the pending live management mutation.
|
|
@@ -1893,6 +3232,10 @@ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline
|
|
|
1893
3232
|
else config.claudeCode = structuredClone(persisted.claudeCode);
|
|
1894
3233
|
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
1895
3234
|
}
|
|
3235
|
+
// The reconciliation may have adopted a providers.<name>.modelCosts edit made
|
|
3236
|
+
// by a cooperating process while the OAuth login was pending; keep the overlay
|
|
3237
|
+
// registry (and the usage-cache overlay version) in sync with the live config.
|
|
3238
|
+
refreshUserCostOverlays(config);
|
|
1896
3239
|
}
|
|
1897
3240
|
|
|
1898
3241
|
/** The literal file, with no schema merge or default injection. */
|
|
@@ -1937,20 +3280,51 @@ function readPersistedServerBinding(
|
|
|
1937
3280
|
*
|
|
1938
3281
|
* Conflict policy, chosen deliberately:
|
|
1939
3282
|
* - disk changed, we did not → their hand edit wins;
|
|
1940
|
-
* - disk changed AND we changed →
|
|
1941
|
-
*
|
|
3283
|
+
* - disk changed AND we changed → disjoint fields are merged, while a same-leaf
|
|
3284
|
+
* conflict keeps the live value;
|
|
3285
|
+
* - a provider or custom-model row deleted on disk stays deleted even if stale
|
|
3286
|
+
* live state edited that same row;
|
|
1942
3287
|
* - file missing/unreadable → save what we have, no throw.
|
|
1943
3288
|
*
|
|
1944
|
-
*
|
|
1945
|
-
*
|
|
1946
|
-
* guarantee.
|
|
3289
|
+
* Custom-model rows are merged by their stable `id`, preserving independent
|
|
3290
|
+
* edits and deletions across stale whole-config saves.
|
|
1947
3291
|
*/
|
|
1948
3292
|
export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
|
|
1949
3293
|
withConfigMutationLockSync(() => {
|
|
1950
3294
|
const bindingBaseline = persistedLiveServerBinding.get(config);
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
3295
|
+
// One authoritative pre-write read feeds both the live-config reconciliation and
|
|
3296
|
+
// custom-model deletion migration. A second read could observe different bytes.
|
|
3297
|
+
const onDisk = readRawConfigJson();
|
|
3298
|
+
const baseline = liveConfigBaseline.get(config);
|
|
3299
|
+
if (baseline && onDisk !== undefined) {
|
|
3300
|
+
const persistedDiagnostics = configDiagnosticsFromRaw(JSON.stringify(onDisk));
|
|
3301
|
+
if (persistedDiagnostics.source === "file") {
|
|
3302
|
+
// Only keys this live config is actually known to have diverged on may be
|
|
3303
|
+
// rebased. The baseline is captured once when the server arms it, so any key
|
|
3304
|
+
// that appeared on disk afterwards — through saveConfig(), a hand edit, or
|
|
3305
|
+
// another process — is absent from the baseline as well as from the live
|
|
3306
|
+
// config. Reconciling those keys reads "live never changed this" and adopts
|
|
3307
|
+
// the disk value, which resurrects a field the live writer had deliberately
|
|
3308
|
+
// deleted (#1462 regression: PUT /api/grok/selection with an empty list).
|
|
3309
|
+
// Restrict the merge to keys the baseline knew about, plus keys the live
|
|
3310
|
+
// config still carries; a key that exists only on disk is left to the
|
|
3311
|
+
// ordinary whole-config write below.
|
|
3312
|
+
const rebaseableKeys = new Set([
|
|
3313
|
+
...Object.keys(baseline as unknown as Record<string, unknown>),
|
|
3314
|
+
...Object.keys(config as unknown as Record<string, unknown>),
|
|
3315
|
+
]);
|
|
3316
|
+
const skipped = new Set(["hostname", "port", "claudeCode"]);
|
|
3317
|
+
for (const key of Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)) {
|
|
3318
|
+
if (!rebaseableKeys.has(key)) skipped.add(key);
|
|
3319
|
+
}
|
|
3320
|
+
reconcileConfigRecord(
|
|
3321
|
+
config as unknown as Record<string, unknown>,
|
|
3322
|
+
baseline as unknown as Record<string, unknown>,
|
|
3323
|
+
persistedDiagnostics.config as unknown as Record<string, unknown>,
|
|
3324
|
+
skipped,
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
1954
3328
|
if (claudeCodeBaseline.has(config)) {
|
|
1955
3329
|
if (onDisk !== undefined) {
|
|
1956
3330
|
const baseline = claudeCodeBaseline.get(config);
|
|
@@ -1962,21 +3336,29 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
|
|
|
1962
3336
|
}
|
|
1963
3337
|
}
|
|
1964
3338
|
}
|
|
3339
|
+
const projectedConfig = projectCustomModelCatalogMigration(
|
|
3340
|
+
onDisk,
|
|
3341
|
+
config,
|
|
3342
|
+
);
|
|
1965
3343
|
const persistedBinding = bindingBaseline && onDisk
|
|
1966
3344
|
? readPersistedServerBinding(onDisk, bindingBaseline)
|
|
1967
3345
|
: bindingBaseline;
|
|
1968
3346
|
if (persistedBinding) {
|
|
1969
|
-
const persistedConfig: OcxConfig = { ...
|
|
3347
|
+
const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port };
|
|
1970
3348
|
if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
|
|
1971
3349
|
else persistedConfig.hostname = persistedBinding.hostname;
|
|
1972
|
-
persistConfigUnlocked(persistedConfig);
|
|
3350
|
+
if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite();
|
|
1973
3351
|
persistedLiveServerBinding.set(config, persistedBinding);
|
|
1974
3352
|
} else {
|
|
1975
|
-
persistConfigUnlocked(
|
|
3353
|
+
if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite();
|
|
1976
3354
|
}
|
|
3355
|
+
adoptCustomModelCatalogMigration(config, projectedConfig);
|
|
1977
3356
|
if (claudeCodeBaseline.has(config)) {
|
|
1978
3357
|
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
1979
3358
|
}
|
|
3359
|
+
if (liveConfigBaseline.has(config)) {
|
|
3360
|
+
liveConfigBaseline.set(config, structuredClone(config));
|
|
3361
|
+
}
|
|
1980
3362
|
});
|
|
1981
3363
|
}
|
|
1982
3364
|
|
|
@@ -2005,6 +3387,7 @@ export function getDefaultConfig(): OcxConfig {
|
|
|
2005
3387
|
// Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
|
|
2006
3388
|
return {
|
|
2007
3389
|
port: 10100,
|
|
3390
|
+
emptyCompletionRetry: false,
|
|
2008
3391
|
managementUsageMaxReadBytes: 64 * 1024 * 1024,
|
|
2009
3392
|
appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024),
|
|
2010
3393
|
// Fresh/re-initialized configs are already written in the current three-tier
|
|
@@ -2062,6 +3445,8 @@ export function applyProxyEnv(config: OcxConfig): void {
|
|
|
2062
3445
|
|
|
2063
3446
|
export function writePid(pid: number): void {
|
|
2064
3447
|
const dir = getConfigDir();
|
|
3448
|
+
// Guard before ANY directory mutation (mkdir or chmod), not just the write.
|
|
3449
|
+
assertNotRealHomeUnderTest(dir);
|
|
2065
3450
|
if (!existsSync(dir)) {
|
|
2066
3451
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
2067
3452
|
} else {
|
|
@@ -2074,22 +3459,28 @@ export type RuntimePortState = {
|
|
|
2074
3459
|
pid: number;
|
|
2075
3460
|
port: number;
|
|
2076
3461
|
hostname?: string;
|
|
3462
|
+
/** Per-process proof key; protected by the config directory and never served. */
|
|
3463
|
+
attestationSecret?: string;
|
|
2077
3464
|
};
|
|
2078
3465
|
|
|
2079
3466
|
function isValidRuntimePortState(value: unknown): value is RuntimePortState {
|
|
2080
3467
|
if (!value || typeof value !== "object") return false;
|
|
2081
3468
|
const state = value as Record<string, unknown>;
|
|
2082
3469
|
const hostnameOk = state.hostname === undefined || typeof state.hostname === "string";
|
|
3470
|
+
const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret);
|
|
2083
3471
|
return Number.isSafeInteger(state.pid)
|
|
2084
3472
|
&& Number(state.pid) > 0
|
|
2085
3473
|
&& Number.isInteger(state.port)
|
|
2086
3474
|
&& Number(state.port) > 0
|
|
2087
3475
|
&& Number(state.port) <= 65535
|
|
2088
|
-
&& hostnameOk
|
|
3476
|
+
&& hostnameOk
|
|
3477
|
+
&& attestationOk;
|
|
2089
3478
|
}
|
|
2090
3479
|
|
|
2091
3480
|
export function writeRuntimePort(state: RuntimePortState): void {
|
|
2092
3481
|
const dir = getConfigDir();
|
|
3482
|
+
// Guard before ANY directory mutation (mkdir or chmod), not just the write.
|
|
3483
|
+
assertNotRealHomeUnderTest(dir);
|
|
2093
3484
|
if (!existsSync(dir)) {
|
|
2094
3485
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
2095
3486
|
} else {
|
|
@@ -2144,6 +3535,194 @@ function warnConfigRepaired(configPath: string, error: z.ZodError): void {
|
|
|
2144
3535
|
console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`);
|
|
2145
3536
|
}
|
|
2146
3537
|
|
|
3538
|
+
/**
|
|
3539
|
+
* Sections whose entries are independent of one another, so one bad entry is
|
|
3540
|
+
* safe to drop without changing what the rest mean.
|
|
3541
|
+
*
|
|
3542
|
+
* Both are validated entry-by-entry in the `superRefine` above, which raises
|
|
3543
|
+
* every finding as a *document*-level issue. That is what made a single routing
|
|
3544
|
+
* candidate naming a disabled provider discard the operator's whole config —
|
|
3545
|
+
* all eleven providers, every API key, and the entire `modelCosts` table —
|
|
3546
|
+
* while the proxy carried on serving from built-in defaults and reporting
|
|
3547
|
+
* healthy.
|
|
3548
|
+
*/
|
|
3549
|
+
const SALVAGEABLE_CONFIG_SECTIONS = ["routingProfiles", "combos"] as const;
|
|
3550
|
+
|
|
3551
|
+
/**
|
|
3552
|
+
* Drop just the named entries a parse failure blamed, so the rest of the
|
|
3553
|
+
* document survives.
|
|
3554
|
+
*
|
|
3555
|
+
* Returns `null` when the failure was not confined to those sections — the
|
|
3556
|
+
* caller then keeps its existing behaviour rather than guessing.
|
|
3557
|
+
*
|
|
3558
|
+
* The whole entry goes, not the individual offending candidate. A routing
|
|
3559
|
+
* profile that quietly loses one candidate still routes, just not where the
|
|
3560
|
+
* operator said it should, and a policy that silently changed shape is a worse
|
|
3561
|
+
* outcome than one that is plainly absent. Absent is also the loud option: a
|
|
3562
|
+
* dry-run against it answers `unknown_profile`, which — paired with the warning
|
|
3563
|
+
* this emits — points at the real mistake.
|
|
3564
|
+
*/
|
|
3565
|
+
function dropInvalidConfigSections(
|
|
3566
|
+
parsed: unknown,
|
|
3567
|
+
error: z.ZodError,
|
|
3568
|
+
): { candidate: Record<string, unknown>; dropped: string[] } | null {
|
|
3569
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
3570
|
+
|
|
3571
|
+
const doomed = new Map<string, Set<string>>();
|
|
3572
|
+
for (const issue of error.issues) {
|
|
3573
|
+
if (isUnsalvageableIssue(issue)) return null;
|
|
3574
|
+
const [section, id] = issue.path;
|
|
3575
|
+
if (typeof section !== "string" || typeof id !== "string") return null;
|
|
3576
|
+
if (!(SALVAGEABLE_CONFIG_SECTIONS as readonly string[]).includes(section)) return null;
|
|
3577
|
+
// A complaint about the container itself ("combos must be an object") is
|
|
3578
|
+
// not about one entry, so there is nothing selective to drop.
|
|
3579
|
+
if (issue.path.length < 2) return null;
|
|
3580
|
+
let ids = doomed.get(section);
|
|
3581
|
+
if (!ids) doomed.set(section, ids = new Set());
|
|
3582
|
+
ids.add(id);
|
|
3583
|
+
}
|
|
3584
|
+
if (doomed.size === 0) return null;
|
|
3585
|
+
|
|
3586
|
+
const candidate: Record<string, unknown> = { ...(parsed as Record<string, unknown>) };
|
|
3587
|
+
const dropped: string[] = [];
|
|
3588
|
+
for (const [section, ids] of doomed) {
|
|
3589
|
+
const current = candidate[section];
|
|
3590
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) return null;
|
|
3591
|
+
const kept: Record<string, unknown> = {};
|
|
3592
|
+
for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
|
|
3593
|
+
if (ids.has(key)) dropped.push(`${section}.${key}`);
|
|
3594
|
+
else kept[key] = value;
|
|
3595
|
+
}
|
|
3596
|
+
candidate[section] = kept;
|
|
3597
|
+
}
|
|
3598
|
+
return dropped.length > 0 ? { candidate, dropped } : null;
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
/**
|
|
3602
|
+
* Salvage until the document parses, not just once.
|
|
3603
|
+
*
|
|
3604
|
+
* One pass is not enough because the sections depend on each other: routing
|
|
3605
|
+
* profiles are validated against the combo map, so dropping an invalid combo can
|
|
3606
|
+
* expose a profile that referenced it. A single-pass salvage sees that second
|
|
3607
|
+
* failure and gives up, discarding the whole config -- the exact outcome this
|
|
3608
|
+
* code exists to prevent.
|
|
3609
|
+
*
|
|
3610
|
+
* `rawDocument` is the operator's document before defaults were merged in. When
|
|
3611
|
+
* supplied, the same entries are deleted from it too, so a diagnostics caller can
|
|
3612
|
+
* still tell an absent optional setting from one we injected.
|
|
3613
|
+
*/
|
|
3614
|
+
|
|
3615
|
+
/**
|
|
3616
|
+
* Findings that must never be salvaged away.
|
|
3617
|
+
*
|
|
3618
|
+
* Salvage removes the entry a finding blamed, which is right for an ordinary
|
|
3619
|
+
* validation mistake and wrong for a namespace collision: the collision is a
|
|
3620
|
+
* *relationship* between a combo/profile and a Codex account selector, and it is
|
|
3621
|
+
* reported on the combo. Dropping that combo makes the document parse and quietly
|
|
3622
|
+
* admits the account selector the schema just refused, turning a hard admission
|
|
3623
|
+
* boundary into a config that loads. Refuse the whole document instead.
|
|
3624
|
+
*/
|
|
3625
|
+
const UNSALVAGEABLE_ISSUE_MESSAGES: readonly string[] = [
|
|
3626
|
+
CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
|
|
3627
|
+
];
|
|
3628
|
+
|
|
3629
|
+
function isUnsalvageableIssue(issue: z.ZodIssue): boolean {
|
|
3630
|
+
return UNSALVAGEABLE_ISSUE_MESSAGES.some(message => issue.message.includes(message));
|
|
3631
|
+
}
|
|
3632
|
+
function salvageConfigCandidate(
|
|
3633
|
+
merged: unknown,
|
|
3634
|
+
initialError: z.ZodError,
|
|
3635
|
+
rawDocument?: unknown,
|
|
3636
|
+
): {
|
|
3637
|
+
candidate: Record<string, unknown>;
|
|
3638
|
+
rawCandidate: unknown;
|
|
3639
|
+
parsed: OcxConfig;
|
|
3640
|
+
dropped: string[];
|
|
3641
|
+
issues: z.ZodIssue[];
|
|
3642
|
+
} | null {
|
|
3643
|
+
let candidate: unknown = merged;
|
|
3644
|
+
let rawCandidate: unknown = rawDocument;
|
|
3645
|
+
let error = initialError;
|
|
3646
|
+
const dropped: string[] = [];
|
|
3647
|
+
const issues: z.ZodIssue[] = [];
|
|
3648
|
+
// Bounded by construction: every pass must remove at least one entry, and there
|
|
3649
|
+
// are only so many entries to remove.
|
|
3650
|
+
const budget = countSalvageableEntries(merged) + 1;
|
|
3651
|
+
for (let pass = 0; pass < budget; pass++) {
|
|
3652
|
+
const step = dropInvalidConfigSections(candidate, error);
|
|
3653
|
+
if (!step || step.dropped.length === 0) return null;
|
|
3654
|
+
dropped.push(...step.dropped);
|
|
3655
|
+
issues.push(...error.issues);
|
|
3656
|
+
candidate = step.candidate;
|
|
3657
|
+
rawCandidate = deleteEntryPaths(rawCandidate, step.dropped);
|
|
3658
|
+
const result = configSchema.safeParse(candidate);
|
|
3659
|
+
if (result.success) {
|
|
3660
|
+
return { candidate: step.candidate, rawCandidate, parsed: result.data as OcxConfig, dropped, issues };
|
|
3661
|
+
}
|
|
3662
|
+
error = result.error;
|
|
3663
|
+
}
|
|
3664
|
+
return null;
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
function countSalvageableEntries(document: unknown): number {
|
|
3668
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) return 0;
|
|
3669
|
+
let total = 0;
|
|
3670
|
+
for (const section of SALVAGEABLE_CONFIG_SECTIONS) {
|
|
3671
|
+
const value = (document as Record<string, unknown>)[section];
|
|
3672
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
3673
|
+
total += Object.keys(value as Record<string, unknown>).length;
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
return total;
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
/** Delete `section.id` entries from a copy of the raw document. */
|
|
3680
|
+
function deleteEntryPaths(document: unknown, entryPaths: readonly string[]): unknown {
|
|
3681
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) return document;
|
|
3682
|
+
const next: Record<string, unknown> = { ...(document as Record<string, unknown>) };
|
|
3683
|
+
for (const entryPath of entryPaths) {
|
|
3684
|
+
const separator = entryPath.indexOf(".");
|
|
3685
|
+
if (separator <= 0) continue;
|
|
3686
|
+
const section = entryPath.slice(0, separator);
|
|
3687
|
+
const id = entryPath.slice(separator + 1);
|
|
3688
|
+
const container = next[section];
|
|
3689
|
+
if (!container || typeof container !== "object" || Array.isArray(container)) continue;
|
|
3690
|
+
const kept: Record<string, unknown> = { ...(container as Record<string, unknown>) };
|
|
3691
|
+
delete kept[id];
|
|
3692
|
+
next[section] = kept;
|
|
3693
|
+
}
|
|
3694
|
+
return next;
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
/**
|
|
3698
|
+
* Entry ids are operator-chosen and can be token-shaped, so nothing dynamic reaches
|
|
3699
|
+
* the log unredacted. Static section names stay readable -- they are the part that
|
|
3700
|
+
* tells the operator where to look.
|
|
3701
|
+
*/
|
|
3702
|
+
function redactEntryPath(entryPath: string): string {
|
|
3703
|
+
const separator = entryPath.indexOf(".");
|
|
3704
|
+
if (separator <= 0) return redactSecretString(entryPath);
|
|
3705
|
+
return entryPath.slice(0, separator) + "." + redactSecretString(entryPath.slice(separator + 1));
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
function redactIssuePath(path: readonly PropertyKey[]): string {
|
|
3709
|
+
return path
|
|
3710
|
+
.map((segment, index) => (index === 0 && typeof segment === "string" ? segment : redactSecretString(String(segment))))
|
|
3711
|
+
.join(".");
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
function warnDroppedConfigSections(configPath: string, dropped: string[], issues: readonly z.ZodIssue[]): void {
|
|
3715
|
+
if (warnedConfigFallbacks.has(configPath)) return;
|
|
3716
|
+
warnedConfigFallbacks.add(configPath);
|
|
3717
|
+
const reasons = issues
|
|
3718
|
+
.map(issue => `${redactIssuePath(issue.path)}: ${redactSecretString(issue.message)}`)
|
|
3719
|
+
.join("; ");
|
|
3720
|
+
console.error(
|
|
3721
|
+
`opencodex config at ${configPath}: dropped [${dropped.map(redactEntryPath).join(", ")}] and loaded the rest — ${reasons}. `
|
|
3722
|
+
+ "Everything else in your config, including providers and modelCosts, is preserved.",
|
|
3723
|
+
);
|
|
3724
|
+
}
|
|
3725
|
+
|
|
2147
3726
|
export function readPidFileValue(): number | null {
|
|
2148
3727
|
try {
|
|
2149
3728
|
return parsePidFile(readFileSync(getPidPath(), "utf-8"));
|
|
@@ -2292,14 +3871,51 @@ export function verifyPidIdentity(candidatePid: number): number | null {
|
|
|
2292
3871
|
return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null;
|
|
2293
3872
|
}
|
|
2294
3873
|
|
|
3874
|
+
type ProcessCommandLineExec = (
|
|
3875
|
+
executable: string,
|
|
3876
|
+
args: string[],
|
|
3877
|
+
options: {
|
|
3878
|
+
encoding: BufferEncoding;
|
|
3879
|
+
stdio: ["ignore", "pipe", "ignore"];
|
|
3880
|
+
timeout: number;
|
|
3881
|
+
windowsHide: boolean;
|
|
3882
|
+
},
|
|
3883
|
+
) => string;
|
|
3884
|
+
|
|
3885
|
+
const defaultProcessCommandLineExec: ProcessCommandLineExec = (executable, args, options) =>
|
|
3886
|
+
execFileSync(executable, args, options);
|
|
3887
|
+
let processCommandLineExec = defaultProcessCommandLineExec;
|
|
3888
|
+
let processCommandLinePlatformForTests: NodeJS.Platform | null = null;
|
|
3889
|
+
|
|
3890
|
+
/** Test-only seam for verifying the exact system executable selected by pid identity probes. */
|
|
3891
|
+
export function setProcessCommandLineExecForTests(next: ProcessCommandLineExec | null): void {
|
|
3892
|
+
processCommandLineExec = next ?? defaultProcessCommandLineExec;
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
/** Test-only seam so cross-platform tests do not mutate process.platform. */
|
|
3896
|
+
export function setProcessCommandLinePlatformForTests(next: NodeJS.Platform | null): void {
|
|
3897
|
+
processCommandLinePlatformForTests = next;
|
|
3898
|
+
}
|
|
3899
|
+
|
|
2295
3900
|
function readProcessCommandLine(pid: number): string | undefined {
|
|
3901
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
|
|
3902
|
+
const platform = processCommandLinePlatformForTests ?? process.platform;
|
|
2296
3903
|
try {
|
|
2297
|
-
if (
|
|
3904
|
+
if (platform === "linux") {
|
|
3905
|
+
try {
|
|
3906
|
+
const output = readFileSync(`/proc/${pid}/cmdline`, "utf-8");
|
|
3907
|
+
const value = output.replace(/\0/g, " ").trim();
|
|
3908
|
+
if (value) return value;
|
|
3909
|
+
} catch {
|
|
3910
|
+
/* procfs unavailable — use the fixed ps fallback below */
|
|
3911
|
+
}
|
|
3912
|
+
}
|
|
3913
|
+
if (platform === "win32") {
|
|
2298
3914
|
// Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash.
|
|
2299
3915
|
// Fall back to PowerShell when WMIC is absent (newer Windows images).
|
|
2300
|
-
const wmic =
|
|
3916
|
+
const wmic = join(resolveTrustedWindowsSystemDirectory(), "wbem", "WMIC.exe");
|
|
2301
3917
|
try {
|
|
2302
|
-
const output =
|
|
3918
|
+
const output = processCommandLineExec(wmic, [
|
|
2303
3919
|
"process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE",
|
|
2304
3920
|
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
|
|
2305
3921
|
const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, ""));
|
|
@@ -2308,24 +3924,30 @@ function readProcessCommandLine(pid: number): string | undefined {
|
|
|
2308
3924
|
} catch {
|
|
2309
3925
|
/* WMIC missing or failed — fall through */
|
|
2310
3926
|
}
|
|
2311
|
-
const output =
|
|
3927
|
+
const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [
|
|
2312
3928
|
"-NoProfile",
|
|
2313
3929
|
"-NoLogo",
|
|
2314
3930
|
"-NonInteractive",
|
|
2315
|
-
"-WindowStyle",
|
|
2316
|
-
"Hidden",
|
|
2317
3931
|
"-Command",
|
|
2318
3932
|
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
|
|
2319
3933
|
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
|
|
2320
3934
|
return output.trim() || undefined;
|
|
2321
3935
|
}
|
|
2322
|
-
const
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
3936
|
+
for (const ps of ["/bin/ps", "/usr/bin/ps"]) {
|
|
3937
|
+
try {
|
|
3938
|
+
const output = processCommandLineExec(ps, ["-p", String(pid), "-o", "command="], {
|
|
3939
|
+
encoding: "utf-8",
|
|
3940
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
3941
|
+
timeout: 1000,
|
|
3942
|
+
windowsHide: true,
|
|
3943
|
+
});
|
|
3944
|
+
const value = output.trim();
|
|
3945
|
+
if (value) return value;
|
|
3946
|
+
} catch {
|
|
3947
|
+
/* try the other fixed system path */
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
return undefined;
|
|
2329
3951
|
} catch {
|
|
2330
3952
|
return undefined;
|
|
2331
3953
|
}
|