From 0e1d4a1b23798b62bf82fcb861d5fce50eeb40f5 Mon Sep 17 00:00:00 2001 From: Haoqian Li Date: Wed, 24 Jun 2026 00:09:20 +0800 Subject: [PATCH] fix proxy no-proxy propagation --- src/cli/pilotdeck.ts | 2 +- tests/cli/pilotdeck-proxy-cold-start.test.ts | 19 +++ ui/server/services/pilotdeckConfig.js | 38 +++++- ui/server/services/pilotdeckConfig.test.js | 110 ++++++++++++++++++ ui/server/services/pilotdeckConfigReloader.js | 12 ++ .../services/pilotdeckConfigReloader.test.js | 77 ++++++++++++ 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 tests/cli/pilotdeck-proxy-cold-start.test.ts create mode 100644 ui/server/services/pilotdeckConfig.test.js create mode 100644 ui/server/services/pilotdeckConfigReloader.test.js diff --git a/src/cli/pilotdeck.ts b/src/cli/pilotdeck.ts index f8f7fa70..81e7bb67 100644 --- a/src/cli/pilotdeck.ts +++ b/src/cli/pilotdeck.ts @@ -34,7 +34,7 @@ async function main(argv = process.argv.slice(2)): Promise { // Apply proxy from config (env-based proxy from top-level installGlobalProxy // takes precedence; this fills in when only pilotdeck.yaml has a proxy). if (snapshot.config.proxy?.url) { - await installGlobalProxy(snapshot.config.proxy.url); + await installGlobalProxy(snapshot.config.proxy.url, snapshot.config.proxy.noProxy); } let alwaysOn: AlwaysOnManager | undefined; diff --git a/tests/cli/pilotdeck-proxy-cold-start.test.ts b/tests/cli/pilotdeck-proxy-cold-start.test.ts new file mode 100644 index 00000000..3fd0fa93 --- /dev/null +++ b/tests/cli/pilotdeck-proxy-cold-start.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +test("server cold start passes configured noProxy to the proxy installer", async () => { + const here = dirname(fileURLToPath(import.meta.url)); + const pilotdeckSource = await readFile(resolve(here, "../../src/cli/pilotdeck.js"), "utf8"); + + assert.match( + pilotdeckSource, + /installGlobalProxy\(\s*snapshot\.config\.proxy\.url,\s*snapshot\.config\.proxy\.noProxy\s*\)/, + ); + assert.doesNotMatch( + pilotdeckSource, + /installGlobalProxy\(\s*snapshot\.config\.proxy\.url\s*\)/, + ); +}); diff --git a/ui/server/services/pilotdeckConfig.js b/ui/server/services/pilotdeckConfig.js index 9560b5ef..2a669073 100644 --- a/ui/server/services/pilotdeckConfig.js +++ b/ui/server/services/pilotdeckConfig.js @@ -29,6 +29,17 @@ const MASK = '********'; const SECRET_KEY_RE = /(api[_-]?key|token|secret|password|auth[_-]?token|access[_-]?token|bot[_-]?token|app[_-]?token|encoding[_-]?aes[_-]?key)$/i; const SECRET_EXACT_KEYS = new Set(['key', 'apiKey', 'api_key', 'authToken', 'accessToken']); +const PROXY_RUNTIME_ENV_KEYS = [ + 'PILOTDECK_PROXY', + 'HTTPS_PROXY', + 'https_proxy', + 'HTTP_PROXY', + 'http_proxy', + 'NO_PROXY', + 'no_proxy', +]; +const managedProxyEnvKeys = new Set(); +const previousProxyEnvValues = new Map(); function clone(value) { return JSON.parse(JSON.stringify(value)); @@ -324,6 +335,11 @@ export function buildRuntimeEnv(config) { if (proxyUrl) { env.HTTPS_PROXY = proxyUrl; env.https_proxy = proxyUrl; + const noProxy = normalizeString(normalized.proxy?.noProxy); + if (noProxy) { + env.NO_PROXY = noProxy; + env.no_proxy = noProxy; + } } if (main) { @@ -381,7 +397,27 @@ export function buildRuntimeEnv(config) { } export function applyConfigToProcessEnv(config) { - Object.assign(process.env, buildRuntimeEnv(config)); + const env = buildRuntimeEnv(config); + for (const key of PROXY_RUNTIME_ENV_KEYS) { + if (Object.prototype.hasOwnProperty.call(env, key)) { + if (!managedProxyEnvKeys.has(key)) { + previousProxyEnvValues.set(key, process.env[key]); + } + managedProxyEnvKeys.add(key); + continue; + } + + if (!managedProxyEnvKeys.has(key)) continue; + const previousValue = previousProxyEnvValues.get(key); + if (previousValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = previousValue; + } + managedProxyEnvKeys.delete(key); + previousProxyEnvValues.delete(key); + } + Object.assign(process.env, env); } // ─── Memory service options ────────────────────────────────────────────────── diff --git a/ui/server/services/pilotdeckConfig.test.js b/ui/server/services/pilotdeckConfig.test.js new file mode 100644 index 00000000..6d8c9084 --- /dev/null +++ b/ui/server/services/pilotdeckConfig.test.js @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { applyConfigToProcessEnv, buildRuntimeEnv } from './pilotdeckConfig.js'; + +describe('buildRuntimeEnv proxy config', () => { + it('exports proxy URL and no-proxy list when both are configured', () => { + const env = buildRuntimeEnv({ + proxy: { + url: 'http://proxy.example:8080', + noProxy: 'localhost,127.0.0.1,internal.example', + }, + }); + + expect(env.HTTPS_PROXY).toBe('http://proxy.example:8080'); + expect(env.https_proxy).toBe('http://proxy.example:8080'); + expect(env.NO_PROXY).toBe('localhost,127.0.0.1,internal.example'); + expect(env.no_proxy).toBe('localhost,127.0.0.1,internal.example'); + }); + + it('does not export no-proxy env vars when no-proxy is blank', () => { + const env = buildRuntimeEnv({ + proxy: { + url: 'http://proxy.example:8080', + noProxy: ' ', + }, + }); + + expect(env.HTTPS_PROXY).toBe('http://proxy.example:8080'); + expect(env.https_proxy).toBe('http://proxy.example:8080'); + expect(env.NO_PROXY).toBeUndefined(); + expect(env.no_proxy).toBeUndefined(); + }); + + it('does not export no-proxy env vars without a proxy URL', () => { + const env = buildRuntimeEnv({ + proxy: { + noProxy: 'internal.example', + }, + }); + + expect(env.HTTPS_PROXY).toBeUndefined(); + expect(env.https_proxy).toBeUndefined(); + expect(env.NO_PROXY).toBeUndefined(); + expect(env.no_proxy).toBeUndefined(); + }); + + it('removes previously applied proxy env vars when proxy config is removed', () => { + const keys = ['HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy']; + const previous = new Map(keys.map((key) => [key, process.env[key]])); + try { + keys.forEach((key) => delete process.env[key]); + + applyConfigToProcessEnv({ + proxy: { + url: 'http://proxy.example:8080', + noProxy: 'internal.example', + }, + }); + expect(process.env.HTTPS_PROXY).toBe('http://proxy.example:8080'); + expect(process.env.NO_PROXY).toBe('internal.example'); + + applyConfigToProcessEnv({}); + expect(process.env.HTTPS_PROXY).toBeUndefined(); + expect(process.env.https_proxy).toBeUndefined(); + expect(process.env.NO_PROXY).toBeUndefined(); + expect(process.env.no_proxy).toBeUndefined(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + }); + + it('restores pre-existing proxy env vars after config-managed proxy is removed', () => { + const keys = ['HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy']; + const previous = new Map(keys.map((key) => [key, process.env[key]])); + try { + process.env.HTTPS_PROXY = 'http://env-proxy.example:8080'; + process.env.https_proxy = 'http://env-proxy.example:8080'; + process.env.NO_PROXY = 'env.internal'; + process.env.no_proxy = 'env.internal'; + + applyConfigToProcessEnv({ + proxy: { + url: 'http://config-proxy.example:8080', + noProxy: 'config.internal', + }, + }); + expect(process.env.HTTPS_PROXY).toBe('http://config-proxy.example:8080'); + expect(process.env.NO_PROXY).toBe('config.internal'); + + applyConfigToProcessEnv({}); + expect(process.env.HTTPS_PROXY).toBe('http://env-proxy.example:8080'); + expect(process.env.https_proxy).toBe('http://env-proxy.example:8080'); + expect(process.env.NO_PROXY).toBe('env.internal'); + expect(process.env.no_proxy).toBe('env.internal'); + } finally { + for (const [key, value] of previous) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + }); +}); diff --git a/ui/server/services/pilotdeckConfigReloader.js b/ui/server/services/pilotdeckConfigReloader.js index 71486979..ddc393de 100644 --- a/ui/server/services/pilotdeckConfigReloader.js +++ b/ui/server/services/pilotdeckConfigReloader.js @@ -1,5 +1,16 @@ import { applyConfigToProcessEnv } from './pilotdeckConfig.js'; import { closeMemoryServices, startMemoryScheduler } from './memoryService.js'; +import { reinstallGlobalProxy } from '../utils/proxy.js'; + +function getRuntimeProxyUrl(env = process.env) { + return ( + env.PILOTDECK_PROXY + || env.https_proxy + || env.HTTPS_PROXY + || env.http_proxy + || env.HTTP_PROXY + ); +} // Applies a validated config to every running subsystem (env, memory) and // returns a per-subsystem summary so the UI can show what actually reloaded. @@ -12,6 +23,7 @@ export async function reloadPilotDeckConfig(config) { }; applyConfigToProcessEnv(config); + reinstallGlobalProxy(getRuntimeProxyUrl()); result.processEnv.reloaded = true; closeMemoryServices(); diff --git a/ui/server/services/pilotdeckConfigReloader.test.js b/ui/server/services/pilotdeckConfigReloader.test.js new file mode 100644 index 00000000..0b82785a --- /dev/null +++ b/ui/server/services/pilotdeckConfigReloader.test.js @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { reinstallGlobalProxy } from '../utils/proxy.js'; +import { reloadPilotDeckConfig } from './pilotdeckConfigReloader.js'; + +vi.mock('../utils/proxy.js', () => ({ + reinstallGlobalProxy: vi.fn(), +})); + +vi.mock('./memoryService.js', () => ({ + closeMemoryServices: vi.fn(), + startMemoryScheduler: vi.fn(), +})); + +describe('reloadPilotDeckConfig proxy reload', () => { + const proxyKeys = ['PILOTDECK_PROXY', 'HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy']; + const previousEnv = new Map(proxyKeys.map((key) => [key, process.env[key]])); + + afterEach(() => { + vi.clearAllMocks(); + for (const [key, value] of previousEnv) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('reinstalls the UI server proxy dispatcher after applying config env', async () => { + proxyKeys.forEach((key) => delete process.env[key]); + + await reloadPilotDeckConfig({ + proxy: { + url: 'http://proxy.example:8080', + noProxy: 'internal.example', + }, + }); + + expect(process.env.HTTPS_PROXY).toBe('http://proxy.example:8080'); + expect(process.env.NO_PROXY).toBe('internal.example'); + expect(reinstallGlobalProxy).toHaveBeenCalledWith('http://proxy.example:8080'); + }); + + it('removes the UI server proxy dispatcher when config and env do not define a proxy', async () => { + proxyKeys.forEach((key) => delete process.env[key]); + await reloadPilotDeckConfig({ + proxy: { + url: 'http://proxy.example:8080', + noProxy: 'internal.example', + }, + }); + + vi.clearAllMocks(); + await reloadPilotDeckConfig({}); + + expect(process.env.HTTPS_PROXY).toBeUndefined(); + expect(process.env.NO_PROXY).toBeUndefined(); + expect(reinstallGlobalProxy).toHaveBeenCalledWith(undefined); + }); + + it('falls back to pre-existing env proxy after config proxy is removed', async () => { + proxyKeys.forEach((key) => delete process.env[key]); + process.env.HTTPS_PROXY = 'http://env-proxy.example:8080'; + + await reloadPilotDeckConfig({ + proxy: { + url: 'http://config-proxy.example:8080', + }, + }); + + vi.clearAllMocks(); + await reloadPilotDeckConfig({}); + + expect(process.env.HTTPS_PROXY).toBe('http://env-proxy.example:8080'); + expect(reinstallGlobalProxy).toHaveBeenCalledWith('http://env-proxy.example:8080'); + }); +});