mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-08 22:45:25 +00:00
feat(system-proxy): implement refreshShellEnvProxyVars to sync proxy vars from shell config
This commit is contained in:
@@ -1,8 +1,42 @@
|
||||
const { getSystemProxy, refreshShellEnvProxyVars } = require('@usebruno/requests');
|
||||
const { getSystemProxy, fetchShellEnv } = require('@usebruno/requests');
|
||||
|
||||
const PROXY_ENV_KEYS = ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY', 'all_proxy', 'ALL_PROXY'];
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
let cachedSystemProxy;
|
||||
let systemProxyPromise;
|
||||
|
||||
// Re-syncs proxy-related process.env values from the user's shell config, without restarting the app.
|
||||
// ponytail: proxy vars are cleared before spawning the shell (not after fetching) because the child
|
||||
// process inherits process.env — a removed .zshrc export would otherwise still be echoed back as "current".
|
||||
const refreshShellEnvProxyVars = async () => {
|
||||
if (process.platform === 'win32') return {};
|
||||
|
||||
const snapshot = {};
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
snapshot[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
|
||||
let timer;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), TIMEOUT_MS);
|
||||
});
|
||||
const result = await Promise.race([fetchShellEnv(), timeout]).finally(() => clearTimeout(timer));
|
||||
|
||||
if (result === null) {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
if (snapshot[key] !== undefined) process.env[key] = snapshot[key];
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
if (result[key]) process.env[key] = result[key];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const loadSystemProxy = async ({ refreshShellEnv = false } = {}) => {
|
||||
try {
|
||||
if (refreshShellEnv) {
|
||||
|
||||
92
packages/bruno-electron/src/store/tests/system-proxy.spec.js
Normal file
92
packages/bruno-electron/src/store/tests/system-proxy.spec.js
Normal file
@@ -0,0 +1,92 @@
|
||||
let mockFetchShellEnv;
|
||||
let mockGetSystemProxy;
|
||||
|
||||
jest.mock('@usebruno/requests', () => ({
|
||||
fetchShellEnv: (...args) => mockFetchShellEnv(...args),
|
||||
getSystemProxy: (...args) => mockGetSystemProxy(...args)
|
||||
}));
|
||||
|
||||
const PROXY_ENV_KEYS = ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY', 'all_proxy', 'ALL_PROXY'];
|
||||
|
||||
describe('system-proxy refresh', () => {
|
||||
let fetchSystemProxy;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
mockFetchShellEnv = jest.fn(() => Promise.resolve({}));
|
||||
mockGetSystemProxy = jest.fn(() => Promise.resolve({ source: 'environment' }));
|
||||
for (const key of PROXY_ENV_KEYS) delete process.env[key];
|
||||
({ fetchSystemProxy } = require('../system-proxy'));
|
||||
});
|
||||
|
||||
it('updates proxy env vars from shell config', async () => {
|
||||
process.env.http_proxy = 'http://old-proxy:8080';
|
||||
mockFetchShellEnv = jest.fn(() => Promise.resolve({ http_proxy: 'http://new-proxy:8080' }));
|
||||
({ fetchSystemProxy } = require('../system-proxy'));
|
||||
|
||||
await fetchSystemProxy({ refresh: true });
|
||||
|
||||
expect(process.env.http_proxy).toBe('http://new-proxy:8080');
|
||||
});
|
||||
|
||||
it('removes proxy env vars missing from shell config (user removed the export)', async () => {
|
||||
process.env.http_proxy = 'http://old-proxy:8080';
|
||||
process.env.no_proxy = 'localhost';
|
||||
mockFetchShellEnv = jest.fn(() => Promise.resolve({}));
|
||||
({ fetchSystemProxy } = require('../system-proxy'));
|
||||
|
||||
await fetchSystemProxy({ refresh: true });
|
||||
|
||||
expect(process.env.http_proxy).toBeUndefined();
|
||||
expect(process.env.no_proxy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restores prior proxy vars when the shell fetch fails', async () => {
|
||||
// fetchShellEnv never rejects - it resolves null on subprocess failure (see shell-env.ts).
|
||||
process.env.http_proxy = 'http://existing:8080';
|
||||
mockFetchShellEnv = jest.fn(() => Promise.resolve(null));
|
||||
({ fetchSystemProxy } = require('../system-proxy'));
|
||||
|
||||
await fetchSystemProxy({ refresh: true });
|
||||
|
||||
expect(process.env.http_proxy).toBe('http://existing:8080');
|
||||
});
|
||||
|
||||
it('does not touch process.env or invoke the shell on Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
process.env.HTTP_PROXY = 'http://launcher-proxy:8080';
|
||||
mockFetchShellEnv = jest.fn(() => Promise.resolve({ HTTP_PROXY: 'http://should-not-appear:8080' }));
|
||||
({ fetchSystemProxy } = require('../system-proxy'));
|
||||
|
||||
await fetchSystemProxy({ refresh: true });
|
||||
|
||||
expect(mockFetchShellEnv).not.toHaveBeenCalled();
|
||||
expect(process.env.HTTP_PROXY).toBe('http://launcher-proxy:8080');
|
||||
});
|
||||
|
||||
it('does not refresh shell env when refresh is not requested', async () => {
|
||||
await fetchSystemProxy();
|
||||
|
||||
expect(mockFetchShellEnv).not.toHaveBeenCalled();
|
||||
expect(mockGetSystemProxy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('timeout', () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('restores prior proxy vars if the shell fetch hangs past 60s', async () => {
|
||||
process.env.http_proxy = 'http://existing:8080';
|
||||
mockFetchShellEnv = jest.fn(() => new Promise(() => {})); // never resolves
|
||||
({ fetchSystemProxy } = require('../system-proxy'));
|
||||
|
||||
const p = fetchSystemProxy({ refresh: true });
|
||||
jest.advanceTimersByTime(60_000);
|
||||
await p;
|
||||
|
||||
expect(process.env.http_proxy).toBe('http://existing:8080');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ 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, resolveAgentsFromPac, PatchedHttpsProxyAgent } from './utils/http-https-agents';
|
||||
export { initializeShellEnv, refreshShellEnvProxyVars } from './utils/shell-env';
|
||||
export { initializeShellEnv, fetchShellEnv } from './utils/shell-env';
|
||||
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
|
||||
export { getPacResolver, clearPacCache } from './utils/pac-resolver';
|
||||
export type { PacWrapper, GetPacResolverParams } from './utils/pac-resolver';
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* Composition tests: refreshShellEnvProxyVars + getSystemProxy wired end-to-end.
|
||||
*
|
||||
* The individual contracts are covered by:
|
||||
* - ./get-system-proxy.spec.ts (cross-platform detection + merge)
|
||||
* - ../../utils/shell-env-proxy-refresh.spec.ts (refresh delete/re-populate + Windows early-return)
|
||||
*
|
||||
* This file exercises only the composition: after a refresh, does the resulting proxy config
|
||||
* from getSystemProxy reflect the intended end-to-end behavior?
|
||||
*/
|
||||
export { }; // Mark this file as a module so top-level declarations don't collide with sibling specs.
|
||||
|
||||
jest.mock('node:child_process', () => ({ execFile: jest.fn() }));
|
||||
jest.mock('node:util', () => ({ promisify: jest.fn((fn) => fn) }));
|
||||
jest.mock('node:fs', () => ({ existsSync: jest.fn() }));
|
||||
jest.mock('node:fs/promises', () => ({ readFile: jest.fn(), readdir: jest.fn() }));
|
||||
|
||||
// Config the mocked login shell would export. Reassigned per test.
|
||||
// Prefix required by jest.mock's out-of-scope-variable guard (mock*/MOCK* allowed).
|
||||
let mockShellEnvResult: Record<string, string> = {};
|
||||
|
||||
jest.mock('shell-env', () => ({
|
||||
shellEnv: () => Promise.resolve(mockShellEnvResult)
|
||||
}));
|
||||
|
||||
const PROXY_ENV_KEYS = [
|
||||
'http_proxy',
|
||||
'HTTP_PROXY',
|
||||
'https_proxy',
|
||||
'HTTPS_PROXY',
|
||||
'no_proxy',
|
||||
'NO_PROXY',
|
||||
'all_proxy',
|
||||
'ALL_PROXY'
|
||||
];
|
||||
|
||||
interface FreshModules {
|
||||
execFile: jest.MockedFunction<any>;
|
||||
existsSync: jest.MockedFunction<any>;
|
||||
refresh: () => Promise<Record<string, string>>;
|
||||
getSystemProxy: () => Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-require the proxy modules with a mocked node:os platform. SystemProxyResolver reads
|
||||
* os.platform() at construction, so this has to happen before requiring ./index.
|
||||
* refreshShellEnvProxyVars branches on process.platform (not os.platform), so Windows tests
|
||||
* must additionally override process.platform via Object.defineProperty.
|
||||
*/
|
||||
const loadForPlatform = (platform: string): FreshModules => {
|
||||
jest.resetModules();
|
||||
jest.doMock('node:os', () => ({ platform: () => platform }));
|
||||
|
||||
const { execFile } = require('node:child_process');
|
||||
const { existsSync } = require('node:fs');
|
||||
const { refreshShellEnvProxyVars } = require('../../utils/shell-env');
|
||||
const { getSystemProxy } = require('./index');
|
||||
|
||||
return { execFile, existsSync, refresh: refreshShellEnvProxyVars, getSystemProxy };
|
||||
};
|
||||
|
||||
/**
|
||||
* Argument-aware execFile router. Matches on a substring of the joined command; first hit wins.
|
||||
* Keeps tests decoupled from the resolver's internal call sequencing.
|
||||
*/
|
||||
const routeExecFile = (
|
||||
execFile: jest.MockedFunction<any>,
|
||||
routes: Array<[string, { stdout: string; stderr?: string } | Error]>
|
||||
) => {
|
||||
execFile.mockImplementation((file: string, args: string[]) => {
|
||||
const command = [file, ...args].join(' ');
|
||||
for (const [needle, response] of routes) {
|
||||
if (command.includes(needle)) {
|
||||
return response instanceof Error
|
||||
? Promise.reject(response)
|
||||
: Promise.resolve({ stderr: '', ...response });
|
||||
}
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
});
|
||||
};
|
||||
|
||||
let originalEnv: typeof process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
mockShellEnvResult = {};
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('refresh + getSystemProxy composition', () => {
|
||||
it('macOS: stale env purged on refresh, system layer wins alone', async () => {
|
||||
// A proxy var lingering on process.env from a previous session — the user has since
|
||||
// removed the export from .zshrc, so the login shell no longer emits it.
|
||||
process.env.http_proxy = 'http://stale.usebruno.com:8080';
|
||||
mockShellEnvResult = {};
|
||||
|
||||
const { execFile, refresh, getSystemProxy } = loadForPlatform('darwin');
|
||||
execFile.mockResolvedValueOnce({
|
||||
stdout: `<dictionary> {
|
||||
HTTPEnable : 1
|
||||
HTTPPort : 8080
|
||||
HTTPProxy : sys-proxy.usebruno.com
|
||||
}`,
|
||||
stderr: ''
|
||||
});
|
||||
|
||||
await refresh();
|
||||
const result = await getSystemProxy();
|
||||
|
||||
// Stale env value is gone, so system detection wins with no "+ environment" suffix.
|
||||
expect(result.http_proxy).toBe('http://sys-proxy.usebruno.com:8080');
|
||||
expect(result.source).toBe('macos-system');
|
||||
});
|
||||
|
||||
it('macOS: profile export beats system detection after refresh', async () => {
|
||||
// .zshrc now exports HTTP_PROXY (either newly added or edited).
|
||||
mockShellEnvResult = { http_proxy: 'http://profile.usebruno.com:9090' };
|
||||
|
||||
const { execFile, refresh, getSystemProxy } = loadForPlatform('darwin');
|
||||
execFile.mockResolvedValueOnce({
|
||||
stdout: `<dictionary> {
|
||||
HTTPEnable : 1
|
||||
HTTPPort : 8080
|
||||
HTTPProxy : sys-proxy.usebruno.com
|
||||
}`,
|
||||
stderr: ''
|
||||
});
|
||||
|
||||
await refresh();
|
||||
const result = await getSystemProxy();
|
||||
|
||||
expect(result.http_proxy).toBe('http://profile.usebruno.com:9090');
|
||||
expect(result.source).toBe('macos-system + environment');
|
||||
});
|
||||
|
||||
it('Linux: profile export merges with gsettings detection — env wins http, system supplies https + bypass', async () => {
|
||||
// .zshrc exports an http proxy; gsettings reports a DIFFERENT http proxy plus https + bypass
|
||||
// the profile does not set. After refresh, the env layer should win for http and combine
|
||||
// with system detection for the rest.
|
||||
mockShellEnvResult = { http_proxy: 'http://profile.usebruno.com:9090' };
|
||||
|
||||
const { execFile, existsSync, refresh, getSystemProxy } = loadForPlatform('linux');
|
||||
existsSync.mockReturnValue(false); // isolate the command-based (gsettings) path
|
||||
routeExecFile(execFile, [
|
||||
['org.gnome.system.proxy mode', { stdout: '\'manual\'' }],
|
||||
['org.gnome.system.proxy.http host', { stdout: '\'sys-proxy.usebruno.com\'' }],
|
||||
['org.gnome.system.proxy.http port', { stdout: '8080' }],
|
||||
['org.gnome.system.proxy.https host', { stdout: '\'secure-proxy.usebruno.com\'' }],
|
||||
['org.gnome.system.proxy.https port', { stdout: '8443' }],
|
||||
['org.gnome.system.proxy ignore-hosts', { stdout: '[\'localhost\', \'127.0.0.1\']' }]
|
||||
]);
|
||||
|
||||
await refresh();
|
||||
const result = await getSystemProxy();
|
||||
|
||||
expect(result.http_proxy).toBe('http://profile.usebruno.com:9090'); // env wins
|
||||
expect(result.https_proxy).toBe('http://secure-proxy.usebruno.com:8443'); // from system
|
||||
expect(result.no_proxy).toBe('localhost,127.0.0.1'); // from system
|
||||
expect(result.source).toBe('linux-system + environment');
|
||||
});
|
||||
|
||||
it('Linux: stale env purged on refresh, gsettings detection wins alone', async () => {
|
||||
// A proxy var lingering on process.env from a previous session — the user has since
|
||||
// removed the export from their profile, so the login shell no longer emits it.
|
||||
process.env.http_proxy = 'http://stale.usebruno.com:1234';
|
||||
mockShellEnvResult = {};
|
||||
|
||||
const { execFile, existsSync, refresh, getSystemProxy } = loadForPlatform('linux');
|
||||
existsSync.mockReturnValue(false);
|
||||
routeExecFile(execFile, [
|
||||
['org.gnome.system.proxy mode', { stdout: '\'manual\'' }],
|
||||
['org.gnome.system.proxy.http host', { stdout: '\'sys-proxy.usebruno.com\'' }],
|
||||
['org.gnome.system.proxy.http port', { stdout: '8080' }]
|
||||
]);
|
||||
|
||||
await refresh();
|
||||
const result = await getSystemProxy();
|
||||
|
||||
// Stale env value is gone, so system detection wins with no "+ environment" suffix.
|
||||
expect(result.http_proxy).toBe('http://sys-proxy.usebruno.com:8080');
|
||||
expect(result.source).toBe('linux-system');
|
||||
});
|
||||
|
||||
it('Linux (KDE): profile export merges with kreadconfig5 detection — env wins http, system supplies https + bypass', async () => {
|
||||
// gsettings is unavailable, so detection falls through to KDE (kreadconfig5). The profile
|
||||
// exports an http proxy; KDE reports a DIFFERENT http proxy plus https + bypass.
|
||||
mockShellEnvResult = { http_proxy: 'http://profile.usebruno.com:9090' };
|
||||
|
||||
const { execFile, existsSync, refresh, getSystemProxy } = loadForPlatform('linux');
|
||||
existsSync.mockReturnValue(false); // isolate the command-based path
|
||||
execFile.mockImplementation((file: string, args: string[]) => {
|
||||
const fullCommand = [file, ...args].join(' ');
|
||||
if (fullCommand.includes('gsettings')) {
|
||||
return Promise.reject(new Error('gsettings not available'));
|
||||
}
|
||||
if (fullCommand.includes('kreadconfig5')) {
|
||||
// Lowercase args before matching so we are agnostic to the key casing the resolver uses.
|
||||
const lowerArgs = args.join(' ').toLowerCase();
|
||||
if (lowerArgs.includes('proxytype')) return Promise.resolve({ stdout: '1', stderr: '' });
|
||||
if (lowerArgs.includes('httpproxy')) return Promise.resolve({ stdout: 'http://sys-proxy.usebruno.com:8080', stderr: '' });
|
||||
if (lowerArgs.includes('httpsproxy')) return Promise.resolve({ stdout: 'http://secure-proxy.usebruno.com:8443', stderr: '' });
|
||||
if (lowerArgs.includes('noproxyfor')) return Promise.resolve({ stdout: 'localhost,127.0.0.1', stderr: '' });
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
await refresh();
|
||||
const result = await getSystemProxy();
|
||||
|
||||
expect(result.http_proxy).toBe('http://profile.usebruno.com:9090'); // env wins
|
||||
expect(result.https_proxy).toBe('http://secure-proxy.usebruno.com:8443'); // from system (KDE)
|
||||
expect(result.no_proxy).toBe('localhost,127.0.0.1'); // from system (KDE)
|
||||
expect(result.source).toBe('linux-system + environment');
|
||||
});
|
||||
|
||||
it('Windows: shell profile export is ignored, registry detection wins', async () => {
|
||||
// fetchShellEnv branches on process.platform (not the mocked node:os), so we force it here.
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
|
||||
try {
|
||||
// A shell profile that WOULD export a proxy — on Windows the refresh is a no-op, so it
|
||||
// must not surface. Registry detection alone should decide the result.
|
||||
mockShellEnvResult = { http_proxy: 'http://should-be-ignored.usebruno.com:9090' };
|
||||
|
||||
const { execFile, refresh, getSystemProxy } = loadForPlatform('win32');
|
||||
routeExecFile(execFile, [
|
||||
['Internet Settings', {
|
||||
stdout: `
|
||||
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
|
||||
ProxyEnable REG_DWORD 0x1
|
||||
ProxyServer REG_SZ sys-proxy.usebruno.com:8080
|
||||
`
|
||||
}]
|
||||
]);
|
||||
|
||||
await refresh();
|
||||
const result = await getSystemProxy();
|
||||
|
||||
// Registry value surfaces; the ignored shell value contributes nothing, so no "+ environment".
|
||||
expect(result.http_proxy).toBe('http://sys-proxy.usebruno.com:8080');
|
||||
expect(result.source).toBe('windows-system');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('Windows: launcher-set env survives refresh and surfaces via getSystemProxy', async () => {
|
||||
// fetchShellEnv branches on process.platform (not the mocked node:os), so we force it here.
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
|
||||
try {
|
||||
// Set by a launcher script / parent shell / registry propagation — anything that reaches
|
||||
// process.env before Bruno starts. Without the Windows early-return in
|
||||
// refreshShellEnvProxyVars, this value would be stripped and never restored.
|
||||
process.env.HTTP_PROXY = 'http://launcher.usebruno.com:8080';
|
||||
// A shell profile that WOULD export a different proxy — must not surface on Windows.
|
||||
mockShellEnvResult = { http_proxy: 'http://should-be-ignored.usebruno.com:9090' };
|
||||
|
||||
const { execFile, refresh, getSystemProxy } = loadForPlatform('win32');
|
||||
// System registry has no proxy, so any surviving proxy in the final result must come
|
||||
// from the env layer we preserved.
|
||||
execFile.mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' }));
|
||||
|
||||
await refresh();
|
||||
const result = await getSystemProxy();
|
||||
|
||||
expect(result.http_proxy).toBe('http://launcher.usebruno.com:8080');
|
||||
expect(result.source).toContain('environment');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* Regression tests for refreshShellEnvProxyVars.
|
||||
*
|
||||
* shell-env spawns a login shell (`$SHELL -i -c 'export -p'`) that INHERITS the parent
|
||||
* process.env. So any proxy var still set on process.env when the shell is spawned would be
|
||||
* inherited and re-exported — meaning a proxy the user removed from their profile would never
|
||||
* go away. refreshShellEnvProxyVars guards against this by deleting all proxy vars BEFORE
|
||||
* invoking the shell on POSIX platforms, and by early-returning on Windows (where fetchShellEnv
|
||||
* is a no-op and the delete would strip values with nothing to restore them).
|
||||
*
|
||||
* The mock for shell-env models login-shell parent-env inheritance and captures a snapshot of
|
||||
* process.env at invocation time, so tests can assert delete-before-fetch ordering directly.
|
||||
*/
|
||||
import { refreshShellEnvProxyVars, PROXY_ENV_KEYS } from './shell-env';
|
||||
|
||||
let mockShellEnvResult: Record<string, string> = {};
|
||||
|
||||
// When set, the mocked shellEnv() rejects with this error. Lets us verify graceful degradation.
|
||||
let mockShellEnvThrows: Error | null = null;
|
||||
|
||||
// Snapshot of the proxy vars present in process.env when shellEnv() was invoked. Lets tests
|
||||
// assert that refreshShellEnvProxyVars deleted stale vars BEFORE spawning the shell.
|
||||
// Prefix required by jest.mock's out-of-scope-variable guard (mock*/MOCK* allowed).
|
||||
let mockProxyVarsSeenByShell: Record<string, string> = {};
|
||||
|
||||
jest.mock('shell-env', () => ({
|
||||
shellEnv: () => {
|
||||
const PROXY_KEYS = [
|
||||
'http_proxy',
|
||||
'HTTP_PROXY',
|
||||
'https_proxy',
|
||||
'HTTPS_PROXY',
|
||||
'no_proxy',
|
||||
'NO_PROXY',
|
||||
'all_proxy',
|
||||
'ALL_PROXY'
|
||||
];
|
||||
|
||||
// Capture what the subprocess would inherit from Bruno's process.env at spawn time.
|
||||
mockProxyVarsSeenByShell = {};
|
||||
for (const key of PROXY_KEYS) {
|
||||
if (process.env[key] !== undefined) {
|
||||
mockProxyVarsSeenByShell[key] = process.env[key] as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (mockShellEnvThrows) {
|
||||
return Promise.reject(mockShellEnvThrows);
|
||||
}
|
||||
|
||||
// Model a login shell: inherits from parent, then applies whatever the profile exports.
|
||||
return Promise.resolve({ ...mockProxyVarsSeenByShell, ...mockShellEnvResult });
|
||||
}
|
||||
}));
|
||||
|
||||
describe('refreshShellEnvProxyVars — shell inheritance (POSIX)', () => {
|
||||
beforeEach(() => {
|
||||
mockProxyVarsSeenByShell = {};
|
||||
mockShellEnvResult = {};
|
||||
mockShellEnvThrows = null;
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
test('updates proxy env vars from shell config', async () => {
|
||||
process.env.http_proxy = 'http://old-proxy:8080';
|
||||
mockShellEnvResult = {
|
||||
http_proxy: 'http://new-proxy:8080',
|
||||
https_proxy: 'http://new-proxy:8443'
|
||||
};
|
||||
|
||||
await refreshShellEnvProxyVars();
|
||||
|
||||
expect(process.env.http_proxy).toBe('http://new-proxy:8080');
|
||||
expect(process.env.https_proxy).toBe('http://new-proxy:8443');
|
||||
});
|
||||
|
||||
test('removes proxy env vars missing from shell config', async () => {
|
||||
// Stale vars lingering from a previous session.
|
||||
process.env.http_proxy = 'http://old-proxy:8080';
|
||||
process.env.no_proxy = 'localhost';
|
||||
// User removed the exports from their profile -> shell config no longer has them.
|
||||
mockShellEnvResult = {};
|
||||
|
||||
await refreshShellEnvProxyVars();
|
||||
|
||||
expect(process.env.http_proxy).toBeUndefined();
|
||||
expect(process.env.no_proxy).toBeUndefined();
|
||||
});
|
||||
|
||||
test('treats an empty-string proxy value as removal', async () => {
|
||||
process.env.http_proxy = 'http://old-proxy:8080';
|
||||
mockShellEnvResult = { http_proxy: '' };
|
||||
|
||||
await refreshShellEnvProxyVars();
|
||||
|
||||
expect(process.env.http_proxy).toBeUndefined();
|
||||
});
|
||||
|
||||
test('leaves non-proxy env vars untouched', async () => {
|
||||
process.env.NON_PROXY_VAR = 'keep-me';
|
||||
mockShellEnvResult = { http_proxy: 'http://new-proxy:8080' };
|
||||
|
||||
await refreshShellEnvProxyVars();
|
||||
|
||||
expect(process.env.http_proxy).toBe('http://new-proxy:8080');
|
||||
expect(process.env.NON_PROXY_VAR).toBe('keep-me');
|
||||
|
||||
delete process.env.NON_PROXY_VAR;
|
||||
});
|
||||
|
||||
test('handles uppercase proxy variants (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, ALL_PROXY)', async () => {
|
||||
process.env.HTTP_PROXY = 'http://old-upper:8080';
|
||||
process.env.ALL_PROXY = 'http://old-all:1080';
|
||||
mockShellEnvResult = {
|
||||
HTTP_PROXY: 'http://new-upper:8080',
|
||||
HTTPS_PROXY: 'http://new-upper:8443',
|
||||
NO_PROXY: 'localhost,127.0.0.1',
|
||||
ALL_PROXY: 'http://new-all:1080'
|
||||
};
|
||||
|
||||
await refreshShellEnvProxyVars();
|
||||
|
||||
expect(process.env.HTTP_PROXY).toBe('http://new-upper:8080');
|
||||
expect(process.env.HTTPS_PROXY).toBe('http://new-upper:8443');
|
||||
expect(process.env.NO_PROXY).toBe('localhost,127.0.0.1');
|
||||
expect(process.env.ALL_PROXY).toBe('http://new-all:1080');
|
||||
});
|
||||
|
||||
test('restores prior proxy vars when the shell-env subprocess fails', async () => {
|
||||
process.env.http_proxy = 'http://existing:8080';
|
||||
mockShellEnvThrows = new Error('shell subprocess failed');
|
||||
|
||||
// On subprocess failure we return {} but restore the snapshot taken before the
|
||||
// delete, so the user is not left silently unproxied.
|
||||
await expect(refreshShellEnvProxyVars()).resolves.toEqual({});
|
||||
expect(process.env.http_proxy).toBe('http://existing:8080');
|
||||
});
|
||||
|
||||
test('returns the raw shell env dict, including non-proxy keys', async () => {
|
||||
mockShellEnvResult = {
|
||||
http_proxy: 'http://p:8080',
|
||||
PATH: '/foo:/bar',
|
||||
EDITOR: 'vim'
|
||||
};
|
||||
|
||||
const result = await refreshShellEnvProxyVars();
|
||||
|
||||
expect(result).toEqual({
|
||||
http_proxy: 'http://p:8080',
|
||||
PATH: '/foo:/bar',
|
||||
EDITOR: 'vim'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshShellEnvProxyVars — Windows', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockProxyVarsSeenByShell = {};
|
||||
mockShellEnvResult = {};
|
||||
mockShellEnvThrows = null;
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
test('leaves process.env proxy vars untouched (registry / launcher / parent-shell values survive)', async () => {
|
||||
// A proxy in process.env from any Windows source: registry propagation, launcher script,
|
||||
// PowerShell $env, Git Bash export, etc. All look the same to us.
|
||||
process.env.HTTP_PROXY = 'http://launcher-proxy:8080';
|
||||
process.env.https_proxy = 'http://another:9090';
|
||||
|
||||
await refreshShellEnvProxyVars();
|
||||
|
||||
expect(process.env.HTTP_PROXY).toBe('http://launcher-proxy:8080');
|
||||
expect(process.env.https_proxy).toBe('http://another:9090');
|
||||
});
|
||||
|
||||
test('does not invoke the shell subprocess on Windows', async () => {
|
||||
// A shell profile that would export a proxy if it were consulted — it must not be.
|
||||
mockShellEnvResult = { http_proxy: 'http://should-not-appear:8080' };
|
||||
|
||||
await refreshShellEnvProxyVars();
|
||||
|
||||
// mockProxyVarsSeenByShell is only populated when the shell-env mock is actually called.
|
||||
// Empty means we never invoked shellEnv() — the Windows early-return short-circuited.
|
||||
expect(mockProxyVarsSeenByShell).toEqual({});
|
||||
expect(process.env.http_proxy).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6,38 +6,7 @@
|
||||
|
||||
import path from 'path';
|
||||
|
||||
export const PROXY_ENV_KEYS = [
|
||||
'http_proxy',
|
||||
'HTTP_PROXY',
|
||||
'https_proxy',
|
||||
'HTTPS_PROXY',
|
||||
'no_proxy',
|
||||
'NO_PROXY',
|
||||
'all_proxy',
|
||||
'ALL_PROXY'
|
||||
] as const;
|
||||
|
||||
const TIMEOUT = Symbol('shell-env-timeout');
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Races a promise against a timeout. Resolves to the TIMEOUT symbol if the
|
||||
* timeout wins, so a misconfigured shell can't hang the caller indefinitely.
|
||||
*/
|
||||
const withTimeout = async <T>(promise: Promise<T>, timeoutMs: number): Promise<T | typeof TIMEOUT> => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
const timeoutPromise = new Promise<typeof TIMEOUT>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(TIMEOUT), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
clearTimeout(timeoutId!);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchShellEnv = async (): Promise<Record<string, string> | null> => {
|
||||
export const fetchShellEnv = async (): Promise<Record<string, string> | null> => {
|
||||
// Windows handles environment variables differently - skip
|
||||
// everything related to windows proxy settings is handled by the system proxy resolver i.e getSystemProxy()
|
||||
if (process.platform === 'win32') {
|
||||
@@ -76,53 +45,3 @@ export const initializeShellEnv = async (): Promise<Record<string, string>> => {
|
||||
}
|
||||
return shellEnvVars;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-syncs proxy-related process.env values from the user's shell configuration.
|
||||
* Used when refreshing system proxy settings without restarting the app.
|
||||
*
|
||||
* @returns The fetched shell environment variables
|
||||
*/
|
||||
export const refreshShellEnvProxyVars = async (): Promise<Record<string, string>> => {
|
||||
// Windows handles environment variables differently - skip
|
||||
// everything related to windows proxy settings is handled by the
|
||||
// system proxy resolver i.e getSystemProxy()
|
||||
if (process.platform === 'win32') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Snapshot and clear stale proxy vars first so shell-env does not inherit them
|
||||
// into the login shell subprocess (removed .zshrc exports would otherwise persist).
|
||||
const snapshot: Record<string, string | undefined> = {};
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
snapshot[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
|
||||
const restoreSnapshot = () => {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
if (snapshot[key] !== undefined) {
|
||||
process.env[key] = snapshot[key] as string;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Race the shell-env subprocess against a 60s timeout so a misconfigured shell
|
||||
// can't hang the refresh indefinitely.
|
||||
const result = await withTimeout(fetchShellEnv(), TIMEOUT_MS);
|
||||
|
||||
if (result === TIMEOUT || result === null) {
|
||||
// Timed out — restore prior values rather than leave the user unproxied.
|
||||
restoreSnapshot();
|
||||
return {};
|
||||
}
|
||||
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
const value = result[key];
|
||||
if (value) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user