fix(gateway): honor all_proxy in env dispatcher

This commit is contained in:
Peter Steinberger
2026-04-27 14:32:09 +01:00
parent fd6c9fc7f5
commit dc859584a3
11 changed files with 188 additions and 31 deletions

View File

@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
- Plugins/install: resolve plugin install destinations from the active profile state dir across CLI, ClawHub, marketplace, local path, and channel setup installs, so `openclaw --profile <name> plugins install ...` no longer writes into the default profile. Fixes #69960; carries forward #69971. Thanks @FrancisLyman and @Sanjays2402.
- Plugins/registry: suppress duplicate-plugin startup warnings when a tracked npm-installed plugin intentionally overrides the bundled plugin with the same id. Carries forward #48673. Thanks @abdushsk.
- Plugins/startup: reuse canonical realpath lookups throughout each plugin discovery pass, including package and manifest boundary checks, so Windows npm-global startups no longer repeat expensive path resolution for the same plugin roots. Fixes #65733. Thanks @welfo-beo.
- Gateway/proxy: pass `ALL_PROXY` / `all_proxy` into the global Undici env-proxy dispatcher and provider proxy-fetch helper while keeping SSRF trusted-proxy auto-upgrade on `HTTP_PROXY` / `HTTPS_PROXY` only, so gateway/provider calls honor all-proxy setups without weakening guarded fetches. Fixes #43919. Thanks @RickyTong1.
- Reply/link understanding: keep media and link preprocessing on stable runtime entrypoints and continue with raw message content if optional enrichment fails, so URL-bearing messages are no longer dropped after stale runtime chunk upgrades. Fixes #68466. Thanks @songshikang0111.
- Discord: persist routed model-picker overrides when the hidden `/model` dispatch succeeds but the bound thread session store is still stale, including LM Studio suffixed model ids. Carries forward #61473. Thanks @Nanako0129.
- Nodes/CLI: add `openclaw nodes remove --node <id|name|ip>` and `node.pair.remove` so stale gateway-owned node pairing records can be cleaned without hand-editing state files.

View File

@@ -171,8 +171,10 @@ Provider-based audio transcription honors standard outbound proxy env vars:
- `HTTPS_PROXY`
- `HTTP_PROXY`
- `ALL_PROXY`
- `https_proxy`
- `http_proxy`
- `all_proxy`
If no proxy env vars are set, direct egress is used. If proxy config is malformed, OpenClaw logs a warning and falls back to direct fetch.

View File

@@ -220,8 +220,10 @@ When provider-based **audio** and **video** media understanding is enabled, Open
- `HTTPS_PROXY`
- `HTTP_PROXY`
- `ALL_PROXY`
- `https_proxy`
- `http_proxy`
- `all_proxy`
If no proxy env vars are set, media understanding uses direct egress. If the proxy value is malformed, OpenClaw logs a warning and falls back to direct fetch.

View File

