mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
fix: narrow Gateway proxy bypass target (#77018)
* fix: narrow Gateway proxy bypass target * fix: narrow Gateway proxy bypass target * fix(clawsweeper): address review for automerge-openclaw-openclaw-77018 (1) * fix(clawsweeper): address review for automerge-openclaw-openclaw-77018 (2) * fix(clawsweeper): address review for automerge-openclaw-openclaw-77018 (validation-3) * fix(clawsweeper): address review for automerge-openclaw-openclaw-77018 (4-final) * fix: narrow Gateway proxy bypass target * fix(clawsweeper): address review for automerge-openclaw-openclaw-77018 (1) * fix(clawsweeper): address review for automerge-openclaw-openclaw-77018 (2) * fix(clawsweeper): reconcile automerge-openclaw-openclaw-77018 with main (1) --------- Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
@@ -99,6 +99,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Contributor PRs: require external pull requests to include after-fix real behavior proof from a real OpenClaw setup, with terminal screenshots, console output, redacted runtime logs, linked artifacts, and copied live output treated as valid evidence while unit tests, mocks, lint, typechecks, snapshots, and CI remain supplemental only.
|
||||
- Plugins/catalog: add an `@tencent-weixin/openclaw-weixin` external entry pinned to `2.4.1` so onboarding and `openclaw channels add` can install the Tencent Weixin (personal WeChat) channel by default. (#77269) Thanks @pumpkinxing1.
|
||||
- Developer tooling: add checked-in VS Code Gateway debugging configs and an opt-in `OUTPUT_SOURCE_MAPS=1` source-map build path for breakpoints in TypeScript source. (#45710) Thanks @SwissArmyBud.
|
||||
- Managed proxy: add `proxy.loopbackMode` for Gateway loopback control-plane traffic, allowing operators to keep the default Gateway loopback bypass, force loopback Gateway traffic through the proxy, or block it. (#77018) Thanks @jesse-merhi.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -75,6 +75,21 @@ OPENCLAW_PROXY_URL=http://127.0.0.1:3128 openclaw gateway run
|
||||
|
||||
`proxy.proxyUrl` takes precedence over `OPENCLAW_PROXY_URL`.
|
||||
|
||||
### Gateway Loopback Mode
|
||||
|
||||
Local Gateway control-plane clients usually connect to a loopback WebSocket such as `ws://127.0.0.1:18789`. Use `proxy.loopbackMode` to choose how that traffic behaves while the managed proxy is active:
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
enabled: true
|
||||
proxyUrl: http://127.0.0.1:3128
|
||||
loopbackMode: gateway-only # gateway-only, proxy, or block
|
||||
```
|
||||
|
||||
- `gateway-only` (default): OpenClaw registers the Gateway loopback authority in the active `global-agent` `NO_PROXY` controller so local Gateway WebSocket traffic can connect directly. Custom loopback Gateway ports work because the active Gateway URL's host and port are registered.
|
||||
- `proxy`: OpenClaw does not register a Gateway loopback `NO_PROXY` authority, so local Gateway traffic is sent through the managed proxy. If the proxy is remote, it must provide special routing for the OpenClaw host's loopback service, such as mapping it to a proxy-reachable hostname, IP, or tunnel. Standard remote proxies resolve `127.0.0.1` and `localhost` from the proxy host, not from the OpenClaw host.
|
||||
- `block`: OpenClaw denies loopback Gateway control-plane connections before opening a socket.
|
||||
|
||||
If `enabled=true` but no valid proxy URL is configured, protected commands fail startup instead of falling back to direct network access.
|
||||
|
||||
For managed gateway services started with `openclaw gateway start`, prefer storing the URL in config:
|
||||
@@ -199,7 +214,8 @@ proxy:
|
||||
## Limits
|
||||
|
||||
- The proxy improves coverage for process-local JavaScript HTTP and WebSocket clients, but it is not an OS-level network sandbox.
|
||||
- Raw `net`, `tls`, and `http2` sockets, native addons, and child processes may bypass Node-level proxy routing unless they inherit and respect proxy environment variables.
|
||||
- Gateway loopback control-plane traffic defaults to direct local bypass through `proxy.loopbackMode: "gateway-only"`. OpenClaw implements that bypass by registering the active Gateway loopback authority in the managed `global-agent` `NO_PROXY` controller. Operators can set `proxy.loopbackMode: "proxy"` to send Gateway loopback traffic through the managed proxy, or `proxy.loopbackMode: "block"` to deny loopback Gateway connections. See [Gateway Loopback Mode](#gateway-loopback-mode) for the remote-proxy caveat.
|
||||
- Raw `net`, `tls`, and `http2` sockets, native addons, and non-OpenClaw child processes may bypass Node-level proxy routing unless they inherit and respect proxy environment variables. Forked OpenClaw child CLIs inherit the managed proxy URL and `proxy.loopbackMode` state.
|
||||
- IRC is a raw TCP/TLS channel outside operator-managed forward proxy routing. In deployments that require all egress through that forward proxy, set `channels.irc.enabled=false` unless direct IRC egress is explicitly approved.
|
||||
- The local debug proxy is diagnostic tooling and its direct upstream forwarding for proxy requests and CONNECT tunnels is disabled by default while managed proxy mode is active; enable direct forwarding only for approved local diagnostics.
|
||||
- User local WebUIs and local model servers should be allowlisted in the operator proxy policy when needed; OpenClaw does not expose a general local-network bypass for them.
|
||||
|
||||
@@ -15,10 +15,15 @@ type ResolveGatewayClientBootstrap = (params: unknown) => Promise<{
|
||||
urlSource: string;
|
||||
auth: GatewayClientAuth;
|
||||
}>;
|
||||
type GatewayClientOptions = GatewayClientCallbacks &
|
||||
GatewayClientAuth & {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
gateways: [] as MockGatewayClient[],
|
||||
gatewayAuth: [] as GatewayClientAuth[],
|
||||
gatewayOptions: [] as GatewayClientOptions[],
|
||||
agentSideConnectionCtor: vi.fn(),
|
||||
agentStart: vi.fn(),
|
||||
routeLogsToStderr: vi.fn(),
|
||||
@@ -37,8 +42,9 @@ const mockState = vi.hoisted(() => ({
|
||||
class MockGatewayClient {
|
||||
private callbacks: GatewayClientCallbacks;
|
||||
|
||||
constructor(opts: GatewayClientCallbacks & GatewayClientAuth) {
|
||||
constructor(opts: GatewayClientOptions) {
|
||||
this.callbacks = opts;
|
||||
mockState.gatewayOptions.push(opts);
|
||||
mockState.gatewayAuth.push({ token: opts.token, password: opts.password });
|
||||
mockState.gateways.push(this);
|
||||
}
|
||||
@@ -196,6 +202,7 @@ describe("serveAcpGateway startup", () => {
|
||||
beforeEach(async () => {
|
||||
mockState.gateways.length = 0;
|
||||
mockState.gatewayAuth.length = 0;
|
||||
mockState.gatewayOptions.length = 0;
|
||||
mockState.agentSideConnectionCtor.mockReset();
|
||||
mockState.agentStart.mockReset();
|
||||
mockState.routeLogsToStderr.mockReset();
|
||||
@@ -324,6 +331,36 @@ describe("serveAcpGateway startup", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes the configured Gateway URL into the ACP gateway client", async () => {
|
||||
mockState.resolveGatewayClientBootstrap.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:19999",
|
||||
urlSource: "cli --url",
|
||||
auth: {
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
},
|
||||
});
|
||||
const { signalHandlers, onceSpy } = captureProcessSignalHandlers();
|
||||
|
||||
try {
|
||||
const servePromise = serveAcpGateway({
|
||||
gatewayUrl: "ws://127.0.0.1:19999",
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockState.gatewayOptions[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:19999",
|
||||
}),
|
||||
);
|
||||
|
||||
await emitHelloAndWaitForAgentSideConnection();
|
||||
await stopServeWithSigint(signalHandlers, servePromise);
|
||||
} finally {
|
||||
onceSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not proxy the standalone ACP control-plane Gateway connection", async () => {
|
||||
const { signalHandlers, onceSpy } = captureProcessSignalHandlers();
|
||||
|
||||
|
||||
@@ -14,13 +14,27 @@ describe("ProxyConfigSchema", () => {
|
||||
const result = ProxyConfigSchema.parse({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
loopbackMode: "gateway-only",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
loopbackMode: "gateway-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts loopbackMode policy values", () => {
|
||||
expect(ProxyConfigSchema.parse({ loopbackMode: "gateway-only" })?.loopbackMode).toBe(
|
||||
"gateway-only",
|
||||
);
|
||||
expect(ProxyConfigSchema.parse({ loopbackMode: "proxy" })?.loopbackMode).toBe("proxy");
|
||||
expect(ProxyConfigSchema.parse({ loopbackMode: "block" })?.loopbackMode).toBe("block");
|
||||
});
|
||||
|
||||
it("rejects unknown loopbackMode values", () => {
|
||||
expect(() => ProxyConfigSchema.parse({ loopbackMode: "bypass" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects HTTPS proxy URLs because the node:http routing layer requires HTTP proxies", () => {
|
||||
expect(() =>
|
||||
ProxyConfigSchema.parse({
|
||||
|
||||
@@ -10,17 +10,19 @@ function isHttpProxyUrl(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export const ProxyLoopbackModeSchema = z.enum(["gateway-only", "proxy", "block"]);
|
||||
|
||||
export const ProxyConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
proxyUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.refine(isHttpProxyUrl, {
|
||||
message: "proxyUrl must use http://",
|
||||
})
|
||||
.register(sensitive)
|
||||
.optional(),
|
||||
loopbackMode: ProxyLoopbackModeSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
@@ -5,8 +5,8 @@ const mockState = vi.hoisted(() => ({
|
||||
resolveGatewayConnectionAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./call.js", () => ({
|
||||
buildGatewayConnectionDetails: (...args: unknown[]) =>
|
||||
vi.mock("./connection-details.js", () => ({
|
||||
buildGatewayConnectionDetailsWithResolvers: (...args: unknown[]) =>
|
||||
mockState.buildGatewayConnectionDetails(...args),
|
||||
}));
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("resolveGatewayClientBootstrap", () => {
|
||||
});
|
||||
|
||||
it("passes cli override context into shared auth resolution", async () => {
|
||||
mockState.buildGatewayConnectionDetails.mockReturnValue({
|
||||
mockState.buildGatewayConnectionDetails.mockReturnValueOnce({
|
||||
url: "wss://override.example/ws",
|
||||
urlSource: "cli --url",
|
||||
});
|
||||
|
||||
@@ -33,8 +33,10 @@ class MockWebSocket {
|
||||
terminateCalls = 0;
|
||||
autoCloseOnClose = true;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
readonly options: unknown;
|
||||
|
||||
constructor(_url: string, _options?: unknown) {
|
||||
constructor(_url: string, options?: unknown) {
|
||||
this.options = options;
|
||||
wsInstances.push(this);
|
||||
}
|
||||
|
||||
@@ -179,17 +181,36 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
describe("GatewayClient security checks", () => {
|
||||
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
|
||||
const envSnapshot = captureEnv([
|
||||
"OPENCLAW_ALLOW_INSECURE_PRIVATE_WS",
|
||||
"OPENCLAW_PROXY_ACTIVE",
|
||||
"OPENCLAW_PROXY_LOOPBACK_MODE",
|
||||
"HTTP_PROXY",
|
||||
"GLOBAL_AGENT_HTTP_PROXY",
|
||||
"GLOBAL_AGENT_FORCE_GLOBAL_AGENT",
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
envSnapshot.restore();
|
||||
delete process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS;
|
||||
delete process.env.OPENCLAW_PROXY_ACTIVE;
|
||||
delete process.env.OPENCLAW_PROXY_LOOPBACK_MODE;
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.GLOBAL_AGENT_HTTP_PROXY;
|
||||
delete process.env.GLOBAL_AGENT_FORCE_GLOBAL_AGENT;
|
||||
delete (global as Record<string, unknown>)["GLOBAL_AGENT"];
|
||||
wsInstances.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
envSnapshot.restore();
|
||||
delete process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS;
|
||||
delete process.env.OPENCLAW_PROXY_ACTIVE;
|
||||
delete process.env.OPENCLAW_PROXY_LOOPBACK_MODE;
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.GLOBAL_AGENT_HTTP_PROXY;
|
||||
delete process.env.GLOBAL_AGENT_FORCE_GLOBAL_AGENT;
|
||||
delete (global as Record<string, unknown>)["GLOBAL_AGENT"];
|
||||
});
|
||||
|
||||
it("blocks ws:// to non-loopback addresses (CWE-319)", () => {
|
||||
@@ -232,9 +253,82 @@ describe("GatewayClient security checks", () => {
|
||||
|
||||
expect(onConnectError).not.toHaveBeenCalled();
|
||||
expect(wsInstances.length).toBe(1); // WebSocket created
|
||||
expect(getLatestWs().options).not.toHaveProperty("agent");
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("bootstraps inherited managed proxy routing before proxy-mode loopback WebSocket creation", () => {
|
||||
process.env.OPENCLAW_PROXY_ACTIVE = "1";
|
||||
process.env.OPENCLAW_PROXY_LOOPBACK_MODE = "proxy";
|
||||
process.env.HTTP_PROXY = "http://127.0.0.1:3128";
|
||||
process.env.GLOBAL_AGENT_HTTP_PROXY = "http://127.0.0.1:3128";
|
||||
const onConnectError = vi.fn();
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
onConnectError,
|
||||
});
|
||||
|
||||
client.start();
|
||||
|
||||
expect(onConnectError).not.toHaveBeenCalled();
|
||||
expect(wsInstances.length).toBe(1);
|
||||
expect(getLatestWs().options).not.toMatchObject({ agent: expect.any(Object) });
|
||||
expect((global as Record<string, unknown>)["GLOBAL_AGENT"]).toEqual(
|
||||
expect.objectContaining({
|
||||
HTTP_PROXY: "http://127.0.0.1:3128",
|
||||
HTTPS_PROXY: "http://127.0.0.1:3128",
|
||||
}),
|
||||
);
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("proxies ws:// loopback addresses when active proxy loopbackMode is proxy", async () => {
|
||||
const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js");
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
loopbackMode: "proxy",
|
||||
});
|
||||
const onConnectError = vi.fn();
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
onConnectError,
|
||||
});
|
||||
|
||||
try {
|
||||
client.start();
|
||||
|
||||
expect(onConnectError).not.toHaveBeenCalled();
|
||||
expect(wsInstances.length).toBe(1);
|
||||
expect(getLatestWs().options).not.toMatchObject({ agent: expect.any(Object) });
|
||||
} finally {
|
||||
client.stop();
|
||||
await stopProxy(handle);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks ws:// loopback addresses when active proxy loopbackMode is block", async () => {
|
||||
const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js");
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
loopbackMode: "block",
|
||||
});
|
||||
const onConnectError = vi.fn();
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
onConnectError,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => client.start()).toThrow("blocked by proxy.loopbackMode");
|
||||
expect(wsInstances.length).toBe(0);
|
||||
} finally {
|
||||
client.stop();
|
||||
await stopProxy(handle);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows wss:// to any address", () => {
|
||||
const onConnectError = vi.fn();
|
||||
const client = new GatewayClient({
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { WebSocket, type ClientOptions, type CertMeta } from "ws";
|
||||
import {
|
||||
clearDeviceAuthToken,
|
||||
@@ -13,7 +11,10 @@ import {
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
signDevicePayload,
|
||||
} from "../infra/device-identity.js";
|
||||
import { dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane } from "../infra/net/proxy/proxy-lifecycle.js";
|
||||
import {
|
||||
ensureInheritedManagedProxyRoutingActive,
|
||||
withManagedProxyGatewayLoopbackRouting,
|
||||
} from "../infra/net/proxy/proxy-lifecycle.js";
|
||||
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
@@ -87,25 +88,14 @@ type FingerprintCheckingClientOptions = Omit<ClientOptions, "checkServerIdentity
|
||||
checkServerIdentity?: (servername: string, cert: CertMeta) => Error | undefined;
|
||||
};
|
||||
|
||||
const DEFAULT_GATEWAY_CLIENT_URL = "ws://127.0.0.1:18789";
|
||||
|
||||
export type GatewayReconnectPausedInfo = {
|
||||
code: number;
|
||||
reason: string;
|
||||
detailCode: string | null;
|
||||
};
|
||||
|
||||
function createDirectGatewayAgent(url: string): http.Agent | https.Agent | undefined {
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(url).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!isLoopbackHost(hostname)) {
|
||||
return undefined;
|
||||
}
|
||||
return url.startsWith("wss://") ? new https.Agent() : new http.Agent();
|
||||
}
|
||||
|
||||
export class GatewayClientRequestError extends Error {
|
||||
readonly gatewayCode: string;
|
||||
readonly details?: unknown;
|
||||
@@ -261,7 +251,7 @@ export class GatewayClient {
|
||||
this.clearConnectChallengeTimeout();
|
||||
this.connectNonce = null;
|
||||
this.connectSent = false;
|
||||
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
||||
const url = this.opts.url ?? DEFAULT_GATEWAY_CLIENT_URL;
|
||||
if (this.opts.tlsFingerprint && !url.startsWith("wss://")) {
|
||||
this.opts.onConnectError?.(new Error("gateway tls fingerprint requires wss:// gateway url"));
|
||||
return;
|
||||
@@ -293,10 +283,9 @@ export class GatewayClient {
|
||||
return;
|
||||
}
|
||||
// Allow node screen snapshots and other large responses.
|
||||
const directAgent = createDirectGatewayAgent(url);
|
||||
ensureInheritedManagedProxyRoutingActive();
|
||||
const wsOptions: FingerprintCheckingClientOptions = {
|
||||
maxPayload: 25 * 1024 * 1024,
|
||||
...(directAgent ? { agent: directAgent } : {}),
|
||||
};
|
||||
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
|
||||
wsOptions.rejectUnauthorized = false;
|
||||
@@ -321,10 +310,10 @@ export class GatewayClient {
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
const createWebSocket = () => new WebSocket(url, wsOptions as ClientOptions);
|
||||
const ws = directAgent
|
||||
? dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(url, createWebSocket)
|
||||
: createWebSocket();
|
||||
const ws = withManagedProxyGatewayLoopbackRouting(
|
||||
url,
|
||||
() => new WebSocket(url, wsOptions as ClientOptions),
|
||||
);
|
||||
this.ws = ws;
|
||||
this.socketOpened = false;
|
||||
this.connectNonce = null;
|
||||
|
||||
@@ -21,6 +21,12 @@ async function getFreePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
function isIpv6UnavailableError(err: unknown): boolean {
|
||||
const code =
|
||||
typeof err === "object" && err !== null ? (err as { code?: unknown }).code : undefined;
|
||||
return code === "EAFNOSUPPORT" || code === "EADDRNOTAVAIL";
|
||||
}
|
||||
|
||||
function createOpenGatewayClient(requestTimeoutMs: number): {
|
||||
client: GatewayClient;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
@@ -156,6 +162,63 @@ describe("GatewayClient", () => {
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
test("connects to IPv6 loopback while managed proxy Gateway-only mode is active", async () => {
|
||||
wss = new WebSocketServer({ host: "::1", port: 0 });
|
||||
const bind = await new Promise<{ port: number } | null>((resolve, reject) => {
|
||||
wss?.once("listening", () => {
|
||||
const address = wss?.address();
|
||||
if (address === undefined || address === null || typeof address === "string") {
|
||||
reject(new Error("IPv6 WebSocket server did not bind to a TCP port"));
|
||||
return;
|
||||
}
|
||||
resolve({ port: address.port });
|
||||
});
|
||||
wss?.once("error", (err) => {
|
||||
if (isIpv6UnavailableError(err)) {
|
||||
wss = null;
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
if (bind === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js");
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:9",
|
||||
loopbackMode: "gateway-only",
|
||||
});
|
||||
const onConnectError = vi.fn();
|
||||
const connected = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("IPv6 loopback Gateway client did not connect"));
|
||||
}, 2000);
|
||||
wss?.once("connection", (socket) => {
|
||||
clearTimeout(timeout);
|
||||
socket.close(1000, "done");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const client = new GatewayClient({
|
||||
url: `ws://[::1]:${bind.port}`,
|
||||
connectChallengeTimeoutMs: 1000,
|
||||
onConnectError,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => client.start()).not.toThrow();
|
||||
await connected;
|
||||
expect(onConnectError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
client.stop();
|
||||
await stopProxy(handle);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
test("lets pending requests own their timeout when ticks are missing", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -23,6 +23,7 @@ export function buildGatewayConnectionDetailsWithResolvers(
|
||||
url?: string;
|
||||
configPath?: string;
|
||||
urlSource?: "cli" | "env";
|
||||
ignoreEnvUrlOverride?: boolean;
|
||||
} = {},
|
||||
resolvers: GatewayConnectionDetailResolvers = {},
|
||||
): GatewayConnectionDetails {
|
||||
@@ -40,9 +41,10 @@ export function buildGatewayConnectionDetailsWithResolvers(
|
||||
const scheme = tlsEnabled ? "wss" : "ws";
|
||||
const localUrl = `${scheme}://127.0.0.1:${localPort}`;
|
||||
const cliUrlOverride = normalizeOptionalString(options.url);
|
||||
const envUrlOverride = cliUrlOverride
|
||||
? undefined
|
||||
: normalizeOptionalString(process.env.OPENCLAW_GATEWAY_URL);
|
||||
const envUrlOverride =
|
||||
cliUrlOverride || options.ignoreEnvUrlOverride
|
||||
? undefined
|
||||
: normalizeOptionalString(process.env.OPENCLAW_GATEWAY_URL);
|
||||
const urlOverride = cliUrlOverride ?? envUrlOverride;
|
||||
const remoteUrl = normalizeOptionalString(remote?.url);
|
||||
const remoteMisconfigured = isRemoteMode && !urlOverride && !remoteUrl;
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
resetDiagnosticEventsForTest,
|
||||
type DiagnosticEventPayload,
|
||||
} from "../infra/diagnostic-events.js";
|
||||
import {
|
||||
_resetActiveManagedProxyStateForTests,
|
||||
registerActiveManagedProxyUrl,
|
||||
stopActiveManagedProxyRegistration,
|
||||
} from "../infra/net/proxy/active-proxy-state.js";
|
||||
import { defaultVoiceWakeTriggers } from "../infra/voicewake.js";
|
||||
import { handleControlUiHttpRequest } from "./control-ui.js";
|
||||
import {
|
||||
@@ -35,7 +40,13 @@ function makeControlUiResponse() {
|
||||
}
|
||||
|
||||
const wsMockState = vi.hoisted(() => ({
|
||||
last: null as { url: unknown; opts: unknown } | null,
|
||||
last: null as {
|
||||
url: unknown;
|
||||
opts: unknown;
|
||||
noProxyDuringConstruction: unknown;
|
||||
httpProxyDuringConstruction: unknown;
|
||||
httpsProxyDuringConstruction: unknown;
|
||||
} | null,
|
||||
}));
|
||||
|
||||
vi.mock("ws", () => ({
|
||||
@@ -45,7 +56,23 @@ vi.mock("ws", () => ({
|
||||
send = vi.fn();
|
||||
|
||||
constructor(url: unknown, opts: unknown) {
|
||||
wsMockState.last = { url, opts };
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"];
|
||||
wsMockState.last = {
|
||||
url,
|
||||
opts,
|
||||
noProxyDuringConstruction:
|
||||
typeof agent === "object" && agent !== null
|
||||
? (agent as Record<string, unknown>)["NO_PROXY"]
|
||||
: undefined,
|
||||
httpProxyDuringConstruction:
|
||||
typeof agent === "object" && agent !== null
|
||||
? (agent as Record<string, unknown>)["HTTP_PROXY"]
|
||||
: undefined,
|
||||
httpsProxyDuringConstruction:
|
||||
typeof agent === "object" && agent !== null
|
||||
? (agent as Record<string, unknown>)["HTTPS_PROXY"]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -59,6 +86,8 @@ describe("GatewayClient", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
wsMockState.last = null;
|
||||
_resetActiveManagedProxyStateForTests();
|
||||
delete (global as Record<string, unknown>)["GLOBAL_AGENT"];
|
||||
});
|
||||
|
||||
async function withControlUiRoot(
|
||||
@@ -86,31 +115,28 @@ describe("GatewayClient", () => {
|
||||
expect(last?.opts).toEqual(expect.objectContaining({ maxPayload: 25 * 1024 * 1024 }));
|
||||
});
|
||||
|
||||
test("uses an explicit direct agent for control-plane WebSocket connections", () => {
|
||||
test("does not pass an explicit direct agent for loopback control-plane WebSocket connections", () => {
|
||||
const client = new GatewayClient({ url: "ws://127.0.0.1:1" });
|
||||
client.start();
|
||||
const last = wsMockState.last as { opts: { agent?: unknown } } | null;
|
||||
|
||||
expect(last?.opts.agent).toBeDefined();
|
||||
expect(last?.opts.agent).not.toBe(
|
||||
(global as unknown as { GLOBAL_AGENT?: { HTTP_PROXY?: unknown } }).GLOBAL_AGENT,
|
||||
);
|
||||
expect(last?.opts.agent).toBeUndefined();
|
||||
});
|
||||
|
||||
test("uses an explicit direct agent for IPv6 loopback control-plane WebSocket connections", () => {
|
||||
test("does not pass an explicit direct agent for IPv6 loopback control-plane WebSocket connections", () => {
|
||||
const client = new GatewayClient({ url: "ws://[::1]:1" });
|
||||
client.start();
|
||||
const last = wsMockState.last as { opts: { agent?: unknown } } | null;
|
||||
|
||||
expect(last?.opts.agent).toBeDefined();
|
||||
expect(last?.opts.agent).toBeUndefined();
|
||||
});
|
||||
|
||||
test("uses the direct control-plane bypass for localhost hostnames", () => {
|
||||
test("does not pass an explicit direct agent for localhost hostnames", () => {
|
||||
const client = new GatewayClient({ url: "ws://localhost:1" });
|
||||
client.start();
|
||||
const last = wsMockState.last as { opts: { agent?: unknown } } | null;
|
||||
|
||||
expect(last?.opts.agent).toBeDefined();
|
||||
expect(last?.opts.agent).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not force a direct agent for remote Gateway WebSocket connections", () => {
|
||||
@@ -124,6 +150,60 @@ describe("GatewayClient", () => {
|
||||
expect(last?.opts.agent).toBeUndefined();
|
||||
});
|
||||
|
||||
test("scopes Gateway loopback NO_PROXY to WebSocket construction", () => {
|
||||
const agent = { NO_PROXY: "corp.example.com" };
|
||||
(global as Record<string, unknown>)["GLOBAL_AGENT"] = agent;
|
||||
const registration = registerActiveManagedProxyUrl(
|
||||
new URL("http://127.0.0.1:3128"),
|
||||
"gateway-only",
|
||||
);
|
||||
|
||||
try {
|
||||
const client = new GatewayClient({ url: "ws://127.0.0.1:18789" });
|
||||
client.start();
|
||||
const last = wsMockState.last as { noProxyDuringConstruction: unknown } | null;
|
||||
|
||||
expect(last?.noProxyDuringConstruction).toBe("corp.example.com,127.0.0.1:18789");
|
||||
expect(agent.NO_PROXY).toBe("corp.example.com");
|
||||
} finally {
|
||||
stopActiveManagedProxyRegistration(registration);
|
||||
delete (global as Record<string, unknown>)["GLOBAL_AGENT"];
|
||||
}
|
||||
});
|
||||
|
||||
test("uses a scoped direct construction path for IPv6 loopback in Gateway-only proxy mode", () => {
|
||||
const agent = {
|
||||
NO_PROXY: "corp.example.com",
|
||||
HTTP_PROXY: "http://127.0.0.1:3128",
|
||||
HTTPS_PROXY: "http://127.0.0.1:3128",
|
||||
};
|
||||
(global as Record<string, unknown>)["GLOBAL_AGENT"] = agent;
|
||||
const registration = registerActiveManagedProxyUrl(
|
||||
new URL("http://127.0.0.1:3128"),
|
||||
"gateway-only",
|
||||
);
|
||||
|
||||
try {
|
||||
const client = new GatewayClient({ url: "ws://[::1]:18789" });
|
||||
client.start();
|
||||
const last = wsMockState.last as {
|
||||
noProxyDuringConstruction: unknown;
|
||||
httpProxyDuringConstruction: unknown;
|
||||
httpsProxyDuringConstruction: unknown;
|
||||
} | null;
|
||||
|
||||
expect(last?.noProxyDuringConstruction).toBe("corp.example.com,[::1]:18789");
|
||||
expect(last?.httpProxyDuringConstruction).toBeNull();
|
||||
expect(last?.httpsProxyDuringConstruction).toBeNull();
|
||||
expect(agent.NO_PROXY).toBe("corp.example.com");
|
||||
expect(agent.HTTP_PROXY).toBe("http://127.0.0.1:3128");
|
||||
expect(agent.HTTPS_PROXY).toBe("http://127.0.0.1:3128");
|
||||
} finally {
|
||||
stopActiveManagedProxyRegistration(registration);
|
||||
delete (global as Record<string, unknown>)["GLOBAL_AGENT"];
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 404 for missing static asset paths instead of SPA fallback", async () => {
|
||||
await withControlUiRoot({ faviconSvg: "<svg/>" }, async (tmp) => {
|
||||
const { res } = makeControlUiResponse();
|
||||
|
||||
@@ -1,14 +1,42 @@
|
||||
import type { ProxyConfig } from "../../../config/zod-schema.proxy.js";
|
||||
|
||||
export type ActiveManagedProxyUrl = Readonly<URL>;
|
||||
|
||||
export type ActiveManagedProxyLoopbackMode = NonNullable<NonNullable<ProxyConfig>["loopbackMode"]>;
|
||||
|
||||
export type ActiveManagedProxyRegistration = {
|
||||
proxyUrl: ActiveManagedProxyUrl;
|
||||
loopbackMode: ActiveManagedProxyLoopbackMode;
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
let activeProxyUrl: ActiveManagedProxyUrl | undefined;
|
||||
let activeProxyLoopbackMode: ActiveManagedProxyLoopbackMode | undefined;
|
||||
let activeProxyRegistrationCount = 0;
|
||||
|
||||
export function registerActiveManagedProxyUrl(proxyUrl: URL): ActiveManagedProxyRegistration {
|
||||
function parseActiveManagedProxyLoopbackMode(
|
||||
value: string | undefined,
|
||||
): ActiveManagedProxyLoopbackMode | undefined {
|
||||
if (value === "gateway-only" || value === "proxy" || value === "block") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readInheritedActiveManagedProxyLoopbackMode(): ActiveManagedProxyLoopbackMode | undefined {
|
||||
if (process.env["OPENCLAW_PROXY_ACTIVE"] !== "1") {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
parseActiveManagedProxyLoopbackMode(process.env["OPENCLAW_PROXY_LOOPBACK_MODE"]) ??
|
||||
"gateway-only"
|
||||
);
|
||||
}
|
||||
|
||||
export function registerActiveManagedProxyUrl(
|
||||
proxyUrl: URL,
|
||||
loopbackMode: ActiveManagedProxyLoopbackMode = "gateway-only",
|
||||
): ActiveManagedProxyRegistration {
|
||||
const normalizedProxyUrl = new URL(proxyUrl.href);
|
||||
if (activeProxyUrl !== undefined) {
|
||||
if (activeProxyUrl.href !== normalizedProxyUrl.href) {
|
||||
@@ -17,13 +45,20 @@ export function registerActiveManagedProxyUrl(proxyUrl: URL): ActiveManagedProxy
|
||||
"stop the current proxy before changing proxy.proxyUrl.",
|
||||
);
|
||||
}
|
||||
if (activeProxyLoopbackMode !== loopbackMode) {
|
||||
throw new Error(
|
||||
"proxy: cannot activate a managed proxy with a different proxy.loopbackMode while another proxy is active; " +
|
||||
"stop the current proxy before changing proxy.loopbackMode.",
|
||||
);
|
||||
}
|
||||
activeProxyRegistrationCount += 1;
|
||||
return { proxyUrl: activeProxyUrl, stopped: false };
|
||||
return { proxyUrl: activeProxyUrl, loopbackMode, stopped: false };
|
||||
}
|
||||
|
||||
activeProxyUrl = normalizedProxyUrl;
|
||||
activeProxyLoopbackMode = loopbackMode;
|
||||
activeProxyRegistrationCount = 1;
|
||||
return { proxyUrl: activeProxyUrl, stopped: false };
|
||||
return { proxyUrl: activeProxyUrl, loopbackMode, stopped: false };
|
||||
}
|
||||
|
||||
export function stopActiveManagedProxyRegistration(
|
||||
@@ -39,14 +74,20 @@ export function stopActiveManagedProxyRegistration(
|
||||
activeProxyRegistrationCount = Math.max(0, activeProxyRegistrationCount - 1);
|
||||
if (activeProxyRegistrationCount === 0) {
|
||||
activeProxyUrl = undefined;
|
||||
activeProxyLoopbackMode = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveManagedProxyLoopbackMode(): ActiveManagedProxyLoopbackMode | undefined {
|
||||
return activeProxyLoopbackMode ?? readInheritedActiveManagedProxyLoopbackMode();
|
||||
}
|
||||
|
||||
export function getActiveManagedProxyUrl(): ActiveManagedProxyUrl | undefined {
|
||||
return activeProxyUrl;
|
||||
}
|
||||
|
||||
export function _resetActiveManagedProxyStateForTests(): void {
|
||||
activeProxyUrl = undefined;
|
||||
activeProxyLoopbackMode = undefined;
|
||||
activeProxyRegistrationCount = 0;
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ describe("SSRF external proxy routing", () => {
|
||||
import { fetch as undiciFetch } from "undici";
|
||||
import { WebSocket } from "ws";
|
||||
import { startProxy, stopProxy } from "./src/infra/net/proxy/proxy-lifecycle.ts";
|
||||
import { dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane } from "./src/infra/net/proxy/proxy-lifecycle.ts";
|
||||
import { registerManagedProxyGatewayLoopbackNoProxy } from "./src/infra/net/proxy/proxy-lifecycle.ts";
|
||||
|
||||
async function nodeHttpGet(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -396,14 +396,18 @@ describe("SSRF external proxy routing", () => {
|
||||
|
||||
async function gatewayLoopbackBypassProbe(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(url, () =>
|
||||
new WebSocket(url, { handshakeTimeout: ${PROBE_TIMEOUT_MS} }),
|
||||
);
|
||||
const unregister = registerManagedProxyGatewayLoopbackNoProxy(url);
|
||||
const ws = new WebSocket(url, { handshakeTimeout: ${PROBE_TIMEOUT_MS} });
|
||||
const cleanup = () => unregister?.();
|
||||
ws.once("open", () => {
|
||||
ws.close();
|
||||
cleanup();
|
||||
resolve();
|
||||
});
|
||||
ws.once("error", reject);
|
||||
ws.once("error", (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js";
|
||||
import { _resetActiveManagedProxyStateForTests } from "./active-proxy-state.js";
|
||||
import {
|
||||
_resetGlobalAgentBootstrapForTests,
|
||||
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane,
|
||||
registerManagedProxyGatewayLoopbackNoProxy,
|
||||
startProxy,
|
||||
stopProxy,
|
||||
} from "./proxy-lifecycle.js";
|
||||
@@ -48,6 +48,7 @@ describe("startProxy", () => {
|
||||
"GLOBAL_AGENT_FORCE_GLOBAL_AGENT",
|
||||
"GLOBAL_AGENT_NO_PROXY",
|
||||
"OPENCLAW_PROXY_ACTIVE",
|
||||
"OPENCLAW_PROXY_LOOPBACK_MODE",
|
||||
"OPENCLAW_PROXY_URL",
|
||||
];
|
||||
const originalHttpRequest = http.request;
|
||||
@@ -64,6 +65,15 @@ describe("startProxy", () => {
|
||||
}
|
||||
mockForceResetGlobalDispatcher.mockReset();
|
||||
mockBootstrapGlobalAgent.mockReset();
|
||||
mockBootstrapGlobalAgent.mockImplementation(() => {
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
const namespace = env["GLOBAL_AGENT_ENVIRONMENT_VARIABLE_NAMESPACE"] ?? "GLOBAL_AGENT_";
|
||||
(global as Record<string, unknown>)["GLOBAL_AGENT"] = {
|
||||
HTTP_PROXY: env[`${namespace}HTTP_PROXY`] ?? "",
|
||||
HTTPS_PROXY: env[`${namespace}HTTPS_PROXY`] ?? "",
|
||||
NO_PROXY: env[`${namespace}NO_PROXY`] ?? null,
|
||||
};
|
||||
});
|
||||
mockLogInfo.mockReset();
|
||||
mockLogWarn.mockReset();
|
||||
_resetGlobalAgentBootstrapForTests();
|
||||
@@ -177,6 +187,25 @@ describe("startProxy", () => {
|
||||
expect(process.env["GLOBAL_AGENT_HTTPS_PROXY"]).toBe("http://127.0.0.1:3128");
|
||||
expect(process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"]).toBe("true");
|
||||
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
|
||||
expect(process.env["OPENCLAW_PROXY_LOOPBACK_MODE"]).toBe("gateway-only");
|
||||
});
|
||||
|
||||
it("persists loopbackMode in env for forked child CLIs", async () => {
|
||||
const { getActiveManagedProxyLoopbackMode } = await import("./active-proxy-state.js");
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
loopbackMode: "block",
|
||||
});
|
||||
|
||||
expect(process.env["OPENCLAW_PROXY_LOOPBACK_MODE"]).toBe("block");
|
||||
expect(getActiveManagedProxyLoopbackMode()).toBe("block");
|
||||
|
||||
await stopProxy(handle);
|
||||
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
|
||||
process.env["OPENCLAW_PROXY_LOOPBACK_MODE"] = "proxy";
|
||||
|
||||
expect(getActiveManagedProxyLoopbackMode()).toBe("proxy");
|
||||
});
|
||||
|
||||
it("redacts proxy credentials before logging the active proxy URL", async () => {
|
||||
@@ -246,9 +275,9 @@ describe("startProxy", () => {
|
||||
expect(process.env["GLOBAL_AGENT_NO_PROXY"]).toBe("global.corp.example.com");
|
||||
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBeUndefined();
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
|
||||
expect(agent["HTTP_PROXY"]).toBe("http://previous-global.example.com:8080");
|
||||
expect(agent["HTTPS_PROXY"]).toBe("http://previous-global.example.com:8443");
|
||||
expect(agent["NO_PROXY"]).toBe("global.corp.example.com");
|
||||
expect(agent["HTTP_PROXY"]).toBe("");
|
||||
expect(agent["HTTPS_PROXY"]).toBe("");
|
||||
expect(agent["NO_PROXY"]).toBeUndefined();
|
||||
expect(agent["forceGlobalAgent"]).toBeUndefined();
|
||||
expect(mockForceResetGlobalDispatcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -359,6 +388,27 @@ describe("startProxy", () => {
|
||||
await stopProxy(firstHandle);
|
||||
});
|
||||
|
||||
it("rejects overlapping handles with the same proxy URL but different loopback modes", async () => {
|
||||
const firstHandle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
loopbackMode: "gateway-only",
|
||||
});
|
||||
|
||||
await expect(
|
||||
startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
loopbackMode: "block",
|
||||
}),
|
||||
).rejects.toThrow("cannot activate a managed proxy with a different proxy.loopbackMode");
|
||||
|
||||
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
|
||||
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
|
||||
|
||||
await stopProxy(firstHandle);
|
||||
});
|
||||
|
||||
it("restores env and throws when undici activation fails", async () => {
|
||||
mockForceResetGlobalDispatcher.mockImplementationOnce(() => {
|
||||
throw new Error("dispatcher failed");
|
||||
@@ -391,152 +441,111 @@ describe("startProxy", () => {
|
||||
expect(process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("temporarily restores the original node HTTP stack for Gateway loopback control-plane setup", async () => {
|
||||
const patchedHttpRequest = vi.fn() as unknown as typeof http.request;
|
||||
const patchedHttpGet = vi.fn() as unknown as typeof http.get;
|
||||
mockBootstrapGlobalAgent.mockImplementationOnce(() => {
|
||||
http.request = patchedHttpRequest;
|
||||
http.get = patchedHttpGet;
|
||||
(global as Record<string, unknown>)["GLOBAL_AGENT"] = {
|
||||
HTTP_PROXY: "",
|
||||
HTTPS_PROXY: "",
|
||||
};
|
||||
});
|
||||
|
||||
it("registers exact Gateway loopback authorities in global-agent NO_PROXY", async () => {
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
});
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
|
||||
|
||||
expect(http.request).toBe(patchedHttpRequest);
|
||||
const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789");
|
||||
|
||||
const requestDuringBypass = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"ws://127.0.0.1:18789",
|
||||
() => http.request,
|
||||
);
|
||||
expect(unregister).toBeTypeOf("function");
|
||||
expect(agent["NO_PROXY"]).toBe("127.0.0.1:18789");
|
||||
|
||||
expect(requestDuringBypass).toBe(originalHttpRequest);
|
||||
expect(http.request).toBe(patchedHttpRequest);
|
||||
unregister?.();
|
||||
expect(agent["NO_PROXY"]).toBeNull();
|
||||
await stopProxy(handle);
|
||||
});
|
||||
|
||||
it("accepts literal loopback IPs and localhost for Gateway NO_PROXY registration", async () => {
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
});
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
|
||||
|
||||
const unregisterIpv6 = registerManagedProxyGatewayLoopbackNoProxy("ws://[::1]:18789");
|
||||
expect(unregisterIpv6).toBeTypeOf("function");
|
||||
expect(agent["NO_PROXY"]).toBe("[::1]:18789");
|
||||
unregisterIpv6?.();
|
||||
|
||||
const unregisterLocalhost = registerManagedProxyGatewayLoopbackNoProxy("ws://localhost.:18789");
|
||||
expect(unregisterLocalhost).toBeTypeOf("function");
|
||||
expect(agent["NO_PROXY"]).toBe("localhost.:18789");
|
||||
unregisterLocalhost?.();
|
||||
|
||||
await stopProxy(handle);
|
||||
});
|
||||
|
||||
it("allows the Gateway control-plane bypass for literal loopback IPs and localhost", () => {
|
||||
expect(
|
||||
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"ws://127.0.0.1:18789",
|
||||
() => "ok",
|
||||
),
|
||||
).toBe("ok");
|
||||
expect(
|
||||
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane("ws://[::1]:18789", () => "ok"),
|
||||
).toBe("ok");
|
||||
expect(
|
||||
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"ws://localhost:18789",
|
||||
() => "ok",
|
||||
),
|
||||
).toBe("ok");
|
||||
expect(
|
||||
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"ws://localhost.:18789",
|
||||
() => "ok",
|
||||
),
|
||||
).toBe("ok");
|
||||
it("does not register Gateway NO_PROXY for non-loopback URLs", () => {
|
||||
expect(registerManagedProxyGatewayLoopbackNoProxy("wss://gateway.example.com")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects dangerous Gateway control-plane bypass for non-loopback URLs", () => {
|
||||
expect(() =>
|
||||
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"wss://gateway.example.com",
|
||||
() => undefined,
|
||||
),
|
||||
).toThrow("loopback-only");
|
||||
});
|
||||
|
||||
it("temporarily clears inherited proxy env for Gateway control-plane setup", () => {
|
||||
process.env["http_proxy"] = "http://lower-http.example.com:8080";
|
||||
process.env["https_proxy"] = "http://lower-https.example.com:8080";
|
||||
process.env["HTTP_PROXY"] = "http://upper-http.example.com:8080";
|
||||
process.env["HTTPS_PROXY"] = "http://upper-https.example.com:8080";
|
||||
process.env["all_proxy"] = "http://lower-all.example.com:8080";
|
||||
process.env["ALL_PROXY"] = "http://upper-all.example.com:8080";
|
||||
process.env["NO_PROXY"] = "localhost";
|
||||
process.env["no_proxy"] = "127.0.0.1";
|
||||
process.env["GLOBAL_AGENT_HTTP_PROXY"] = "http://global-http.example.com:8080";
|
||||
process.env["GLOBAL_AGENT_HTTPS_PROXY"] = "http://global-https.example.com:8080";
|
||||
process.env["GLOBAL_AGENT_NO_PROXY"] = "localhost";
|
||||
process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"] = "true";
|
||||
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
|
||||
|
||||
const during = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"ws://localhost:18789",
|
||||
() => ({
|
||||
httpProxy: process.env["HTTP_PROXY"],
|
||||
httpsProxy: process.env["HTTPS_PROXY"],
|
||||
allProxy: process.env["ALL_PROXY"],
|
||||
lowerAllProxy: process.env["all_proxy"],
|
||||
noProxy: process.env["NO_PROXY"],
|
||||
globalProxy: process.env["GLOBAL_AGENT_HTTP_PROXY"],
|
||||
proxyActive: process.env["OPENCLAW_PROXY_ACTIVE"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(during).toEqual({
|
||||
httpProxy: undefined,
|
||||
httpsProxy: undefined,
|
||||
allProxy: undefined,
|
||||
lowerAllProxy: undefined,
|
||||
noProxy: undefined,
|
||||
globalProxy: undefined,
|
||||
proxyActive: undefined,
|
||||
});
|
||||
expect(process.env["HTTP_PROXY"]).toBe("http://upper-http.example.com:8080");
|
||||
expect(process.env["HTTPS_PROXY"]).toBe("http://upper-https.example.com:8080");
|
||||
expect(process.env["ALL_PROXY"]).toBe("http://upper-all.example.com:8080");
|
||||
expect(process.env["all_proxy"]).toBe("http://lower-all.example.com:8080");
|
||||
expect(process.env["NO_PROXY"]).toBe("localhost");
|
||||
expect(process.env["GLOBAL_AGENT_HTTP_PROXY"]).toBe("http://global-http.example.com:8080");
|
||||
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
|
||||
});
|
||||
|
||||
it("temporarily clears managed proxy env while restoring the original HTTP stack", async () => {
|
||||
const patchedHttpRequest = vi.fn() as unknown as typeof http.request;
|
||||
mockBootstrapGlobalAgent.mockImplementationOnce(() => {
|
||||
http.request = patchedHttpRequest;
|
||||
(global as Record<string, unknown>)["GLOBAL_AGENT"] = {
|
||||
HTTP_PROXY: "",
|
||||
HTTPS_PROXY: "",
|
||||
};
|
||||
});
|
||||
|
||||
it("allows Gateway NO_PROXY registration for custom configured loopback ports", async () => {
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
});
|
||||
process.env["ALL_PROXY"] = "http://inherited-all.example.com:8080";
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
|
||||
|
||||
const during = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"ws://127.0.0.1:18789",
|
||||
() => ({
|
||||
httpRequest: http.request,
|
||||
httpProxy: process.env["HTTP_PROXY"],
|
||||
allProxy: process.env["ALL_PROXY"],
|
||||
proxyActive: process.env["OPENCLAW_PROXY_ACTIVE"],
|
||||
}),
|
||||
);
|
||||
const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:3000");
|
||||
|
||||
expect(during).toEqual({
|
||||
httpRequest: originalHttpRequest,
|
||||
httpProxy: undefined,
|
||||
allProxy: undefined,
|
||||
proxyActive: undefined,
|
||||
expect(unregister).toBeTypeOf("function");
|
||||
expect(agent["NO_PROXY"]).toBe("127.0.0.1:3000");
|
||||
|
||||
unregister?.();
|
||||
await stopProxy(handle);
|
||||
});
|
||||
|
||||
it("blocks Gateway NO_PROXY registration when active proxy loopbackMode is block", async () => {
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
loopbackMode: "block",
|
||||
});
|
||||
expect(http.request).toBe(patchedHttpRequest);
|
||||
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
|
||||
expect(process.env["ALL_PROXY"]).toBe("http://inherited-all.example.com:8080");
|
||||
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
|
||||
|
||||
try {
|
||||
expect(() => registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789")).toThrow(
|
||||
"blocked by proxy.loopbackMode",
|
||||
);
|
||||
} finally {
|
||||
await stopProxy(handle);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not register Gateway NO_PROXY when active proxy loopbackMode is proxy", async () => {
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
loopbackMode: "proxy",
|
||||
});
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789");
|
||||
expect(agent["NO_PROXY"]).toBe("");
|
||||
expect(unregister).toBeUndefined();
|
||||
} finally {
|
||||
await stopProxy(handle);
|
||||
}
|
||||
});
|
||||
|
||||
it("restores the active global-agent NO_PROXY value after Gateway registration", async () => {
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
});
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
|
||||
agent["NO_PROXY"] = "corp.example.com";
|
||||
|
||||
const unregister = registerManagedProxyGatewayLoopbackNoProxy("ws://127.0.0.1:18789");
|
||||
|
||||
expect(unregister).toBeTypeOf("function");
|
||||
expect(agent["NO_PROXY"]).toBe("corp.example.com,127.0.0.1:18789");
|
||||
|
||||
unregister?.();
|
||||
expect(agent["NO_PROXY"]).toBe("corp.example.com");
|
||||
await stopProxy(handle);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,10 +12,13 @@ import https from "node:https";
|
||||
import { isIP } from "node:net";
|
||||
import { bootstrap as bootstrapGlobalAgent } from "global-agent";
|
||||
import type { ProxyConfig } from "../../../config/zod-schema.proxy.js";
|
||||
|
||||
export type ProxyLoopbackMode = NonNullable<NonNullable<ProxyConfig>["loopbackMode"]>;
|
||||
import { logInfo, logWarn } from "../../../logger.js";
|
||||
import { isLoopbackIpAddress } from "../../../shared/net/ip.js";
|
||||
import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js";
|
||||
import {
|
||||
getActiveManagedProxyLoopbackMode,
|
||||
getActiveManagedProxyUrl,
|
||||
registerActiveManagedProxyUrl,
|
||||
stopActiveManagedProxyRegistration,
|
||||
@@ -39,7 +42,7 @@ const PROXY_ENV_KEYS = ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"
|
||||
const GLOBAL_AGENT_PROXY_KEYS = ["GLOBAL_AGENT_HTTP_PROXY", "GLOBAL_AGENT_HTTPS_PROXY"] as const;
|
||||
const GLOBAL_AGENT_FORCE_KEYS = ["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"] as const;
|
||||
const NO_PROXY_ENV_KEYS = ["no_proxy", "NO_PROXY", "GLOBAL_AGENT_NO_PROXY"] as const;
|
||||
const PROXY_ACTIVE_KEYS = ["OPENCLAW_PROXY_ACTIVE"] as const;
|
||||
const PROXY_ACTIVE_KEYS = ["OPENCLAW_PROXY_ACTIVE", "OPENCLAW_PROXY_LOOPBACK_MODE"] as const;
|
||||
const ALL_PROXY_ENV_KEYS = [
|
||||
...PROXY_ENV_KEYS,
|
||||
...GLOBAL_AGENT_PROXY_KEYS,
|
||||
@@ -47,19 +50,8 @@ const ALL_PROXY_ENV_KEYS = [
|
||||
...NO_PROXY_ENV_KEYS,
|
||||
...PROXY_ACTIVE_KEYS,
|
||||
] as const;
|
||||
const GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS = [
|
||||
...ALL_PROXY_ENV_KEYS,
|
||||
"all_proxy",
|
||||
"ALL_PROXY",
|
||||
] as const;
|
||||
type ProxyEnvKey = (typeof ALL_PROXY_ENV_KEYS)[number];
|
||||
type ProxyEnvSnapshot = Record<ProxyEnvKey, string | undefined>;
|
||||
type GatewayControlPlaneProxyBypassEnvKey =
|
||||
(typeof GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS)[number];
|
||||
type GatewayControlPlaneProxyBypassEnvSnapshot = Record<
|
||||
GatewayControlPlaneProxyBypassEnvKey,
|
||||
string | undefined
|
||||
>;
|
||||
type NodeHttpStackSnapshot = {
|
||||
httpRequest: typeof http.request;
|
||||
httpGet: typeof http.get;
|
||||
@@ -107,16 +99,17 @@ function captureProxyEnv(): ProxyEnvSnapshot {
|
||||
NO_PROXY: process.env["NO_PROXY"],
|
||||
GLOBAL_AGENT_NO_PROXY: process.env["GLOBAL_AGENT_NO_PROXY"],
|
||||
OPENCLAW_PROXY_ACTIVE: process.env["OPENCLAW_PROXY_ACTIVE"],
|
||||
OPENCLAW_PROXY_LOOPBACK_MODE: process.env["OPENCLAW_PROXY_LOOPBACK_MODE"],
|
||||
};
|
||||
}
|
||||
|
||||
function injectProxyEnv(proxyUrl: string): ProxyEnvSnapshot {
|
||||
function injectProxyEnv(proxyUrl: string, loopbackMode: ProxyLoopbackMode): ProxyEnvSnapshot {
|
||||
const snapshot = captureProxyEnv();
|
||||
applyProxyEnv(proxyUrl);
|
||||
applyProxyEnv(proxyUrl, loopbackMode);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function applyProxyEnv(proxyUrl: string): void {
|
||||
function applyProxyEnv(proxyUrl: string, loopbackMode: ProxyLoopbackMode): void {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
process.env[key] = proxyUrl;
|
||||
}
|
||||
@@ -125,6 +118,7 @@ function applyProxyEnv(proxyUrl: string): void {
|
||||
}
|
||||
process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"] = "true";
|
||||
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
|
||||
process.env["OPENCLAW_PROXY_LOOPBACK_MODE"] = loopbackMode;
|
||||
for (const key of NO_PROXY_ENV_KEYS) {
|
||||
process.env[key] = "";
|
||||
}
|
||||
@@ -141,39 +135,6 @@ function restoreProxyEnv(snapshot: ProxyEnvSnapshot): void {
|
||||
}
|
||||
}
|
||||
|
||||
function captureGatewayControlPlaneProxyBypassEnv(): GatewayControlPlaneProxyBypassEnvSnapshot {
|
||||
const snapshot = {} as GatewayControlPlaneProxyBypassEnvSnapshot;
|
||||
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
|
||||
snapshot[key] = process.env[key];
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreGatewayControlPlaneProxyBypassEnv(
|
||||
snapshot: GatewayControlPlaneProxyBypassEnvSnapshot,
|
||||
): void {
|
||||
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
|
||||
const value = snapshot[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withoutGatewayControlPlaneProxyEnv<T>(run: () => T): T {
|
||||
const snapshot = captureGatewayControlPlaneProxyBypassEnv();
|
||||
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
restoreGatewayControlPlaneProxyBypassEnv(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreGlobalAgentRuntime(snapshot: ProxyEnvSnapshot): void {
|
||||
if (
|
||||
typeof global === "undefined" ||
|
||||
@@ -387,15 +348,28 @@ function redactProxyUrlForLog(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureInheritedManagedProxyRoutingActive(): void {
|
||||
if (process.env["OPENCLAW_PROXY_ACTIVE"] !== "1") {
|
||||
return;
|
||||
}
|
||||
const proxyUrl = process.env["GLOBAL_AGENT_HTTP_PROXY"] ?? process.env["HTTP_PROXY"];
|
||||
if (!proxyUrl || !isSupportedProxyUrl(proxyUrl)) {
|
||||
return;
|
||||
}
|
||||
bootstrapNodeHttpStack(proxyUrl);
|
||||
forceResetGlobalDispatcher();
|
||||
}
|
||||
|
||||
export async function startProxy(config: ProxyConfig | undefined): Promise<ProxyHandle | null> {
|
||||
if (config?.enabled !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const proxyUrl = resolveProxyUrl(config);
|
||||
const loopbackMode = config.loopbackMode ?? "gateway-only";
|
||||
const activeProxyUrl = getActiveManagedProxyUrl();
|
||||
if (activeProxyUrl) {
|
||||
const registration = registerActiveManagedProxyUrl(new URL(proxyUrl));
|
||||
const registration = registerActiveManagedProxyUrl(new URL(proxyUrl), loopbackMode);
|
||||
const handle: ProxyHandle = {
|
||||
proxyUrl,
|
||||
injectedProxyUrl: proxyUrl,
|
||||
@@ -415,10 +389,10 @@ export async function startProxy(config: ProxyConfig | undefined): Promise<Proxy
|
||||
let registration: ActiveManagedProxyRegistration | null = null;
|
||||
|
||||
try {
|
||||
injectedEnvSnapshot = injectProxyEnv(proxyUrl);
|
||||
injectedEnvSnapshot = injectProxyEnv(proxyUrl, loopbackMode);
|
||||
forceResetGlobalDispatcher();
|
||||
bootstrapNodeHttpStack(proxyUrl);
|
||||
registration = registerActiveManagedProxyUrl(new URL(proxyUrl));
|
||||
registration = registerActiveManagedProxyUrl(new URL(proxyUrl), loopbackMode);
|
||||
} catch (err) {
|
||||
restoreAfterFailedProxyActivation(lifecycleBaseEnvSnapshot);
|
||||
throw new Error(`proxy: failed to activate external proxy routing: ${String(err)}`, {
|
||||
@@ -456,73 +430,134 @@ export async function stopProxy(handle: ProxyHandle | null): Promise<void> {
|
||||
await handle.stop();
|
||||
}
|
||||
|
||||
function isGatewayLoopbackControlPlaneUrl(value: string): boolean {
|
||||
let url: URL;
|
||||
function parseGatewayControlPlaneUrl(value: string): URL | null {
|
||||
try {
|
||||
url = new URL(value);
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isGatewayControlPlaneProtocol(protocol: string): boolean {
|
||||
return protocol === "ws:" || protocol === "wss:" || protocol === "http:" || protocol === "https:";
|
||||
}
|
||||
|
||||
function getGatewayControlPlaneNoProxyAuthority(value: string): string | null {
|
||||
const url = parseGatewayControlPlaneUrl(value);
|
||||
if (
|
||||
url.protocol !== "ws:" &&
|
||||
url.protocol !== "wss:" &&
|
||||
url.protocol !== "http:" &&
|
||||
url.protocol !== "https:"
|
||||
url === null ||
|
||||
!isGatewayControlPlaneProtocol(url.protocol) ||
|
||||
!isGatewayControlPlaneLoopbackHost(url.hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
||||
}
|
||||
|
||||
function unbracketHost(hostname: string): string {
|
||||
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
||||
}
|
||||
|
||||
function isGatewayControlPlaneIpv6LoopbackUrl(value: string): boolean {
|
||||
const url = parseGatewayControlPlaneUrl(value);
|
||||
if (
|
||||
url === null ||
|
||||
!isGatewayControlPlaneProtocol(url.protocol) ||
|
||||
!isGatewayControlPlaneLoopbackHost(url.hostname)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return isGatewayControlPlaneLoopbackHost(url.hostname);
|
||||
return isIP(unbracketHost(url.hostname)) === 6;
|
||||
}
|
||||
|
||||
function readGlobalAgentNoProxy(): string {
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"];
|
||||
if (!isRecord(agent)) {
|
||||
return "";
|
||||
}
|
||||
return typeof agent["NO_PROXY"] === "string" ? agent["NO_PROXY"] : "";
|
||||
}
|
||||
|
||||
function writeGlobalAgentNoProxy(value: string): void {
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"];
|
||||
if (isRecord(agent)) {
|
||||
agent["NO_PROXY"] = value === "" ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
function appendNoProxyAuthority(noProxy: string, authority: string): string {
|
||||
const entries = noProxy.split(/[\s,]+/).filter(Boolean);
|
||||
return entries.includes(authority) ? noProxy : [...entries, authority].join(",");
|
||||
}
|
||||
|
||||
function disableGlobalAgentProxyForIpv6GatewayLoopback(url: string): (() => void) | undefined {
|
||||
if (
|
||||
getActiveManagedProxyLoopbackMode() !== "gateway-only" ||
|
||||
!isGatewayControlPlaneIpv6LoopbackUrl(url)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"];
|
||||
if (!isRecord(agent)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previousHttpProxy = agent["HTTP_PROXY"];
|
||||
const previousHttpsProxy = agent["HTTPS_PROXY"];
|
||||
agent["HTTP_PROXY"] = null;
|
||||
agent["HTTPS_PROXY"] = null;
|
||||
let stopped = false;
|
||||
return () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
agent["HTTP_PROXY"] = previousHttpProxy;
|
||||
agent["HTTPS_PROXY"] = previousHttpsProxy;
|
||||
};
|
||||
}
|
||||
|
||||
export function registerManagedProxyGatewayLoopbackNoProxy(url: string): (() => void) | undefined {
|
||||
const authority = getGatewayControlPlaneNoProxyAuthority(url);
|
||||
if (!authority) {
|
||||
return undefined;
|
||||
}
|
||||
const loopbackMode = getActiveManagedProxyLoopbackMode();
|
||||
if (loopbackMode === "block") {
|
||||
throw new Error(
|
||||
"proxy: Gateway loopback control-plane connections are blocked by proxy.loopbackMode",
|
||||
);
|
||||
}
|
||||
if (loopbackMode === "proxy") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previousNoProxy = readGlobalAgentNoProxy();
|
||||
writeGlobalAgentNoProxy(appendNoProxyAuthority(previousNoProxy, authority));
|
||||
let stopped = false;
|
||||
return () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
writeGlobalAgentNoProxy(previousNoProxy);
|
||||
};
|
||||
}
|
||||
|
||||
export function withManagedProxyGatewayLoopbackRouting<T>(url: string, run: () => T): T {
|
||||
let unregisterNoProxy: (() => void) | undefined;
|
||||
let restoreIpv6Bypass: (() => void) | undefined;
|
||||
try {
|
||||
unregisterNoProxy = registerManagedProxyGatewayLoopbackNoProxy(url);
|
||||
restoreIpv6Bypass = disableGlobalAgentProxyForIpv6GatewayLoopback(url);
|
||||
return run();
|
||||
} finally {
|
||||
restoreIpv6Bypass?.();
|
||||
unregisterNoProxy?.();
|
||||
}
|
||||
}
|
||||
|
||||
function isGatewayControlPlaneLoopbackHost(hostname: string): boolean {
|
||||
const normalizedHost = hostname.trim().toLowerCase().replace(/\.+$/, "");
|
||||
return normalizedHost === "localhost" || isLoopbackIpAddress(hostname);
|
||||
}
|
||||
|
||||
export function dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane<T>(
|
||||
url: string,
|
||||
run: () => T,
|
||||
): T {
|
||||
if (!isGatewayLoopbackControlPlaneUrl(url)) {
|
||||
throw new Error("proxy: dangerous Gateway control-plane bypass is loopback-only");
|
||||
}
|
||||
|
||||
const snapshot = nodeHttpStackSnapshot;
|
||||
if (!snapshot) {
|
||||
return withoutGatewayControlPlaneProxyEnv(run);
|
||||
}
|
||||
|
||||
// Security-sensitive: this temporarily removes managed proxy hooks for the
|
||||
// synchronous Gateway loopback WebSocket constructor only. Do not reuse this
|
||||
// helper for provider, plugin, user WebUI, model server, or arbitrary egress.
|
||||
return withoutGatewayControlPlaneProxyEnv(() => {
|
||||
const activeStack = captureNodeHttpStack();
|
||||
const globalRecord = global as Record<string, unknown>;
|
||||
try {
|
||||
http.request = snapshot.httpRequest;
|
||||
http.get = snapshot.httpGet;
|
||||
http.globalAgent = snapshot.httpGlobalAgent;
|
||||
https.request = snapshot.httpsRequest;
|
||||
https.get = snapshot.httpsGet;
|
||||
https.globalAgent = snapshot.httpsGlobalAgent;
|
||||
if (snapshot.hadGlobalAgent) {
|
||||
globalRecord["GLOBAL_AGENT"] = snapshot.globalAgent;
|
||||
} else {
|
||||
delete globalRecord["GLOBAL_AGENT"];
|
||||
}
|
||||
return run();
|
||||
} finally {
|
||||
http.request = activeStack.httpRequest;
|
||||
http.get = activeStack.httpGet;
|
||||
http.globalAgent = activeStack.httpGlobalAgent;
|
||||
https.request = activeStack.httpsRequest;
|
||||
https.get = activeStack.httpsGet;
|
||||
https.globalAgent = activeStack.httpsGlobalAgent;
|
||||
if (activeStack.hadGlobalAgent) {
|
||||
globalRecord["GLOBAL_AGENT"] = activeStack.globalAgent;
|
||||
} else {
|
||||
delete globalRecord["GLOBAL_AGENT"];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
87
src/node-host/runner.test.ts
Normal file
87
src/node-host/runner.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayClientOptions } from "../gateway/client.js";
|
||||
import { runNodeHost } from "./runner.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
capturedGatewayClientOptions: [] as GatewayClientOptions[],
|
||||
ensureNodeHostConfig: vi.fn(async () => ({
|
||||
version: 1,
|
||||
nodeId: "node-test",
|
||||
})),
|
||||
saveNodeHostConfig: vi.fn(async () => undefined),
|
||||
getRuntimeConfig: vi.fn(() => ({
|
||||
gateway: {
|
||||
handshakeTimeoutMs: 1_000,
|
||||
},
|
||||
})),
|
||||
startGatewayClientWhenEventLoopReady: vi.fn(async () => ({
|
||||
ready: false,
|
||||
aborted: false,
|
||||
elapsedMs: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
getRuntimeConfig: mocks.getRuntimeConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/client-start-readiness.js", () => ({
|
||||
startGatewayClientWhenEventLoopReady: mocks.startGatewayClientWhenEventLoopReady,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/client.js", () => ({
|
||||
GatewayClient: function GatewayClient(opts: GatewayClientOptions) {
|
||||
mocks.capturedGatewayClientOptions.push(opts);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/connection-auth.js", () => ({
|
||||
resolveGatewayConnectionAuth: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/device-identity.js", () => ({
|
||||
loadOrCreateDeviceIdentity: vi.fn(() => ({
|
||||
id: "device-test",
|
||||
publicKey: "public-key-test",
|
||||
privateKey: "private-key-test",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/machine-name.js", () => ({
|
||||
getMachineDisplayName: vi.fn(async () => "test-node"),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/path-env.js", () => ({
|
||||
ensureOpenClawCliOnPath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
ensureNodeHostConfig: mocks.ensureNodeHostConfig,
|
||||
saveNodeHostConfig: mocks.saveNodeHostConfig,
|
||||
}));
|
||||
|
||||
vi.mock("./plugin-node-host.js", () => ({
|
||||
ensureNodeHostPluginRegistry: vi.fn(async () => undefined),
|
||||
listRegisteredNodeHostCapsAndCommands: vi.fn(() => ({
|
||||
caps: [],
|
||||
commands: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("runNodeHost", () => {
|
||||
it("passes the resolved Gateway URL to the Gateway client", async () => {
|
||||
await expect(
|
||||
runNodeHost({
|
||||
gatewayHost: "127.0.0.1",
|
||||
gatewayPort: 18789,
|
||||
}),
|
||||
).rejects.toThrow("event loop readiness timeout");
|
||||
|
||||
expect(mocks.capturedGatewayClientOptions).toHaveLength(1);
|
||||
expect(mocks.capturedGatewayClientOptions[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -541,6 +541,34 @@ describe("GatewayChatClient", () => {
|
||||
).toBe(30_000);
|
||||
});
|
||||
|
||||
it("surfaces loopback block-mode start failures through disconnect handler", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js");
|
||||
const proxyHandle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
loopbackMode: "block",
|
||||
});
|
||||
const onDisconnected = vi.fn();
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
client.onDisconnected = onDisconnected;
|
||||
|
||||
try {
|
||||
client.start();
|
||||
await vi.advanceTimersByTimeAsync(2);
|
||||
|
||||
expect(onDisconnected).toHaveBeenCalledWith(
|
||||
expect.stringContaining("blocked by proxy.loopbackMode"),
|
||||
);
|
||||
} finally {
|
||||
await stopProxy(proxyHandle);
|
||||
}
|
||||
});
|
||||
|
||||
it("retries startup-unavailable chat history until the gateway finishes booting", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ type ResolvedGatewayConnection = {
|
||||
token?: string;
|
||||
password?: string;
|
||||
preauthHandshakeTimeoutMs?: number;
|
||||
allowInsecureLocalOperatorUi?: boolean;
|
||||
allowInsecureLocalOperatorUi: boolean;
|
||||
};
|
||||
|
||||
function throwGatewayAuthResolutionError(reason: string): never {
|
||||
@@ -163,11 +163,15 @@ export class GatewayChatClient implements TuiBackend {
|
||||
start() {
|
||||
void startGatewayClientWhenEventLoopReady(this.client, {
|
||||
clientOptions: { preauthHandshakeTimeoutMs: this.connection.preauthHandshakeTimeoutMs },
|
||||
}).then((readiness) => {
|
||||
if (!readiness.ready && !readiness.aborted) {
|
||||
this.onDisconnected?.("gateway event loop readiness timeout");
|
||||
}
|
||||
});
|
||||
})
|
||||
.then((readiness) => {
|
||||
if (!readiness.ready && !readiness.aborted) {
|
||||
this.onDisconnected?.("gateway event loop readiness timeout");
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
this.onDisconnected?.(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
|
||||
Reference in New Issue
Block a user