Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions packages/cli/src/browser/launch.proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import puppeteer, { type Browser } from "puppeteer-core";
import { acquireBrowser, type BrowserLease } from "@hyperframes/engine";
import { launchManagedBrowser } from "./launch.js";
import { ensureBrowser } from "./manager.js";

async function listen(server: Server): Promise<number> {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
return (server.address() as AddressInfo).port;
}

async function close(server: Server): Promise<void> {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}

afterEach(() => vi.unstubAllEnvs());

describe("native browser proxy routing", () => {
it("refuses credentials from an explicit launch environment before starting Chrome", async () => {
await expect(
launchManagedBrowser(puppeteer, { env: { HTTPS_PROXY: "http://user:password@proxy.test" } }),
).rejects.toThrow("HTTPS_PROXY contains proxy credentials.");
});

it.each(["cli", "engine"] as const)(
"%s launch routes external fetches through the proxy and bypasses loopback",
async (owner) => {
const requests: string[] = [];
const connections: string[] = [];
const proxy = createServer((request, response) => {
requests.push(request.url ?? "");
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Content-Type": "text/plain",
});
response.end("local-proxy-witness");
});
proxy.on("connect", (request, socket) => {
connections.push(request.url ?? "");
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
});
const fileServer = createServer((_request, response) => {
response.writeHead(200, { "Content-Type": "text/html" });
response.end("<!doctype html><title>loopback-witness</title>");
});
let browser: Browser | undefined;
let lease: BrowserLease | undefined;
try {
const proxyPort = await listen(proxy);
const filePort = await listen(fileServer);
vi.stubEnv("http_proxy", `http://127.0.0.1:${proxyPort}`);
vi.stubEnv("https_proxy", `http://127.0.0.1:${proxyPort}`);
vi.stubEnv("all_proxy", "");
vi.stubEnv("no_proxy", "");
const { executablePath } = await ensureBrowser();
const args = ["--no-sandbox", "--disable-dev-shm-usage", "--disable-background-networking"];
if (owner === "cli") {
browser = await launchManagedBrowser(puppeteer, { executablePath, args, headless: true });
} else {
lease = await acquireBrowser(args, {
chromePath: executablePath,
enableBrowserPool: false,
forceScreenshot: true,
});
browser = lease.browser;
}
expect(browser.process()?.spawnargs).toContain(
`--proxy-server=http=http://127.0.0.1:${proxyPort};https=http://127.0.0.1:${proxyPort}`,
);
const page = await browser.newPage();
await page.goto(`http://127.0.0.1:${filePort}/`);
expect(await page.title()).toBe("loopback-witness");
expect(
await page.evaluate(async () => {
const response = await fetch("http://render-network-probe.invalid/probe", {
signal: AbortSignal.timeout(5_000),
});
return response.text();
}),
).toBe("local-proxy-witness");
expect(requests).toContain("http://render-network-probe.invalid/probe");
expect(requests.some((url) => url.includes(`127.0.0.1:${filePort}`))).toBe(false);
const denied = await page.evaluate(async () => {
try {
await fetch("https://render-network-probe.invalid/probe", {
signal: AbortSignal.timeout(5_000),
});
return false;
} catch {
return true;
}
});
expect(denied).toBe(true);
expect(connections).toContain("render-network-probe.invalid:443");
} finally {
if (lease) await lease.release();
else await browser?.close();
if (fileServer.listening) await close(fileServer);
if (proxy.listening) await close(proxy);
}
},
60_000,
);
});
3 changes: 3 additions & 0 deletions packages/cli/src/browser/launch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolveChromeProxyArgs } from "@hyperframes/engine";
import type { Browser, LaunchOptions, PuppeteerNode } from "puppeteer-core";
import {
resolveCaptureBrowserGpuMode,
Expand Down Expand Up @@ -47,11 +48,13 @@ export async function launchManagedBrowser(
options: LaunchOptions,
): Promise<Browser> {
if (shutdown) throw new Error("The CLI is stopping; no browser can start.");
const args = [...(options.args ?? []), ...resolveChromeProxyArgs(options.env ?? process.env)];
ownSignals();
const abort = new AbortController();
const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal;
const launch = puppeteer.launch({
...options,
args,
signal,
handleSIGINT: false,
handleSIGTERM: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@ vi.mock("../browser/manager.js", () => ({

vi.mock("puppeteer-core", () => ({ default: { launch: mocks.launch } }));

// captureCompositionFrame.js imports these two from ../browser/gpuPolicy.js,
// which re-exports them from this mocked module — unmocked here, they'd read
// undefined and throw as soon as openSettledCompositionPage calls them.
vi.mock("@hyperframes/engine", () => ({
vi.mock("@hyperframes/engine", async (importOriginal) => ({
...(await importOriginal<typeof import("@hyperframes/engine")>()),
buildChromeArgs: mocks.buildChromeArgs,
resolveBrowserGpuMode: mocks.resolveBrowserGpuMode,
compositionRequiresWebGpu: mocks.compositionRequiresWebGpu,
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export {
} from "./services/systemMemory.js";

// ── Browser management ─────────────────────────────────────────────────────────
export { resolveChromeProxyArgs } from "./services/chromeProxy.js";
export { chromeMajorCeiling } from "./services/chromeHostCeiling.js";
export {
acquireBrowser,
Expand Down
66 changes: 66 additions & 0 deletions packages/engine/src/services/browserManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,49 @@ describe("resolveBrowserGpuMode", () => {
_resetAutoBrowserGpuModeCacheForTests();
});

it("routes the GPU probe through the shared proxy policy", async () => {
const { launch } = setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA" });
vi.stubEnv("http_proxy", "");
vi.stubEnv("https_proxy", "http://proxy.test:8080");
vi.stubEnv("all_proxy", "");
try {
await resolveBrowserGpuMode("auto");
expect(launch.mock.calls[0]?.[0].args).toContain(
"--proxy-server=https=http://proxy.test:8080",
);
} finally {
vi.unstubAllEnvs();
}
});

it("refuses proxy credentials before the GPU probe without masking the error", async () => {
const { launch } = setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA" });
vi.stubEnv("https_proxy", "http://user:password@proxy.test");
try {
await expect(resolveBrowserGpuMode("auto")).rejects.toThrow(
"HTTPS_PROXY contains proxy credentials.",
);
expect(launch).not.toHaveBeenCalled();
} finally {
vi.unstubAllEnvs();
}
});

it("a refused proxy does not poison the next GPU probe after the environment is corrected", async () => {
const { launch } = setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA" });
vi.stubEnv("https_proxy", "http://user:password@proxy.test");
try {
await expect(resolveBrowserGpuMode("auto")).rejects.toThrow(
"HTTPS_PROXY contains proxy credentials.",
);
vi.stubEnv("https_proxy", "http://proxy.test:8080");
await expect(resolveBrowserGpuMode("auto")).resolves.toBe("hardware");
expect(launch).toHaveBeenCalledOnce();
} finally {
vi.unstubAllEnvs();
}
});

it("passes 'software' through unchanged without probing", async () => {
const mode = await resolveBrowserGpuMode("software");
expect(mode).toBe("software");
Expand Down Expand Up @@ -882,6 +925,29 @@ describe("browser pool", () => {
_setPuppeteerForTests(undefined);
});

it("includes resolved environment proxy flags in the pool identity", async () => {
vi.stubEnv("http_proxy", "http://127.0.0.1:18001");
vi.stubEnv("https_proxy", "http://127.0.0.1:18001");
vi.stubEnv("all_proxy", "");
vi.stubEnv("no_proxy", "example.test");
try {
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
vi.stubEnv("https_proxy", "http://127.0.0.1:18002");
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
expect(launchFn).toHaveBeenCalledTimes(2);
expect(launchFn.mock.calls[0]?.[0].args).toContain(
"--proxy-server=http=http://127.0.0.1:18001;https=http://127.0.0.1:18001",
);
expect(launchFn.mock.calls[1]?.[0].args).toContain(
"--proxy-server=http=http://127.0.0.1:18001;https=http://127.0.0.1:18002",
);
await first.release();
await second.release();
} finally {
vi.unstubAllEnvs();
}
});

it("sequential acquires with pool enabled return the same browser", async () => {
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
Expand Down
23 changes: 14 additions & 9 deletions packages/engine/src/services/browserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { execSync } from "child_process";
import { existsSync, readdirSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { resolveChromeProxyArgs } from "./chromeProxy.js";
import { chromeMajorCeiling, exceedsChromeCeiling } from "./chromeHostCeiling.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { getSystemTotalMb, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js";
Expand Down Expand Up @@ -505,11 +506,14 @@ function formatProbeFailure(err: unknown): string {
return `probe failed (${err instanceof Error ? err.message : String(err)})`;
}

async function probeAutoBrowserGpuMode(options: {
chromePath?: string;
browserTimeout?: number;
platform?: NodeJS.Platform;
}): Promise<GpuProbeOutcome> {
async function probeAutoBrowserGpuMode(
options: {
chromePath?: string;
browserTimeout?: number;
platform?: NodeJS.Platform;
},
proxyArgs: string[],
): Promise<GpuProbeOutcome> {
const platform = options.platform ?? process.platform;
const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
const executablePath = options.chromePath ?? resolveHeadlessShellPath({});
Expand All @@ -522,7 +526,7 @@ async function probeAutoBrowserGpuMode(options: {

try {
const info = await probeHardwareWebGlInfo(ppt, {
args: getHardwareGpuProbeArgs(platform),
args: [...getHardwareGpuProbeArgs(platform), ...proxyArgs],
browserTimeout,
executablePath,
});
Expand Down Expand Up @@ -561,7 +565,7 @@ async function probeAutoBrowserGpuMode(options: {
* safe failure mode; misclassifying toward hardware would error on the real
* render.
*/
export function resolveBrowserGpuMode(
export async function resolveBrowserGpuMode(
mode: EngineConfig["browserGpuMode"],
options: {
chromePath?: string;
Expand All @@ -571,7 +575,8 @@ export function resolveBrowserGpuMode(
): Promise<"software" | "hardware"> {
if (mode === "software") return Promise.resolve(mode);

_autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options);
const proxyArgs = resolveChromeProxyArgs(process.env);
_autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options, proxyArgs);
if (mode === "auto") return _autoBrowserGpuModeCache.then((probed) => probed.mode);

return _autoBrowserGpuModeCache.then((probed) => {
Expand Down Expand Up @@ -676,7 +681,7 @@ function createBrowserLaunchFingerprint(
? "beginframe"
: "screenshot";
return {
args: chromeArgs,
args: [...chromeArgs, ...resolveChromeProxyArgs(process.env)],
executablePath: headlessShell,
browserTimeoutMs: launchConfig.browserTimeout,
protocolTimeoutMs: launchConfig.protocolTimeout,
Expand Down
Loading
Loading