diff --git a/packages/bruno-electron/src/store/system-proxy.js b/packages/bruno-electron/src/store/system-proxy.js index 3a40daa59..023c32e30 100644 --- a/packages/bruno-electron/src/store/system-proxy.js +++ b/packages/bruno-electron/src/store/system-proxy.js @@ -1,10 +1,47 @@ -const { getSystemProxy } = 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; -const loadSystemProxy = async () => { +// 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) { + await refreshShellEnvProxyVars(); + } cachedSystemProxy = await getSystemProxy(); } catch (error) { console.error('Failed to initialize system proxy:', error); @@ -21,7 +58,7 @@ const loadSystemProxy = async () => { const fetchSystemProxy = ({ refresh = false } = {}) => { if (refresh || !systemProxyPromise) { - systemProxyPromise = loadSystemProxy(); + systemProxyPromise = loadSystemProxy({ refreshShellEnv: refresh }); } return systemProxyPromise; }; diff --git a/packages/bruno-electron/src/store/tests/system-proxy.spec.js b/packages/bruno-electron/src/store/tests/system-proxy.spec.js new file mode 100644 index 000000000..776100f1f --- /dev/null +++ b/packages/bruno-electron/src/store/tests/system-proxy.spec.js @@ -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'); + }); + }); +}); diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts index e3598e93a..98d623d4c 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -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 } 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'; diff --git a/packages/bruno-requests/src/utils/shell-env.ts b/packages/bruno-requests/src/utils/shell-env.ts index 49c8ac231..d98bdd487 100644 --- a/packages/bruno-requests/src/utils/shell-env.ts +++ b/packages/bruno-requests/src/utils/shell-env.ts @@ -6,8 +6,9 @@ import path from 'path'; -const fetchShellEnv = async (): Promise> => { +export const fetchShellEnv = async (): Promise | 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') { return {}; } @@ -18,7 +19,7 @@ const fetchShellEnv = async (): Promise> => { const env = await shellEnv(); return env; } catch (error) { - return {}; + return null; } }; @@ -30,6 +31,11 @@ const fetchShellEnv = async (): Promise> => { */ export const initializeShellEnv = async (): Promise> => { const shellEnvVars = await fetchShellEnv(); + + if (shellEnvVars === null) { + return {}; + } + for (const [key, value] of Object.entries(shellEnvVars)) { if (key === 'PATH' && process.env.PATH) { process.env.PATH = `${value}${path.delimiter}${process.env.PATH}`;