@bitkyc08/opencodex 2.60.0 → 2.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-CH6C5H7x.js +50 -0
- package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/index-wpTOyepx.js +86 -0
- package/gui/dist/assets/usage-companion-chart-a0N58rRI.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 +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- 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.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- 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 +10 -0
- 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/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 +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -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-listener.ts +2 -5
- 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/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- 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/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- 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/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- 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 +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- 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/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- 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/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -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/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/schema.ts +3 -0
- package/src/router.ts +17 -2
- 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 +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- 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 +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- 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/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- 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 +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- 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 +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- 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 +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -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 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -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/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
package/src/lib/debug.ts
CHANGED
|
@@ -52,3 +52,43 @@ export function debugProviderDiagnosticLazy(
|
|
|
52
52
|
/* diagnostics must never affect request handling */
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One line per finalized attempt, formatted from what the recorder already counted.
|
|
58
|
+
*
|
|
59
|
+
* #3983 wanted this visibility and emitted a line per stream event to get it. Two things made
|
|
60
|
+
* that the wrong shape. It is a second record: `emitDebugLine` writes the ring AND stderr, and
|
|
61
|
+
* a service manager redirects stderr to a file, so an installed service accumulates a per-event
|
|
62
|
+
* history beside the ledger with its own retention and sequencing. And per-event lines needed a
|
|
63
|
+
* per-payload fingerprint to correlate, which under a process-global key makes every repeated
|
|
64
|
+
* prompt fragment and tool name correlatable for the life of the process.
|
|
65
|
+
*
|
|
66
|
+
* So this writes the ring ONLY -- `appendDebugLogLine` directly, never `emitDebugLine` -- and
|
|
67
|
+
* says nothing the ledger does not already hold. The ring becomes a live view of the durable
|
|
68
|
+
* record rather than a parallel source for it.
|
|
69
|
+
*/
|
|
70
|
+
export function debugAttemptDeliverySummary(
|
|
71
|
+
requestId: string,
|
|
72
|
+
attempt: {
|
|
73
|
+
ordinal: number;
|
|
74
|
+
adapter: string;
|
|
75
|
+
deliverySummary?: {
|
|
76
|
+
adapterEvents: number;
|
|
77
|
+
relayedEvents: number;
|
|
78
|
+
semanticBytes: number;
|
|
79
|
+
sideEffectEvents: number;
|
|
80
|
+
terminalEvents: number;
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
): void {
|
|
84
|
+
if (!isDebugEnabled() || !attempt.deliverySummary) return;
|
|
85
|
+
try {
|
|
86
|
+
appendDebugLogLine(`[ocx:${attempt.adapter}:delivery] ${JSON.stringify({
|
|
87
|
+
requestId,
|
|
88
|
+
ordinal: attempt.ordinal,
|
|
89
|
+
...attempt.deliverySummary,
|
|
90
|
+
})}`);
|
|
91
|
+
} catch {
|
|
92
|
+
/* diagnostics must never affect request handling */
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/lib/open-url.ts
CHANGED
|
@@ -8,8 +8,33 @@ function windowsRundll32(): string {
|
|
|
8
8
|
return existsSync(candidate) ? candidate : "rundll32";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Whether the OS launcher actually started. `started` does not prove a browser rendered the
|
|
13
|
+
* page — nothing observable from here can — but it does separate "we handed the URL off" from
|
|
14
|
+
* "there was nothing to hand it to", which is the distinction a caller needs (#5261).
|
|
15
|
+
*/
|
|
16
|
+
export type OpenUrlResult =
|
|
17
|
+
| { status: "started" }
|
|
18
|
+
| { status: "failed"; reason: "invalid-url" | "spawn-error" | "launcher-exit" };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How long to watch a launcher that did spawn before calling it started.
|
|
22
|
+
*
|
|
23
|
+
* `spawn` only proves a process began. `xdg-open` with no desktop handler, and rundll32 given a
|
|
24
|
+
* broken association, both spawn happily and exit nonzero a moment later without opening
|
|
25
|
+
* anything — so resolving on `spawn` alone would report a launch that did not happen. Those
|
|
26
|
+
* failures are immediate, and the only caller that awaits this is a login start, so a short
|
|
27
|
+
* window buys a true answer cheaply. A launcher still running when it elapses has started.
|
|
28
|
+
*/
|
|
29
|
+
const LAUNCHER_SETTLE_MS = 400;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Never rejects. A browser that would not open is an inconvenience, not a login failure: the
|
|
33
|
+
* URL is still a valid thing to open by hand, so the caller decides what to say about it.
|
|
34
|
+
* Callers that genuinely do not care use `void openUrl(...)`.
|
|
35
|
+
*/
|
|
36
|
+
export function openUrl(url: string): Promise<OpenUrlResult> {
|
|
37
|
+
if (!/^https?:\/\//i.test(url)) return Promise.resolve({ status: "failed", reason: "invalid-url" });
|
|
13
38
|
const cmd =
|
|
14
39
|
process.platform === "darwin" ? "open"
|
|
15
40
|
: process.platform === "win32" ? windowsRundll32()
|
|
@@ -17,9 +42,28 @@ export function openUrl(url: string): void {
|
|
|
17
42
|
const args = process.platform === "win32"
|
|
18
43
|
? ["url.dll,FileProtocolHandler", url]
|
|
19
44
|
: [url];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
45
|
+
return new Promise<OpenUrlResult>(resolve => {
|
|
46
|
+
let settled = false;
|
|
47
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
+
const settle = (result: OpenUrlResult) => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
settled = true;
|
|
51
|
+
if (timer) clearTimeout(timer);
|
|
52
|
+
resolve(result);
|
|
53
|
+
};
|
|
54
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore", shell: false });
|
|
55
|
+
// Headless hosts (no xdg-open) emit ENOENT as an async 'error' event; without a
|
|
56
|
+
// listener that is an uncaught exception that kills the whole proxy/login flow.
|
|
57
|
+
// It is also the signal itself: on Windows this is how a missing rundll32 arrives.
|
|
58
|
+
child.on("error", () => settle({ status: "failed", reason: "spawn-error" }));
|
|
59
|
+
child.on("spawn", () => {
|
|
60
|
+
// Unref'd either way: this never keeps the process alive, it only decides what to report.
|
|
61
|
+
timer = setTimeout(() => settle({ status: "started" }), LAUNCHER_SETTLE_MS);
|
|
62
|
+
timer.unref?.();
|
|
63
|
+
});
|
|
64
|
+
child.on("exit", code => settle(code === 0 || code === null
|
|
65
|
+
? { status: "started" }
|
|
66
|
+
: { status: "failed", reason: "launcher-exit" }));
|
|
67
|
+
child.unref();
|
|
68
|
+
});
|
|
25
69
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
|
+
import { isStandaloneBinary } from "./standalone";
|
|
2
3
|
|
|
3
4
|
export interface PackageTreeObservation {
|
|
4
5
|
readonly device: bigint;
|
|
@@ -96,6 +97,6 @@ export function createRuntimePackageTreeIntegrityGuard(
|
|
|
96
97
|
observe: ObservePackageTree = observePackageManifest,
|
|
97
98
|
now: () => number = Date.now,
|
|
98
99
|
): PackageTreeIntegrityGuard {
|
|
99
|
-
if (installer === "source") return { status: () => ({ ok: true }) };
|
|
100
|
+
if (installer === "source" || isStandaloneBinary()) return { status: () => ({ ok: true }) };
|
|
100
101
|
return createPackageTreeIntegrityGuard(observe, now);
|
|
101
102
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
2
|
+
|
|
3
|
+
type PackageManifest = { version?: unknown };
|
|
4
|
+
|
|
5
|
+
export function packageVersion(fallback = "unknown"): string {
|
|
6
|
+
const version = (pkg as PackageManifest).version;
|
|
7
|
+
return typeof version === "string" ? version : fallback;
|
|
8
|
+
}
|
|
@@ -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 {
|
|
@@ -165,6 +166,17 @@ async function providerOutboundRequest(
|
|
|
165
166
|
// throw inside discovery and fail the provider for a reason nothing in its configuration
|
|
166
167
|
// explains; the built-in transport is what a configured value means.
|
|
167
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
|
+
}
|
|
168
180
|
// A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config
|
|
169
181
|
// checks and redirect blocking, but does not provide the resolved-address guarantees of
|
|
170
182
|
// the built-in transport. Main-request migration must define that executor contract first.
|
|
@@ -186,13 +198,39 @@ async function providerOutboundRequest(
|
|
|
186
198
|
return provider.fetch(url, { ...init, method, redirect: "manual" });
|
|
187
199
|
}
|
|
188
200
|
const parsed = postUrl ?? new URL(url);
|
|
189
|
-
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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;
|
|
194
224
|
const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false);
|
|
195
|
-
|
|
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)))
|
|
196
234
|
|| transparentFakeIpException(url, parsed, isCanonicalUrl, name);
|
|
197
235
|
const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses;
|
|
198
236
|
const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet;
|
|
@@ -216,7 +254,7 @@ async function providerOutboundRequest(
|
|
|
216
254
|
// proof is on the final request URL — not the provider name — because an
|
|
217
255
|
// OAuth/forward name matches any baseUrl by design while the bearer is
|
|
218
256
|
// pinned to the registry destination independently.
|
|
219
|
-
allowBenchmarkAddresses:
|
|
257
|
+
allowBenchmarkAddresses: proxyApplies
|
|
220
258
|
|| transparentFakeIpException(url, parsed, isCanonicalUrl, name),
|
|
221
259
|
// Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted either when bound
|
|
222
260
|
// to a scheme-matched proxy (#3462) or under the TUN transparency exception for a
|
|
@@ -229,21 +267,28 @@ async function providerOutboundRequest(
|
|
|
229
267
|
if (!dnsResolutionFailed) {
|
|
230
268
|
throw new ProviderOutboundPolicyError(error instanceof Error ? error.message : "provider destination was blocked");
|
|
231
269
|
}
|
|
232
|
-
if (!
|
|
270
|
+
if (!proxyApplies) throw error;
|
|
233
271
|
warnProxyBoundaryOnce();
|
|
234
272
|
warnProxyDnsDegradationOnce();
|
|
235
|
-
|
|
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
|
+
});
|
|
236
279
|
}
|
|
237
280
|
// A canonical TUN exception with no scheme-matched proxy must retain the
|
|
238
281
|
// validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present.
|
|
239
|
-
if (
|
|
282
|
+
if (proxyApplies && !resolved.privateNetwork) {
|
|
240
283
|
warnProxyBoundaryOnce();
|
|
241
284
|
// When the Mihomo exception could have admitted an answer, pin the transport to the
|
|
242
285
|
// proxy the admission assumed instead of letting fetch re-infer it from the environment.
|
|
243
|
-
|
|
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);
|
|
244
289
|
return configuredOutboundFetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) });
|
|
245
290
|
}
|
|
246
|
-
if (
|
|
291
|
+
if (proxyApplies && resolved.privateNetwork) {
|
|
247
292
|
const hostname = normalizeProxyHostname(parsed.hostname);
|
|
248
293
|
throw new Error(
|
|
249
294
|
`provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`,
|