@@ -20,7 +20,7 @@ const getProgramContextMock = vi.hoisted(() => vi.fn(() => null));
const registerCoreCliByNameMock = vi.hoisted(() => vi.fn());
const registerSubCliByNameMock = vi.hoisted(() => vi.fn());
const restoreTerminalStateMock = vi.hoisted(() => vi.fn());
const hasEnvHttpProxyConfiguredMock = vi.hoisted(() => vi.fn(() => false));
const hasEnvHttpProxyAgentConfiguredMock = vi.hoisted(() => vi.fn(() => false));
const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn());
const runCrestodianMock = vi.hoisted(() => vi.fn(async () => {}));
const progressDoneMock = vi.hoisted(() => vi.fn());
@@ -106,7 +106,7 @@ vi.mock("../terminal/restore.js", () => ({
}));
vi.mock("../infra/net/proxy-env.js", () => ({
hasEnvHttpProxyConfigured: hasEnvHttpProxyConfiguredMock,
hasEnvHttpProxyAgentConfigured: hasEnvHttpProxyAgentConfiguredMock,
}));
vi.mock("../infra/net/undici-global-dispatcher.js", () => ({
@@ -127,7 +127,7 @@ describe("runCli exit behavior", () => {
hasMemoryRuntimeMock.mockReturnValue(false);
outputPrecomputedBrowserHelpTextMock.mockReturnValue(false);
outputPrecomputedRootHelpTextMock.mockReturnValue(false);
hasEnvHttpProxyConfiguredMock.mockReturnValue(false);
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(false);
getProgramContextMock.mockReturnValue(null);
delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH;
});
@@ -178,7 +178,7 @@ describe("runCli exit behavior", () => {
await runCli(["node", "openclaw", "--help"]);
expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1);
expect(hasEnvHttpProxyConfiguredMock).not.toHaveBeenCalled();
expect(hasEnvHttpProxyAgentConfiguredMock).not.toHaveBeenCalled();
expect(ensureGlobalUndiciEnvProxyDispatcherMock).not.toHaveBeenCalled();
expect(runCrestodianMock).not.toHaveBeenCalled();
});
@@ -201,7 +201,7 @@ describe("runCli exit behavior", () => {
});
it("bootstraps env proxy before bare Crestodian startup", async () => {
hasEnvHttpProxyConfiguredMock.mockReturnValue(true);
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true);
const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
@@ -230,7 +230,7 @@ describe("runCli exit behavior", () => {
});
it("bootstraps env proxy before modern onboard Crestodian startup", async () => {
hasEnvHttpProxyConfiguredMock.mockReturnValue(true);
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true);
await runCli(["node", "openclaw", "onboard", "--modern", "--json"]);

View File

@@ -84,8 +84,8 @@ function isCommanderParseExit(error: unknown): error is { exitCode: number } {
async function ensureCliEnvProxyDispatcher(): Promise<void> {
try {
const { hasEnvHttpProxyConfigured } = await import("../infra/net/proxy-env.js");
if (!hasEnvHttpProxyConfigured("https")) {
const { hasEnvHttpProxyAgentConfigured } = await import("../infra/net/proxy-env.js");
if (!hasEnvHttpProxyAgentConfigured()) {
return;
}
const { ensureGlobalUndiciEnvProxyDispatcher } =

View File

@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import {
hasEnvHttpProxyAgentConfigured,
hasEnvHttpProxyConfigured,
hasProxyEnvConfigured,
matchesNoProxy,
resolveEnvHttpProxyAgentOptions,
resolveEnvHttpProxyUrl,
shouldUseEnvHttpProxyForUrl,
} from "./proxy-env.js";
@@ -96,6 +98,55 @@ describe("resolveEnvHttpProxyUrl", () => {
});
});
describe("resolveEnvHttpProxyAgentOptions", () => {
it.each([
{
name: "maps HTTPS_PROXY to httpsProxy only",
env: { HTTPS_PROXY: "http://https-proxy.test:8443" } as NodeJS.ProcessEnv,
expected: { httpsProxy: "http://https-proxy.test:8443" },
},
{
name: "uses HTTP_PROXY as HTTPS fallback",
env: { HTTP_PROXY: "http://http-proxy.test:8080" } as NodeJS.ProcessEnv,
expected: {
httpProxy: "http://http-proxy.test:8080",
httpsProxy: "http://http-proxy.test:8080",
},
},
{
name: "uses ALL_PROXY for both protocols",
env: { ALL_PROXY: "socks5://all-proxy.test:1080" } as NodeJS.ProcessEnv,
expected: {
httpProxy: "socks5://all-proxy.test:1080",
httpsProxy: "socks5://all-proxy.test:1080",
},
},
{
name: "lets protocol-specific proxy override ALL_PROXY",
env: {
ALL_PROXY: "socks5://all-proxy.test:1080",
HTTP_PROXY: "http://http-proxy.test:8080",
HTTPS_PROXY: "http://https-proxy.test:8443",
} as NodeJS.ProcessEnv,
expected: {
httpProxy: "http://http-proxy.test:8080",
httpsProxy: "http://https-proxy.test:8443",
},
},
{
name: "treats empty lower-case all_proxy as authoritative over upper-case ALL_PROXY",
env: {
all_proxy: "",
ALL_PROXY: "socks5://upper-all-proxy.test:1080",
} as NodeJS.ProcessEnv,
expected: undefined,
},
])("$name", ({ env, expected }) => {
expect(resolveEnvHttpProxyAgentOptions(env)).toEqual(expected);
expect(hasEnvHttpProxyAgentConfigured(env)).toBe(expected !== undefined);
});
});
describe("matchesNoProxy", () => {
it.each([
{

View File

@@ -25,6 +25,11 @@ function normalizeProxyEnvValue(value: string | undefined): string | null | unde
return trimmed.length > 0 ? trimmed : null;
}
export type EnvHttpProxyAgentProxyOptions = {
httpProxy?: string;
httpsProxy?: string;
};
/**
* Match undici EnvHttpProxyAgent semantics for env-based HTTP/S proxy selection:
* - lower-case vars take precedence over upper-case
@@ -54,6 +59,37 @@ export function hasEnvHttpProxyConfigured(
return resolveEnvHttpProxyUrl(protocol, env) !== undefined;
}
function resolveEnvAllProxyUrl(env: NodeJS.ProcessEnv): string | undefined {
const lowerAllProxy = normalizeProxyEnvValue(env.all_proxy);
const allProxy =
lowerAllProxy !== undefined ? lowerAllProxy : normalizeProxyEnvValue(env.ALL_PROXY);
return allProxy ?? undefined;
}
/**
* Build explicit options for undici's EnvHttpProxyAgent.
*
* EnvHttpProxyAgent does not read ALL_PROXY itself, but it accepts explicit
* HTTP/HTTPS proxy overrides. Keep this helper separate from the
* HTTP(S)-only URL helpers so SSRF trusted-env proxy gates do not widen.
*/
export function resolveEnvHttpProxyAgentOptions(
env: NodeJS.ProcessEnv = process.env,
): EnvHttpProxyAgentProxyOptions | undefined {
const allProxy = resolveEnvAllProxyUrl(env);
const httpProxy = resolveEnvHttpProxyUrl("http", env) ?? allProxy;
const httpsProxy = resolveEnvHttpProxyUrl("https", env) ?? httpProxy;
const options: EnvHttpProxyAgentProxyOptions = {
...(httpProxy ? { httpProxy } : {}),
...(httpsProxy ? { httpsProxy } : {}),
};
return options.httpProxy || options.httpsProxy ? options : undefined;
}
export function hasEnvHttpProxyAgentConfigured(env: NodeJS.ProcessEnv = process.env): boolean {
return resolveEnvHttpProxyAgentOptions(env) !== undefined;
}
export function shouldUseEnvHttpProxyForUrl(
targetUrl: string,
env: NodeJS.ProcessEnv = process.env,

View File

@@ -29,9 +29,9 @@ const { ProxyAgent, EnvHttpProxyAgent, undiciFetch, proxyAgentSpy, envAgentSpy,
}
class EnvHttpProxyAgent {
static lastCreated: EnvHttpProxyAgent | undefined;
constructor() {
constructor(public readonly options?: Record<string, unknown>) {
EnvHttpProxyAgent.lastCreated = this;
envAgentSpy();
envAgentSpy(options);
}
}
@@ -159,7 +159,7 @@ describe("resolveProxyFetchFromEnv", () => {
HTTPS_PROXY: "http://proxy.test:8080",
});
expect(fetchFn).toBeDefined();
expect(envAgentSpy).toHaveBeenCalled();
expect(envAgentSpy).toHaveBeenCalledWith({ httpsProxy: "http://proxy.test:8080" });
await fetchFn!("https://api.example.com");
expect(undiciFetch).toHaveBeenCalledWith(
@@ -174,7 +174,10 @@ describe("resolveProxyFetchFromEnv", () => {
HTTP_PROXY: "http://fallback.test:3128",
});
expect(fetchFn).toBeDefined();
expect(envAgentSpy).toHaveBeenCalled();
expect(envAgentSpy).toHaveBeenCalledWith({
httpProxy: "http://fallback.test:3128",
httpsProxy: "http://fallback.test:3128",
});
});
it("returns proxy fetch when lowercase https_proxy is set", () => {
@@ -185,7 +188,7 @@ describe("resolveProxyFetchFromEnv", () => {
https_proxy: "http://lower.test:1080",
});
expect(fetchFn).toBeDefined();
expect(envAgentSpy).toHaveBeenCalled();
expect(envAgentSpy).toHaveBeenCalledWith({ httpsProxy: "http://lower.test:1080" });
});
it("returns proxy fetch when lowercase http_proxy is set", () => {
@@ -196,7 +199,25 @@ describe("resolveProxyFetchFromEnv", () => {
http_proxy: "http://lower-http.test:1080",
});
expect(fetchFn).toBeDefined();
expect(envAgentSpy).toHaveBeenCalled();
expect(envAgentSpy).toHaveBeenCalledWith({
httpProxy: "http://lower-http.test:1080",
httpsProxy: "http://lower-http.test:1080",
});
});
it("returns proxy fetch when ALL_PROXY is set", () => {
const fetchFn = resolveProxyFetchFromEnv({
HTTPS_PROXY: "",
HTTP_PROXY: "",
https_proxy: "",
http_proxy: "",
ALL_PROXY: "socks5://all-proxy.test:1080",
});
expect(fetchFn).toBeDefined();
expect(envAgentSpy).toHaveBeenCalledWith({
httpProxy: "socks5://all-proxy.test:1080",
httpsProxy: "socks5://all-proxy.test:1080",
});
});
it("returns undefined when EnvHttpProxyAgent constructor throws", () => {

View File

@@ -1,7 +1,7 @@
import { EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici";
import { logWarn } from "../../logger.js";
import { formatErrorMessage } from "../errors.js";
import { hasEnvHttpProxyConfigured } from "./proxy-env.js";
import { resolveEnvHttpProxyAgentOptions } from "./proxy-env.js";
export const PROXY_FETCH_PROXY_URL = Symbol.for("openclaw.proxyFetch.proxyUrl");
type ProxyFetchWithMetadata = typeof fetch & {
@@ -46,8 +46,7 @@ export function getProxyUrlFromFetch(fetchImpl?: typeof fetch): string | undefin
}
/**
* Resolve a proxy-aware fetch from standard environment variables
* (HTTPS_PROXY, HTTP_PROXY, https_proxy, http_proxy).
* Resolve a proxy-aware fetch from standard environment variables.
* Respects NO_PROXY / no_proxy exclusions via undici's EnvHttpProxyAgent.
* Returns undefined when no proxy is configured.
* Gracefully returns undefined if the proxy URL is malformed.
@@ -55,11 +54,12 @@ export function getProxyUrlFromFetch(fetchImpl?: typeof fetch): string | undefin
export function resolveProxyFetchFromEnv(
env: NodeJS.ProcessEnv = process.env,
): typeof fetch | undefined {
if (!hasEnvHttpProxyConfigured("https", env)) {
const proxyOptions = resolveEnvHttpProxyAgentOptions(env);
if (!proxyOptions) {
return undefined;
}
try {
const agent = new EnvHttpProxyAgent();
const agent = new EnvHttpProxyAgent(proxyOptions);
return ((input: RequestInfo | URL, init?: RequestInit) =>
undiciFetch(input as string | URL, {
...(init as Record<string, unknown>),

View File

@@ -60,7 +60,8 @@ vi.mock("node:net", () => ({
}));
vi.mock("./proxy-env.js", () => ({
hasEnvHttpProxyConfigured: vi.fn(() => false),
hasEnvHttpProxyAgentConfigured: vi.fn(() => false),
resolveEnvHttpProxyAgentOptions: vi.fn(() => undefined),
}));
vi.mock("../wsl.js", () => ({
@@ -68,7 +69,7 @@ vi.mock("../wsl.js", () => ({
}));
import { isWSL2Sync } from "../wsl.js";
import { hasEnvHttpProxyConfigured } from "./proxy-env.js";
import { hasEnvHttpProxyAgentConfigured, resolveEnvHttpProxyAgentOptions } from "./proxy-env.js";
let DEFAULT_UNDICI_STREAM_TIMEOUT_MS: typeof import("./undici-global-dispatcher.js").DEFAULT_UNDICI_STREAM_TIMEOUT_MS;
let ensureGlobalUndiciEnvProxyDispatcher: typeof import("./undici-global-dispatcher.js").ensureGlobalUndiciEnvProxyDispatcher;
let ensureGlobalUndiciStreamTimeouts: typeof import("./undici-global-dispatcher.js").ensureGlobalUndiciStreamTimeouts;
@@ -91,7 +92,8 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
resetGlobalUndiciStreamTimeoutsForTests();
setCurrentDispatcher(new Agent());
getDefaultAutoSelectFamily.mockReturnValue(undefined);
vi.mocked(hasEnvHttpProxyConfigured).mockReturnValue(false);
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(false);
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue(undefined);
});
it("replaces default Agent dispatcher with extended stream timeouts", () => {
@@ -127,6 +129,28 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
});
});
it("preserves explicit env proxy options when replacing EnvHttpProxyAgent dispatcher", () => {
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue({
httpProxy: "socks5://proxy.test:1080",
httpsProxy: "socks5://proxy.test:1080",
});
setCurrentDispatcher(new EnvHttpProxyAgent());
ensureGlobalUndiciStreamTimeouts();
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
const next = getCurrentDispatcher() as { options?: Record<string, unknown> };
expect(next).toBeInstanceOf(EnvHttpProxyAgent);
expect(next.options).toEqual(
expect.objectContaining({
httpProxy: "socks5://proxy.test:1080",
httpsProxy: "socks5://proxy.test:1080",
bodyTimeout: DEFAULT_UNDICI_STREAM_TIMEOUT_MS,
headersTimeout: DEFAULT_UNDICI_STREAM_TIMEOUT_MS,
}),
);
});
it("records timeout bridge but does not override unsupported custom proxy dispatcher types", () => {
setCurrentDispatcher(new ProxyAgent("http://proxy.test:8080"));
@@ -201,11 +225,12 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
vi.clearAllMocks();
resetGlobalUndiciStreamTimeoutsForTests();
setCurrentDispatcher(new Agent());
vi.mocked(hasEnvHttpProxyConfigured).mockReturnValue(false);
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(false);
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue(undefined);
});
it("installs EnvHttpProxyAgent when env HTTP proxy is configured on a default Agent", () => {
vi.mocked(hasEnvHttpProxyConfigured).mockReturnValue(true);
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
ensureGlobalUndiciEnvProxyDispatcher();
@@ -213,8 +238,26 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
expect(getCurrentDispatcher()).toBeInstanceOf(EnvHttpProxyAgent);
});
it("installs EnvHttpProxyAgent with explicit ALL_PROXY fallback options", () => {
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
vi.mocked(resolveEnvHttpProxyAgentOptions).mockReturnValue({
httpProxy: "socks5://proxy.test:1080",
httpsProxy: "socks5://proxy.test:1080",
});
ensureGlobalUndiciEnvProxyDispatcher();
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
const next = getCurrentDispatcher() as { options?: Record<string, unknown> };
expect(next).toBeInstanceOf(EnvHttpProxyAgent);
expect(next.options).toEqual({
httpProxy: "socks5://proxy.test:1080",
httpsProxy: "socks5://proxy.test:1080",
});
});
it("does not override unsupported custom proxy dispatcher types", () => {
vi.mocked(hasEnvHttpProxyConfigured).mockReturnValue(true);
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
setCurrentDispatcher(new ProxyAgent("http://proxy.test:8080"));
ensureGlobalUndiciEnvProxyDispatcher();
@@ -223,7 +266,7 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
});
it("retries proxy bootstrap after an unsupported dispatcher later becomes a default Agent", () => {
vi.mocked(hasEnvHttpProxyConfigured).mockReturnValue(true);
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
setCurrentDispatcher(new ProxyAgent("http://proxy.test:8080"));
ensureGlobalUndiciEnvProxyDispatcher();
@@ -237,7 +280,7 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
});
it("is idempotent after proxy bootstrap succeeds", () => {
vi.mocked(hasEnvHttpProxyConfigured).mockReturnValue(true);
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
ensureGlobalUndiciEnvProxyDispatcher();
ensureGlobalUndiciEnvProxyDispatcher();
@@ -246,7 +289,7 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
});
it("reinstalls env proxy if an external change later reverts the dispatcher to Agent", () => {
vi.mocked(hasEnvHttpProxyConfigured).mockReturnValue(true);
vi.mocked(hasEnvHttpProxyAgentConfigured).mockReturnValue(true);
ensureGlobalUndiciEnvProxyDispatcher();
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);

View File

@@ -1,7 +1,7 @@
import * as net from "node:net";
import { Agent, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from "undici";
import { isWSL2Sync } from "../wsl.js";
import { hasEnvHttpProxyConfigured } from "./proxy-env.js";
import { hasEnvHttpProxyAgentConfigured, resolveEnvHttpProxyAgentOptions } from "./proxy-env.js";
export const DEFAULT_UNDICI_STREAM_TIMEOUT_MS = 30 * 60 * 1000;
@@ -89,7 +89,7 @@ function resolveCurrentDispatcherKind(): DispatcherKind | null {
}
export function ensureGlobalUndiciEnvProxyDispatcher(): void {
const shouldUseEnvProxy = hasEnvHttpProxyConfigured("https");
const shouldUseEnvProxy = hasEnvHttpProxyAgentConfigured();
if (!shouldUseEnvProxy) {
return;
}
@@ -108,7 +108,7 @@ export function ensureGlobalUndiciEnvProxyDispatcher(): void {
return;
}
try {
setGlobalDispatcher(new EnvHttpProxyAgent());
setGlobalDispatcher(new EnvHttpProxyAgent(resolveEnvHttpProxyAgentOptions()));
lastAppliedProxyBootstrap = true;
} catch {
// Best-effort bootstrap only.
@@ -137,6 +137,7 @@ export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }):
try {
if (kind === "env-proxy") {
const proxyOptions = {
...resolveEnvHttpProxyAgentOptions(),
bodyTimeout: timeoutMs,
headersTimeout: timeoutMs,
...(connect ? { connect } : {}),