diff --git a/.github/actions/tests/run-e2e-tests/action.yml b/.github/actions/tests/run-e2e-tests/action.yml
index fd9c9e109..c36509752 100644
--- a/.github/actions/tests/run-e2e-tests/action.yml
+++ b/.github/actions/tests/run-e2e-tests/action.yml
@@ -18,7 +18,7 @@ runs:
- name: Run Playwright Tests (Ubuntu)
if: inputs.os == 'ubuntu'
shell: bash
- run: xvfb-run npm run test:e2e
+ run: xvfb-run dbus-run-session -- npm run test:e2e
- name: Run Playwright Tests
if: inputs.os != 'ubuntu'
diff --git a/.github/workflows/tests-linux.yml b/.github/workflows/tests-linux.yml
index b7a0ff3e4..de4c0b02e 100644
--- a/.github/workflows/tests-linux.yml
+++ b/.github/workflows/tests-linux.yml
@@ -59,7 +59,8 @@ jobs:
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
- xvfb
+ xvfb \
+ gsettings-desktop-schemas dbus-x11
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
diff --git a/package.json b/package.json
index fd9089b97..f33eb7177 100644
--- a/package.json
+++ b/package.json
@@ -80,7 +80,7 @@
"watch:common": "npm run watch --workspace=packages/bruno-common",
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
"test:codegen": "node playwright/codegen.ts",
- "test:e2e": "playwright test --project=default",
+ "test:e2e": "playwright test --project=default --project=system-pac",
"test:e2e:ssl": "playwright test --project=ssl",
"test:e2e:auth": "playwright test --project=auth",
"test:benchmark": "playwright test --config=playwright.benchmark.config.ts",
diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js
index 6c6a39e88..4d937c899 100644
--- a/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js
+++ b/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js
@@ -7,7 +7,7 @@ import StyledWrapper from '../StyledWrapper';
const SystemProxy = () => {
const dispatch = useDispatch();
const systemProxyVariables = useSelector((state) => state.app.systemProxyVariables);
- const { source, http_proxy, https_proxy, no_proxy } = systemProxyVariables || {};
+ const { source, http_proxy, https_proxy, no_proxy, pac_url } = systemProxyVariables || {};
const [isFetching, setIsFetching] = useState(true);
const [error, setError] = useState(null);
@@ -85,6 +85,12 @@ const SystemProxy = () => {
{no_proxy || '-'}
+
+
+
{pac_url || '-'}
+
{
+ async (error) => {
if (error.response) {
const end = Date.now();
const start = error.config.headers['request-start-time'];
@@ -179,7 +179,7 @@ function makeAxiosInstance({
const requestConfig = createRedirectConfig(error, redirectUrl);
- setupProxyAgents({
+ await setupProxyAgents({
requestConfig,
proxyMode,
proxyConfig,
diff --git a/packages/bruno-cli/src/utils/proxy-util.js b/packages/bruno-cli/src/utils/proxy-util.js
index 106602199..df14c4646 100644
--- a/packages/bruno-cli/src/utils/proxy-util.js
+++ b/packages/bruno-cli/src/utils/proxy-util.js
@@ -2,10 +2,14 @@ const parseUrl = require('url').parse;
const http = require('node:http');
const https = require('node:https');
const { isEmpty, get, isUndefined, isNull } = require('lodash');
-const { HttpsProxyAgent } = require('https-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
-const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
+const {
+ getOrCreateHttpsAgent,
+ getOrCreateHttpAgent,
+ resolveAgentsFromPac,
+ PatchedHttpsProxyAgent
+} = require('@usebruno/requests');
const { interpolateString } = require('../runner/interpolate-string');
const DEFAULT_PORTS = {
@@ -68,41 +72,7 @@ const shouldUseProxy = (url, proxyBypass) => {
});
};
-/**
- * Options that should be forwarded from the constructor to the target TLS upgrade.
- */
-const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'];
-
-/**
- * Patched version of HttpsProxyAgent that correctly handles TLS options for
- * both the proxy connection and the target server connection.
- *
- * The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194)
- * ignores constructor options when upgrading the tunneled socket to TLS for the
- * target server. This patch forwards the relevant TLS options to the target upgrade.
- */
-class PatchedHttpsProxyAgent extends HttpsProxyAgent {
- constructor(proxy, opts) {
- super(proxy, opts);
- this.constructorOpts = opts;
- }
-
- async connect(req, opts) {
- const targetOpts = { ...opts };
-
- if (this.constructorOpts) {
- for (const key of TARGET_TLS_OPTIONS) {
- if (key in this.constructorOpts) {
- targetOpts[key] = this.constructorOpts[key];
- }
- }
- }
-
- return super.connect(req, targetOpts);
- }
-}
-
-function setupProxyAgents({
+async function setupProxyAgents({
requestConfig,
proxyMode = 'off',
proxyConfig,
@@ -163,26 +133,36 @@ function setupProxyAgents({
}
} else if (proxyMode === 'system') {
try {
- const { http_proxy, https_proxy, no_proxy } = systemProxyConfig || {};
- const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
- if (shouldUseSystemProxy) {
+ const { http_proxy, https_proxy, no_proxy, pac_url } = systemProxyConfig || {};
+
+ // If the OS is configured with a PAC URL, resolve it using the existing PAC infrastructure
+ if (pac_url) {
try {
- if (http_proxy?.length && !isHttpsRequest) {
- const parsedHttpProxy = new URL(http_proxy);
- const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
- const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
- requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
+ const { httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, disableCache, hostname });
+ if (httpAgent) requestConfig.httpAgent = httpAgent;
+ if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
+ } catch (error) {}
+ } else {
+ const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
+ if (shouldUseSystemProxy) {
+ try {
+ if (http_proxy?.length && !isHttpsRequest) {
+ const parsedHttpProxy = new URL(http_proxy);
+ const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
+ const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
+ requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
+ }
+ } catch (error) {
+ throw new Error('Invalid system http_proxy');
}
- } catch (error) {
- throw new Error('Invalid system http_proxy');
- }
- try {
- if (https_proxy?.length && isHttpsRequest) {
- new URL(https_proxy);
- requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
+ try {
+ if (https_proxy?.length && isHttpsRequest) {
+ new URL(https_proxy);
+ requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
+ }
+ } catch (error) {
+ throw new Error('Invalid system https_proxy');
}
- } catch (error) {
- throw new Error('Invalid system https_proxy');
}
}
} catch (error) {}
diff --git a/packages/bruno-electron/src/ipc/network/cert-utils.js b/packages/bruno-electron/src/ipc/network/cert-utils.js
index a807ad347..e17cba331 100644
--- a/packages/bruno-electron/src/ipc/network/cert-utils.js
+++ b/packages/bruno-electron/src/ipc/network/cert-utils.js
@@ -148,7 +148,7 @@ const getCertsAndProxyConfig = async ({
} else if (globalProxySource === 'inherit') {
proxyMode = 'system';
const systemProxyConfig = await getCachedSystemProxy();
- proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, source: 'cache-miss' };
+ proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, pac_url: null, source: 'cache-miss' };
} else {
// source === 'manual'
proxyConfig = globalProxyConfigData;
diff --git a/packages/bruno-electron/src/store/system-proxy.js b/packages/bruno-electron/src/store/system-proxy.js
index b471af951..3a40daa59 100644
--- a/packages/bruno-electron/src/store/system-proxy.js
+++ b/packages/bruno-electron/src/store/system-proxy.js
@@ -12,6 +12,7 @@ const loadSystemProxy = async () => {
http_proxy: null,
https_proxy: null,
no_proxy: null,
+ pac_url: null,
source: 'error'
};
}
diff --git a/packages/bruno-electron/src/utils/proxy-util.js b/packages/bruno-electron/src/utils/proxy-util.js
index 8398ba311..9c2eb7873 100644
--- a/packages/bruno-electron/src/utils/proxy-util.js
+++ b/packages/bruno-electron/src/utils/proxy-util.js
@@ -1,14 +1,17 @@
const parseUrl = require('url').parse;
const https = require('node:https');
const http = require('node:http');
-const { HttpsProxyAgent } = require('https-proxy-agent');
const { interpolateString } = require('../ipc/network/interpolate-string');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent');
const { isEmpty, get, isUndefined, isNull } = require('lodash');
-const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
+const {
+ getOrCreateHttpsAgent,
+ getOrCreateHttpAgent,
+ resolveAgentsFromPac,
+ PatchedHttpsProxyAgent
+} = require('@usebruno/requests');
const { preferencesUtil } = require('../store/preferences');
-const { getPacResolver } = require('@usebruno/requests');
const DEFAULT_PORTS = {
ftp: 21,
@@ -70,40 +73,6 @@ const shouldUseProxy = (url, proxyBypass) => {
});
};
-/**
- * Options that should be forwarded from the constructor to the target TLS upgrade.
- */
-const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'];
-
-/**
- * Patched version of HttpsProxyAgent that correctly handles TLS options for
- * both the proxy connection and the target server connection.
- *
- * The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194)
- * ignores constructor options when upgrading the tunneled socket to TLS for the
- * target server. This patch forwards the relevant TLS options to the target upgrade.
- */
-class PatchedHttpsProxyAgent extends HttpsProxyAgent {
- constructor(proxy, opts) {
- super(proxy, opts);
- this.constructorOpts = opts;
- }
-
- async connect(req, opts) {
- const targetOpts = { ...opts };
-
- if (this.constructorOpts) {
- for (const key of TARGET_TLS_OPTIONS) {
- if (key in this.constructorOpts) {
- targetOpts[key] = this.constructorOpts[key];
- }
- }
- }
-
- return super.connect(req, targetOpts);
- }
-}
-
async function setupProxyAgents({
requestConfig,
proxyMode = 'off',
@@ -184,40 +153,58 @@ async function setupProxyAgents({
}
}
} else if (proxyMode === 'system') {
- const { http_proxy, https_proxy, no_proxy } = proxyConfig || {};
- const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
- if (shouldUseSystemProxy) {
+ const { http_proxy, https_proxy, no_proxy, pac_url } = proxyConfig || {};
+
+ // If the OS is configured with a PAC URL, resolve it using the existing PAC infrastructure
+ if (pac_url) {
+ if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `Resolving system PAC: ${pac_url}` });
try {
- if (http_proxy?.length && !isHttpsRequest) {
- const parsedHttpProxy = new URL(http_proxy);
- const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
- const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
- if (timeline) {
- timeline.push({
- timestamp: new Date(),
- type: 'info',
- message: `Using system proxy: ${http_proxy}`
- });
- }
- requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, timeline, disableCache, hostname });
+ const { directives, httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, timeline, disableCache, hostname });
+ if (httpAgent) requestConfig.httpAgent = httpAgent;
+ if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
+ if (directives) {
+ if (timeline) { timeline.push({ timestamp: new Date(), type: 'info', message: `PAC directives: ${directives.join('; ')}` }); }
+ } else {
+ if (timeline) { timeline.push({ timestamp: new Date(), type: 'info', message: 'System PAC resolved: DIRECT (no proxy)' }); }
}
- } catch (error) {
- throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`);
+ } catch (err) {
+ if (timeline) { timeline.push({ timestamp: new Date(), type: 'error', message: `System PAC resolution failed: ${err.message}` }); }
}
- try {
- if (https_proxy?.length && isHttpsRequest) {
- new URL(https_proxy);
- if (timeline) {
- timeline.push({
- timestamp: new Date(),
- type: 'info',
- message: `Using system proxy: ${https_proxy}`
- });
+ } else {
+ const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
+ if (shouldUseSystemProxy) {
+ try {
+ if (http_proxy?.length && !isHttpsRequest) {
+ const parsedHttpProxy = new URL(http_proxy);
+ const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
+ const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
+ if (timeline) {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Using system proxy: ${http_proxy}`
+ });
+ }
+ requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, timeline, disableCache, hostname });
}
- requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, timeline, disableCache, hostname });
+ } catch (error) {
+ throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`);
+ }
+ try {
+ if (https_proxy?.length && isHttpsRequest) {
+ new URL(https_proxy);
+ if (timeline) {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Using system proxy: ${https_proxy}`
+ });
+ }
+ requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, timeline, disableCache, hostname });
+ }
+ } catch (error) {
+ throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`);
}
- } catch (error) {
- throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`);
}
}
} else if (proxyMode === 'pac') {
@@ -225,26 +212,11 @@ async function setupProxyAgents({
if (pacSource) {
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `Resolving PAC: ${pacSource}` });
try {
- const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields });
- const directives = await resolver.resolve(requestConfig.url);
- if (directives && directives.length) {
- const first = directives[0];
+ const { directives, httpAgent, httpsAgent } = await resolveAgentsFromPac({ pacSource, requestUrl: requestConfig.url, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, httpsAgentRequestFields, timeline, disableCache, hostname });
+ if (httpAgent) requestConfig.httpAgent = httpAgent;
+ if (httpsAgent) requestConfig.httpsAgent = httpsAgent;
+ if (directives) {
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `PAC directives: ${directives.join('; ')}` });
- if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
- const parts = first.split(/\s+/);
- const keyword = parts[0].toUpperCase();
- const hostPort = parts[1];
- const scheme = keyword === 'HTTPS' ? 'https' : 'http';
- const proxyUri = `${scheme}://${hostPort}`;
- requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname });
- requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
- } else if (/^SOCKS/i.test(first)) {
- const hostPort = first.split(/\s+/)[1];
- const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
- const proxyUri = `${proto}://${hostPort}`;
- requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname });
- requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
- }
} else {
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: 'PAC resolved: DIRECT (no proxy)' });
}
diff --git a/packages/bruno-electron/test/proxy-util.test.js b/packages/bruno-electron/test/proxy-util.test.js
index 9dc9fbfff..55b869cde 100644
--- a/packages/bruno-electron/test/proxy-util.test.js
+++ b/packages/bruno-electron/test/proxy-util.test.js
@@ -12,16 +12,51 @@ const setupMocks = ({ pacDirectives = ['PROXY p.example:8080'] } = {}) => {
}
}));
- // @usebruno/requests — agent factories + pac resolver
- jest.doMock('@usebruno/requests', () => ({
- getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
- getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
- getPacResolver: jest.fn(async () => ({
+ // @usebruno/requests — agent factories + pac resolver + shared resolveAgentsFromPac
+ jest.doMock('@usebruno/requests', () => {
+ const getOrCreateHttpsAgent = jest.fn(() => ({ type: 'https-agent' }));
+ const getOrCreateHttpAgent = jest.fn(() => ({ type: 'http-agent' }));
+ const getPacResolver = jest.fn(async () => ({
resolve: async () => pacDirectives,
dispose: () => {}
- })),
- clearPacCache: jest.fn()
- }));
+ }));
+ // Inline mock of resolveAgentsFromPac that wires through the mocked factories
+ // so existing assertions on getOrCreateHttp(s)Agent call args still hold.
+ const resolveAgentsFromPac = jest.fn(async ({ pacSource, requestUrl, tlsOptions, httpsAgentRequestFields, timeline, disableCache, hostname }) => {
+ const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields });
+ const directives = await resolver.resolve(requestUrl);
+ if (!directives || !directives.length) return { directives: null };
+ const first = directives[0];
+ if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
+ const parts = first.split(/\s+/);
+ const scheme = parts[0].toUpperCase() === 'HTTPS' ? 'https' : 'http';
+ const proxyUri = `${scheme}://${parts[1]}`;
+ return {
+ directives,
+ httpAgent: getOrCreateHttpAgent({ proxyUri, options: { keepAlive: true }, timeline, disableCache, hostname }),
+ httpsAgent: getOrCreateHttpsAgent({ proxyUri, options: tlsOptions, timeline, disableCache, hostname })
+ };
+ }
+ if (/^SOCKS/i.test(first)) {
+ const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
+ const proxyUri = `${proto}://${first.split(/\s+/)[1]}`;
+ return {
+ directives,
+ httpAgent: getOrCreateHttpAgent({ proxyUri, options: { keepAlive: true }, timeline, disableCache, hostname }),
+ httpsAgent: getOrCreateHttpsAgent({ proxyUri, options: tlsOptions, timeline, disableCache, hostname })
+ };
+ }
+ return { directives };
+ });
+ return {
+ getOrCreateHttpsAgent,
+ getOrCreateHttpAgent,
+ getPacResolver,
+ resolveAgentsFromPac,
+ PatchedHttpsProxyAgent: class {},
+ clearPacCache: jest.fn()
+ };
+ });
};
describe('proxy-util', () => {
@@ -120,6 +155,8 @@ describe('proxy-util', () => {
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
getPacResolver: jest.fn(async () => { throw new Error('PAC fetch timeout'); }),
+ resolveAgentsFromPac: jest.fn(async () => { throw new Error('PAC fetch timeout'); }),
+ PatchedHttpsProxyAgent: class {},
clearPacCache: jest.fn()
}));
diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts
index 6520c4e72..a71cc7481 100644
--- a/packages/bruno-requests/src/index.ts
+++ b/packages/bruno-requests/src/index.ts
@@ -7,7 +7,7 @@ export { getCACertificates } from './utils/ca-cert';
export { transformProxyConfig } from './utils/proxy-util';
export { default as createVaultClient, VaultError } from './utils/node-vault';
export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node-vault';
-export { getHttpHttpsAgents } from './utils/http-https-agents';
+export { getHttpHttpsAgents, resolveAgentsFromPac, PatchedHttpsProxyAgent } from './utils/http-https-agents';
export { initializeShellEnv } from './utils/shell-env';
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
export { getPacResolver, clearPacCache } from './utils/pac-resolver';
diff --git a/packages/bruno-requests/src/network/system-proxy/index.spec.js b/packages/bruno-requests/src/network/system-proxy/index.spec.js
index 2438d566b..893dab218 100644
--- a/packages/bruno-requests/src/network/system-proxy/index.spec.js
+++ b/packages/bruno-requests/src/network/system-proxy/index.spec.js
@@ -181,6 +181,7 @@ describe('SystemProxyResolver Integration', () => {
http_proxy: 'http://env-proxy.usebruno.com:9090',
https_proxy: 'https://system-proxy.usebruno.com:8443',
no_proxy: 'localhost',
+ pac_url: null,
source: 'windows-system + environment'
});
});
@@ -209,6 +210,7 @@ describe('SystemProxyResolver Integration', () => {
http_proxy: 'http://system-proxy.usebruno.com:8080',
https_proxy: 'https://system-proxy.usebruno.com:8443',
no_proxy: 'localhost',
+ pac_url: null,
source: 'macos-system'
});
});
@@ -263,6 +265,7 @@ describe('SystemProxyResolver Integration', () => {
http_proxy: null,
https_proxy: null,
no_proxy: null,
+ pac_url: null,
source: 'macos-system'
});
});
diff --git a/packages/bruno-requests/src/network/system-proxy/index.ts b/packages/bruno-requests/src/network/system-proxy/index.ts
index f56117a8c..56fa26686 100644
--- a/packages/bruno-requests/src/network/system-proxy/index.ts
+++ b/packages/bruno-requests/src/network/system-proxy/index.ts
@@ -95,6 +95,7 @@ export async function getSystemProxy(): Promise {
http_proxy: proxyEnvironmentVariables?.http_proxy || systemProxyEnvironmentVariables?.http_proxy,
https_proxy: proxyEnvironmentVariables?.https_proxy || systemProxyEnvironmentVariables?.https_proxy,
no_proxy: proxyEnvironmentVariables?.no_proxy || systemProxyEnvironmentVariables?.no_proxy,
+ pac_url: systemProxyEnvironmentVariables?.pac_url || null,
source: hasEnvironmentProxy ? `${systemProxyEnvironmentVariables?.source} + environment` : systemProxyEnvironmentVariables?.source
};
} catch (error) {
diff --git a/packages/bruno-requests/src/network/system-proxy/types.ts b/packages/bruno-requests/src/network/system-proxy/types.ts
index a438691ae..dfb2a95d4 100644
--- a/packages/bruno-requests/src/network/system-proxy/types.ts
+++ b/packages/bruno-requests/src/network/system-proxy/types.ts
@@ -2,6 +2,7 @@ export interface ProxyConfiguration {
http_proxy?: string | null;
https_proxy?: string | null;
no_proxy?: string | null;
+ pac_url?: string | null;
source: string;
};
diff --git a/packages/bruno-requests/src/network/system-proxy/utils/linux.spec.ts b/packages/bruno-requests/src/network/system-proxy/utils/linux.spec.ts
index be931bb3d..03efb1ab9 100644
--- a/packages/bruno-requests/src/network/system-proxy/utils/linux.spec.ts
+++ b/packages/bruno-requests/src/network/system-proxy/utils/linux.spec.ts
@@ -90,10 +90,36 @@ describe('LinuxProxyResolver', () => {
});
});
- it('should handle non-manual proxy mode', async () => {
- const modeOutput = '\'auto\'';
+ it('should detect PAC URL when gsettings is in auto mode', async () => {
+ mockExecFile
+ .mockResolvedValueOnce({ stdout: '\'auto\'', stderr: '' })
+ .mockResolvedValueOnce({ stdout: '\'http://wpad.usebruno.com/proxy.pac\'', stderr: '' });
- mockExecFile.mockResolvedValueOnce({ stdout: modeOutput, stderr: '' });
+ const result = await detector.detect();
+
+ expect(result).toEqual({
+ http_proxy: null,
+ https_proxy: null,
+ no_proxy: null,
+ pac_url: 'http://wpad.usebruno.com/proxy.pac',
+ source: 'linux-system'
+ });
+ });
+
+ it('should fall through when gsettings auto mode has an empty autoconfig-url', async () => {
+ mockExecFile
+ .mockResolvedValueOnce({ stdout: '\'auto\'', stderr: '' })
+ .mockResolvedValueOnce({ stdout: '\'\'', stderr: '' });
+
+ mockExistsSync.mockReturnValue(false);
+
+ await expect(detector.detect()).rejects.toThrow('Linux proxy detection failed');
+ });
+
+ it('should fall through when gsettings mode is none', async () => {
+ mockExecFile.mockResolvedValueOnce({ stdout: '\'none\'', stderr: '' });
+
+ mockExistsSync.mockReturnValue(false);
await expect(detector.detect()).rejects.toThrow('Linux proxy detection failed');
});
diff --git a/packages/bruno-requests/src/network/system-proxy/utils/linux.ts b/packages/bruno-requests/src/network/system-proxy/utils/linux.ts
index 0c8cf45b6..16e9b8d67 100644
--- a/packages/bruno-requests/src/network/system-proxy/utils/linux.ts
+++ b/packages/bruno-requests/src/network/system-proxy/utils/linux.ts
@@ -49,6 +49,23 @@ export class LinuxProxyResolver implements ProxyResolver {
private async getGSettingsProxy(execOpts: ExecFileOptions): Promise {
try {
const mode = await safeExec('gsettings', ['get', 'org.gnome.system.proxy', 'mode'], execOpts);
+
+ // Handle PAC (auto) mode
+ if (mode === '\'auto\'') {
+ const autoConfigUrl = await safeExec('gsettings', ['get', 'org.gnome.system.proxy', 'autoconfig-url'], execOpts);
+ const cleanUrl = (autoConfigUrl || '').replace(/'/g, '').trim();
+ if (cleanUrl) {
+ return {
+ http_proxy: null,
+ https_proxy: null,
+ no_proxy: null,
+ pac_url: cleanUrl,
+ source: 'linux-system'
+ };
+ }
+ return null;
+ }
+
if (mode !== '\'manual\'') {
return null;
}
@@ -93,8 +110,22 @@ export class LinuxProxyResolver implements ProxyResolver {
// 3 = Automatic proxy detection
// 4 = Use system proxy configuration (environment variables)
+ if (proxyType === '2') {
+ const pacUrl = await safeExec('kreadconfig5', ['--group', 'Proxy Settings', '--key', 'Proxy Config Script'], execOpts);
+ const cleanPacUrl = (pacUrl || '').trim();
+ if (cleanPacUrl) {
+ return {
+ http_proxy: null,
+ https_proxy: null,
+ no_proxy: null,
+ pac_url: cleanPacUrl,
+ source: 'linux-system'
+ };
+ }
+ return null;
+ }
+
if (proxyType !== '1') {
- // Only handle manual proxy configuration for now
return null;
}
diff --git a/packages/bruno-requests/src/network/system-proxy/utils/macos.spec.ts b/packages/bruno-requests/src/network/system-proxy/utils/macos.spec.ts
index 89b31839d..24758dbf0 100644
--- a/packages/bruno-requests/src/network/system-proxy/utils/macos.spec.ts
+++ b/packages/bruno-requests/src/network/system-proxy/utils/macos.spec.ts
@@ -45,6 +45,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: 'http://secure-proxy.usebruno.com:8443',
no_proxy: 'localhost,127.0.0.1,',
+ pac_url: null,
source: 'macos-system'
});
});
@@ -65,6 +66,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: null,
https_proxy: null,
no_proxy: null,
+ pac_url: null,
source: 'macos-system'
});
});
@@ -102,6 +104,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: null,
no_proxy: null,
+ pac_url: null,
source: 'macos-system'
});
});
@@ -123,6 +126,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: null,
https_proxy: 'http://secure-proxy.usebruno.com:8443',
no_proxy: null,
+ pac_url: null,
source: 'macos-system'
});
});
@@ -148,6 +152,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: 'http://proxy.usebruno.com:8080',
no_proxy: null,
+ pac_url: null,
source: 'macos-system'
});
});
@@ -171,6 +176,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: 'http://proxy.usebruno.com:8080',
no_proxy: '',
+ pac_url: null,
source: 'macos-system'
});
});
@@ -200,10 +206,72 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: 'http://proxy.usebruno.com:8080',
no_proxy: 'localhost,127.0.0.1,*.local,192.168.1.0/24,',
+ pac_url: null,
source: 'macos-system'
});
});
+ it('should extract PAC URL when ProxyAutoConfigEnable is 1', async () => {
+ const scutilOutput = ` {
+ HTTPEnable : 0
+ HTTPSEnable : 0
+ ProxyAutoConfigEnable : 1
+ ProxyAutoConfigURLString : http://wpad.usebruno.com/proxy.pac
+}`;
+
+ mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' });
+
+ const result = await detector.detect();
+
+ expect(result).toEqual({
+ http_proxy: null,
+ https_proxy: null,
+ no_proxy: null,
+ pac_url: 'http://wpad.usebruno.com/proxy.pac',
+ source: 'macos-system'
+ });
+ });
+
+ it('should return PAC URL alongside manual HTTP/HTTPS proxies when both are configured', async () => {
+ const scutilOutput = ` {
+ HTTPEnable : 1
+ HTTPPort : 8080
+ HTTPProxy : proxy.usebruno.com
+ HTTPSEnable : 1
+ HTTPSPort : 8443
+ HTTPSProxy : secure-proxy.usebruno.com
+ ProxyAutoConfigEnable : 1
+ ProxyAutoConfigURLString : file:///etc/proxy.pac
+}`;
+
+ mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' });
+
+ const result = await detector.detect();
+
+ expect(result).toEqual({
+ http_proxy: 'http://proxy.usebruno.com:8080',
+ https_proxy: 'http://secure-proxy.usebruno.com:8443',
+ no_proxy: null,
+ pac_url: 'file:///etc/proxy.pac',
+ source: 'macos-system'
+ });
+ });
+
+ it('should not return PAC URL when ProxyAutoConfigEnable is 0', async () => {
+ const scutilOutput = ` {
+ HTTPEnable : 0
+ HTTPSEnable : 0
+ ProxyAutoConfigEnable : 0
+ ProxyAutoConfigURLString : http://wpad.usebruno.com/proxy.pac
+}`;
+
+ mockExecFile.mockResolvedValueOnce({ stdout: scutilOutput, stderr: '' });
+
+ const result = await detector.detect();
+
+ expect(result.pac_url).toBeNull();
+ });
+
it('should handle malformed scutil output gracefully', async () => {
const scutilOutput = ` {
HTTPEnable : 1
@@ -222,6 +290,7 @@ describe('MacOSProxyResolver', () => {
http_proxy: 'http://proxy.usebruno.com:8080',
https_proxy: null,
no_proxy: null,
+ pac_url: null,
source: 'macos-system'
});
});
diff --git a/packages/bruno-requests/src/network/system-proxy/utils/macos.ts b/packages/bruno-requests/src/network/system-proxy/utils/macos.ts
index 8bbee0a58..fe948469c 100644
--- a/packages/bruno-requests/src/network/system-proxy/utils/macos.ts
+++ b/packages/bruno-requests/src/network/system-proxy/utils/macos.ts
@@ -82,6 +82,12 @@ export class MacOSProxyResolver implements ProxyResolver {
let http_proxy: string | null = null;
let https_proxy: string | null = null;
let no_proxy: string | null = null;
+ let pac_url: string | null = null;
+
+ // Check PAC (Proxy Auto-Configuration)
+ if (config.ProxyAutoConfigEnable === 1 && config.ProxyAutoConfigURLString) {
+ pac_url = config.ProxyAutoConfigURLString;
+ }
// Check HTTP proxy
if (config.HTTPEnable === 1 && config.HTTPProxy) {
@@ -109,6 +115,7 @@ export class MacOSProxyResolver implements ProxyResolver {
http_proxy,
https_proxy,
no_proxy: normalizeNoProxy(no_proxy),
+ pac_url,
source: 'macos-system'
};
}
diff --git a/packages/bruno-requests/src/network/system-proxy/utils/windows.spec.ts b/packages/bruno-requests/src/network/system-proxy/utils/windows.spec.ts
index c35b9b1d3..7dbc28791 100644
--- a/packages/bruno-requests/src/network/system-proxy/utils/windows.spec.ts
+++ b/packages/bruno-requests/src/network/system-proxy/utils/windows.spec.ts
@@ -83,6 +83,71 @@ Current WinHTTP proxy settings:
});
});
+ describe('AutoConfigURL (PAC) Detection', () => {
+ it('should return pac_url when only AutoConfigURL is set', async () => {
+ const regOutput = `
+HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
+ ProxyEnable REG_DWORD 0x0
+ AutoConfigURL REG_SZ http://wpad.usebruno.com/proxy.pac
+`;
+
+ mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' });
+
+ const result = await detector.detect();
+
+ expect(result).toEqual({
+ http_proxy: null,
+ https_proxy: null,
+ no_proxy: null,
+ pac_url: 'http://wpad.usebruno.com/proxy.pac',
+ source: 'windows-system'
+ });
+ });
+
+ it('should return both manual proxy and pac_url when both are configured', async () => {
+ const regOutput = `
+HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
+ ProxyEnable REG_DWORD 0x1
+ ProxyServer REG_SZ proxy.usebruno.com:8080
+ ProxyOverride REG_SZ localhost;127.0.0.1
+ AutoConfigURL REG_SZ http://wpad.usebruno.com/proxy.pac
+`;
+
+ mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' });
+
+ const result = await detector.detect();
+
+ expect(result).toEqual({
+ http_proxy: 'http://proxy.usebruno.com:8080',
+ https_proxy: 'http://proxy.usebruno.com:8080',
+ no_proxy: 'localhost,127.0.0.1',
+ pac_url: 'http://wpad.usebruno.com/proxy.pac',
+ source: 'windows-system'
+ });
+ });
+
+ it('should return pac_url with null proxies when ProxyEnable=0 but AutoConfigURL is set', async () => {
+ const regOutput = `
+HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
+ ProxyEnable REG_DWORD 0x0
+ ProxyServer REG_SZ proxy.usebruno.com:8080
+ AutoConfigURL REG_SZ http://wpad.usebruno.com/proxy.pac
+`;
+
+ mockExecFile.mockResolvedValueOnce({ stdout: regOutput, stderr: '' });
+
+ const result = await detector.detect();
+
+ expect(result).toEqual({
+ http_proxy: null,
+ https_proxy: null,
+ no_proxy: null,
+ pac_url: 'http://wpad.usebruno.com/proxy.pac',
+ source: 'windows-system'
+ });
+ });
+ });
+
describe('WinHTTP Detection', () => {
it('should handle direct access configuration', async () => {
mockExecFile
diff --git a/packages/bruno-requests/src/network/system-proxy/utils/windows.ts b/packages/bruno-requests/src/network/system-proxy/utils/windows.ts
index 9962ac715..1bf4f7f9d 100644
--- a/packages/bruno-requests/src/network/system-proxy/utils/windows.ts
+++ b/packages/bruno-requests/src/network/system-proxy/utils/windows.ts
@@ -46,6 +46,7 @@ export class WindowsProxyResolver implements ProxyResolver {
let proxyEnabled = false;
let proxyServer: string | null = null;
let proxyOverride: string | null = null;
+ let autoConfigURL: string | null = null;
for (const line of lines) {
const trimmedLine = line.trim();
@@ -68,6 +69,19 @@ export class WindowsProxyResolver implements ProxyResolver {
const match = trimmedLine.match(/ProxyOverride\s+REG_SZ\s+(.+)/);
if (match) proxyOverride = match[1].trim();
}
+
+ if (trimmedLine.includes('AutoConfigURL') && trimmedLine.includes('REG_SZ')) {
+ const match = trimmedLine.match(/AutoConfigURL\s+REG_SZ\s+(.+)/);
+ if (match) autoConfigURL = match[1].trim();
+ }
+ }
+
+ // PAC URL takes precedence — return it even without a manual proxy
+ if (autoConfigURL) {
+ const config = proxyEnabled && proxyServer
+ ? this.parseProxyString(proxyServer, proxyOverride)
+ : { http_proxy: null, https_proxy: null, no_proxy: null, source: 'windows-system' };
+ return { ...config, pac_url: autoConfigURL };
}
if (proxyEnabled && proxyServer) {
diff --git a/packages/bruno-requests/src/utils/http-https-agents.ts b/packages/bruno-requests/src/utils/http-https-agents.ts
index 21153aad3..54037742d 100644
--- a/packages/bruno-requests/src/utils/http-https-agents.ts
+++ b/packages/bruno-requests/src/utils/http-https-agents.ts
@@ -49,6 +49,7 @@ type SystemProxyConfig = {
http_proxy?: string;
https_proxy?: string;
no_proxy?: string;
+ pac_url?: string | null;
};
type ClientCertificate = {
@@ -215,7 +216,7 @@ const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthori
* `ca` to a secureContext (via addCACert) before construction, so custom CAs
* are added on top of the OpenSSL defaults rather than replacing them.
*/
-class PatchedHttpsProxyAgent extends HttpsProxyAgent {
+export class PatchedHttpsProxyAgent extends HttpsProxyAgent {
private constructorOpts: any;
constructor(proxy: string, opts: any) {
@@ -349,8 +350,8 @@ const getCertsAndProxyConfig = ({
proxyConfig = { pac: get(appLevelProxyConfig, 'pac.source') };
proxyMode = 'pac';
} else if (globalProxySource === 'inherit') {
- const { http_proxy, https_proxy } = systemProxyConfig || {};
- if (http_proxy?.length || https_proxy?.length) {
+ const { http_proxy, https_proxy, pac_url } = systemProxyConfig || {};
+ if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
proxyMode = 'system';
}
} else {
@@ -362,8 +363,8 @@ const getCertsAndProxyConfig = ({
// else: app-level proxy is disabled, proxyMode stays 'off'
} else {
// No app-level proxy config (e.g. CLI), fall through to system proxy
- const { http_proxy, https_proxy } = systemProxyConfig || {};
- if (http_proxy?.length || https_proxy?.length) {
+ const { http_proxy, https_proxy, pac_url } = systemProxyConfig || {};
+ if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
proxyMode = 'system';
}
}
@@ -382,6 +383,79 @@ function extractHostname(url: string | undefined): string | null {
}
}
+type ResolveAgentsFromPacParams = {
+ pacSource: string;
+ requestUrl: string;
+ tlsOptions: TlsOptions;
+ httpsAgentRequestFields?: HttpsAgentRequestFields;
+ requestProtocol?: 'http' | 'https' | 'both';
+ timeline?: TimelineEntry[] | null;
+ disableCache: boolean;
+ hostname: string | null;
+};
+
+type ResolveAgentsFromPacResult = {
+ directives: string[] | null;
+ httpAgent?: HttpAgent;
+ httpsAgent?: HttpsAgent | HttpsProxyAgent | SocksProxyAgent;
+};
+
+/**
+ * Resolves a PAC URL and creates proxy agents from the first directive.
+ * `requestProtocol` controls which agent(s) get created:
+ * - 'http' or 'https': create only the matching agent (optimization for known request type)
+ * - 'both' (default): create both, caller picks
+ */
+export async function resolveAgentsFromPac({
+ pacSource,
+ requestUrl,
+ tlsOptions,
+ httpsAgentRequestFields,
+ requestProtocol = 'both',
+ timeline,
+ disableCache,
+ hostname
+}: ResolveAgentsFromPacParams): Promise {
+ const pacResolverFields = httpsAgentRequestFields || {
+ ca: tlsOptions.ca,
+ rejectUnauthorized: tlsOptions.rejectUnauthorized,
+ minVersion: tlsOptions.minVersion
+ };
+ const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields: pacResolverFields });
+ const directives = await resolver.resolve(requestUrl);
+
+ if (!directives || !directives.length) {
+ return { directives: null };
+ }
+
+ const wantHttp = requestProtocol === 'http' || requestProtocol === 'both';
+ const wantHttps = requestProtocol === 'https' || requestProtocol === 'both';
+ const first = directives[0];
+
+ if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
+ const parts = first.split(/\s+/);
+ const keyword = parts[0].toUpperCase();
+ const hostPort = parts[1];
+ const scheme = keyword === 'HTTPS' ? 'https' : 'http';
+ const proxyUri = `${scheme}://${hostPort}`;
+ const result: ResolveAgentsFromPacResult = { directives };
+ if (wantHttp) result.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
+ if (wantHttps) result.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
+ return result;
+ }
+ if (/^SOCKS/i.test(first)) {
+ const hostPort = first.split(/\s+/)[1];
+ const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
+ const proxyUri = `${proto}://${hostPort}`;
+ const result: ResolveAgentsFromPacResult = { directives };
+ if (wantHttp) result.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
+ if (wantHttps) result.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
+ return result;
+ }
+
+ return { directives };
+}
+
async function createAgents({
requestUrl,
proxyMode,
@@ -459,32 +533,9 @@ async function createAgents({
const pacSource = get(proxyConfig, 'pac.source');
if (pacSource && requestUrl) {
try {
- const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields: { ca: tlsOptions.ca, rejectUnauthorized: tlsOptions.rejectUnauthorized, minVersion: tlsOptions.minVersion } });
- const directives = await resolver.resolve(requestUrl);
- if (directives && directives.length) {
- const first = directives[0];
- if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
- const parts = first.split(/\s+/);
- const keyword = parts[0].toUpperCase();
- const hostPort = parts[1];
- const scheme = keyword === 'HTTPS' ? 'https' : 'http';
- const proxyUri = `${scheme}://${hostPort}`;
- if (isHttpsRequest) {
- httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
- } else {
- httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
- }
- } else if (/^SOCKS/i.test(first)) {
- const hostPort = first.split(/\s+/)[1];
- const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
- const proxyUri = `${proto}://${hostPort}`;
- if (isHttpsRequest) {
- httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
- } else {
- httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
- }
- }
- }
+ const result = await resolveAgentsFromPac({ pacSource, requestUrl, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, timeline, disableCache, hostname });
+ if (result.httpAgent) httpAgent = result.httpAgent;
+ if (result.httpsAgent) httpsAgent = result.httpsAgent;
} catch {
// PAC resolution failed — fall through to direct connection
}
@@ -493,25 +544,37 @@ async function createAgents({
const http_proxy = get(systemProxyConfig, 'http_proxy');
const https_proxy = get(systemProxyConfig, 'https_proxy');
const no_proxy = get(systemProxyConfig, 'no_proxy');
- const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
- if (shouldUseSystemProxy) {
+ const pac_url = get(systemProxyConfig, 'pac_url');
+
+ // If the OS is configured with a PAC URL, resolve it using the existing PAC infrastructure
+ if (pac_url && requestUrl) {
try {
- if (http_proxy?.length && !isHttpsRequest) {
- const parsedHttpProxy = new URL(http_proxy);
- const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
- const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
- httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions as any, proxyUri: http_proxy, timeline: timeline || null, disableCache, hostname });
- }
- } catch (error) {
- throw new Error('Invalid system http_proxy');
+ const result = await resolveAgentsFromPac({ pacSource: pac_url, requestUrl, requestProtocol: isHttpsRequest ? 'https' : 'http', tlsOptions, timeline, disableCache, hostname });
+ if (result.httpAgent) httpAgent = result.httpAgent;
+ if (result.httpsAgent) httpsAgent = result.httpsAgent;
+ } catch {
}
- try {
- if (https_proxy?.length && isHttpsRequest) {
- new URL(https_proxy);
- httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri: https_proxy, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
+ } else {
+ const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
+ if (shouldUseSystemProxy) {
+ try {
+ if (http_proxy?.length && !isHttpsRequest) {
+ const parsedHttpProxy = new URL(http_proxy);
+ const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
+ const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
+ httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions as any, proxyUri: http_proxy, timeline: timeline || null, disableCache, hostname });
+ }
+ } catch (error) {
+ throw new Error('Invalid system http_proxy');
+ }
+ try {
+ if (https_proxy?.length && isHttpsRequest) {
+ new URL(https_proxy);
+ httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri: https_proxy, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
+ }
+ } catch (error) {
+ throw new Error('Invalid system https_proxy');
}
- } catch (error) {
- throw new Error('Invalid system https_proxy');
}
}
}
@@ -572,4 +635,4 @@ const getHttpHttpsAgents = async ({
export { getHttpHttpsAgents };
-export type { GetHttpHttpsAgentsParams };
+export type { GetHttpHttpsAgentsParams, ResolveAgentsFromPacParams, ResolveAgentsFromPacResult };
diff --git a/packages/bruno-tests/collection/bruno.json b/packages/bruno-tests/collection/bruno.json
index 09a346b4a..03d9f650c 100644
--- a/packages/bruno-tests/collection/bruno.json
+++ b/packages/bruno-tests/collection/bruno.json
@@ -3,20 +3,28 @@
"name": "bruno-testbench",
"type": "collection",
"proxy": {
- "enabled": false,
- "protocol": "http",
- "hostname": "{{proxyHostname}}",
- "port": 4000,
- "auth": {
- "enabled": false,
- "username": "anoop",
- "password": "password"
- },
- "bypassProxy": ""
+ "inherit": true,
+ "config": {
+ "protocol": "http",
+ "hostname": "{{proxyHostname}}",
+ "port": 4000,
+ "auth": {
+ "username": "anoop",
+ "password": "password",
+ "disabled": true
+ },
+ "bypassProxy": ""
+ }
},
"scripts": {
- "moduleWhitelist": ["crypto", "buffer", "form-data"],
- "additionalContextRoots": ["../additional-context-root-lib"]
+ "moduleWhitelist": [
+ "crypto",
+ "buffer",
+ "form-data"
+ ],
+ "additionalContextRoots": [
+ "../additional-context-root-lib"
+ ]
},
"clientCertificates": {
"enabled": true,
diff --git a/playwright.config.ts b/playwright.config.ts
index eb758f08d..02bbbd60f 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -24,7 +24,8 @@ export default defineConfig({
testIgnore: [
'ssl/**', // custom CA certificate tests require separate server setup and certificate generation
'auth/**', // auth tests have their own project
- 'benchmarks/**'
+ 'benchmarks/**',
+ 'proxy/system-pac/**' // shares ports with proxy/pac — runs in its own project after default
]
},
{
@@ -34,6 +35,12 @@ export default defineConfig({
{
name: 'ssl',
testDir: './tests/ssl'
+ },
+ {
+ // system-pac and pac specs share the same PAC/proxy/target ports.
+ name: 'system-pac',
+ testDir: './tests/proxy/system-pac',
+ dependencies: ['default']
}
],
diff --git a/tests/proxy/system-pac/fixtures/collection/bruno.json b/tests/proxy/system-pac/fixtures/collection/bruno.json
new file mode 100644
index 000000000..7a6c2c8e5
--- /dev/null
+++ b/tests/proxy/system-pac/fixtures/collection/bruno.json
@@ -0,0 +1,6 @@
+{
+ "version": "1",
+ "name": "system-pac-proxy-test",
+ "type": "collection",
+ "ignore": []
+}
diff --git a/tests/proxy/system-pac/fixtures/collection/direct.bru b/tests/proxy/system-pac/fixtures/collection/direct.bru
new file mode 100644
index 000000000..bebfc86b1
--- /dev/null
+++ b/tests/proxy/system-pac/fixtures/collection/direct.bru
@@ -0,0 +1,21 @@
+meta {
+ name: direct
+ type: http
+ seq: 2
+}
+
+get {
+ url: http://localhost:19000/direct
+ body: none
+ auth: none
+}
+
+assert {
+ res.status: eq 200
+}
+
+tests {
+ test("request bypassed proxy (system PAC returned DIRECT)", function() {
+ expect(res.headers['x-proxied']).to.be.undefined;
+ });
+}
diff --git a/tests/proxy/system-pac/fixtures/collection/proxied.bru b/tests/proxy/system-pac/fixtures/collection/proxied.bru
new file mode 100644
index 000000000..b5bd679f3
--- /dev/null
+++ b/tests/proxy/system-pac/fixtures/collection/proxied.bru
@@ -0,0 +1,21 @@
+meta {
+ name: proxied
+ type: http
+ seq: 1
+}
+
+get {
+ url: http://localhost:19000/proxied
+ body: none
+ auth: none
+}
+
+assert {
+ res.status: eq 200
+}
+
+tests {
+ test("request was routed through system PAC proxy", function() {
+ expect(res.headers['x-proxied']).to.equal('test-proxy');
+ });
+}
diff --git a/tests/proxy/system-pac/init-user-data/preferences.json b/tests/proxy/system-pac/init-user-data/preferences.json
new file mode 100644
index 000000000..3b7577d84
--- /dev/null
+++ b/tests/proxy/system-pac/init-user-data/preferences.json
@@ -0,0 +1,14 @@
+{
+ "maximized": false,
+ "lastOpenedCollections": ["{{projectRoot}}/tests/proxy/system-pac/fixtures/collection"],
+ "preferences": {
+ "onboarding": {
+ "hasLaunchedBefore": true,
+ "hasSeenWelcomeModal": true
+ },
+ "proxy": {
+ "source": "inherit",
+ "config": {}
+ }
+ }
+}
diff --git a/tests/proxy/system-pac/system-pac-proxy.spec.ts b/tests/proxy/system-pac/system-pac-proxy.spec.ts
new file mode 100644
index 000000000..4548f9cb8
--- /dev/null
+++ b/tests/proxy/system-pac/system-pac-proxy.spec.ts
@@ -0,0 +1,103 @@
+import * as path from 'path';
+import { execFileSync } from 'child_process';
+import { pathToFileURL } from 'url';
+import { test } from '../../../playwright';
+import { setSandboxMode, runCollection, validateRunnerResults } from '../../utils/page';
+import { startServers, stopServers, PAC_PORT, type TestServers } from '../pac/server';
+
+// GNOME's system-wide proxy schema — provided by gsettings-desktop-schemas.
+// Writes need an active dbus session (see CI workflow's dbus-run-session wrapper).
+const GNOME_PROXY_SCHEMA = 'org.gnome.system.proxy';
+
+function enableSystemPac(pacUrl: string) {
+ // Set URL first, then flip mode — otherwise auto mode briefly has no URL
+ execFileSync('gsettings', ['set', GNOME_PROXY_SCHEMA, 'autoconfig-url', pacUrl]);
+ execFileSync('gsettings', ['set', GNOME_PROXY_SCHEMA, 'mode', 'auto']);
+}
+
+function disableSystemPac() {
+ execFileSync('gsettings', ['reset', GNOME_PROXY_SCHEMA, 'mode']);
+ execFileSync('gsettings', ['reset', GNOME_PROXY_SCHEMA, 'autoconfig-url']);
+}
+
+// Detects schema availability so we can skip cleanly on minimal images
+// (e.g. containers without gsettings-desktop-schemas installed).
+function gnomeProxySchemaAvailable(): boolean {
+ try {
+ execFileSync('gsettings', ['get', GNOME_PROXY_SCHEMA, 'mode'], { stdio: 'ignore' });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+test.describe('System Proxy with PAC', () => {
+ test.skip(
+ process.platform !== 'linux',
+ 'Linux-only: relies on gsettings to set OS-level PAC'
+ );
+
+ test.skip(
+ process.platform === 'linux' && !gnomeProxySchemaAvailable(),
+ 'Linux: skipping because the org.gnome.system.proxy GSettings schema is not available on this runner'
+ );
+
+ let servers: TestServers;
+
+ test.beforeAll(async () => {
+ servers = await startServers();
+ });
+
+ test.afterAll(async () => {
+ // Revert OS proxy settings even if a test failed, so the runner is left clean.
+ try {
+ disableSystemPac();
+ } finally {
+ if (servers) {
+ await stopServers(servers);
+ }
+ }
+ });
+
+ // Covers the common corporate setup: PAC hosted at an HTTP URL (e.g. WPAD).
+ test('resolves OS-level PAC URL in system proxy mode (HTTP PAC)', async ({ launchElectronApp }) => {
+ const pacUrl = `http://localhost:${PAC_PORT}/test.pac`;
+ enableSystemPac(pacUrl);
+
+ const initUserDataPath = path.join(__dirname, 'init-user-data');
+ const app = await launchElectronApp({ initUserDataPath });
+
+ const page = await app.firstWindow();
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ await setSandboxMode(page, 'system-pac-proxy-test', 'developer');
+ await runCollection(page, 'system-pac-proxy-test');
+ await validateRunnerResults(page, {
+ totalRequests: 2,
+ passed: 2,
+ failed: 0,
+ skipped: 0
+ });
+ });
+
+ // Covers the local-file PAC case (user-selected .pac file on disk).
+ test('resolves OS-level PAC URL in system proxy mode (file:// PAC)', async ({ launchElectronApp }) => {
+ const pacUrl = pathToFileURL(path.join(__dirname, '..', 'pac', 'fixtures', 'pac-files', 'test.pac')).href;
+ enableSystemPac(pacUrl);
+
+ const initUserDataPath = path.join(__dirname, 'init-user-data');
+ const app = await launchElectronApp({ initUserDataPath });
+
+ const page = await app.firstWindow();
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ await setSandboxMode(page, 'system-pac-proxy-test', 'developer');
+ await runCollection(page, 'system-pac-proxy-test');
+ await validateRunnerResults(page, {
+ totalRequests: 2,
+ passed: 2,
+ failed: 0,
+ skipped: 0
+ });
+ });
+});