@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922
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 +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-E64Rzjap.js +50 -0
- package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
- package/gui/dist/assets/index-DpdfZWMK.js +86 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +8 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +20 -1
- package/src/adapters/base.ts +23 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +32 -4
- package/src/adapters/command-code.ts +52 -4
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +20 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +14 -3
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
- package/src/adapters/devin.ts +125 -36
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +34 -7
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google-http.ts +49 -10
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +66 -11
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +23 -58
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +17 -3
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +13 -151
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/account-extended.ts +4 -4
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +46 -7
- package/src/cli/doctor.ts +28 -9
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +203 -59
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-api.ts +2 -2
- package/src/client/machine-listener.ts +4 -7
- package/src/client/runtime.ts +26 -2
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/account-store.ts +65 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/auth-api/pool-quota-probe.ts +30 -7
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/gather-capture.ts +21 -2
- package/src/codex/catalog/model-hints.ts +29 -28
- package/src/codex/catalog/parsing.ts +7 -0
- package/src/codex/catalog/provider-models.ts +43 -17
- package/src/codex/catalog/retained-sync.ts +24 -28
- package/src/codex/catalog/routed-gather.ts +19 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +31 -166
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +99 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +116 -54
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/observed-model-denials.ts +101 -8
- package/src/codex/prompt-text-probe.ts +9 -6
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +12 -42
- package/src/codex/shim-templates.ts +29 -3
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +22 -4
- package/src/combos/failover.ts +3 -0
- package/src/companion/settings.ts +132 -0
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/diagnostics.ts +22 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +52 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/proxy-env.ts +8 -2
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +31 -1
- package/src/config/schema/leaf-validators.ts +59 -0
- package/src/config.ts +3 -3
- package/src/generated/compatibility-version.json +604 -280
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +921 -0
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +134 -110
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +63 -0
- package/src/lib/errors.ts +79 -0
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +109 -16
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/socks5-fetch.ts +136 -26
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +218 -27
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +1 -0
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +27 -108
- package/src/oauth/login-cli.ts +80 -29
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/key-failover.ts +97 -19
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +179 -38
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/vendor-probes-key.ts +38 -23
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +47 -20
- package/src/providers/registry/entries-extended.ts +63 -4
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +56 -10
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/apply-patch-envelope.ts +0 -12
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +3 -0
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +108 -117
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +60 -4
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +61 -20
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +142 -39
- package/src/server/index/spend-ledger-lifecycle.ts +92 -0
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +34 -38
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/config-routes.ts +18 -2
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +20 -0
- package/src/server/management/shared.ts +28 -4
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +10 -9
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +138 -12
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +169 -2
- package/src/server/request-metrics.ts +298 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +11 -6
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +43 -10
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +8 -3
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +6 -12
- package/src/server/responses/core-opaque-recovery.ts +91 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +45 -15
- package/src/server/responses/passthrough-dispatch.ts +215 -44
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +109 -18
- package/src/server/responses/request-send-budget.ts +17 -1
- package/src/server/responses/request-sidecar-auth.ts +1 -1
- package/src/server/responses/request-transport.ts +26 -6
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses/run-turn-execution.ts +25 -3
- package/src/server/responses/sidecar-execution.ts +17 -2
- package/src/server/responses/ws-upstream.ts +14 -27
- package/src/server/responses-custom-tool-repair.ts +27 -54
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +35 -2
- package/src/server/search.ts +25 -1
- package/src/server/sse-payload-rewrite.ts +1 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +156 -4
- package/src/types/config.ts +48 -3
- package/src/types/provider.ts +91 -0
- package/src/types/request.ts +30 -2
- package/src/types/tools.ts +33 -0
- package/src/types.ts +4 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/alpha-search.ts +21 -1
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/gui/dist/assets/index-C5IebErG.js +0 -136
- package/gui/dist/assets/index-OESInAjC.css +0 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-provider egress: which transport a request for THIS provider actually leaves by.
|
|
3
|
+
*
|
|
4
|
+
* The global `proxy`/`noProxy` pair is process-wide (mirrored into HTTP_PROXY/HTTPS_PROXY/
|
|
5
|
+
* ALL_PROXY/NO_PROXY by `applyProxyEnv`), so it cannot express the split #2894 describes:
|
|
6
|
+
* one upstream must exit through a regional proxy, another must stay direct on the local
|
|
7
|
+
* network. This module is the single authority that answers that question for one request,
|
|
8
|
+
* and every transport owner that can carry the answer consumes it rather than re-deriving it.
|
|
9
|
+
*
|
|
10
|
+
* It deliberately mirrors the shape #5087 established for the global decision in
|
|
11
|
+
* `effectiveProxyFor`: the question is never "is a proxy configured" but "does a proxy apply
|
|
12
|
+
* to THIS request". A provider route is resolved against the request URL, so a per-provider
|
|
13
|
+
* bypass list is part of the decision rather than a second check somewhere downstream.
|
|
14
|
+
*
|
|
15
|
+
* The three states are exactly the ones the issue asks for, with one deliberate divergence:
|
|
16
|
+
*
|
|
17
|
+
* - field absent -> `inherit`: the global decision stands, byte-identical to today;
|
|
18
|
+
* - `null` or `"direct"` -> `direct`: this provider never uses the global proxy;
|
|
19
|
+
* - an http(s) URL -> `proxy`: this provider uses its own HTTP(S) proxy;
|
|
20
|
+
* - a socks5(h) URL -> `proxy`: this provider uses its own SOCKS5 proxy.
|
|
21
|
+
*
|
|
22
|
+
* The divergence is the empty string. #2894 sketches `""` as a third spelling of DIRECT.
|
|
23
|
+
* Treating it that way would make a dashboard field the operator merely cleared silently
|
|
24
|
+
* change a provider from "inherit the global proxy" to "never use the global proxy" — the
|
|
25
|
+
* quiet reinterpretation this batch exists to remove. An empty or whitespace-only value is
|
|
26
|
+
* therefore a configuration error naming both real alternatives.
|
|
27
|
+
*/
|
|
28
|
+
import type { OcxProviderConfig } from "../types";
|
|
29
|
+
import { isSocks5ProxyUrl, noProxyMatches } from "./proxy-env";
|
|
30
|
+
|
|
31
|
+
export class InvalidProviderEgressError extends Error {
|
|
32
|
+
override readonly name = "InvalidProviderEgressError";
|
|
33
|
+
constructor(
|
|
34
|
+
/** The provider field that carries the offending value. */
|
|
35
|
+
readonly field: "proxy" | "noProxy",
|
|
36
|
+
/** The failure on its own, so configuration surfaces can phrase it their own way. */
|
|
37
|
+
readonly reason: string,
|
|
38
|
+
message: string,
|
|
39
|
+
) {
|
|
40
|
+
super(message);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The literal an operator writes to pin one provider to direct egress. */
|
|
45
|
+
export const PROVIDER_EGRESS_DIRECT = "direct";
|
|
46
|
+
|
|
47
|
+
export type ProviderEgress =
|
|
48
|
+
| { kind: "inherit" }
|
|
49
|
+
| { kind: "direct"; reason: "configured" | "noProxy" }
|
|
50
|
+
| { kind: "proxy"; proxyUrl: string; transport: "http" | "socks5" };
|
|
51
|
+
|
|
52
|
+
export interface ProviderEgressContext {
|
|
53
|
+
providerName: string;
|
|
54
|
+
provider: Pick<OcxProviderConfig, "proxy" | "noProxy">;
|
|
55
|
+
url: string | URL;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function egressFailure(providerName: string, field: "proxy" | "noProxy", reason: string): never {
|
|
59
|
+
throw new InvalidProviderEgressError(field, reason, `providers.${providerName}.${field} is invalid: ${reason}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A proxy URL reduced to scheme, host and port for operator-facing output.
|
|
64
|
+
*
|
|
65
|
+
* A proxy URL routinely carries `user:password@`, and this value reaches startup banners,
|
|
66
|
+
* diagnostics and the dashboard DTO. `URL.origin` drops userinfo, query and path, so what is
|
|
67
|
+
* left identifies the route without reproducing the credential. Nothing derived from the
|
|
68
|
+
* credential is emitted either — not a hash, not a prefix — because a short digest over a
|
|
69
|
+
* known host is a guessable stand-in for the secret and a durable correlation key for the
|
|
70
|
+
* account behind it.
|
|
71
|
+
*/
|
|
72
|
+
export function sanitizeProxyUrlForLog(proxyUrl: string): string {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = new URL(proxyUrl);
|
|
75
|
+
return parsed.port ? `${parsed.protocol}//${parsed.hostname}:${parsed.port}` : parsed.origin;
|
|
76
|
+
} catch {
|
|
77
|
+
return "<unparseable-proxy-url>";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function describeProviderEgressForLog(egress: ProviderEgress): string {
|
|
82
|
+
if (egress.kind === "inherit") return "inherit";
|
|
83
|
+
if (egress.kind === "direct") return `direct(${egress.reason})`;
|
|
84
|
+
return `${egress.transport}(${sanitizeProxyUrlForLog(egress.proxyUrl)})`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseTargetUrl(providerName: string, url: string | URL): URL {
|
|
88
|
+
if (url instanceof URL) return url;
|
|
89
|
+
try {
|
|
90
|
+
return new URL(url);
|
|
91
|
+
} catch {
|
|
92
|
+
return egressFailure(providerName, "proxy", "the request URL is not parseable, so no provider route can be decided for it");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeNoProxy(providerName: string, raw: string | string[] | undefined): string | null {
|
|
97
|
+
if (raw === undefined) return null;
|
|
98
|
+
const entries = Array.isArray(raw) ? raw : [raw];
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (typeof entry !== "string") {
|
|
101
|
+
return egressFailure(providerName, "noProxy", "every entry must be a string host pattern");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const joined = entries.join(",").trim();
|
|
105
|
+
return joined.length > 0 ? joined : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseProviderProxyRoute(providerName: string, raw: string): ProviderEgress {
|
|
109
|
+
const trimmed = raw.trim();
|
|
110
|
+
if (trimmed.length === 0) {
|
|
111
|
+
return egressFailure(
|
|
112
|
+
providerName,
|
|
113
|
+
"proxy",
|
|
114
|
+
`an empty value is ambiguous; write "${PROVIDER_EGRESS_DIRECT}" to force direct egress, or remove the field to inherit the global proxy`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (trimmed.toLowerCase() === PROVIDER_EGRESS_DIRECT) return { kind: "direct", reason: "configured" };
|
|
118
|
+
let parsed: URL;
|
|
119
|
+
try {
|
|
120
|
+
parsed = new URL(trimmed);
|
|
121
|
+
} catch {
|
|
122
|
+
return egressFailure(
|
|
123
|
+
providerName,
|
|
124
|
+
"proxy",
|
|
125
|
+
`"${PROVIDER_EGRESS_DIRECT}" or an absolute proxy URL is required; this value is neither`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (isSocks5ProxyUrl(trimmed)) {
|
|
129
|
+
if (!parsed.hostname) {
|
|
130
|
+
return egressFailure(providerName, "proxy", "the SOCKS5 proxy URL has no host");
|
|
131
|
+
}
|
|
132
|
+
return { kind: "proxy", proxyUrl: trimmed, transport: "socks5" };
|
|
133
|
+
}
|
|
134
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
135
|
+
return egressFailure(
|
|
136
|
+
providerName,
|
|
137
|
+
"proxy",
|
|
138
|
+
`unsupported proxy scheme "${parsed.protocol}"; supported schemes are http, https, socks5 and socks5h`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (!parsed.hostname) {
|
|
142
|
+
return egressFailure(providerName, "proxy", "the proxy URL has no host");
|
|
143
|
+
}
|
|
144
|
+
return { kind: "proxy", proxyUrl: parsed.toString(), transport: "http" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The route this provider's request leaves by, or `inherit` when the global decision stands.
|
|
149
|
+
*
|
|
150
|
+
* Throws `InvalidProviderEgressError` rather than degrading to `inherit`: a malformed egress
|
|
151
|
+
* field is the one case where guessing is worst. Falling back to the global proxy would send a
|
|
152
|
+
* credential through a route the operator did not choose, and falling back to direct would
|
|
153
|
+
* leave a restricted network with no exit. Both read as success at the call site.
|
|
154
|
+
*
|
|
155
|
+
* A per-provider `noProxy` match outranks the provider's own proxy for the same reason it
|
|
156
|
+
* outranks the global one: it names destinations this provider must reach without a proxy.
|
|
157
|
+
* It is evaluated against the resolved route, so it also carves holes in an inherited global
|
|
158
|
+
* proxy — which is how a provider exempts one host without owning a proxy of its own.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveProviderEgress(context: ProviderEgressContext): ProviderEgress {
|
|
161
|
+
const { providerName, provider } = context;
|
|
162
|
+
const raw = provider.proxy;
|
|
163
|
+
let route: ProviderEgress;
|
|
164
|
+
if (raw === undefined) {
|
|
165
|
+
route = { kind: "inherit" };
|
|
166
|
+
} else if (raw === null) {
|
|
167
|
+
route = { kind: "direct", reason: "configured" };
|
|
168
|
+
} else if (typeof raw !== "string") {
|
|
169
|
+
return egressFailure(providerName, "proxy", "the value must be a proxy URL string, \"direct\", null, or absent");
|
|
170
|
+
} else {
|
|
171
|
+
route = parseProviderProxyRoute(providerName, raw);
|
|
172
|
+
}
|
|
173
|
+
const noProxy = normalizeNoProxy(providerName, provider.noProxy);
|
|
174
|
+
if (noProxy !== null) {
|
|
175
|
+
const target = parseTargetUrl(providerName, context.url);
|
|
176
|
+
if (noProxyMatches(target, { NO_PROXY: noProxy })) return { kind: "direct", reason: "noProxy" };
|
|
177
|
+
}
|
|
178
|
+
return route;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether this provider decided the route itself, as opposed to deferring to the global one.
|
|
183
|
+
*
|
|
184
|
+
* Transport owners use this to tell "the operator chose this" from "nothing was configured",
|
|
185
|
+
* which are the two cases that must not be collapsed when a transport cannot carry the choice.
|
|
186
|
+
*/
|
|
187
|
+
export function providerEgressIsExplicit(egress: ProviderEgress): boolean {
|
|
188
|
+
return egress.kind !== "inherit";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The request-scoped fetch options that express `egress` to Bun's fetch.
|
|
193
|
+
*
|
|
194
|
+
* `proxy: false` is Bun's documented per-request direct connection: it ignores HTTP_PROXY,
|
|
195
|
+
* HTTPS_PROXY and ALL_PROXY, and it ignores NO_PROXY as well, which is what makes it a
|
|
196
|
+
* decision rather than a hint. `undefined`, `null` and `""` all mean "no option given" to
|
|
197
|
+
* Bun and fall through to the environment, so none of them can express direct egress — the
|
|
198
|
+
* reason this returns the literal `false` and never an empty string.
|
|
199
|
+
*
|
|
200
|
+
* A SOCKS5 route is returned as the same `proxy` string; `configuredOutboundFetch` recognises
|
|
201
|
+
* the scheme and hands the request to the SOCKS transport, because Bun's own fetch ignores a
|
|
202
|
+
* socks5 value.
|
|
203
|
+
*/
|
|
204
|
+
export function providerEgressFetchInit(egress: ProviderEgress): { proxy?: string | false } {
|
|
205
|
+
if (egress.kind === "inherit") return {};
|
|
206
|
+
if (egress.kind === "direct") return { proxy: false };
|
|
207
|
+
return { proxy: egress.proxyUrl };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Marker for an executor that forwards its `RequestInit` to a transport which honours the
|
|
212
|
+
* request-scoped proxy option.
|
|
213
|
+
*
|
|
214
|
+
* A provider route is refused on an executor that owns its own transport, because applying it
|
|
215
|
+
* is impossible and ignoring it is worse. But not every `provider.fetch` owns a transport:
|
|
216
|
+
* some are internal wrappers that add a header and delegate, and `src/providers/xai-transport.ts`
|
|
217
|
+
* installs exactly such a wrapper on every xAI route. Refusing those would make the per-provider
|
|
218
|
+
* proxy unusable on one of the two providers the original issue names.
|
|
219
|
+
*
|
|
220
|
+
* The marker is opt-in and applied by the wrapper's author, so an executor that arrives from
|
|
221
|
+
* configuration or from a caller is opaque by default and still refused. `Symbol.for` keeps the
|
|
222
|
+
* mark readable across duplicated module instances.
|
|
223
|
+
*/
|
|
224
|
+
const EGRESS_TRANSPARENT_EXECUTOR = Symbol.for("opencodex.provider-egress.transparent-executor");
|
|
225
|
+
|
|
226
|
+
export function markEgressTransparentExecutor<Fetch extends typeof globalThis.fetch>(executor: Fetch): Fetch {
|
|
227
|
+
(executor as unknown as Record<symbol, boolean>)[EGRESS_TRANSPARENT_EXECUTOR] = true;
|
|
228
|
+
return executor;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function isEgressTransparentExecutor(executor: unknown): boolean {
|
|
232
|
+
return typeof executor === "function"
|
|
233
|
+
&& (executor as unknown as Record<symbol, unknown>)[EGRESS_TRANSPARENT_EXECUTOR] === true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The destination of a fetch input, or null when it cannot be read as a URL. */
|
|
237
|
+
export function egressTargetUrl(input: string | URL | Request): string | null {
|
|
238
|
+
if (typeof input === "string") return input;
|
|
239
|
+
if (input instanceof URL) return input.toString();
|
|
240
|
+
return typeof input?.url === "string" ? input.url : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Everything a physical send needs to decide the route for the request it is about to make. */
|
|
244
|
+
export interface ProviderEgressBinding {
|
|
245
|
+
providerName: string;
|
|
246
|
+
provider: Pick<OcxProviderConfig, "proxy" | "noProxy">;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The request options expressing `binding`'s route for the destination actually being sent to.
|
|
251
|
+
*
|
|
252
|
+
* Resolved at the physical send rather than when the executor was built, for the reason #4992
|
|
253
|
+
* already established for the connection policy: a queued request can be rebuilt against a
|
|
254
|
+
* different upstream host before it leaves, and a route decided against the original
|
|
255
|
+
* destination would then be applied to a different one. With a host-scoped `noProxy` that
|
|
256
|
+
* inverts the decision, and the credential leaves by a route the operator did not choose.
|
|
257
|
+
*
|
|
258
|
+
* Refuses rather than degrades when the selected executor owns its own transport.
|
|
259
|
+
*/
|
|
260
|
+
export function providerEgressSendInit(
|
|
261
|
+
binding: ProviderEgressBinding,
|
|
262
|
+
physicalFetch: unknown,
|
|
263
|
+
input: string | URL | Request,
|
|
264
|
+
): { proxy?: string | false } {
|
|
265
|
+
const url = egressTargetUrl(input);
|
|
266
|
+
if (url === null) return {};
|
|
267
|
+
const egress = resolveProviderEgress({ providerName: binding.providerName, provider: binding.provider, url });
|
|
268
|
+
if (providerEgressIsExplicit(egress) && !isEgressTransparentExecutor(physicalFetch)) {
|
|
269
|
+
// Name the field that actually made the route explicit. A bypass-list match with no
|
|
270
|
+
// `proxy` field at all would otherwise tell the operator to remove an override they
|
|
271
|
+
// never wrote.
|
|
272
|
+
const field = egress.kind === "direct" && egress.reason === "noProxy" ? "noProxy" : "proxy";
|
|
273
|
+
throw new InvalidProviderEgressError(
|
|
274
|
+
field,
|
|
275
|
+
"the selected transport owns its own routing, so this route cannot be applied",
|
|
276
|
+
`providers.${binding.providerName}.${field} cannot be applied to the selected provider transport; `
|
|
277
|
+
+ "remove the provider egress override or the custom executor",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return providerEgressFetchInit(egress);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A destination used only to exercise the resolver at configuration time.
|
|
285
|
+
*
|
|
286
|
+
* Validation has no request URL, but `noProxy` is only meaningful against one. Resolving a
|
|
287
|
+
* reserved name checks the shape of both fields without asserting anything about which route a
|
|
288
|
+
* real request would take.
|
|
289
|
+
*/
|
|
290
|
+
const EGRESS_VALIDATION_URL = "https://validation.invalid/";
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The configuration error for a provider's egress fields, or null when they are usable.
|
|
294
|
+
*
|
|
295
|
+
* Delegates to `resolveProviderEgress` so configuration and request time cannot drift apart:
|
|
296
|
+
* a value accepted by `ocx config set` or the dashboard is one the transport will accept, and
|
|
297
|
+
* one rejected here is rejected there for the identical reason. Restating the rules would give
|
|
298
|
+
* this repository two definitions of a valid proxy value and no check that they agree.
|
|
299
|
+
*/
|
|
300
|
+
export function providerEgressConfigError(
|
|
301
|
+
provider: Pick<OcxProviderConfig, "proxy" | "noProxy">,
|
|
302
|
+
): string | null {
|
|
303
|
+
try {
|
|
304
|
+
resolveProviderEgress({ providerName: "<validation>", provider, url: EGRESS_VALIDATION_URL });
|
|
305
|
+
return null;
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (error instanceof InvalidProviderEgressError) return `${error.field} is invalid: ${error.reason}`;
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -7,12 +7,13 @@ import {
|
|
|
7
7
|
resolvePublicAddresses,
|
|
8
8
|
} from "./destination-policy";
|
|
9
9
|
import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http";
|
|
10
|
-
import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname,
|
|
10
|
+
import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, schemeMatchedProxyFor } from "./proxy-env";
|
|
11
|
+
import { InvalidProviderEgressError, resolveProviderEgress } from "./provider-egress";
|
|
11
12
|
import { publicProviderBaseUrl } from "./provider-url";
|
|
12
13
|
|
|
13
14
|
type ProviderGetInit = Omit<RequestInit, "body" | "method" | "redirect">;
|
|
14
15
|
type ProviderPostInit = ProviderGetInit & { body: string };
|
|
15
|
-
type ProviderOutboundConfig = Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork"> & {
|
|
16
|
+
type ProviderOutboundConfig = Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork" | "proxy" | "noProxy"> & {
|
|
16
17
|
fetch?: typeof globalThis.fetch;
|
|
17
18
|
};
|
|
18
19
|
export interface ProviderOutboundDependencies {
|
|
@@ -104,19 +105,78 @@ export async function providerRedirectError(response: Response, requestUrl: stri
|
|
|
104
105
|
return `provider returned ${response.status} redirect to ${target}; configure the final provider URL directly`;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Default client identity for proxy-originated provider outbound requests.
|
|
110
|
+
*
|
|
111
|
+
* Every request through the provider outbound wrapper — connection tests, model
|
|
112
|
+
* discovery, quota probes — is initiated by the proxy itself, so there is no
|
|
113
|
+
* client request to inherit a User-Agent from, and the pinned Node-style
|
|
114
|
+
* transport sends none. WAF/CDN front ends commonly answer UA-less requests
|
|
115
|
+
* with a 403 that surfaced as "provider added but no models" (#5104). A caller
|
|
116
|
+
* that materializes its own User-Agent — registry static headers, provider
|
|
117
|
+
* `headers`, or a vendor-specific client fingerprint — keeps that value and is
|
|
118
|
+
* never given a second User-Agent; this only fills the name nobody claimed.
|
|
119
|
+
* The value is what survives, not its spelling: both the pinned transport and
|
|
120
|
+
* the SOCKS transport rebuild the set through `new Headers()`, which lowercases
|
|
121
|
+
* every name before it reaches the wire. Inference traffic never uses this
|
|
122
|
+
* wrapper, so the client-fingerprint rationale of #1751 is unaffected.
|
|
123
|
+
*/
|
|
124
|
+
const PROVIDER_OUTBOUND_DEFAULT_USER_AGENT = "opencodex";
|
|
125
|
+
|
|
126
|
+
function hasUserAgentHeader(headers: HeadersInit | null | undefined): boolean {
|
|
127
|
+
if (!headers) return false;
|
|
128
|
+
if (headers instanceof Headers) return headers.has("user-agent");
|
|
129
|
+
if (Array.isArray(headers)) return headers.some(([name]) => name.toLowerCase() === "user-agent");
|
|
130
|
+
return Object.keys(headers).some(name => name.toLowerCase() === "user-agent");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function withDefaultOutboundUserAgent(
|
|
134
|
+
init: ProviderGetInit | ProviderPostInit,
|
|
135
|
+
): ProviderGetInit | ProviderPostInit {
|
|
136
|
+
const headers = init.headers;
|
|
137
|
+
if (hasUserAgentHeader(headers)) return init;
|
|
138
|
+
if (headers instanceof Headers) {
|
|
139
|
+
const merged = new Headers(headers);
|
|
140
|
+
merged.set("User-Agent", PROVIDER_OUTBOUND_DEFAULT_USER_AGENT);
|
|
141
|
+
return { ...init, headers: merged };
|
|
142
|
+
}
|
|
143
|
+
if (Array.isArray(headers)) {
|
|
144
|
+
return { ...init, headers: [...headers, ["User-Agent", PROVIDER_OUTBOUND_DEFAULT_USER_AGENT]] };
|
|
145
|
+
}
|
|
146
|
+
return { ...init, headers: { ...(headers ?? {}), "User-Agent": PROVIDER_OUTBOUND_DEFAULT_USER_AGENT } };
|
|
147
|
+
}
|
|
148
|
+
|
|
107
149
|
async function providerOutboundRequest(
|
|
108
150
|
name: string,
|
|
109
151
|
provider: ProviderOutboundConfig,
|
|
110
152
|
url: string,
|
|
111
153
|
method: "GET" | "POST",
|
|
112
|
-
|
|
154
|
+
rawInit: ProviderGetInit | ProviderPostInit,
|
|
113
155
|
dependencies: ProviderOutboundDependencies = {},
|
|
114
156
|
): Promise<Response> {
|
|
157
|
+
// See PROVIDER_OUTBOUND_DEFAULT_USER_AGENT: this wrapper only carries proxy-originated
|
|
158
|
+
// diagnostic traffic, so it identifies itself unless the caller already did.
|
|
159
|
+
const init = withDefaultOutboundUserAgent(rawInit);
|
|
115
160
|
const postUrl = method === "POST" ? new URL(url) : undefined;
|
|
116
161
|
if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") {
|
|
117
162
|
throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS");
|
|
118
163
|
}
|
|
119
|
-
|
|
164
|
+
// A provider entry keeps unknown configuration keys, so `fetch` can arrive as a value the
|
|
165
|
+
// operator wrote into the file rather than an executor a caller attached. Calling that would
|
|
166
|
+
// throw inside discovery and fail the provider for a reason nothing in its configuration
|
|
167
|
+
// explains; the built-in transport is what a configured value means.
|
|
168
|
+
if (typeof provider.fetch === "function") {
|
|
169
|
+
// A caller-owned executor decides its own transport, so a provider egress route cannot be
|
|
170
|
+
// applied to it. Refusing is the only honest answer: running the executor anyway would send
|
|
171
|
+
// the request by whatever route that executor picked while the configuration says otherwise.
|
|
172
|
+
if (resolveProviderEgress({ providerName: name, provider, url }).kind !== "inherit") {
|
|
173
|
+
throw new InvalidProviderEgressError(
|
|
174
|
+
"proxy",
|
|
175
|
+
"a caller-supplied fetch executor owns its own routing, so this route cannot be applied",
|
|
176
|
+
`providers.${name}.proxy cannot be applied to a caller-supplied fetch executor; `
|
|
177
|
+
+ "remove the provider egress override or the custom executor",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
120
180
|
// A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config
|
|
121
181
|
// checks and redirect blocking, but does not provide the resolved-address guarantees of
|
|
122
182
|
// the built-in transport. Main-request migration must define that executor contract first.
|
|
@@ -138,13 +198,39 @@ async function providerOutboundRequest(
|
|
|
138
198
|
return provider.fetch(url, { ...init, method, redirect: "manual" });
|
|
139
199
|
}
|
|
140
200
|
const parsed = postUrl ?? new URL(url);
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
201
|
+
// The provider's own route, decided against this request URL. `inherit` leaves every value
|
|
202
|
+
// below exactly as the global decision computed it.
|
|
203
|
+
const egress = resolveProviderEgress({ providerName: name, provider, url: parsed });
|
|
204
|
+
const providerProxy = egress.kind === "proxy" ? egress.proxyUrl : null;
|
|
205
|
+
// Snapshot the proxy fetch would actually use once, before the DNS await, so admission
|
|
206
|
+
// and transport below reason about the same value. `null` here means "no proxy fetch
|
|
207
|
+
// would actually use", even if some other proxy variable is set.
|
|
208
|
+
const globalProxy = effectiveProxyFor(parsed);
|
|
209
|
+
// The request leaves the DNS-pinned transport only when a proxy will actually carry it:
|
|
210
|
+
// a proxy variable fetch would use for this URL that NO_PROXY does not exempt.
|
|
211
|
+
// A scheme-mismatched or unusable variable, a NO_PROXY match, or an ALL_PROXY
|
|
212
|
+
// this target's scheme cannot use must not downgrade pinning or admit
|
|
213
|
+
// proxy-only DNS answers.
|
|
214
|
+
//
|
|
215
|
+
// A provider route replaces that decision outright rather than combining with it. An
|
|
216
|
+
// explicit provider proxy applies even where global NO_PROXY exempts the host, because the
|
|
217
|
+
// operator named this proxy for this provider; `providers.<name>.noProxy` is the exemption
|
|
218
|
+
// that belongs to that choice, and `resolveProviderEgress` has already applied it. A
|
|
219
|
+
// provider pinned to `direct` keeps the DNS-pinned transport, which reaches the peer
|
|
220
|
+
// through no proxy at all — the one route on this path that needs nothing from Bun.
|
|
221
|
+
const proxyApplies = egress.kind === "inherit"
|
|
222
|
+
? globalProxy !== null && !noProxyMatches(parsed)
|
|
223
|
+
: providerProxy !== null;
|
|
146
224
|
const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false);
|
|
147
|
-
|
|
225
|
+
// The IPv6 fake-IP gate keeps its stricter documented condition — a
|
|
226
|
+
// scheme-matched variable or a SOCKS5 ALL_PROXY, never a non-SOCKS
|
|
227
|
+
// ALL_PROXY — even when proxyApplies admits one for the transport
|
|
228
|
+
// decision, because admission binds the fetch to this value explicitly.
|
|
229
|
+
// An explicit provider proxy is exactly such a binding: the fetch below is pinned to it.
|
|
230
|
+
const bindingProxy = egress.kind === "inherit"
|
|
231
|
+
? schemeMatchedProxyFor(parsed)
|
|
232
|
+
: providerProxy;
|
|
233
|
+
const allowMihomoIpv6FakeIp = (bindingProxy !== null && (providerProxy !== null || !noProxyMatches(parsed)))
|
|
148
234
|
|| transparentFakeIpException(url, parsed, isCanonicalUrl, name);
|
|
149
235
|
const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses;
|
|
150
236
|
const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet;
|
|
@@ -168,7 +254,7 @@ async function providerOutboundRequest(
|
|
|
168
254
|
// proof is on the final request URL — not the provider name — because an
|
|
169
255
|
// OAuth/forward name matches any baseUrl by design while the bearer is
|
|
170
256
|
// pinned to the registry destination independently.
|
|
171
|
-
allowBenchmarkAddresses:
|
|
257
|
+
allowBenchmarkAddresses: proxyApplies
|
|
172
258
|
|| transparentFakeIpException(url, parsed, isCanonicalUrl, name),
|
|
173
259
|
// Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted either when bound
|
|
174
260
|
// to a scheme-matched proxy (#3462) or under the TUN transparency exception for a
|
|
@@ -181,21 +267,28 @@ async function providerOutboundRequest(
|
|
|
181
267
|
if (!dnsResolutionFailed) {
|
|
182
268
|
throw new ProviderOutboundPolicyError(error instanceof Error ? error.message : "provider destination was blocked");
|
|
183
269
|
}
|
|
184
|
-
if (!
|
|
270
|
+
if (!proxyApplies) throw error;
|
|
185
271
|
warnProxyBoundaryOnce();
|
|
186
272
|
warnProxyDnsDegradationOnce();
|
|
187
|
-
|
|
273
|
+
// An explicit provider proxy stays pinned through the degradation too; re-inferring the
|
|
274
|
+
// route from the environment here would quietly move the request to a different exit.
|
|
275
|
+
return configuredOutboundFetch(url, {
|
|
276
|
+
...init, method, redirect: "manual",
|
|
277
|
+
...(providerProxy ? { proxy: providerProxy } : {}),
|
|
278
|
+
});
|
|
188
279
|
}
|
|
189
280
|
// A canonical TUN exception with no scheme-matched proxy must retain the
|
|
190
281
|
// validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present.
|
|
191
|
-
if (
|
|
282
|
+
if (proxyApplies && !resolved.privateNetwork) {
|
|
192
283
|
warnProxyBoundaryOnce();
|
|
193
284
|
// When the Mihomo exception could have admitted an answer, pin the transport to the
|
|
194
285
|
// proxy the admission assumed instead of letting fetch re-infer it from the environment.
|
|
195
|
-
|
|
286
|
+
// An explicit provider proxy is always pinned, for the same reason and unconditionally:
|
|
287
|
+
// the operator named the exit for this provider, so the environment must not re-decide it.
|
|
288
|
+
const proxy = providerProxy ?? ((allowMihomoIpv6FakeIp && bindingProxy) ? bindingProxy : undefined);
|
|
196
289
|
return configuredOutboundFetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) });
|
|
197
290
|
}
|
|
198
|
-
if (
|
|
291
|
+
if (proxyApplies && resolved.privateNetwork) {
|
|
199
292
|
const hostname = normalizeProxyHostname(parsed.hostname);
|
|
200
293
|
throw new Error(
|
|
201
294
|
`provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`,
|
package/src/lib/proxy-env.ts
CHANGED
|
@@ -86,12 +86,30 @@ export function outboundProxyConfigured(
|
|
|
86
86
|
return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env));
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The value when `raw` is a proxy URL Bun fetch can actually use, else null.
|
|
91
|
+
* Bun rejects unparseable values and non-http(s) schemes (UnsupportedProxyProtocol),
|
|
92
|
+
* so admitting them as "the proxy that applies" would only downgrade DNS pinning.
|
|
93
|
+
*/
|
|
94
|
+
function usableHttpProxyUrl(raw: string | undefined): string | null {
|
|
95
|
+
if (!raw) return null;
|
|
96
|
+
try {
|
|
97
|
+
const scheme = new URL(raw).protocol;
|
|
98
|
+
return scheme === "http:" || scheme === "https:" ? raw : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
89
104
|
/**
|
|
90
105
|
* The proxy URL selected by configured outbound fetch for `url`, or null when none applies.
|
|
91
106
|
*
|
|
92
107
|
* Bun selects by scheme: `HTTPS_PROXY` for `https:` targets, `HTTP_PROXY` for `http:`.
|
|
93
|
-
* A SOCKS5 `ALL_PROXY` is selected by the explicit wrapper first
|
|
94
|
-
*
|
|
108
|
+
* A SOCKS5 `ALL_PROXY` is selected by the explicit wrapper first. A non-SOCKS
|
|
109
|
+
* `ALL_PROXY` is still honoured by the native fetch for plain `http:` targets on
|
|
110
|
+
* every platform the CI matrix covers — the provider-outbound e2e drives exactly that
|
|
111
|
+
* request through the proxy on Linux, macOS and Windows. For `https:` targets the
|
|
112
|
+
* SOCKS wrapper remains the only `ALL_PROXY` route this module counts.
|
|
95
113
|
* Presence of *some* proxy variable (`outboundProxyConfigured`) is not that guarantee.
|
|
96
114
|
*/
|
|
97
115
|
export function effectiveProxyFor(
|
|
@@ -107,8 +125,47 @@ export function effectiveProxyFor(
|
|
|
107
125
|
// The installed SOCKS wrapper takes this route before Bun sees scheme proxies.
|
|
108
126
|
const socksProxy = socks5ProxyFromEnv(env);
|
|
109
127
|
if (socksProxy) return socksProxy;
|
|
128
|
+
const schemeValue = env[key]?.trim() || env[key.toLowerCase()]?.trim();
|
|
129
|
+
if (schemeValue) {
|
|
130
|
+
// A SOCKS URL in a scheme-matched variable is a usable proxy: admission
|
|
131
|
+
// binds it explicitly and the transport follows, so it applies here too.
|
|
132
|
+
if (isSocks5ProxyUrl(schemeValue)) return schemeValue;
|
|
133
|
+
// A present but unusable scheme-matched variable fails closed: it is not a
|
|
134
|
+
// proxy Bun fetch can use, and it must not fall through to ALL_PROXY either.
|
|
135
|
+
// If Bun would have used ALL_PROXY here, keeping the DNS-pinned transport is
|
|
136
|
+
// the safe direction; if it would not, this is exactly right.
|
|
137
|
+
return usableHttpProxyUrl(schemeValue);
|
|
138
|
+
}
|
|
139
|
+
if (url.protocol !== "http:") return null;
|
|
140
|
+
return usableHttpProxyUrl(env.ALL_PROXY?.trim() || env.all_proxy?.trim());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The proxy a request can be explicitly bound to for fake-IP admission, or
|
|
145
|
+
* null: a scheme-matched variable or a SOCKS5 `ALL_PROXY`. This is the
|
|
146
|
+
* stricter documented gate for Mihomo IPv6 fake-IP answers — a non-SOCKS
|
|
147
|
+
* `ALL_PROXY` does not count here even when `effectiveProxyFor` reports it,
|
|
148
|
+
* because admission pins the transport to the returned value and the gate's
|
|
149
|
+
* contract is stated in those terms. The scheme-matched value counts only as
|
|
150
|
+
* a usable binding — a SOCKS or http(s) URL; anything else would admit a
|
|
151
|
+
* fake-IP answer nothing can resolve.
|
|
152
|
+
*/
|
|
153
|
+
export function schemeMatchedProxyFor(
|
|
154
|
+
url: URL,
|
|
155
|
+
env: ProxyEnvMap = process.env,
|
|
156
|
+
): string | null {
|
|
157
|
+
const key: ProxyEnvKey | null = url.protocol === "https:"
|
|
158
|
+
? "HTTPS_PROXY"
|
|
159
|
+
: url.protocol === "http:"
|
|
160
|
+
? "HTTP_PROXY"
|
|
161
|
+
: null;
|
|
162
|
+
if (!key) return null;
|
|
163
|
+
const socksProxy = socks5ProxyFromEnv(env);
|
|
164
|
+
if (socksProxy) return socksProxy;
|
|
110
165
|
const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
|
|
111
|
-
|
|
166
|
+
if (!value) return null;
|
|
167
|
+
if (isSocks5ProxyUrl(value)) return value;
|
|
168
|
+
return usableHttpProxyUrl(value);
|
|
112
169
|
}
|
|
113
170
|
|
|
114
171
|
export function isSocks5ProxyUrl(proxy: string): boolean {
|
|
@@ -120,16 +177,34 @@ export function socks5ProxyFromEnv(env: ProxyEnvMap = process.env): string | und
|
|
|
120
177
|
return candidates.find(value => typeof value === "string" && isSocks5ProxyUrl(value));
|
|
121
178
|
}
|
|
122
179
|
|
|
180
|
+
/**
|
|
181
|
+
* A request-scoped proxy decision as the outbound transports express it.
|
|
182
|
+
*
|
|
183
|
+
* `false` is Bun's documented "connect directly": it overrides HTTP_PROXY, HTTPS_PROXY and
|
|
184
|
+
* ALL_PROXY, and it overrides NO_PROXY too. Bun treats `undefined`, `null` and `""` alike as
|
|
185
|
+
* "no option given" and falls back to the environment, so none of those can express direct
|
|
186
|
+
* egress. Declared locally because the value travels through `RequestInit`, which does not
|
|
187
|
+
* carry it in the ambient DOM types.
|
|
188
|
+
*/
|
|
189
|
+
export type ProxyCapableRequestInit = RequestInit & { proxy?: string | false };
|
|
190
|
+
|
|
123
191
|
export function configuredOutboundFetch(
|
|
124
192
|
input: RequestInfo | URL,
|
|
125
193
|
init?: RequestInit,
|
|
126
194
|
fallback?: typeof globalThis.fetch,
|
|
127
195
|
): Promise<Response> {
|
|
128
196
|
const base = fallback ?? (globalThis.fetch === installedFetch ? nativeFetch : globalThis.fetch);
|
|
129
|
-
const explicitProxy = (init as
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
197
|
+
const explicitProxy = (init as ProxyCapableRequestInit | undefined)?.proxy;
|
|
198
|
+
// An explicit `false` is a decision, so it also has to win over the installed SOCKS wrapper.
|
|
199
|
+
// Reading it as "no string was supplied" would fall through to ALL_PROXY and send a request
|
|
200
|
+
// the caller pinned to direct egress through the global SOCKS proxy instead — the silent
|
|
201
|
+
// substitution the caller asked this option to prevent. Bun applies the same `false` to its
|
|
202
|
+
// own HTTP(S) proxy environment once the request reaches the base fetch below.
|
|
203
|
+
const proxy = explicitProxy === false
|
|
204
|
+
? undefined
|
|
205
|
+
: typeof explicitProxy === "string"
|
|
206
|
+
? (isSocks5ProxyUrl(explicitProxy) ? explicitProxy : undefined)
|
|
207
|
+
: socks5ProxyFromEnv();
|
|
133
208
|
let url: URL;
|
|
134
209
|
try {
|
|
135
210
|
url = new URL(input instanceof Request ? input.url : String(input));
|