@bitkyc08/opencodex 2.58.0 → 2.60.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 +28 -10
- package/gui/dist/assets/index-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +34 -1
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +21 -4
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +45 -2
- package/src/adapters/devin/cloud-direct/chat.ts +14 -3
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
- package/src/adapters/devin.ts +125 -26
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +55 -4
- package/src/adapters/google-http.ts +57 -11
- 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 +60 -10
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +10 -11
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +25 -4
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +28 -35
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +26 -6
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/dispatch.ts +57 -3
- package/src/cli/doctor.ts +218 -4
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +95 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +2 -0
- package/src/client/machine-api.ts +6 -5
- package/src/client/machine-listener.ts +16 -3
- package/src/client/runtime.ts +26 -2
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +146 -5
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +44 -10
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +22 -2
- package/src/codex/catalog/model-hints.ts +66 -33
- package/src/codex/catalog/parsing.ts +90 -5
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +41 -26
- package/src/codex/catalog/routed-gather.ts +61 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +24 -167
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +80 -2
- package/src/codex/observed-model-denials.ts +230 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +289 -16
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +20 -16
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +114 -2
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +125 -5
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +43 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +33 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +31 -7
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +25 -0
- package/src/config/schema/leaf-validators.ts +76 -17
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +410 -258
- package/src/generated/model-metadata.ts +1 -1
- 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/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- 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/bounded-body.ts +4 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +82 -15
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/local-destinations.ts +32 -5
- 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-outbound.ts +53 -5
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +741 -0
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +332 -35
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +41 -27
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +327 -25
- package/src/providers/model-rename-migration.ts +56 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +131 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +87 -21
- 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/responses/apply-patch-envelope.ts +32 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +9 -2
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- 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/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +13 -5
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +30 -5
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +24 -25
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +48 -7
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- 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-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +136 -34
- package/src/server/request-log.ts +80 -3
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +74 -30
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +63 -30
- package/src/server/responses/compact.ts +89 -18
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +90 -24
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +16 -15
- package/src/server/responses/core-opaque-recovery.ts +1 -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 +68 -2
- package/src/server/responses/passthrough-delivery.ts +41 -15
- package/src/server/responses/passthrough-dispatch.ts +145 -53
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +96 -17
- package/src/server/responses/request-send-budget.ts +89 -8
- package/src/server/responses/request-sidecar-auth.ts +17 -9
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +15 -12
- package/src/server/responses/run-turn-execution.ts +45 -8
- package/src/server/responses/sidecar-execution.ts +19 -2
- package/src/server/responses/ws-upstream.ts +16 -28
- package/src/server/responses-custom-tool-repair.ts +29 -56
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +37 -10
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +111 -6
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +9 -1
- package/src/types/tools.ts +14 -0
- package/src/types/wire.ts +9 -1
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
- package/gui/dist/assets/index-C5-RdDmD.css +0 -1
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
import { PinnedHttpError, pinnedHttpGet, pinnedHttpPost } from "./pinned-http";
|
|
1
|
+
import { PinnedHttpError, pinnedHttpGet, pinnedHttpPost, type PinnedHttpErrorCode } from "./pinned-http";
|
|
2
2
|
import { TransportError } from "../lab/live/transport";
|
|
3
|
-
import type { LabCredentialLeaseV1, LabPinnedSender } from "../lab/live/types";
|
|
3
|
+
import type { LabCredentialLeaseV1, LabPinnedSender, TransportErrorCode } from "../lab/live/types";
|
|
4
4
|
|
|
5
5
|
/** Only response metadata required by current live assertions crosses into Lab. */
|
|
6
6
|
const LAB_RESPONSE_HEADER_ALLOWLIST = ["content-type"] as const;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Every pinned-transport failure code, mapped to the Lab transport taxonomy or to nothing.
|
|
10
|
+
*
|
|
11
|
+
* A switch over the codes that existed when it was written silently let later ones fall through
|
|
12
|
+
* to a raw rethrow. That is not the same as leaving them unclassified: Lab's executor turns an
|
|
13
|
+
* unrecognized error into `harness_failure` / `execution_error`, so a response the peer coded
|
|
14
|
+
* in a format this transport cannot undo was reported as a fault of the runner. Both unreadable
|
|
15
|
+
* answers therefore carry `unreadable_response`, which Lab classifies as a protocol failure.
|
|
16
|
+
* A total map keeps a future code from acquiring that misattribution by default.
|
|
17
|
+
*/
|
|
18
|
+
const LAB_TRANSPORT_FAILURES = {
|
|
19
|
+
connect_timeout: { code: "connect_timeout", message: "pinned provider connection timed out" },
|
|
20
|
+
first_byte_timeout: { code: "first_byte_timeout", message: "pinned provider first byte timed out" },
|
|
21
|
+
inactivity_timeout: { code: "inactivity_timeout", message: "pinned provider response stalled" },
|
|
22
|
+
output_byte_limit: { code: "output_byte_limit", message: "pinned provider response exceeded byte budget" },
|
|
23
|
+
content_decode_failed: { code: "unreadable_response", message: "pinned provider response did not decode" },
|
|
24
|
+
unsupported_content_encoding: {
|
|
25
|
+
code: "unreadable_response",
|
|
26
|
+
message: "pinned provider used a content-encoding this transport cannot decode",
|
|
27
|
+
},
|
|
28
|
+
} satisfies Record<PinnedHttpErrorCode, { code: TransportErrorCode; message: string }>;
|
|
29
|
+
|
|
8
30
|
/**
|
|
9
31
|
* Trusted credential/transport owner. Secret headers exist only in this non-Lab module and are
|
|
10
32
|
* consumed directly by the pinned HTTP primitive; they are never returned to Lab code.
|
|
@@ -33,16 +55,8 @@ export function createLabAuthorizedPinnedSender(
|
|
|
33
55
|
body = await response.text();
|
|
34
56
|
} catch (error) {
|
|
35
57
|
if (error instanceof PinnedHttpError) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
throw new TransportError("connect_timeout", "pinned provider connection timed out");
|
|
39
|
-
case "first_byte_timeout":
|
|
40
|
-
throw new TransportError("first_byte_timeout", "pinned provider first byte timed out");
|
|
41
|
-
case "inactivity_timeout":
|
|
42
|
-
throw new TransportError("inactivity_timeout", "pinned provider response stalled");
|
|
43
|
-
case "output_byte_limit":
|
|
44
|
-
throw new TransportError("output_byte_limit", "pinned provider response exceeded byte budget");
|
|
45
|
-
}
|
|
58
|
+
const mapped = LAB_TRANSPORT_FAILURES[error.code];
|
|
59
|
+
throw new TransportError(mapped.code, mapped.message);
|
|
46
60
|
}
|
|
47
61
|
throw error;
|
|
48
62
|
}
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
* loopback listener enabled → `127.0.0.1:<effective listener port>`, no credential
|
|
22
22
|
* loopback/absent `hostname` → `127.0.0.1:<public port>`, no credential
|
|
23
23
|
* wildcard `hostname` → `127.0.0.1:<public port>`, ADMISSION CREDENTIAL REQUIRED
|
|
24
|
-
*
|
|
24
|
+
* literal non-loopback IP → `<probeHostname(hostname)>:<public port>`, credential REQUIRED
|
|
25
|
+
* DNS bind name → `127.0.0.1:<public port>`, credential REQUIRED (fail closed)
|
|
25
26
|
*
|
|
26
27
|
* A wildcard bind does answer on 127.0.0.1, which is why its origin stays loopback, but the
|
|
27
28
|
* public listener demands data-plane admission regardless of which address received the
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
* token: no exported client configuration may carry management authority (reviewer constraint
|
|
37
38
|
* on #4236).
|
|
38
39
|
*/
|
|
40
|
+
import { isIP } from "node:net";
|
|
39
41
|
import { effectiveLoopbackListenerPort, isLoopbackHostname, isWildcardHostname, shouldInjectApiAuthHeader } from "../codex/loopback-target";
|
|
40
42
|
import { probeHostname } from "../server/proxy-liveness";
|
|
41
43
|
import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "./service-secrets";
|
|
@@ -59,6 +61,31 @@ export interface LocalInferenceDestination {
|
|
|
59
61
|
requiresAdmissionToken: boolean;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Turn a bind setting into an address that is safe to combine with local credentials.
|
|
66
|
+
*
|
|
67
|
+
* Bun resolves a DNS bind name when the listener starts, but a later client lookup can receive
|
|
68
|
+
* a different answer. Local integrations therefore use only literal addresses; a DNS bind falls
|
|
69
|
+
* back to loopback and fails closed when the listener does not answer there. Operators who need
|
|
70
|
+
* local integrations on a DNS-bound listener can enable the dedicated loopback listener.
|
|
71
|
+
*
|
|
72
|
+
* `localhost` is deliberately NOT treated as a DNS bind here, even though it is a name. It is
|
|
73
|
+
* reserved to loopback by RFC 6761, so a second lookup cannot select a peer off this machine —
|
|
74
|
+
* the thing this function exists to prevent. Rewriting it to `127.0.0.1` would buy no safety and
|
|
75
|
+
* would change what every existing loopback install writes into its exported client
|
|
76
|
+
* configuration, which is a contract `tests/claude-integration/claude-cli.test.ts` pins.
|
|
77
|
+
* `isLoopbackHostname` is the existing encoding of "this name is loopback", so the two stay in
|
|
78
|
+
* agreement by construction rather than by a second list.
|
|
79
|
+
*/
|
|
80
|
+
export function localCredentialDestinationHostname(hostname: string | undefined): string {
|
|
81
|
+
const probed = probeHostname(hostname);
|
|
82
|
+
if (isLoopbackHostname(probed)) return probed;
|
|
83
|
+
const literal = probed.startsWith("[") && probed.endsWith("]")
|
|
84
|
+
? probed.slice(1, -1)
|
|
85
|
+
: probed;
|
|
86
|
+
return isIP(literal) !== 0 ? probed : "127.0.0.1";
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
/**
|
|
63
90
|
* The one answer for "where does a local client send inference, and does it need a key?".
|
|
64
91
|
*
|
|
@@ -85,7 +112,7 @@ export function localInferenceDestination(
|
|
|
85
112
|
// literal; `shouldInjectApiAuthHeader` is the existing encoding of "this bind demands a
|
|
86
113
|
// data-plane credential", so the two stay in agreement by construction.
|
|
87
114
|
return {
|
|
88
|
-
origin: `http://${
|
|
115
|
+
origin: `http://${localCredentialDestinationHostname(hostname)}:${publicPort}`,
|
|
89
116
|
port: publicPort,
|
|
90
117
|
requiresAdmissionToken: shouldInjectApiAuthHeader(config),
|
|
91
118
|
};
|
|
@@ -146,8 +173,8 @@ const ADMISSION_TOKEN_SHAPE = /^[A-Za-z0-9._~+/=-]{8,4096}$/;
|
|
|
146
173
|
*
|
|
147
174
|
* A hub's management ingress is loopback-only and exists precisely so the operator's own
|
|
148
175
|
* machine has a management address when the proxy listener is bound elsewhere. Everything else
|
|
149
|
-
* keeps dialing
|
|
150
|
-
*
|
|
176
|
+
* keeps dialing a literal public bind address it can actually reach. DNS bind names fail closed
|
|
177
|
+
* to loopback because resolving them again could select a different peer after startup.
|
|
151
178
|
*
|
|
152
179
|
* The caller still supplies the management credential. Never write that credential into an
|
|
153
180
|
* exported client configuration.
|
|
@@ -158,5 +185,5 @@ export function localManagementOrigin(
|
|
|
158
185
|
): string {
|
|
159
186
|
const ingress = config?.runtimeRole === "hub" ? config.hub?.managementIngress : undefined;
|
|
160
187
|
if (ingress?.enabled) return `http://127.0.0.1:${ingress.port}`;
|
|
161
|
-
return `http://${
|
|
188
|
+
return `http://${localCredentialDestinationHostname(config?.hostname)}:${publicPort}`;
|
|
162
189
|
}
|
package/src/lib/pinned-http.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import http, { type ClientRequest, type IncomingMessage, type RequestOptions } from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
|
+
import { classifyContentCoding, isNullBodyStatus } from "./http-response-semantics";
|
|
3
4
|
|
|
4
5
|
export type PinnedAddress = { address: string; family: number };
|
|
5
6
|
|
|
@@ -7,7 +8,9 @@ export type PinnedHttpErrorCode =
|
|
|
7
8
|
| "connect_timeout"
|
|
8
9
|
| "first_byte_timeout"
|
|
9
10
|
| "inactivity_timeout"
|
|
10
|
-
| "output_byte_limit"
|
|
11
|
+
| "output_byte_limit"
|
|
12
|
+
| "unsupported_content_encoding"
|
|
13
|
+
| "content_decode_failed";
|
|
11
14
|
|
|
12
15
|
export class PinnedHttpError extends Error {
|
|
13
16
|
override readonly name = "PinnedHttpError";
|
|
@@ -32,6 +35,99 @@ export interface PinnedHttpRequestOptions {
|
|
|
32
35
|
/** @deprecated Use {@link PinnedHttpRequestOptions}. */
|
|
33
36
|
export type PinnedHttpGetOptions = PinnedHttpRequestOptions;
|
|
34
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Undo the content-coding this transport has to undo itself, under the caller's byte ceiling.
|
|
40
|
+
*
|
|
41
|
+
* `maxBytes` keeps its existing meaning for the bytes that arrive on the socket, and gains the
|
|
42
|
+
* same meaning for the bytes the caller ends up reading. Bounding only the coded side would let
|
|
43
|
+
* a small compressed response expand past a ceiling the caller set precisely so it would not
|
|
44
|
+
* have to hold an unbounded body in memory.
|
|
45
|
+
*
|
|
46
|
+
* A completed body is not torn down here. The response already ended, so there is nothing to
|
|
47
|
+
* release, and destroying it would take a connection the agent is entitled to reuse. That
|
|
48
|
+
* matches the identity path, which also only closes. Teardown belongs to the paths that end a
|
|
49
|
+
* response early: a decode failure, an exceeded ceiling, and a caller that cancels.
|
|
50
|
+
*
|
|
51
|
+
* Only a decoder failure is renamed. A mid-body reset, a stalled response and an exceeded
|
|
52
|
+
* socket-byte ceiling all reach this pipeline as "the stream failed", and calling any of them a
|
|
53
|
+
* decode failure would tell the caller the peer sent unreadable bytes when the truth is that the
|
|
54
|
+
* connection died. The source failure is recorded as it passes so the original error survives;
|
|
55
|
+
* what is left after that is the decompressor's own, and that one is named because the caller's
|
|
56
|
+
* alternative is a bare TypeError from a stream it never constructed.
|
|
57
|
+
*/
|
|
58
|
+
function decodedBody(
|
|
59
|
+
source: ReadableStream<Uint8Array>,
|
|
60
|
+
format: "gzip" | "deflate",
|
|
61
|
+
maxBytes: number | undefined,
|
|
62
|
+
context: string,
|
|
63
|
+
release: () => void,
|
|
64
|
+
): ReadableStream<Uint8Array> {
|
|
65
|
+
// Interposed purely to attribute failures. Once bytes enter the decompressor, a transport
|
|
66
|
+
// error and a corrupt trailer are indistinguishable from the far side of the pipe.
|
|
67
|
+
let sourceFailure: { error: unknown } | undefined;
|
|
68
|
+
const sourceReader = source.getReader();
|
|
69
|
+
const attributed = new ReadableStream<Uint8Array>({
|
|
70
|
+
async pull(controller) {
|
|
71
|
+
try {
|
|
72
|
+
const next = await sourceReader.read();
|
|
73
|
+
if (next.done) {
|
|
74
|
+
controller.close();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
controller.enqueue(next.value);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
sourceFailure = { error };
|
|
80
|
+
controller.error(error);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
cancel(reason) {
|
|
84
|
+
return sourceReader.cancel(reason);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
// `DecompressionStream` declares its writable side as `WritableStream<BufferSource>`, and
|
|
88
|
+
// TypeScript measures `WritableStream` as invariant in its chunk type, so the pair is not
|
|
89
|
+
// assignable to `ReadableWritablePair<Uint8Array, Uint8Array>` even though every chunk this
|
|
90
|
+
// body produces is a valid `BufferSource`. The conversion states that relationship and
|
|
91
|
+
// nothing else; it does not widen what is actually written.
|
|
92
|
+
const decompressor = new DecompressionStream(format) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
|
|
93
|
+
const reader = attributed.pipeThrough(decompressor).getReader();
|
|
94
|
+
let decoded = 0;
|
|
95
|
+
return new ReadableStream<Uint8Array>({
|
|
96
|
+
async pull(controller) {
|
|
97
|
+
try {
|
|
98
|
+
const next = await reader.read();
|
|
99
|
+
if (next.done) {
|
|
100
|
+
controller.close();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
decoded += next.value.byteLength;
|
|
104
|
+
if (maxBytes !== undefined && decoded > maxBytes) {
|
|
105
|
+
throw new PinnedHttpError("output_byte_limit", `${context} exceeds ${maxBytes} byte cap`);
|
|
106
|
+
}
|
|
107
|
+
controller.enqueue(next.value);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// A failure the socket stream raised is the caller's answer, whatever shape it has.
|
|
110
|
+
// Only what the decompressor itself rejected is renamed.
|
|
111
|
+
const named = sourceFailure !== undefined
|
|
112
|
+
? sourceFailure.error
|
|
113
|
+
: error instanceof PinnedHttpError
|
|
114
|
+
? error
|
|
115
|
+
: new PinnedHttpError("content_decode_failed", `${context} could not decode its ${format} body`);
|
|
116
|
+
// Cancelling the decoded reader propagates back through the decompressor to the socket
|
|
117
|
+
// stream's own `cancel`, which destroys the request; `release` covers the case where
|
|
118
|
+
// that propagation is already finished.
|
|
119
|
+
await reader.cancel(named).catch(() => { /* already torn down */ });
|
|
120
|
+
controller.error(named);
|
|
121
|
+
release();
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
async cancel(reason) {
|
|
125
|
+
await reader.cancel(reason).catch(() => { /* already torn down */ });
|
|
126
|
+
release();
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
35
131
|
function pinnedHttpRequest(
|
|
36
132
|
url: string,
|
|
37
133
|
pinned: PinnedAddress,
|
|
@@ -55,6 +151,10 @@ function pinnedHttpRequest(
|
|
|
55
151
|
const maxBytes = options?.maxBytes;
|
|
56
152
|
const headers = new Headers(options?.headers);
|
|
57
153
|
headers.set("host", parsed.host);
|
|
154
|
+
// This transport assembles the response itself, so a coding it did not ask for becomes its own
|
|
155
|
+
// problem to undo. Ask for none by default and leave an explicit caller choice alone, which is
|
|
156
|
+
// the same rule `src/lib/socks5-fetch.ts` applies to the other raw route.
|
|
157
|
+
if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
|
|
58
158
|
if (body !== undefined && !headers.has("content-length")) {
|
|
59
159
|
headers.set("content-length", String(Buffer.byteLength(body)));
|
|
60
160
|
}
|
|
@@ -150,6 +250,39 @@ function pinnedHttpRequest(
|
|
|
150
250
|
return;
|
|
151
251
|
}
|
|
152
252
|
|
|
253
|
+
// A success status can still be null-body. `new Response(stream, { status: 204 })` throws a
|
|
254
|
+
// TypeError, so attaching the body below would turn a correct no-content answer into a
|
|
255
|
+
// construction failure raised inside this event handler rather than a resolved response.
|
|
256
|
+
// Nothing is coming on the socket either, so streaming one of these would hold the caller
|
|
257
|
+
// until the peer closed a connection it is entitled to keep alive. The headers still
|
|
258
|
+
// describe the representation the peer would have sent and are preserved as they arrived.
|
|
259
|
+
if (isNullBodyStatus(status)) {
|
|
260
|
+
try { response.destroy(); } catch { /* ignore */ }
|
|
261
|
+
try { req?.destroy(); } catch { /* ignore */ }
|
|
262
|
+
if (settled) return;
|
|
263
|
+
settled = true;
|
|
264
|
+
resolve(new Response(null, { status, headers: responseHeaders }));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The peer may have coded the body whatever this request asked for. Classify before the
|
|
269
|
+
// stream takes the socket so a coding this transport cannot undo fails on the ordinary
|
|
270
|
+
// error path rather than reaching the caller as bytes its parser cannot read.
|
|
271
|
+
const coding = classifyContentCoding(responseHeaders);
|
|
272
|
+
if (coding.kind === "unsupported") {
|
|
273
|
+
try { response.destroy(); } catch { /* ignore */ }
|
|
274
|
+
fail(new PinnedHttpError(
|
|
275
|
+
"unsupported_content_encoding",
|
|
276
|
+
`${context} returned an unsupported content-encoding: ${coding.coding}`,
|
|
277
|
+
));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (coding.kind === "decodable") {
|
|
281
|
+
responseHeaders.delete("content-encoding");
|
|
282
|
+
// The declared length counted the coded bytes, not what the caller now reads.
|
|
283
|
+
responseHeaders.delete("content-length");
|
|
284
|
+
}
|
|
285
|
+
|
|
153
286
|
let received = 0;
|
|
154
287
|
const stream = new ReadableStream<Uint8Array>({
|
|
155
288
|
start(controller) {
|
|
@@ -191,7 +324,14 @@ function pinnedHttpRequest(
|
|
|
191
324
|
|
|
192
325
|
if (settled) return;
|
|
193
326
|
settled = true;
|
|
194
|
-
|
|
327
|
+
const release = () => {
|
|
328
|
+
try { response.destroy(); } catch { /* ignore */ }
|
|
329
|
+
try { req?.destroy(); } catch { /* ignore */ }
|
|
330
|
+
};
|
|
331
|
+
const payload = coding.kind === "decodable"
|
|
332
|
+
? decodedBody(stream, coding.format, maxBytes, context, release)
|
|
333
|
+
: stream;
|
|
334
|
+
resolve(new Response(payload, { status, headers: responseHeaders }));
|
|
195
335
|
};
|
|
196
336
|
|
|
197
337
|
const requestFn = parsed.protocol === "https:" ? https.request : http.request;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying a value the way a consumer that must not observe later edits needs it copied.
|
|
3
|
+
*
|
|
4
|
+
* Serializing with JSON is not a copier and using it as one is a quiet hole: it drops functions
|
|
5
|
+
* without saying so, and it invokes getters and toJSON, so the object being copied gets to decide
|
|
6
|
+
* what the copy contains. Every read here goes through a property descriptor instead, including
|
|
7
|
+
* array elements, which an exotic array can define as accessors just as an object can.
|
|
8
|
+
*
|
|
9
|
+
* The contract is narrow on purpose. This copies what JSON could have produced and refuses
|
|
10
|
+
* everything else rather than approximating it: an accessor, a cycle, a function, a class instance,
|
|
11
|
+
* a Map, a Date, a symbol value, a bigint, a non-finite number. A caller that is refused has to
|
|
12
|
+
* decide what to do about it, which is the point; a copier that silently degraded would leave the
|
|
13
|
+
* caller believing it held a snapshot of something it never read.
|
|
14
|
+
*
|
|
15
|
+
* Symbol-keyed properties are skipped rather than refused. Process bookkeeping rides on symbols by
|
|
16
|
+
* convention here, and none of it is the data a consumer is being given a copy of.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A copy, or a refusal. A union rather than a nullable value, because null is copyable data. */
|
|
20
|
+
export type PlainDataCopy<T> = { readonly ok: true; readonly value: T } | { readonly ok: false };
|
|
21
|
+
|
|
22
|
+
const REFUSED = Symbol("refused");
|
|
23
|
+
|
|
24
|
+
export function copyPlainData<T>(value: T): PlainDataCopy<T> {
|
|
25
|
+
const copied = copyValue(value, new Set());
|
|
26
|
+
return copied === REFUSED ? { ok: false } : { ok: true, value: copied as T };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A stable string for comparing two plain-data values.
|
|
31
|
+
*
|
|
32
|
+
* Key-sorted entry pairs rather than objects, because property order is observable through
|
|
33
|
+
* serialization and two values that differ only in it are the same value.
|
|
34
|
+
*/
|
|
35
|
+
export function canonicalPlainData(value: unknown): string {
|
|
36
|
+
return JSON.stringify(sorted(value));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Own enumerable string keys, with null in the position of any key that is an accessor. */
|
|
40
|
+
export function ownDataKeys(value: Record<string, unknown>): Array<string | null> {
|
|
41
|
+
return Object.keys(value).map(key => {
|
|
42
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
43
|
+
return descriptor !== undefined && descriptor.get === undefined && descriptor.set === undefined ? key : null;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
49
|
+
const prototype = Object.getPrototypeOf(value);
|
|
50
|
+
return prototype === Object.prototype || prototype === null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function copyValue(value: unknown, seen: Set<object>): unknown {
|
|
54
|
+
if (value === null) return null;
|
|
55
|
+
const type = typeof value;
|
|
56
|
+
if (type === "string" || type === "boolean") return value;
|
|
57
|
+
if (type === "number") return Number.isFinite(value as number) ? value : REFUSED;
|
|
58
|
+
if (type !== "object") return REFUSED;
|
|
59
|
+
const object = value as object;
|
|
60
|
+
if (seen.has(object)) return REFUSED;
|
|
61
|
+
seen.add(object);
|
|
62
|
+
try {
|
|
63
|
+
if (Array.isArray(object)) return copyArray(object, seen);
|
|
64
|
+
if (!isPlainObject(object)) return REFUSED;
|
|
65
|
+
const copied: Record<string, unknown> = {};
|
|
66
|
+
for (const key of ownDataKeys(object)) {
|
|
67
|
+
if (key === null) return REFUSED;
|
|
68
|
+
const entry = (object as Record<string, unknown>)[key];
|
|
69
|
+
if (entry === undefined) continue;
|
|
70
|
+
const item = copyValue(entry, seen);
|
|
71
|
+
if (item === REFUSED) return REFUSED;
|
|
72
|
+
copied[key] = item;
|
|
73
|
+
}
|
|
74
|
+
return copied;
|
|
75
|
+
} finally {
|
|
76
|
+
seen.delete(object);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function copyArray(source: unknown[], seen: Set<object>): unknown {
|
|
81
|
+
const copied: unknown[] = [];
|
|
82
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
83
|
+
const descriptor = Object.getOwnPropertyDescriptor(source, index);
|
|
84
|
+
// A hole is written as null, which is what a reader of the serialized value would find there.
|
|
85
|
+
if (descriptor === undefined) { copied.push(null); continue; }
|
|
86
|
+
if (descriptor.get !== undefined || descriptor.set !== undefined) return REFUSED;
|
|
87
|
+
if (descriptor.value === undefined) { copied.push(null); continue; }
|
|
88
|
+
const item = copyValue(descriptor.value, seen);
|
|
89
|
+
if (item === REFUSED) return REFUSED;
|
|
90
|
+
copied.push(item);
|
|
91
|
+
}
|
|
92
|
+
return copied;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sorted(value: unknown): unknown {
|
|
96
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
97
|
+
if (value !== null && typeof value === "object") {
|
|
98
|
+
return Object.keys(value as Record<string, unknown>)
|
|
99
|
+
.sort()
|
|
100
|
+
.map(key => [key, sorted((value as Record<string, unknown>)[key])]);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { isIP } from "node:net";
|
|
2
3
|
import { loadConfig } from "../config";
|
|
3
4
|
import { readRuntimePort } from "../config/process-state";
|
|
4
5
|
import { configuredAdminToken } from "./admin-secrets";
|
|
@@ -50,9 +51,11 @@ export interface GracefulStopIo {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
|
-
* Host to POST /api/stop against: follow the recorded bind hostname when it
|
|
54
|
-
*
|
|
55
|
-
*
|
|
54
|
+
* Host to POST /api/stop against: follow the recorded bind hostname only when it is
|
|
55
|
+
* an IP literal (a proxy bound to ::1 or a LAN IP is unreachable on 127.0.0.1).
|
|
56
|
+
* DNS names are not safe here because resolution may change between bind and stop,
|
|
57
|
+
* while this request carries the management token. Loopback aliases, DNS names, and
|
|
58
|
+
* wildcard binds therefore all fall back to IPv4 loopback.
|
|
56
59
|
*/
|
|
57
60
|
export function gracefulStopHost(hostname: string | undefined): string {
|
|
58
61
|
const trimmed = (hostname ?? "").trim();
|
|
@@ -61,8 +64,13 @@ export function gracefulStopHost(hostname: string | undefined): string {
|
|
|
61
64
|
return "127.0.0.1";
|
|
62
65
|
}
|
|
63
66
|
if (lower === "::1" || lower === "[::1]") return "[::1]";
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
const unbracketed = trimmed.startsWith("[") && trimmed.endsWith("]")
|
|
68
|
+
? trimmed.slice(1, -1)
|
|
69
|
+
: trimmed;
|
|
70
|
+
const ipVersion = isIP(unbracketed);
|
|
71
|
+
if (ipVersion === 6) return `[${unbracketed}]`;
|
|
72
|
+
if (ipVersion === 4) return unbracketed;
|
|
73
|
+
return "127.0.0.1";
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
/**
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
resolvePublicAddresses,
|
|
8
8
|
} from "./destination-policy";
|
|
9
9
|
import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http";
|
|
10
|
-
import { effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env";
|
|
10
|
+
import { configuredOutboundFetch, effectiveProxyFor, noProxyMatches, normalizeProxyHostname, outboundProxyConfigured } from "./proxy-env";
|
|
11
11
|
import { publicProviderBaseUrl } from "./provider-url";
|
|
12
12
|
|
|
13
13
|
type ProviderGetInit = Omit<RequestInit, "body" | "method" | "redirect">;
|
|
@@ -104,19 +104,67 @@ export async function providerRedirectError(response: Response, requestUrl: stri
|
|
|
104
104
|
return `provider returned ${response.status} redirect to ${target}; configure the final provider URL directly`;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Default client identity for proxy-originated provider outbound requests.
|
|
109
|
+
*
|
|
110
|
+
* Every request through the provider outbound wrapper — connection tests, model
|
|
111
|
+
* discovery, quota probes — is initiated by the proxy itself, so there is no
|
|
112
|
+
* client request to inherit a User-Agent from, and the pinned Node-style
|
|
113
|
+
* transport sends none. WAF/CDN front ends commonly answer UA-less requests
|
|
114
|
+
* with a 403 that surfaced as "provider added but no models" (#5104). A caller
|
|
115
|
+
* that materializes its own User-Agent — registry static headers, provider
|
|
116
|
+
* `headers`, or a vendor-specific client fingerprint — keeps that value and is
|
|
117
|
+
* never given a second User-Agent; this only fills the name nobody claimed.
|
|
118
|
+
* The value is what survives, not its spelling: both the pinned transport and
|
|
119
|
+
* the SOCKS transport rebuild the set through `new Headers()`, which lowercases
|
|
120
|
+
* every name before it reaches the wire. Inference traffic never uses this
|
|
121
|
+
* wrapper, so the client-fingerprint rationale of #1751 is unaffected.
|
|
122
|
+
*/
|
|
123
|
+
const PROVIDER_OUTBOUND_DEFAULT_USER_AGENT = "opencodex";
|
|
124
|
+
|
|
125
|
+
function hasUserAgentHeader(headers: HeadersInit | null | undefined): boolean {
|
|
126
|
+
if (!headers) return false;
|
|
127
|
+
if (headers instanceof Headers) return headers.has("user-agent");
|
|
128
|
+
if (Array.isArray(headers)) return headers.some(([name]) => name.toLowerCase() === "user-agent");
|
|
129
|
+
return Object.keys(headers).some(name => name.toLowerCase() === "user-agent");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function withDefaultOutboundUserAgent(
|
|
133
|
+
init: ProviderGetInit | ProviderPostInit,
|
|
134
|
+
): ProviderGetInit | ProviderPostInit {
|
|
135
|
+
const headers = init.headers;
|
|
136
|
+
if (hasUserAgentHeader(headers)) return init;
|
|
137
|
+
if (headers instanceof Headers) {
|
|
138
|
+
const merged = new Headers(headers);
|
|
139
|
+
merged.set("User-Agent", PROVIDER_OUTBOUND_DEFAULT_USER_AGENT);
|
|
140
|
+
return { ...init, headers: merged };
|
|
141
|
+
}
|
|
142
|
+
if (Array.isArray(headers)) {
|
|
143
|
+
return { ...init, headers: [...headers, ["User-Agent", PROVIDER_OUTBOUND_DEFAULT_USER_AGENT]] };
|
|
144
|
+
}
|
|
145
|
+
return { ...init, headers: { ...(headers ?? {}), "User-Agent": PROVIDER_OUTBOUND_DEFAULT_USER_AGENT } };
|
|
146
|
+
}
|
|
147
|
+
|
|
107
148
|
async function providerOutboundRequest(
|
|
108
149
|
name: string,
|
|
109
150
|
provider: ProviderOutboundConfig,
|
|
110
151
|
url: string,
|
|
111
152
|
method: "GET" | "POST",
|
|
112
|
-
|
|
153
|
+
rawInit: ProviderGetInit | ProviderPostInit,
|
|
113
154
|
dependencies: ProviderOutboundDependencies = {},
|
|
114
155
|
): Promise<Response> {
|
|
156
|
+
// See PROVIDER_OUTBOUND_DEFAULT_USER_AGENT: this wrapper only carries proxy-originated
|
|
157
|
+
// diagnostic traffic, so it identifies itself unless the caller already did.
|
|
158
|
+
const init = withDefaultOutboundUserAgent(rawInit);
|
|
115
159
|
const postUrl = method === "POST" ? new URL(url) : undefined;
|
|
116
160
|
if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") {
|
|
117
161
|
throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS");
|
|
118
162
|
}
|
|
119
|
-
|
|
163
|
+
// A provider entry keeps unknown configuration keys, so `fetch` can arrive as a value the
|
|
164
|
+
// operator wrote into the file rather than an executor a caller attached. Calling that would
|
|
165
|
+
// throw inside discovery and fail the provider for a reason nothing in its configuration
|
|
166
|
+
// explains; the built-in transport is what a configured value means.
|
|
167
|
+
if (typeof provider.fetch === "function") {
|
|
120
168
|
// A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config
|
|
121
169
|
// checks and redirect blocking, but does not provide the resolved-address guarantees of
|
|
122
170
|
// the built-in transport. Main-request migration must define that executor contract first.
|
|
@@ -184,7 +232,7 @@ async function providerOutboundRequest(
|
|
|
184
232
|
if (!proxyConfigured) throw error;
|
|
185
233
|
warnProxyBoundaryOnce();
|
|
186
234
|
warnProxyDnsDegradationOnce();
|
|
187
|
-
return
|
|
235
|
+
return configuredOutboundFetch(url, { ...init, method, redirect: "manual" });
|
|
188
236
|
}
|
|
189
237
|
// A canonical TUN exception with no scheme-matched proxy must retain the
|
|
190
238
|
// validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present.
|
|
@@ -193,7 +241,7 @@ async function providerOutboundRequest(
|
|
|
193
241
|
// When the Mihomo exception could have admitted an answer, pin the transport to the
|
|
194
242
|
// proxy the admission assumed instead of letting fetch re-infer it from the environment.
|
|
195
243
|
const proxy = (allowMihomoIpv6FakeIp && effectiveProxy) ? effectiveProxy : undefined;
|
|
196
|
-
return
|
|
244
|
+
return configuredOutboundFetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) });
|
|
197
245
|
}
|
|
198
246
|
if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) {
|
|
199
247
|
const hostname = normalizeProxyHostname(parsed.hostname);
|
package/src/lib/proxy-env.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { socks5Fetch } from "./socks5-fetch";
|
|
2
|
+
|
|
1
3
|
export const OUTBOUND_PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"] as const;
|
|
2
4
|
export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const;
|
|
3
5
|
|
|
@@ -85,11 +87,11 @@ export function outboundProxyConfigured(
|
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
/**
|
|
88
|
-
* The proxy URL
|
|
90
|
+
* The proxy URL selected by configured outbound fetch for `url`, or null when none applies.
|
|
89
91
|
*
|
|
90
92
|
* Bun selects by scheme: `HTTPS_PROXY` for `https:` targets, `HTTP_PROXY` for `http:`.
|
|
91
|
-
* `ALL_PROXY` is
|
|
92
|
-
*
|
|
93
|
+
* A SOCKS5 `ALL_PROXY` is selected by the explicit wrapper first; other ALL_PROXY
|
|
94
|
+
* schemes remain excluded because the native HTTP fetch does not honor them.
|
|
93
95
|
* Presence of *some* proxy variable (`outboundProxyConfigured`) is not that guarantee.
|
|
94
96
|
*/
|
|
95
97
|
export function effectiveProxyFor(
|
|
@@ -102,6 +104,71 @@ export function effectiveProxyFor(
|
|
|
102
104
|
? "HTTP_PROXY"
|
|
103
105
|
: null;
|
|
104
106
|
if (!key) return null;
|
|
107
|
+
// The installed SOCKS wrapper takes this route before Bun sees scheme proxies.
|
|
108
|
+
const socksProxy = socks5ProxyFromEnv(env);
|
|
109
|
+
if (socksProxy) return socksProxy;
|
|
105
110
|
const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
|
|
106
111
|
return value ? value : null;
|
|
107
112
|
}
|
|
113
|
+
|
|
114
|
+
export function isSocks5ProxyUrl(proxy: string): boolean {
|
|
115
|
+
return /^socks5h?:\/\//i.test(proxy.trim());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function socks5ProxyFromEnv(env: ProxyEnvMap = process.env): string | undefined {
|
|
119
|
+
const candidates = [env.ALL_PROXY, env.all_proxy];
|
|
120
|
+
return candidates.find(value => typeof value === "string" && isSocks5ProxyUrl(value));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function configuredOutboundFetch(
|
|
124
|
+
input: RequestInfo | URL,
|
|
125
|
+
init?: RequestInit,
|
|
126
|
+
fallback?: typeof globalThis.fetch,
|
|
127
|
+
): Promise<Response> {
|
|
128
|
+
const base = fallback ?? (globalThis.fetch === installedFetch ? nativeFetch : globalThis.fetch);
|
|
129
|
+
const explicitProxy = (init as (RequestInit & { proxy?: string }) | undefined)?.proxy;
|
|
130
|
+
const proxy = typeof explicitProxy === "string"
|
|
131
|
+
? (isSocks5ProxyUrl(explicitProxy) ? explicitProxy : undefined)
|
|
132
|
+
: socks5ProxyFromEnv();
|
|
133
|
+
let url: URL;
|
|
134
|
+
try {
|
|
135
|
+
url = new URL(input instanceof Request ? input.url : String(input));
|
|
136
|
+
} catch {
|
|
137
|
+
return base!(input, init);
|
|
138
|
+
}
|
|
139
|
+
if (proxy && (url.protocol === "http:" || url.protocol === "https:") && (explicitProxy !== undefined || !noProxyMatches(url))) {
|
|
140
|
+
return socks5Fetch(input, init, proxy);
|
|
141
|
+
}
|
|
142
|
+
return base!(input, init);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type FetchWithPreconnect = typeof globalThis.fetch & {
|
|
146
|
+
preconnect?: typeof globalThis.fetch.preconnect;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
let nativeFetch: FetchWithPreconnect | undefined;
|
|
150
|
+
let installedFetch: FetchWithPreconnect | undefined;
|
|
151
|
+
|
|
152
|
+
export function configureSocks5Fetch(): void {
|
|
153
|
+
if (globalThis.fetch !== installedFetch) {
|
|
154
|
+
nativeFetch = globalThis.fetch as FetchWithPreconnect;
|
|
155
|
+
installedFetch = undefined;
|
|
156
|
+
}
|
|
157
|
+
const proxy = socks5ProxyFromEnv();
|
|
158
|
+
if (!proxy) {
|
|
159
|
+
if (installedFetch && globalThis.fetch === installedFetch && nativeFetch) globalThis.fetch = nativeFetch;
|
|
160
|
+
installedFetch = undefined;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (installedFetch && globalThis.fetch === installedFetch) return;
|
|
164
|
+
const base = nativeFetch ?? globalThis.fetch as FetchWithPreconnect;
|
|
165
|
+
nativeFetch = base;
|
|
166
|
+
const wrapped = Object.assign(
|
|
167
|
+
(input: RequestInfo | URL, init?: RequestInit) => {
|
|
168
|
+
return configuredOutboundFetch(input, init, base);
|
|
169
|
+
},
|
|
170
|
+
{ preconnect: base.preconnect?.bind(base) },
|
|
171
|
+
) as FetchWithPreconnect;
|
|
172
|
+
installedFetch = wrapped;
|
|
173
|
+
globalThis.fetch = wrapped;
|
|
174
|
+
}
|