fix: honor OS-level PAC configuration in system proxy mode (#7766)

This commit is contained in:
Pooja
2026-05-27 14:04:00 +05:30
committed by GitHub
parent 6b7e5f3813
commit 413697cbe7
29 changed files with 680 additions and 223 deletions

View File

@@ -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'

View File

@@ -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

View File

@@ -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",

View File

@@ -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 = () => {
</label>
<div className="system-proxy-value">{no_proxy || '-'}</div>
</div>
<div className="mb-1 flex items-center">
<label className="settings-label">
pac_url
</label>
<div className="system-proxy-value">{pac_url || '-'}</div>
</div>
</div>
<span
className="text-link cursor-pointer hover:underline default-collection-location-browse flex flex-row items-center"

View File

@@ -421,8 +421,8 @@ const runSingleRequest = async function (
} else if (!collectionProxyDisabled && collectionProxyInherit) {
// Inherit from system proxy
if (cachedSystemProxy) {
const { http_proxy, https_proxy } = cachedSystemProxy;
if (http_proxy?.length || https_proxy?.length) {
const { http_proxy, https_proxy, pac_url } = cachedSystemProxy;
if (http_proxy?.length || https_proxy?.length || pac_url?.length) {
proxyMode = 'system';
}
}
@@ -430,7 +430,7 @@ const runSingleRequest = async function (
}
// else: collection proxy is disabled, proxyMode stays 'off'
setupProxyAgents({
await setupProxyAgents({
requestConfig: request,
proxyMode,
proxyConfig,

View File

@@ -139,7 +139,7 @@ function makeAxiosInstance({
return response;
},
(error) => {
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,

View File

@@ -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) {}

View File

@@ -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;

View File

@@ -12,6 +12,7 @@ const loadSystemProxy = async () => {
http_proxy: null,
https_proxy: null,
no_proxy: null,
pac_url: null,
source: 'error'
};
}

View File

@@ -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)' });
}

View File

@@ -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()
}));

View File

@@ -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';

View File

@@ -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'
});
});

View File

@@ -95,6 +95,7 @@ export async function getSystemProxy(): Promise<ProxyConfiguration> {
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) {

View File

@@ -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;
};

View File

@@ -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');
});

View File

@@ -49,6 +49,23 @@ export class LinuxProxyResolver implements ProxyResolver {
private async getGSettingsProxy(execOpts: ExecFileOptions): Promise<ProxyConfiguration | null> {
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;
}

View File

@@ -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,<local>',
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: '<local>',
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,<local>',
pac_url: null,
source: 'macos-system'
});
});
it('should extract PAC URL when ProxyAutoConfigEnable is 1', async () => {
const scutilOutput = `<dictionary> {
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 = `<dictionary> {
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 = `<dictionary> {
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 = `<dictionary> {
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'
});
});

View File

@@ -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'
};
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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<any> {
export class PatchedHttpsProxyAgent extends HttpsProxyAgent<any> {
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<any> | 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<ResolveAgentsFromPacResult> {
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 };

View File

@@ -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,

View File

@@ -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']
}
],

View File

@@ -0,0 +1,6 @@
{
"version": "1",
"name": "system-pac-proxy-test",
"type": "collection",
"ignore": []
}

View File

@@ -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;
});
}

View File

@@ -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');
});
}

View File

@@ -0,0 +1,14 @@
{
"maximized": false,
"lastOpenedCollections": ["{{projectRoot}}/tests/proxy/system-pac/fixtures/collection"],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
},
"proxy": {
"source": "inherit",
"config": {}
}
}
}

View File

@@ -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
});
});
});