@bitkyc08/opencodex 2.83.0 → 2.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -4
- package/bin/ocx.mjs +61 -3
- package/gui/dist/assets/App-BjlttVLy.js +58 -0
- package/gui/dist/assets/Tray-DyR9yBee.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-BMssP4k3.js +94 -0
- package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -3
- package/src/adapters/anthropic/beta-allowlist.ts +87 -8
- package/src/adapters/anthropic/client-identity.ts +1 -1
- package/src/adapters/anthropic/passthrough.ts +33 -4
- package/src/adapters/azure.ts +19 -1
- package/src/adapters/client-fingerprint.ts +24 -0
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/coding-agent/turn.ts +22 -9
- package/src/adapters/cursor/arg-codec.ts +17 -5
- package/src/adapters/cursor/catalog.ts +8 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/freeform-args.ts +75 -0
- package/src/adapters/cursor/protobuf-events.ts +36 -23
- package/src/adapters/google-antigravity-wire.ts +22 -1
- package/src/adapters/google.ts +2 -2
- package/src/adapters/ollama-native.ts +29 -1
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-chat/response-events.ts +59 -1
- package/src/adapters/openai-chat.ts +8 -5
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +3 -0
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +56 -15
- package/src/advisor/consult.ts +220 -0
- package/src/advisor/context.ts +226 -0
- package/src/advisor/disclosure.ts +20 -0
- package/src/advisor/runtime.ts +305 -0
- package/src/advisor/settings.ts +135 -0
- package/src/advisor/state.ts +369 -0
- package/src/advisor/synthetic-tool.ts +33 -0
- package/src/bridge/errors.ts +24 -0
- package/src/bridge.ts +1 -1
- package/src/chat/inbound.ts +30 -6
- package/src/chat/outbound.ts +5 -0
- package/src/claude/context-windows.ts +54 -1
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +22 -8
- package/src/claude/desktop-picker-profile.ts +11 -4
- package/src/claude/desktop-profile.ts +10 -1
- package/src/claude/desktop-remote-store-io.ts +5 -3
- package/src/claude/inbound.ts +121 -50
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/local-ca.ts +8 -1
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/claude/model-default-settings.ts +154 -0
- package/src/claude/model-info.ts +7 -7
- package/src/claude/outbound.ts +13 -6
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +26 -2
- package/src/cli/account.ts +41 -5
- package/src/cli/advisor.ts +151 -0
- package/src/cli/auxiliary-listener-preflight.ts +37 -0
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-agents-routing.ts +18 -0
- package/src/cli/capabilities-base.ts +14 -2
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/chatgpt-command.ts +35 -14
- package/src/cli/claude.ts +60 -11
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +21 -4
- package/src/cli/doctor.ts +9 -0
- package/src/cli/ensure-desktop-cold-start.ts +329 -0
- package/src/cli/export-command.ts +15 -2
- package/src/cli/help.ts +2 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +26 -8
- package/src/cli/integration-plan-dto.ts +2 -0
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +20 -7
- package/src/cli/restart-scope.ts +3 -1
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export/contracts.ts +1 -0
- package/src/clients/config-export/model-metadata.ts +3 -2
- package/src/clients/config-export/qoder.ts +93 -0
- package/src/clients/config-export.ts +59 -16
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/pool-mode-gate.ts +5 -2
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/auth-context.ts +79 -5
- package/src/codex/autostart-health.ts +7 -1
- package/src/codex/catalog/build-entries.ts +11 -2
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/configured-tiers.ts +50 -0
- package/src/codex/catalog/effort.ts +6 -3
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/model-hints.ts +21 -3
- package/src/codex/catalog/native-models.ts +16 -2
- package/src/codex/catalog/native-ultrafast.ts +106 -0
- package/src/codex/catalog/parsing.ts +5 -0
- package/src/codex/catalog/provider-models.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +2 -0
- package/src/codex/catalog/routed-gather.ts +22 -4
- package/src/codex/catalog/vendor-context.ts +82 -0
- package/src/codex/config-write-lock.ts +40 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desktop-app/types.ts +2 -0
- package/src/codex/desktop-app/windows.ts +29 -16
- package/src/codex/desktop-app-restart.ts +4 -2
- package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
- package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
- package/src/codex/desktop-compatibility/connection-store.ts +87 -0
- package/src/codex/desktop-compatibility/installed-build.ts +55 -0
- package/src/codex/desktop-compatibility/json-body.ts +29 -0
- package/src/codex/desktop-compatibility/native-identity.ts +62 -0
- package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
- package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
- package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
- package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
- package/src/codex/desktop-compatibility/runtime.ts +173 -0
- package/src/codex/desktop-compatibility/service.ts +30 -0
- package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
- package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
- package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
- package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
- package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
- package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
- package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
- package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
- package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
- package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
- package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
- package/src/codex/history-provider.ts +1 -1
- package/src/codex/inject/config-write-section.ts +4 -1
- package/src/codex/inject/remove.ts +18 -6
- package/src/codex/journal.ts +16 -1
- package/src/codex/main-account.ts +99 -15
- package/src/codex/native-main-refresh-mode.ts +217 -0
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing/health-store.ts +1 -1
- package/src/codex/routing/pool-recovery.ts +79 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-diagnostics.ts +6 -0
- package/src/codex/shim-overlay.ts +15 -6
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +18 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +56 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +8 -2
- package/src/config/paths.ts +13 -3
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +19 -0
- package/src/config/schema/desktop-compatibility.ts +9 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +713 -285
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/reset-coupon-ledger.ts +75 -5
- package/src/grok/reset-coupons.ts +4 -2
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +20 -2
- package/src/integrations/journal.ts +15 -1
- package/src/integrations/mutation-plan.ts +14 -7
- package/src/integrations/registry.ts +52 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/store.ts +3 -0
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +24 -7
- package/src/lib/advisor-activation.ts +16 -0
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/background-work.ts +65 -0
- package/src/lib/bounded-file-read.ts +58 -0
- package/src/lib/bun-path-runtime.d.mts +10 -1
- package/src/lib/bun-path-runtime.mjs +62 -3
- package/src/lib/desktop-proxy-route.ts +30 -0
- package/src/lib/desktop-upstream-tunnel.ts +107 -0
- package/src/lib/errors.ts +7 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/local-internal-call-capability.ts +55 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/package-tree-integrity.ts +2 -2
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/shadow-call.ts +44 -5
- package/src/lib/socks5-fetch.ts +48 -71
- package/src/lib/socks5-handshake.ts +112 -0
- package/src/lib/translator-budget.ts +7 -1
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/generic-account-failover.ts +29 -5
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +28 -17
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro-device-login.ts +2 -1
- package/src/oauth/kiro.ts +55 -6
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +11 -0
- package/src/protocols/codecs/chat-ir.ts +467 -0
- package/src/protocols/codecs/chat.ts +10 -1
- package/src/protocols/codecs/messages-ir.ts +500 -0
- package/src/protocols/features.ts +10 -2
- package/src/protocols/path.ts +11 -1
- package/src/protocols/plan-snapshot.ts +39 -16
- package/src/protocols/plan.ts +9 -1
- package/src/protocols/settings.ts +8 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
- package/src/providers/antigravity-account-roster.ts +102 -0
- package/src/providers/antigravity-models.ts +85 -7
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/model-rename-migration.ts +5 -3
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +21 -20
- package/src/providers/registry/entries-extended.ts +19 -1
- package/src/providers/registry/model-seeds.ts +17 -0
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +37 -20
- package/src/responses/state.ts +32 -29
- package/src/routing/history/build-file.ts +92 -0
- package/src/routing/history/indexer.ts +248 -216
- package/src/routing/history/ingest.ts +238 -0
- package/src/routing/history/rebuild-job.ts +111 -0
- package/src/routing/history/rebuild-protocol.ts +15 -0
- package/src/routing/history/rebuild-worker.ts +34 -0
- package/src/server/admission-model-scope.ts +4 -1
- package/src/server/antigravity-direct-eligibility.ts +208 -0
- package/src/server/auth-cors.ts +46 -0
- package/src/server/chat-completions.ts +75 -9
- package/src/server/claude-messages.ts +110 -20
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/index/desktop-compatibility-startup.ts +56 -0
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +15 -2
- package/src/server/index/startup-warnings.ts +9 -0
- package/src/server/index/websocket-handler.ts +9 -0
- package/src/server/index.ts +13 -10
- package/src/server/inference/client-encoder-delivery.ts +6 -5
- package/src/server/lifecycle.ts +2 -1
- package/src/server/login-shell-path.ts +146 -0
- package/src/server/management/advisor-routes.ts +191 -0
- package/src/server/management/agent-settings-routes.ts +50 -14
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/companion-routes.ts +4 -0
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/desktop-compatibility-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
- package/src/server/management/grok-coupon-routes.ts +78 -27
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +82 -402
- package/src/server/management/model-routes.ts +2 -2
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/protocol-settings-patch.ts +1 -0
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +16 -3
- package/src/server/management/shared.ts +3 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management-api.ts +9 -1
- package/src/server/messages-antigravity-direct.ts +195 -0
- package/src/server/messages-native.ts +73 -43
- package/src/server/relay-eager.ts +2 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +63 -6
- package/src/server/request-log.ts +7 -0
- package/src/server/responses/adapter-continuation.ts +35 -18
- package/src/server/responses/adapter-delivery.ts +24 -2
- package/src/server/responses/adapter-dispatch.ts +261 -81
- package/src/server/responses/advisor-plan-slot.ts +27 -0
- package/src/server/responses/advisor-slot.ts +336 -0
- package/src/server/responses/code-mode-collab-surface.ts +313 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +126 -48
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +24 -1
- package/src/server/responses/core-combo.ts +28 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-lifetime.ts +2 -0
- package/src/server/responses/core-normalize.ts +11 -0
- package/src/server/responses/core-options.ts +48 -0
- package/src/server/responses/core-replay.ts +7 -4
- package/src/server/responses/core.ts +1 -1
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/passthrough-delivery.ts +20 -11
- package/src/server/responses/passthrough-dispatch.ts +29 -7
- package/src/server/responses/request-prepare.ts +74 -9
- package/src/server/responses/request-transport.ts +22 -8
- package/src/server/responses/run-turn-execution.ts +70 -8
- package/src/server/responses/sidecar-execution.ts +89 -5
- package/src/server/responses/terminal-guard.ts +2 -1
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/responses-undeclared-tool-guard.ts +30 -9
- package/src/server/startup-action-control.ts +38 -4
- package/src/server/startup-health-cache.ts +5 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +161 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +466 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +71 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/service/logging.ts +72 -0
- package/src/tray/windows-tray.ps1 +43 -24
- package/src/types/config.ts +68 -6
- package/src/types/provider.ts +7 -0
- package/src/types/request.ts +15 -0
- package/src/types/tools.ts +2 -0
- package/src/update/async-check.ts +1 -1
- package/src/update/badge.ts +2 -2
- package/src/update/index.ts +88 -15
- package/src/update/job-supervised-restart.ts +107 -0
- package/src/update/job.ts +32 -32
- package/src/update/notify.ts +1 -1
- package/src/update/refresh-scheduler.ts +2 -2
- package/src/update/supervised-recovery.d.mts +27 -0
- package/src/update/supervised-recovery.mjs +157 -0
- package/src/usage/cache-diagnostic.ts +2 -0
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +19 -1
- package/src/web-search/gemini-executor.ts +2 -2
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/App-Ba8uG696.js +0 -54
- package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
- package/gui/dist/assets/index-BHRZdJuv.js +0 -94
- package/gui/dist/assets/quota-summary-DiJO6sYY.js +0 -1
|
@@ -34,6 +34,11 @@ export interface PickerListenerOptions {
|
|
|
34
34
|
maxEncodedBytes?: number;
|
|
35
35
|
/** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */
|
|
36
36
|
maxActiveUpstreams?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Test seam: arms the drop of a connection whose backend ended, after PICKER_ABANDONED_INPUT_LINGER_MS;
|
|
39
|
+
* returns a cancel function. Production uses a timer.
|
|
40
|
+
*/
|
|
41
|
+
scheduleAbandonedInputDrop?: (drop: () => void, ms: number) => () => void;
|
|
37
42
|
log?: (line: string) => void;
|
|
38
43
|
}
|
|
39
44
|
export interface PickerListenerHandle { port: number; close(): Promise<void> }
|
|
@@ -51,6 +56,12 @@ export const PICKER_MAX_ACTIVE_UPSTREAMS = 256;
|
|
|
51
56
|
export const PICKER_MAX_CONCURRENT_STREAMS = 100;
|
|
52
57
|
/** A connection that has not sent a complete ClientHello by then is dropped. */
|
|
53
58
|
const CLIENT_HELLO_TIMEOUT_MS = 10_000;
|
|
59
|
+
/**
|
|
60
|
+
* Once the backend has ended a connection and its reply has been flushed, input the client still
|
|
61
|
+
* sends is discarded for at most this long before the connection is dropped. A cooperative client
|
|
62
|
+
* closes as soon as it reads a Connection: close reply; this only bounds one that keeps uploading.
|
|
63
|
+
*/
|
|
64
|
+
export const PICKER_ABANDONED_INPUT_LINGER_MS = 30_000;
|
|
54
65
|
|
|
55
66
|
const HOP_HEADERS = new Set([
|
|
56
67
|
"connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization",
|
|
@@ -152,6 +163,10 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
152
163
|
const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" };
|
|
153
164
|
const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES;
|
|
154
165
|
const maxActiveUpstreams = options.maxActiveUpstreams ?? PICKER_MAX_ACTIVE_UPSTREAMS;
|
|
166
|
+
const scheduleAbandonedInputDrop = options.scheduleAbandonedInputDrop ?? ((drop: () => void, ms: number) => {
|
|
167
|
+
const timer = setTimeout(drop, ms);
|
|
168
|
+
return () => clearTimeout(timer);
|
|
169
|
+
});
|
|
155
170
|
let activeUpstreams = 0;
|
|
156
171
|
const upgrades = new Set<Duplex>();
|
|
157
172
|
const spliced = new Set<Socket>();
|
|
@@ -205,8 +220,18 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
205
220
|
} else req.destroy();
|
|
206
221
|
}
|
|
207
222
|
};
|
|
223
|
+
// An HTTP/1.1 reply sent while the upload is unfinished advertises Connection: close. Bun 1.4.3
|
|
224
|
+
// ends such a response with socket.destroySoon() before our finish listeners run, which resets
|
|
225
|
+
// the splice before the reply reaches the client; keep it a graceful FIN that discards input.
|
|
226
|
+
// Only that closing socket is changed, and destroy() stays available for errors and shutdown.
|
|
227
|
+
const closesUnfinishedUpload = () => !h2 && uploading && !req.complete;
|
|
228
|
+
const keepClosingReplyIntact = () => {
|
|
229
|
+
const socket = req.socket;
|
|
230
|
+
socket.destroySoon = () => { req.resume(); socket.end(); return socket; };
|
|
231
|
+
};
|
|
208
232
|
// Refusals answer with an empty response and a fixed log line that carries no request data.
|
|
209
233
|
const refuse = (status: 400 | 503) => {
|
|
234
|
+
if (closesUnfinishedUpload()) keepClosingReplyIntact();
|
|
210
235
|
// Do not leave a refused, still-uploading stream behind after delivering its empty reply.
|
|
211
236
|
if (uploading && !req.complete) {
|
|
212
237
|
req.once("error", () => res.destroy());
|
|
@@ -236,6 +261,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
236
261
|
if (clientGone) return;
|
|
237
262
|
if (res.headersSent) res.destroy();
|
|
238
263
|
else {
|
|
264
|
+
if (closesUnfinishedUpload()) keepClosingReplyIntact();
|
|
239
265
|
res.writeHead(502, { "Content-Length": "0", ...(!h2 && uploading && !req.complete ? { Connection: "close" } : {}) });
|
|
240
266
|
res.end(); log(502);
|
|
241
267
|
}
|
|
@@ -260,7 +286,10 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
260
286
|
// HTTP/2 has no reason phrase; its compat writeHead takes the same flat raw header array.
|
|
261
287
|
if (h2) (relayRes as Http2ServerResponse).writeHead(status, raw as unknown as Record<string, string>);
|
|
262
288
|
else {
|
|
263
|
-
if (uploading && !req.complete)
|
|
289
|
+
if (uploading && !req.complete) {
|
|
290
|
+
keepClosingReplyIntact();
|
|
291
|
+
raw.push("Connection", "close");
|
|
292
|
+
}
|
|
264
293
|
res.writeHead(status, upRes.statusMessage, raw);
|
|
265
294
|
}
|
|
266
295
|
log(status);
|
|
@@ -438,6 +467,8 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
438
467
|
const offersH2 = clientHelloOffersH2(head);
|
|
439
468
|
if (offersH2 === null) return;
|
|
440
469
|
client.off("data", onData);
|
|
470
|
+
// Keep reading an aborted upload after the backend reply FIN.
|
|
471
|
+
client.allowHalfOpen = true;
|
|
441
472
|
// Paused, a FIN or later bytes wait in the readable buffer and follow `head` once piped.
|
|
442
473
|
client.pause();
|
|
443
474
|
clearTimeout(deadline);
|
|
@@ -450,6 +481,23 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
|
|
|
450
481
|
// its peer (reset, dial failure) tears the peer down instead of leaving it open.
|
|
451
482
|
inner.once("close", () => { spliced.delete(inner); if (!client.writableEnded) client.destroy(); });
|
|
452
483
|
client.once("close", () => { if (!inner.writableEnded) inner.destroy(); });
|
|
484
|
+
// The backend reply ended; discard ciphertext still coming from the aborted upload.
|
|
485
|
+
// Unpipe before resuming, so backpressure cannot hide the peer FIN. The discard is bounded
|
|
486
|
+
// from the moment the reply has been flushed to the socket (finish), never before, so the
|
|
487
|
+
// drop cannot cut a reply still being written; finish is not peer receipt, so a peer that
|
|
488
|
+
// also stops reading for the whole budget can lose unread bytes. A peer that keeps sending
|
|
489
|
+
// past the budget is dropped instead of holding the connection until shutdown (#6984).
|
|
490
|
+
inner.once("end", () => {
|
|
491
|
+
client.unpipe(inner);
|
|
492
|
+
client.resume();
|
|
493
|
+
const linger = () => {
|
|
494
|
+
if (client.destroyed) return;
|
|
495
|
+
const cancel = scheduleAbandonedInputDrop(() => client.destroy(), PICKER_ABANDONED_INPUT_LINGER_MS);
|
|
496
|
+
client.once("close", cancel);
|
|
497
|
+
};
|
|
498
|
+
if (client.writableFinished) linger();
|
|
499
|
+
else client.once("finish", linger);
|
|
500
|
+
});
|
|
453
501
|
inner.once("connect", () => {
|
|
454
502
|
inner.setNoDelay(true);
|
|
455
503
|
client.setNoDelay(true);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { atomicWriteFile } from "../../config/atomic-write";
|
|
4
|
+
import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The loopback bypass opencodex keeps in Claude Code's settings env (#6936), and the receipt
|
|
8
|
+
* that proves which part of `NO_PROXY` opencodex wrote.
|
|
9
|
+
*
|
|
10
|
+
* `NO_PROXY` is shared with the user, so a value alone cannot say which entries are ours. Apply
|
|
11
|
+
* records `{ hadKey, previous, written }` in `<claudeConfigDir>/opencodex-no-proxy.json` and
|
|
12
|
+
* removal restores `previous` only while `NO_PROXY` still equals `written`; anything else is
|
|
13
|
+
* preserved. The receipt lives exactly as long as the ownership episode that wrote it: an apply
|
|
14
|
+
* that starts a new episode, and every removal, invalidates it first and fails closed when it
|
|
15
|
+
* cannot. It holds only `NO_PROXY` values, never the proxy token.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Local hosts Claude Code must reach directly, in the order opencodex appends them. The proxy
|
|
20
|
+
* refuses every loopback and unspecified target (connect-proxy.ts isLoopbackTarget); these are the
|
|
21
|
+
* forms a NO_PROXY entry can name. `localhost` also covers `*.localhost` because Claude Code's
|
|
22
|
+
* client suffix-matches entries. Other 127/8 addresses cannot be listed (NO_PROXY has no CIDR) and
|
|
23
|
+
* need their own entry; the proxy's 405 body says so.
|
|
24
|
+
*/
|
|
25
|
+
export const CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY = ["127.0.0.1", "localhost", "::1", "0.0.0.0"] as const;
|
|
26
|
+
|
|
27
|
+
export const NO_PROXY_RECEIPT_FILE = "opencodex-no-proxy.json";
|
|
28
|
+
|
|
29
|
+
export interface NoProxyReceipt {
|
|
30
|
+
version: 1;
|
|
31
|
+
/** Whether settings env held a NO_PROXY key before the apply. */
|
|
32
|
+
hadKey: boolean;
|
|
33
|
+
/** The value before the apply (null when the key was absent or held null). */
|
|
34
|
+
previous: string | null;
|
|
35
|
+
/** The value the apply wrote. */
|
|
36
|
+
written: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Canonical index of a loopback bypass entry, or -1. `[::1]` is the bracketed form of `::1`. */
|
|
40
|
+
function loopbackEntryIndex(entry: string): number {
|
|
41
|
+
const normalized = entry.trim().toLowerCase();
|
|
42
|
+
return CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.indexOf(
|
|
43
|
+
(normalized === "[::1]" ? "::1" : normalized) as typeof CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY[number],
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function noProxyEntries(value: string): string[] {
|
|
48
|
+
return value.split(/[,\s]+/).filter(entry => entry.length > 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when the value already sends every loopback host around the proxy. */
|
|
52
|
+
export function noProxyCoversLoopback(value: unknown): boolean {
|
|
53
|
+
if (typeof value !== "string") return false;
|
|
54
|
+
const entries = noProxyEntries(value);
|
|
55
|
+
if (entries.includes("*")) return true;
|
|
56
|
+
const present = new Set(entries.map(loopbackEntryIndex));
|
|
57
|
+
return CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.every((_, index) => present.has(index));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A NO_PROXY value opencodex can extend: absent, null, or a string. */
|
|
61
|
+
export function noProxyMergeable(value: unknown): boolean {
|
|
62
|
+
return value === undefined || value === null || typeof value === "string";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The NO_PROXY value apply writes, or null when nothing needs writing: the value already covers
|
|
67
|
+
* loopback, or it is not one opencodex can extend.
|
|
68
|
+
*/
|
|
69
|
+
export function mergeClaudeInterceptNoProxy(current: unknown): string | null {
|
|
70
|
+
if (!noProxyMergeable(current) || noProxyCoversLoopback(current)) return null;
|
|
71
|
+
const base = typeof current === "string" ? current : "";
|
|
72
|
+
const present = new Set(noProxyEntries(base).map(loopbackEntryIndex));
|
|
73
|
+
const missing = CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.filter((_, index) => !present.has(index));
|
|
74
|
+
const trimmed = base.replace(/[,\s]+$/, "");
|
|
75
|
+
return trimmed.length === 0 ? missing.join(",") : `${trimmed},${missing.join(",")}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function noProxyReceiptPath(configDir: string): string {
|
|
79
|
+
return join(configDir, NO_PROXY_RECEIPT_FILE);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ReceiptIo {
|
|
83
|
+
write(path: string, content: string): void;
|
|
84
|
+
unlink(path: string): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const defaultReceiptIo: ReceiptIo = {
|
|
88
|
+
write: (path, content) => atomicWriteFile(path, content),
|
|
89
|
+
unlink: path => unlinkSync(path),
|
|
90
|
+
};
|
|
91
|
+
let receiptIo: ReceiptIo = defaultReceiptIo;
|
|
92
|
+
|
|
93
|
+
/** Replace receipt write/unlink in tests; the real-home guard still runs. Returns a restore. */
|
|
94
|
+
export function setNoProxyReceiptIoForTests(io: Partial<ReceiptIo>): () => void {
|
|
95
|
+
const before = receiptIo;
|
|
96
|
+
receiptIo = { ...defaultReceiptIo, ...io };
|
|
97
|
+
return () => { receiptIo = before; };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The receipt, or null when it is missing or malformed (both mean: no provenance). */
|
|
101
|
+
export function readNoProxyReceipt(configDir: string): NoProxyReceipt | null {
|
|
102
|
+
let parsed: unknown;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(readFileSync(noProxyReceiptPath(configDir), "utf8"));
|
|
105
|
+
} catch { // no-excuse-ok: catch -- a missing or corrupt receipt is the no-provenance state.
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
109
|
+
const receipt = parsed as Record<string, unknown>;
|
|
110
|
+
if (receipt.version !== 1 || typeof receipt.hadKey !== "boolean" || typeof receipt.written !== "string") return null;
|
|
111
|
+
if (receipt.previous !== null && typeof receipt.previous !== "string") return null;
|
|
112
|
+
return { version: 1, hadKey: receipt.hadKey, previous: receipt.previous, written: receipt.written };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Raw receipt bytes for rollback capture, or null when there is no readable file. */
|
|
116
|
+
export function readNoProxyReceiptBytes(configDir: string): string | null {
|
|
117
|
+
try {
|
|
118
|
+
return readFileSync(noProxyReceiptPath(configDir), "utf8");
|
|
119
|
+
} catch { // no-excuse-ok: catch -- an unreadable receipt is captured as absent.
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Delete the receipt. Returns false when it exists but could not be removed. */
|
|
125
|
+
export function invalidateNoProxyReceipt(configDir: string): boolean {
|
|
126
|
+
const path = noProxyReceiptPath(configDir);
|
|
127
|
+
// Guard first and outside the catch, so a refusal is visible and nothing is touched.
|
|
128
|
+
assertNotRealClaudeConfigUnderTest(configDir, path);
|
|
129
|
+
try {
|
|
130
|
+
receiptIo.unlink(path);
|
|
131
|
+
return true;
|
|
132
|
+
} catch (error) { // no-excuse-ok: catch -- ENOENT is success; anything else is reported to the caller.
|
|
133
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Write raw receipt bytes. Throws on failure; callers decide whether that is fatal. */
|
|
138
|
+
export function writeNoProxyReceiptBytes(configDir: string, content: string): void {
|
|
139
|
+
const path = noProxyReceiptPath(configDir);
|
|
140
|
+
assertNotRealClaudeConfigUnderTest(configDir, path);
|
|
141
|
+
receiptIo.write(path, content);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function serializeNoProxyReceipt(receipt: NoProxyReceipt): string {
|
|
145
|
+
return `${JSON.stringify(receipt, null, 2)}\n`;
|
|
146
|
+
}
|
|
@@ -4,6 +4,19 @@ import { atomicWriteFile } from "../../config/atomic-write";
|
|
|
4
4
|
import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard";
|
|
5
5
|
import { claudeConfigDir } from "../auth-detect";
|
|
6
6
|
import { invalidateClaudeCodeServedCatalog } from "./cli-catalog";
|
|
7
|
+
import {
|
|
8
|
+
invalidateNoProxyReceipt,
|
|
9
|
+
mergeClaudeInterceptNoProxy,
|
|
10
|
+
noProxyCoversLoopback,
|
|
11
|
+
noProxyMergeable,
|
|
12
|
+
noProxyReceiptPath,
|
|
13
|
+
readNoProxyReceipt,
|
|
14
|
+
readNoProxyReceiptBytes,
|
|
15
|
+
serializeNoProxyReceipt,
|
|
16
|
+
writeNoProxyReceiptBytes,
|
|
17
|
+
} from "./settings-no-proxy";
|
|
18
|
+
|
|
19
|
+
export { CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY, mergeClaudeInterceptNoProxy } from "./settings-no-proxy";
|
|
7
20
|
|
|
8
21
|
/**
|
|
9
22
|
* Claude Code `settings.json` env block for intercept mode.
|
|
@@ -16,9 +29,21 @@ import { invalidateClaudeCodeServedCatalog } from "./cli-catalog";
|
|
|
16
29
|
* env.HTTPS_PROXY = http://opencodex:<token>@127.0.0.1:<proxy port>
|
|
17
30
|
* env.NODE_EXTRA_CA_CERTS = <configDir>/claude-intercept/ca.pem
|
|
18
31
|
*
|
|
32
|
+
* A third key keeps loopback off that proxy. Claude Code otherwise sends local HTTP MCP traffic
|
|
33
|
+
* (`POST http://127.0.0.1:<port>/mcp`) through it, and the proxy only tunnels CONNECT to
|
|
34
|
+
* non-loopback hosts, so every loopback MCP server failed with a 405 (#6936):
|
|
35
|
+
*
|
|
36
|
+
* env.NO_PROXY = <user entries>,127.0.0.1,localhost,::1,0.0.0.0
|
|
37
|
+
*
|
|
19
38
|
* Ownership is tracked by value, never by a marker key. The CA path is the anchor: it lives
|
|
20
39
|
* under opencodex's own config directory, so only a block whose `NODE_EXTRA_CA_CERTS` names
|
|
21
40
|
* that file is treated as ours. A user's own proxy or CA setting is left alone.
|
|
41
|
+
*
|
|
42
|
+
* `NO_PROXY` is shared with the user. Apply appends only the missing loopback entries and leaves
|
|
43
|
+
* a covering value (including `*`) alone; removal puts back exactly what was there before, using
|
|
44
|
+
* the receipt described in ./settings-no-proxy.ts, and otherwise preserves the current value.
|
|
45
|
+
* A non-string `NO_PROXY` other than null is not one opencodex can extend: the block stays stale
|
|
46
|
+
* and apply refuses with `foreign_env` instead of rewriting it.
|
|
22
47
|
*/
|
|
23
48
|
|
|
24
49
|
export const CLAUDE_INTERCEPT_MANAGED_ENV = ["HTTPS_PROXY", "NODE_EXTRA_CA_CERTS"] as const;
|
|
@@ -79,6 +104,11 @@ function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string {
|
|
|
79
104
|
return typeof value === "string" && value.trim() === ownedCaPath;
|
|
80
105
|
}
|
|
81
106
|
|
|
107
|
+
function setOrDelete(env: Record<string, unknown>, key: string, value: unknown): void {
|
|
108
|
+
if (value === undefined) delete env[key];
|
|
109
|
+
else env[key] = value;
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
/** Classify the current settings env against the values this router would write. */
|
|
83
113
|
export function inspectClaudeInterceptSettings(
|
|
84
114
|
expected: ClaudeInterceptEnv,
|
|
@@ -91,7 +121,11 @@ export function inspectClaudeInterceptSettings(
|
|
|
91
121
|
const proxy = env.HTTPS_PROXY;
|
|
92
122
|
const ca = env.NODE_EXTRA_CA_CERTS;
|
|
93
123
|
if (proxy === undefined && ca === undefined) return { kind: "absent" };
|
|
94
|
-
|
|
124
|
+
// An owned block that still routes loopback into the proxy is stale, so the startup migrate
|
|
125
|
+
// and every reconcile repair installs written before the bypass existed.
|
|
126
|
+
if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS && noProxyCoversLoopback(env.NO_PROXY)) {
|
|
127
|
+
return { kind: "applied", env: expected };
|
|
128
|
+
}
|
|
95
129
|
const caOurs = isOwnedCaPath(ca, expected.NODE_EXTRA_CA_CERTS);
|
|
96
130
|
const proxyOurs = proxy === undefined || isClaudeInterceptProxyUrl(proxy);
|
|
97
131
|
if (caOurs && proxyOurs) {
|
|
@@ -112,13 +146,28 @@ export function inspectClaudeInterceptSettings(
|
|
|
112
146
|
};
|
|
113
147
|
}
|
|
114
148
|
|
|
115
|
-
|
|
149
|
+
const defaultPersist = (path: string, content: string): void => atomicWriteFile(path, content);
|
|
150
|
+
let persistFile = defaultPersist;
|
|
151
|
+
|
|
152
|
+
/** Replace the settings.json write in tests; the real-home guard still runs first. Returns a restore. */
|
|
153
|
+
export function setClaudeSettingsPersistForTests(persist: (path: string, content: string) => void): () => void {
|
|
154
|
+
const before = persistFile;
|
|
155
|
+
persistFile = persist;
|
|
156
|
+
return () => { persistFile = before; };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Commit settings.json. Once this returns, the file on disk holds the new document. */
|
|
160
|
+
function persistSettings(path: string, doc: SettingsDoc): void {
|
|
116
161
|
// An armed test process must not rewrite the real Claude settings.json (#6775).
|
|
117
162
|
assertNotRealClaudeConfigUnderTest(dirname(path), path);
|
|
118
163
|
mkdirSync(dirname(path), { recursive: true });
|
|
119
164
|
// The managed env embeds the proxy token, so the file must stay owner-only:
|
|
120
165
|
// atomicWriteFile applies the real NTFS ACL on Windows where chmod is a no-op.
|
|
121
|
-
|
|
166
|
+
persistFile(path, `${JSON.stringify(doc, null, 2)}\n`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function writeSettings(path: string, doc: SettingsDoc): void {
|
|
170
|
+
persistSettings(path, doc);
|
|
122
171
|
// Whether Claude Code reaches the proxy just changed, so its cached /model list is stale.
|
|
123
172
|
invalidateClaudeCodeServedCatalog(dirname(path));
|
|
124
173
|
}
|
|
@@ -137,20 +186,31 @@ export function captureClaudeInterceptSettingsRollback(
|
|
|
137
186
|
const before = readSettings(path);
|
|
138
187
|
if ("error" in before && before.error !== "missing") return () => false;
|
|
139
188
|
const previous = "doc" in before ? { ...envRecord(before.doc) } : {};
|
|
189
|
+
const previousReceipt = readNoProxyReceiptBytes(configDir);
|
|
190
|
+
// What apply writes over the captured value; rollback restores NO_PROXY only while it still
|
|
191
|
+
// holds exactly that, so a user edit made after the apply survives. The receipt is restored
|
|
192
|
+
// with it, because it describes that value.
|
|
193
|
+
const appliedNoProxy = mergeClaudeInterceptNoProxy(previous.NO_PROXY) ?? previous.NO_PROXY;
|
|
140
194
|
return () => {
|
|
141
195
|
try {
|
|
196
|
+
assertNotRealClaudeConfigUnderTest(configDir, path, noProxyReceiptPath(configDir));
|
|
142
197
|
const target = typeof expected === "function" ? expected() : expected;
|
|
143
198
|
const current = readSettings(path);
|
|
144
199
|
if (!("doc" in current)) return false;
|
|
145
200
|
const env = envRecord(current.doc);
|
|
146
201
|
if (CLAUDE_INTERCEPT_MANAGED_ENV.some(key => env[key] !== target[key])) return false;
|
|
147
202
|
for (const key of CLAUDE_INTERCEPT_MANAGED_ENV) {
|
|
148
|
-
|
|
149
|
-
else env[key] = previous[key];
|
|
203
|
+
setOrDelete(env, key, previous[key]);
|
|
150
204
|
}
|
|
205
|
+
const restoreNoProxy = env.NO_PROXY === appliedNoProxy;
|
|
206
|
+
if (restoreNoProxy) setOrDelete(env, "NO_PROXY", previous.NO_PROXY);
|
|
151
207
|
if (Object.keys(env).length === 0) delete current.doc.env;
|
|
152
208
|
else current.doc.env = env;
|
|
153
209
|
writeSettings(path, current.doc);
|
|
210
|
+
if (restoreNoProxy) {
|
|
211
|
+
if (previousReceipt === null) return invalidateNoProxyReceipt(configDir);
|
|
212
|
+
writeNoProxyReceiptBytes(configDir, previousReceipt);
|
|
213
|
+
}
|
|
154
214
|
return true;
|
|
155
215
|
} catch { return false; }
|
|
156
216
|
};
|
|
@@ -158,7 +218,8 @@ export function captureClaudeInterceptSettingsRollback(
|
|
|
158
218
|
|
|
159
219
|
/**
|
|
160
220
|
* Write the intercept env into `settings.json`. Refuses when a managed key already holds a
|
|
161
|
-
* value opencodex did not write (a user-configured corporate proxy, for instance)
|
|
221
|
+
* value opencodex did not write (a user-configured corporate proxy, for instance), or when
|
|
222
|
+
* NO_PROXY holds a non-string value opencodex cannot extend.
|
|
162
223
|
*/
|
|
163
224
|
export function applyClaudeInterceptSettings(
|
|
164
225
|
env: ClaudeInterceptEnv,
|
|
@@ -171,8 +232,21 @@ export function applyClaudeInterceptSettings(
|
|
|
171
232
|
if (state.kind === "applied") return { ok: true, changed: false, path };
|
|
172
233
|
const read = readSettings(path);
|
|
173
234
|
const doc: SettingsDoc = "doc" in read ? read.doc : {};
|
|
174
|
-
|
|
235
|
+
const current = envRecord(doc);
|
|
236
|
+
if (!noProxyMergeable(current.NO_PROXY)) return { ok: false, reason: "foreign_env", path };
|
|
237
|
+
const noProxy = mergeClaudeInterceptNoProxy(current.NO_PROXY);
|
|
238
|
+
// A new ownership episode, or a new NO_PROXY value, makes any existing receipt describe the
|
|
239
|
+
// wrong value. Drop it before writing, and write nothing when it cannot be dropped.
|
|
240
|
+
if ((state.kind === "absent" || noProxy !== null) && !invalidateNoProxyReceipt(configDir)) {
|
|
241
|
+
return { ok: false, reason: "unreadable", path: noProxyReceiptPath(configDir) };
|
|
242
|
+
}
|
|
243
|
+
doc.env = { ...current, ...env, ...(noProxy === null ? {} : { NO_PROXY: noProxy }) };
|
|
175
244
|
writeSettings(path, doc);
|
|
245
|
+
if (noProxy !== null) {
|
|
246
|
+
const receipt = { version: 1 as const, hadKey: "NO_PROXY" in current, previous: typeof current.NO_PROXY === "string" ? current.NO_PROXY : null, written: noProxy };
|
|
247
|
+
// Without a receipt, removal keeps the appended entries: the harmless direction.
|
|
248
|
+
try { writeNoProxyReceiptBytes(configDir, serializeNoProxyReceipt(receipt)); } catch { /* no provenance */ }
|
|
249
|
+
}
|
|
176
250
|
return { ok: true, changed: true, path };
|
|
177
251
|
}
|
|
178
252
|
|
|
@@ -195,7 +269,11 @@ export function migrateClaudeInterceptSettings(
|
|
|
195
269
|
return applyClaudeInterceptSettings(env, configDir);
|
|
196
270
|
}
|
|
197
271
|
|
|
198
|
-
/**
|
|
272
|
+
/**
|
|
273
|
+
* Remove the managed keys and put NO_PROXY back as it was, but only the values opencodex owns.
|
|
274
|
+
* The receipt is dropped before the settings write; when that fails nothing is changed, so a
|
|
275
|
+
* receipt can never outlive the episode it describes.
|
|
276
|
+
*/
|
|
199
277
|
export function removeClaudeInterceptSettings(
|
|
200
278
|
ownedCaPath: string,
|
|
201
279
|
configDir = claudeConfigDir(),
|
|
@@ -207,10 +285,24 @@ export function removeClaudeInterceptSettings(
|
|
|
207
285
|
}
|
|
208
286
|
const env = envRecord(read.doc);
|
|
209
287
|
if (!isOwnedCaPath(env.NODE_EXTRA_CA_CERTS, ownedCaPath)) return { ok: true, changed: false, path };
|
|
288
|
+
const receipt = readNoProxyReceipt(configDir);
|
|
289
|
+
const receiptBytes = readNoProxyReceiptBytes(configDir);
|
|
210
290
|
delete env.NODE_EXTRA_CA_CERTS;
|
|
211
291
|
if (isClaudeInterceptProxyUrl(env.HTTPS_PROXY)) delete env.HTTPS_PROXY;
|
|
292
|
+
if (receipt && env.NO_PROXY === receipt.written) setOrDelete(env, "NO_PROXY", receipt.hadKey ? receipt.previous : undefined);
|
|
293
|
+
if (!invalidateNoProxyReceipt(configDir)) return { ok: false, reason: "unreadable", path: noProxyReceiptPath(configDir) };
|
|
212
294
|
if (Object.keys(env).length === 0) delete read.doc.env;
|
|
213
295
|
else read.doc.env = env;
|
|
214
|
-
|
|
296
|
+
try {
|
|
297
|
+
persistSettings(path, read.doc);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
// A removal that was not committed has not ended the episode, so its receipt stays valid. Only the
|
|
300
|
+
// persist step is covered: once settings.json changed, the receipt must not come back.
|
|
301
|
+
if (receiptBytes !== null) {
|
|
302
|
+
try { writeNoProxyReceiptBytes(configDir, receiptBytes); } catch { /* keep the original error */ }
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
invalidateClaudeCodeServedCatalog(dirname(path));
|
|
215
307
|
return { ok: true, changed: true, path };
|
|
216
308
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { atomicWriteFileNoFollow } from "../config/atomic-write";
|
|
5
|
+
import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
6
|
+
import { assertNotRealClaudeConfigUnderTest } from "../lib/test-home-guard";
|
|
7
|
+
import { claudeConfigDir } from "./auth-detect";
|
|
8
|
+
import { claudeTierDisplayName, type ClaudeTierModels } from "./context-windows";
|
|
9
|
+
|
|
10
|
+
const tiers = ["opus", "sonnet", "haiku", "fable"] as const;
|
|
11
|
+
|
|
12
|
+
function readSettings(path: string): { raw: string; exists: boolean; doc: Record<string, unknown>; env: Record<string, unknown> } {
|
|
13
|
+
let raw = "";
|
|
14
|
+
let exists = false;
|
|
15
|
+
try {
|
|
16
|
+
const stat = lstatSync(path);
|
|
17
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw new Error("settings_unreadable");
|
|
18
|
+
raw = readFileSync(path, "utf8");
|
|
19
|
+
exists = true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("settings_unreadable");
|
|
22
|
+
}
|
|
23
|
+
let doc: Record<string, unknown>;
|
|
24
|
+
try {
|
|
25
|
+
const parsed: unknown = exists ? JSON.parse(raw) : {};
|
|
26
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
27
|
+
doc = parsed as Record<string, unknown>;
|
|
28
|
+
if (doc.env !== undefined && (!doc.env || typeof doc.env !== "object" || Array.isArray(doc.env))) throw new Error();
|
|
29
|
+
} catch { throw new Error("settings_unreadable"); }
|
|
30
|
+
return { raw, exists, doc, env: (doc.env ?? {}) as Record<string, unknown> };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function snapshotFingerprint(current: ReturnType<typeof readSettings>, models: ClaudeTierModels | undefined) {
|
|
34
|
+
const target = selectedEnv(models, current.env);
|
|
35
|
+
return createHash("sha256").update(JSON.stringify([current.exists, current.raw, target])).digest("hex");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Test seams for edits at publication boundaries; never configured through the API. */
|
|
39
|
+
export interface ModelDefaultWriteHooks {
|
|
40
|
+
beforeCapture?: () => void;
|
|
41
|
+
beforePublish?: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class ModelDefaultsRecoveryError extends Error {
|
|
45
|
+
constructor(readonly backupPath: string, options?: ErrorOptions) {
|
|
46
|
+
super("settings_recovery_required", options);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Unset tiers stay untouched; only a label matching its current alias is managed. */
|
|
51
|
+
function selectedEnv(models: ClaudeTierModels | undefined, current: Record<string, unknown>): Record<string, string | null> {
|
|
52
|
+
const env: Record<string, string | null> = {};
|
|
53
|
+
for (const tier of tiers) {
|
|
54
|
+
const slot = `ANTHROPIC_DEFAULT_${tier.toUpperCase()}_MODEL`;
|
|
55
|
+
const name = `${slot}_NAME`;
|
|
56
|
+
const value = models?.[tier]?.trim();
|
|
57
|
+
if (!value) continue;
|
|
58
|
+
env[slot] = value;
|
|
59
|
+
const previous = typeof current[slot] === "string" ? claudeTierDisplayName(current[slot]) : undefined;
|
|
60
|
+
const managed = previous !== undefined && current[name] === previous;
|
|
61
|
+
// Custom (including empty/non-string) labels are user-owned. Exact pairing
|
|
62
|
+
// follows the launcher label convention; the name alone proves no ownership.
|
|
63
|
+
if (!Object.hasOwn(current, name) || managed) {
|
|
64
|
+
const label = claudeTierDisplayName(value);
|
|
65
|
+
if (label !== undefined) env[name] = label;
|
|
66
|
+
else if (managed) env[name] = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return env;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function previewClaudeModelDefaults(models: ClaudeTierModels | undefined, configDir = claudeConfigDir()) {
|
|
73
|
+
const path = join(configDir, "settings.json");
|
|
74
|
+
const current = readSettings(path);
|
|
75
|
+
const target = selectedEnv(models, current.env);
|
|
76
|
+
const changes = Object.entries(target).filter(([key, value]) => current.env[key] !== value)
|
|
77
|
+
// The existing preview wire shape uses an empty replacement for label removal.
|
|
78
|
+
.map(([key, value]) => ({ key, before: typeof current.env[key] === "string" ? current.env[key] as string : null, after: value ?? "" }));
|
|
79
|
+
const fingerprint = snapshotFingerprint(current, models);
|
|
80
|
+
return { path, fingerprint, changes };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function applyClaudeModelDefaults(
|
|
84
|
+
models: ClaudeTierModels | undefined,
|
|
85
|
+
fingerprint: string,
|
|
86
|
+
configDir = claudeConfigDir(),
|
|
87
|
+
backupDir = join(configDir, "model-default-backups"),
|
|
88
|
+
hooks: ModelDefaultWriteHooks = {},
|
|
89
|
+
) {
|
|
90
|
+
const preview = previewClaudeModelDefaults(models, configDir);
|
|
91
|
+
if (preview.fingerprint !== fingerprint) throw new Error("settings_changed");
|
|
92
|
+
if (!preview.changes.length) return { changed: false, path: preview.path };
|
|
93
|
+
assertNotRealClaudeConfigUnderTest(configDir, preview.path);
|
|
94
|
+
const current = readSettings(preview.path);
|
|
95
|
+
const check = snapshotFingerprint(current, models);
|
|
96
|
+
if (check !== fingerprint) throw new Error("settings_changed");
|
|
97
|
+
mkdirSync(configDir, { recursive: true });
|
|
98
|
+
mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
99
|
+
const recoveryDir = mkdtempSync(join(backupDir, "apply-"));
|
|
100
|
+
if (process.platform === "win32") hardenSecretDir(recoveryDir, { required: true });
|
|
101
|
+
const backupPath = join(recoveryDir, "settings.json");
|
|
102
|
+
const staged = join(configDir, `.model-defaults-${randomUUID()}.tmp`);
|
|
103
|
+
let captured = false;
|
|
104
|
+
let published = false;
|
|
105
|
+
try {
|
|
106
|
+
const env = { ...current.env };
|
|
107
|
+
for (const [key, value] of Object.entries(selectedEnv(models, current.env))) {
|
|
108
|
+
if (value === null) delete env[key];
|
|
109
|
+
else env[key] = value;
|
|
110
|
+
}
|
|
111
|
+
current.doc.env = env;
|
|
112
|
+
atomicWriteFileNoFollow(staged, `${JSON.stringify(current.doc, null, 2)}\n`);
|
|
113
|
+
// Verify same-volume exclusive publication before moving the shared file.
|
|
114
|
+
const probe = join(recoveryDir, "link-probe");
|
|
115
|
+
linkSync(staged, probe);
|
|
116
|
+
unlinkSync(probe);
|
|
117
|
+
hooks.beforeCapture?.();
|
|
118
|
+
if (current.exists) {
|
|
119
|
+
// Move the actual destination, not a stale in-memory copy. A same-filesystem
|
|
120
|
+
// move captures late edits; cross-device failures leave the original alone.
|
|
121
|
+
try { renameSync(preview.path, backupPath); } catch (error) {
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("settings_changed");
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
captured = true;
|
|
126
|
+
const capturedEntry = lstatSync(backupPath);
|
|
127
|
+
if (!capturedEntry.isFile() || capturedEntry.isSymbolicLink()) throw new Error("settings_changed");
|
|
128
|
+
if (process.platform === "win32") hardenSecretPath(backupPath, { required: true });
|
|
129
|
+
if (snapshotFingerprint(readSettings(backupPath), models) !== fingerprint) throw new Error("settings_changed");
|
|
130
|
+
}
|
|
131
|
+
hooks.beforePublish?.();
|
|
132
|
+
// A hard-link publish is exclusive on POSIX and Windows: never replace a
|
|
133
|
+
// document created by another writer during the brief move-aside interval.
|
|
134
|
+
try { linkSync(staged, preview.path); } catch (error) {
|
|
135
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("settings_changed");
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
published = true;
|
|
139
|
+
return { changed: true, path: preview.path, ...(captured ? { backupPath } : {}) };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (captured && !published) {
|
|
142
|
+
try { linkSync(backupPath, preview.path); } catch (restoreError) {
|
|
143
|
+
// A newer destination wins. Retain the captured document privately;
|
|
144
|
+
// never delete the winner or overwrite it as an automatic rollback.
|
|
145
|
+
throw new ModelDefaultsRecoveryError(backupPath, { cause: restoreError });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
} finally {
|
|
150
|
+
// The private stage is also present at the destination after successful
|
|
151
|
+
// linking; cleanup failure must not turn a committed apply into failure.
|
|
152
|
+
try { unlinkSync(staged); } catch { /* private residue, no shared-file cleanup */ }
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/claude/model-info.ts
CHANGED
|
@@ -103,19 +103,19 @@ function modelInfo(id: string, displayName: string, ladder: readonly string[], i
|
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
105
|
* Which id family the discovery list carries (devlog 050): Claude Code (CLI)
|
|
106
|
-
* gets readable `claude
|
|
107
|
-
* `claude-
|
|
106
|
+
* gets readable `ocx-claude-*` ids; Claude Desktop keeps its profile's
|
|
107
|
+
* `claude-fable-5-p<code>` ids (legacy no-profile callers use hash aliases). Both families
|
|
108
108
|
* decode in resolveInboundModel regardless of the style served here.
|
|
109
109
|
*/
|
|
110
110
|
export type AnthropicIdStyle = "desktop3p" | "readable";
|
|
111
111
|
|
|
112
|
-
/** Build
|
|
112
|
+
/** Build discovery rows; a Desktop resolver can return undefined to withhold an unbound ID. */
|
|
113
113
|
export function buildAnthropicModelInfos(
|
|
114
114
|
nativeSlugs: readonly string[],
|
|
115
115
|
routedModels: readonly CatalogModel[],
|
|
116
116
|
auto: AutoContextMode = AUTO_CONTEXT_OFF,
|
|
117
117
|
idStyle: AnthropicIdStyle = "desktop3p",
|
|
118
|
-
aliasForRoute: (provider: string, modelId: string) => string = desktop3pAlias,
|
|
118
|
+
aliasForRoute: (provider: string, modelId: string) => string | undefined = desktop3pAlias,
|
|
119
119
|
nativeContextCap?: NativeContextLimitsInput,
|
|
120
120
|
fastMode?: boolean,
|
|
121
121
|
// Presence is the feature gate: the caller passes undefined when `fastRows` is off, so a
|
|
@@ -144,7 +144,7 @@ export function buildAnthropicModelInfos(
|
|
|
144
144
|
? claudeCodeAlias(m.provider, listed)
|
|
145
145
|
: aliasForRoute(m.provider, m.id);
|
|
146
146
|
}),
|
|
147
|
-
]);
|
|
147
|
+
].filter((id): id is string => typeof id === "string" && id.length > 0));
|
|
148
148
|
// [1m] picker variant (devlog 260712 B1; widened in devlog/_plan/261009_claude_1m_default/020):
|
|
149
149
|
// Claude Code accounts exactly 1M for ids carrying the marker (2.1.207 binary: /\[1m\]/i -> 1e6,
|
|
150
150
|
// compaction preserved). A discovery client may lack CLAUDE_CODE_AUTO_COMPACT_WINDOW, so the
|
|
@@ -201,7 +201,7 @@ export function buildAnthropicModelInfos(
|
|
|
201
201
|
};
|
|
202
202
|
for (const slug of nativeSlugs) {
|
|
203
203
|
const id = idStyle === "readable" ? claudeCodeNativeAlias(slug) : aliasForRoute("native", slug);
|
|
204
|
-
if (seen.has(id)) continue;
|
|
204
|
+
if (!id || seen.has(id)) continue;
|
|
205
205
|
seen.add(id);
|
|
206
206
|
const nativeWindow = nativeOpenAiContextWindow(slug, nativeContextCap);
|
|
207
207
|
const nativeMaxInput = nativeOpenAiMaxInputTokens(slug, nativeContextCap);
|
|
@@ -228,7 +228,7 @@ export function buildAnthropicModelInfos(
|
|
|
228
228
|
const id = idStyle === "readable"
|
|
229
229
|
? claudeCodeAlias(m.provider, listedModelId)
|
|
230
230
|
: aliasForRoute(m.provider, m.id);
|
|
231
|
-
if (seen.has(id)) continue;
|
|
231
|
+
if (!id || seen.has(id)) continue;
|
|
232
232
|
seen.add(id);
|
|
233
233
|
const groupStart = out.length;
|
|
234
234
|
const ladder = Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts : [];
|