From 715893631eeede4660f967cf09f7b6342d5b2550 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 4 May 2026 02:30:39 +1000 Subject: [PATCH] fix: use managed proxy state for APNs --- package.json | 7 +-- src/infra/net/proxy/active-proxy-state.ts | 44 ++++++++++++++++++ src/infra/net/proxy/proxy-lifecycle.test.ts | 19 ++++++++ src/infra/net/proxy/proxy-lifecycle.ts | 51 ++++++++------------- src/infra/push-apns-http2.test.ts | 40 +++++++++++++--- src/infra/push-apns-http2.ts | 6 +-- 6 files changed, 119 insertions(+), 48 deletions(-) create mode 100644 src/infra/net/proxy/active-proxy-state.ts diff --git a/package.json b/package.json index e27c43d620e..5551e9b955b 100644 --- a/package.json +++ b/package.json @@ -1420,13 +1420,8 @@ "lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts", "lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs", "lint:plugins:plugin-sdk-subpaths-exported": "node scripts/check-plugin-sdk-subpath-exports.mjs", -<<<<<<< HEAD - "lint:scripts": "pnpm lint:docker-e2e && node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json scripts", + "lint:scripts": "pnpm lint:docker-e2e && pnpm lint:tmp:no-raw-http2-imports && node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json scripts", "lint:swift": "swiftlint lint --config config/swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", -======= - "lint:scripts": "pnpm lint:docker-e2e && pnpm lint:tmp:no-raw-http2-imports && node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.scripts.json scripts", - "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", ->>>>>>> 59b4c14509 (lint: ban raw HTTP2 imports) "lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs", "lint:tmp:dynamic-import-warts": "node scripts/check-dynamic-import-warts.mjs", "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", diff --git a/src/infra/net/proxy/active-proxy-state.ts b/src/infra/net/proxy/active-proxy-state.ts new file mode 100644 index 00000000000..f2baa2602ee --- /dev/null +++ b/src/infra/net/proxy/active-proxy-state.ts @@ -0,0 +1,44 @@ +export type ActiveManagedProxyRegistration = { + proxyUrl: string; + stopped: boolean; +}; + +let activeProxyUrl: string | undefined; +let activeProxyHandleCount = 0; + +export function registerActiveManagedProxyUrl(proxyUrl: string): ActiveManagedProxyRegistration { + if (activeProxyUrl !== undefined && activeProxyUrl !== proxyUrl) { + throw new Error( + "proxy: cannot activate a different managed proxy while another proxy is active; " + + "stop the current proxy before changing proxy.proxyUrl.", + ); + } + + activeProxyUrl = proxyUrl; + activeProxyHandleCount += 1; + return { proxyUrl, stopped: false }; +} + +export function stopActiveManagedProxyRegistration( + registration: ActiveManagedProxyRegistration, +): void { + if (registration.stopped) { + return; + } + registration.stopped = true; + if (activeProxyHandleCount > 0) { + activeProxyHandleCount -= 1; + } + if (activeProxyHandleCount === 0) { + activeProxyUrl = undefined; + } +} + +export function getActiveManagedProxyUrl(): string | undefined { + return activeProxyUrl; +} + +export function _resetActiveManagedProxyStateForTests(): void { + activeProxyUrl = undefined; + activeProxyHandleCount = 0; +} diff --git a/src/infra/net/proxy/proxy-lifecycle.test.ts b/src/infra/net/proxy/proxy-lifecycle.test.ts index e98215418f9..af87b79d492 100644 --- a/src/infra/net/proxy/proxy-lifecycle.test.ts +++ b/src/infra/net/proxy/proxy-lifecycle.test.ts @@ -19,6 +19,7 @@ vi.mock("../../../logger.js", () => ({ import { bootstrap as bootstrapGlobalAgent } from "global-agent"; import { logInfo, logWarn } from "../../../logger.js"; import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js"; +import { _resetActiveManagedProxyStateForTests } from "./active-proxy-state.js"; import { _resetGlobalAgentBootstrapForTests, dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane, @@ -66,6 +67,7 @@ describe("startProxy", () => { mockLogInfo.mockReset(); mockLogWarn.mockReset(); _resetGlobalAgentBootstrapForTests(); + _resetActiveManagedProxyStateForTests(); (global as Record)["GLOBAL_AGENT"] = undefined; http.request = originalHttpRequest; http.get = originalHttpGet; @@ -113,6 +115,23 @@ describe("startProxy", () => { expect(mockLogWarn).not.toHaveBeenCalled(); }); + it("exposes the active managed proxy URL", async () => { + const { getActiveManagedProxyUrl } = await import("./active-proxy-state.js"); + + expect(getActiveManagedProxyUrl()).toBeUndefined(); + + const handle = await startProxy({ + enabled: true, + proxyUrl: "http://127.0.0.1:3128", + }); + + expect(getActiveManagedProxyUrl()).toBe("http://127.0.0.1:3128"); + + await stopProxy(handle); + + expect(getActiveManagedProxyUrl()).toBeUndefined(); + }); + it("uses OPENCLAW_PROXY_URL when config proxyUrl is omitted", async () => { process.env["OPENCLAW_PROXY_URL"] = "http://127.0.0.1:3128"; diff --git a/src/infra/net/proxy/proxy-lifecycle.ts b/src/infra/net/proxy/proxy-lifecycle.ts index 7b6ffe2fe88..7669f7c2127 100644 --- a/src/infra/net/proxy/proxy-lifecycle.ts +++ b/src/infra/net/proxy/proxy-lifecycle.ts @@ -15,6 +15,12 @@ import type { ProxyConfig } from "../../../config/zod-schema.proxy.js"; import { logInfo, logWarn } from "../../../logger.js"; import { isLoopbackIpAddress } from "../../../shared/net/ip.js"; import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js"; +import { + getActiveManagedProxyUrl, + registerActiveManagedProxyUrl, + stopActiveManagedProxyRegistration, + type ActiveManagedProxyRegistration, +} from "./active-proxy-state.js"; export type ProxyHandle = { /** The operator-managed proxy URL injected into process.env. */ @@ -64,10 +70,6 @@ type NodeHttpStackSnapshot = { hadGlobalAgent: boolean; globalAgent: unknown; }; -type ActiveProxyRegistration = { - proxyUrl: string; - stopped: boolean; -}; type GlobalAgentConnectConfiguration = Record & { host: string; tls: Record; @@ -82,14 +84,12 @@ type GlobalAgentHttpsAgent = { let globalAgentBootstrapped = false; let nodeHttpStackSnapshot: NodeHttpStackSnapshot | null = null; -let activeProxyRegistrations: ActiveProxyRegistration[] = []; let baseProxyEnvSnapshot: ProxyEnvSnapshot | null = null; let patchedGlobalAgentHttpsAgents = new WeakSet(); export function _resetGlobalAgentBootstrapForTests(): void { globalAgentBootstrapped = false; nodeHttpStackSnapshot = null; - activeProxyRegistrations = []; baseProxyEnvSnapshot = null; patchedGlobalAgentHttpsAgents = new WeakSet(); } @@ -302,16 +302,6 @@ function patchGlobalAgentHttpsConnectTlsTargetHost(): void { patchedGlobalAgentHttpsAgents.add(agent); } -function findTopActiveProxyRegistration(): ActiveProxyRegistration | null { - for (let index = activeProxyRegistrations.length - 1; index >= 0; index -= 1) { - const registration = activeProxyRegistrations[index]; - if (!registration.stopped) { - return registration; - } - } - return null; -} - function resetUndiciDispatcherForProxyLifecycle(): void { try { forceResetGlobalDispatcher(); @@ -354,27 +344,26 @@ function restoreInactiveProxyRuntime(snapshot: ProxyEnvSnapshot): void { } function restoreAfterFailedProxyActivation( - previousActiveRegistration: ActiveProxyRegistration | null, + previousActiveProxyUrl: string | undefined, restoreSnapshot: ProxyEnvSnapshot, ): void { - if (previousActiveRegistration) { - reapplyActiveProxyRuntime(previousActiveRegistration.proxyUrl); + if (previousActiveProxyUrl) { + reapplyActiveProxyRuntime(previousActiveProxyUrl); return; } restoreInactiveProxyRuntime(restoreSnapshot); baseProxyEnvSnapshot = null; } -function stopActiveProxyRegistration(registration: ActiveProxyRegistration): void { +function stopActiveProxyRegistration(registration: ActiveManagedProxyRegistration): void { if (registration.stopped) { return; } - registration.stopped = true; - activeProxyRegistrations = activeProxyRegistrations.filter((entry) => !entry.stopped); + stopActiveManagedProxyRegistration(registration); - const nextActiveRegistration = findTopActiveProxyRegistration(); - if (nextActiveRegistration) { - reapplyActiveProxyRuntime(nextActiveRegistration.proxyUrl); + const nextActiveProxyUrl = getActiveManagedProxyUrl(); + if (nextActiveProxyUrl) { + reapplyActiveProxyRuntime(nextActiveProxyUrl); return; } @@ -424,23 +413,19 @@ export async function startProxy(config: ProxyConfig | undefined): Promise { const fakeSession = { close: vi.fn(), destroy: vi.fn() }; @@ -28,13 +28,18 @@ vi.mock("./net/http-connect-tunnel.js", () => ({ })); describe("connectApnsHttp2Session", () => { - it("uses direct http2.connect when no HTTPS proxy is configured", async () => { + beforeEach(() => { + connectSpy.mockClear(); + tlsConnectSpy.mockClear(); + tunnelSpy.mockClear(); + }); + it("uses direct http2.connect when managed proxy is inactive", async () => { const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); const session = await connectApnsHttp2Session({ authority: "https://api.sandbox.push.apple.com", timeoutMs: 10_000, - env: {}, + getManagedProxyUrl: () => undefined, }); expect(session).toBe(fakeSession); @@ -42,13 +47,13 @@ describe("connectApnsHttp2Session", () => { expect(connectSpy).toHaveBeenCalledWith("https://api.sandbox.push.apple.com"); }); - it("uses an HTTP CONNECT tunnel and disables direct fallback when HTTPS proxy is configured", async () => { + it("uses an HTTP CONNECT tunnel when managed proxy is active", async () => { const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); const session = await connectApnsHttp2Session({ authority: "https://api.push.apple.com", timeoutMs: 10_000, - env: { HTTPS_PROXY: "http://proxy.example:8080" }, + getManagedProxyUrl: () => "http://proxy.example:8080", }); expect(session).toBe(fakeSession); @@ -73,6 +78,29 @@ describe("connectApnsHttp2Session", () => { expect(createConnection?.(new URL("https://api.push.apple.com"), {})).toBe(fakeTlsSocket); }); + it("ignores ambient proxy env when managed proxy is inactive", async () => { + const originalHttpsProxy = process.env["HTTPS_PROXY"]; + process.env["HTTPS_PROXY"] = "http://ambient.example:8080"; + try { + const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); + + const session = await connectApnsHttp2Session({ + authority: "https://api.push.apple.com", + timeoutMs: 10_000, + getManagedProxyUrl: () => undefined, + }); + + expect(session).toBe(fakeSession); + expect(tunnelSpy).not.toHaveBeenCalled(); + } finally { + if (originalHttpsProxy === undefined) { + delete process.env["HTTPS_PROXY"]; + } else { + process.env["HTTPS_PROXY"] = originalHttpsProxy; + } + } + }); + it("rejects non-APNs authorities", async () => { const { connectApnsHttp2Session } = await import("./push-apns-http2.js"); @@ -80,7 +108,7 @@ describe("connectApnsHttp2Session", () => { connectApnsHttp2Session({ authority: "https://example.com", timeoutMs: 10_000, - env: { HTTPS_PROXY: "http://proxy.example:8080" }, + getManagedProxyUrl: () => "http://proxy.example:8080", }), ).rejects.toThrow("Unsupported APNs authority"); }); diff --git a/src/infra/push-apns-http2.ts b/src/infra/push-apns-http2.ts index 671d26dd9f5..898b4ae7e5d 100644 --- a/src/infra/push-apns-http2.ts +++ b/src/infra/push-apns-http2.ts @@ -1,7 +1,7 @@ import http2 from "node:http2"; import tls from "node:tls"; import { openHttpConnectTunnel } from "./net/http-connect-tunnel.js"; -import { resolveEnvHttpProxyUrl } from "./net/proxy-env.js"; +import { getActiveManagedProxyUrl } from "./net/proxy/active-proxy-state.js"; const APNS_AUTHORITIES = new Set([ "https://api.push.apple.com", @@ -13,7 +13,7 @@ type ApnsAuthority = "https://api.push.apple.com" | "https://api.sandbox.push.ap export type ConnectApnsHttp2SessionParams = { authority: string; timeoutMs: number; - env?: NodeJS.ProcessEnv; + getManagedProxyUrl?: () => string | undefined; }; function assertApnsAuthority(authority: string): ApnsAuthority { @@ -34,7 +34,7 @@ export async function connectApnsHttp2Session( params: ConnectApnsHttp2SessionParams, ): Promise { const authority = assertApnsAuthority(params.authority); - const proxyUrl = resolveEnvHttpProxyUrl("https", params.env); + const proxyUrl = (params.getManagedProxyUrl ?? getActiveManagedProxyUrl)(); if (!proxyUrl) { return http2.connect(authority); }