Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cli/pilotdeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
// 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;
Expand Down
19 changes: 19 additions & 0 deletions tests/cli/pilotdeck-proxy-cold-start.test.ts
Original file line number Diff line number Diff line change
@@ -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*\)/,
);
});
38 changes: 37 additions & 1 deletion ui/server/services/pilotdeckConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────
Expand Down
110 changes: 110 additions & 0 deletions ui/server/services/pilotdeckConfig.test.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
});
});
12 changes: 12 additions & 0 deletions ui/server/services/pilotdeckConfigReloader.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -12,6 +23,7 @@ export async function reloadPilotDeckConfig(config) {
};

applyConfigToProcessEnv(config);
reinstallGlobalProxy(getRuntimeProxyUrl());
result.processEnv.reloaded = true;

closeMemoryServices();
Expand Down
77 changes: 77 additions & 0 deletions ui/server/services/pilotdeckConfigReloader.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});