fix(platform): support live system proxy changes (#3093)

* fix(platform): support live system proxy changes

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Honor lowercase proxy env vars within a single source before merging proxy-aware envs.\n\nGenerated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Refresh provider request proxy env on each dispatcher creation and cover it with a focused regression test.

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): enable node env proxy for user proxy vars

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)

* fix(platform): support live system proxy changes

Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)
This commit is contained in:
Marc Chan 2026-05-28 14:11:47 +08:00 committed by GitHub
parent 9de5ecd87c
commit 338cb4d423
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2641 additions and 146 deletions

View file

@ -240,6 +240,10 @@ export interface BYOKToolContext {
* (e.g. 1 ms) to keep the suite fast without changing the polling
* semantics. */
videoPollIntervalMs?: number;
/** Optional per-request init copied from the live chat turn. Used to
* forward the current proxy dispatcher into every upstream/download
* fetch the BYOK tool executor performs. */
requestInit?: Pick<RequestInit, 'dispatcher'>;
}
export interface ImageToolResult {
@ -251,6 +255,16 @@ export interface ImageToolResult {
error?: string;
}
function withToolRequestInit(
ctx: BYOKToolContext,
init: RequestInit,
): RequestInit {
return {
...ctx.requestInit,
...init,
};
}
export async function executeGenerateSpeech(
args: { text?: unknown; voice_id?: unknown },
ctx: BYOKToolContext,
@ -281,7 +295,7 @@ export async function executeGenerateSpeech(
base_resp?: { status_code?: number; status_msg?: string };
};
try {
const resp = await fetch(appendSenseAudioApiPath(baseUrl, '/t2a_v2'), {
const resp = await fetch(appendSenseAudioApiPath(baseUrl, '/t2a_v2'), withToolRequestInit(ctx, {
method: 'POST',
redirect: 'error',
headers: {
@ -305,7 +319,7 @@ export async function executeGenerateSpeech(
channel: 2,
},
}),
});
}));
const respText = await resp.text();
if (!resp.ok) {
return { ok: false, error: `senseaudio speech ${resp.status}: ${respText.slice(0, 240)}` };
@ -420,7 +434,7 @@ export async function executeGenerateImage(
const trimmedBase = baseUrl.replace(/\/+$/, '');
let imageUrl: string;
try {
const resp = await fetch(`${trimmedBase}/v1/image/sync`, {
const resp = await fetch(`${trimmedBase}/v1/image/sync`, withToolRequestInit(ctx, {
method: 'POST',
headers: {
authorization: `Bearer ${apiKey}`,
@ -431,7 +445,7 @@ export async function executeGenerateImage(
prompt,
size,
}),
});
}));
if (!resp.ok) {
const text = await resp.text().catch(() => '');
return {
@ -469,7 +483,7 @@ export async function executeGenerateImage(
let bytes: Buffer;
try {
const imgResp = await fetch(imageUrl, { redirect: 'error' });
const imgResp = await fetch(imageUrl, withToolRequestInit(ctx, { redirect: 'error' }));
if (!imgResp.ok) {
return { ok: false, error: `image download ${imgResp.status}` };
}
@ -596,7 +610,7 @@ export async function executeGenerateVideo(
// Step 1: POST /v1/video/create → task_id.
let taskId: string;
try {
const resp = await fetch(`${trimmedBase}/v1/video/create`, {
const resp = await fetch(`${trimmedBase}/v1/video/create`, withToolRequestInit(ctx, {
method: 'POST',
headers: {
authorization: `Bearer ${apiKey}`,
@ -610,7 +624,7 @@ export async function executeGenerateVideo(
ratio,
provider_specific: { generate_audio: generateAudio },
}),
});
}));
if (!resp.ok) {
const text = await resp.text().catch(() => '');
return {
@ -639,10 +653,10 @@ export async function executeGenerateVideo(
try {
statusResp = await fetch(
`${trimmedBase}/v1/video/status?id=${encodeURIComponent(taskId)}`,
{
withToolRequestInit(ctx, {
method: 'GET',
headers: { authorization: `Bearer ${apiKey}` },
},
}),
);
} catch (err) {
return {
@ -702,7 +716,7 @@ export async function executeGenerateVideo(
let bytes: Buffer;
try {
const videoResp = await fetch(videoUrl, { redirect: 'error' });
const videoResp = await fetch(videoUrl, withToolRequestInit(ctx, { redirect: 'error' }));
if (!videoResp.ok) {
return { ok: false, error: `video download ${videoResp.status}` };
}

View file

@ -17,7 +17,7 @@ import {
} from './byok-tools.js';
import { isSafeId as isSafeProjectId } from './projects.js';
import { projectKindToTracking } from '@open-design/contracts/analytics';
import { validateBaseUrlResolved } from './connectionTest.js';
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
import { googleStreamGenerateContentUrl } from './google-models.js';
// Allowlist for the `/feedback` route. Mirrors the
@ -270,15 +270,21 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
);
}
try {
const result = await listProviderModels({
protocol,
baseUrl: body.baseUrl,
apiKey: body.apiKey,
apiVersion:
typeof body.apiVersion === 'string' ? body.apiVersion : undefined,
signal: controller.signal,
});
return res.json(result);
const proxyDispatcher = proxyDispatcherRequestInit();
try {
const result = await listProviderModels({
protocol,
baseUrl: body.baseUrl,
apiKey: body.apiKey,
apiVersion:
typeof body.apiVersion === 'string' ? body.apiVersion : undefined,
signal: controller.signal,
requestInit: proxyDispatcher.requestInit,
});
return res.json(result);
} finally {
await proxyDispatcher.close();
}
} catch (err: any) {
console.warn(
`[provider:models] uncaught: ${err instanceof Error ? err.message : String(err)}`,
@ -671,9 +677,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
}
const sse = createSseResponse(res);
sse.send('start', { model });
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try {
proxyDispatcher = proxyDispatcherRequestInit();
sse.send('start', { model });
const response = await fetch(url, {
...proxyDispatcher.requestInit,
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -722,6 +731,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:anthropic] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end();
} finally {
await proxyDispatcher?.close();
}
});
@ -771,9 +782,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
};
const sse = createSseResponse(res);
sse.send('start', { model });
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try {
proxyDispatcher = proxyDispatcherRequestInit();
sse.send('start', { model });
const response = await fetch(url, {
...proxyDispatcher.requestInit,
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -820,6 +834,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:openai] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end();
} finally {
await proxyDispatcher?.close();
}
});
@ -891,9 +907,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
};
const sse = createSseResponse(res);
sse.send('start', { model });
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try {
proxyDispatcher = proxyDispatcherRequestInit();
sse.send('start', { model });
const requestInit = {
...proxyDispatcher.requestInit,
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -962,6 +981,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:azure] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end();
} finally {
await proxyDispatcher?.close();
}
});
@ -1011,9 +1032,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
}
const sse = createSseResponse(res);
sse.send('start', { model });
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try {
proxyDispatcher = proxyDispatcherRequestInit();
sse.send('start', { model });
const response = await fetch(url, {
...proxyDispatcher.requestInit,
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -1061,6 +1085,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:google] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end();
} finally {
await proxyDispatcher?.close();
}
});
@ -1098,9 +1124,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
}
const sse = createSseResponse(res);
sse.send('start', { model });
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try {
proxyDispatcher = proxyDispatcherRequestInit();
sse.send('start', { model });
const response = await fetch(url, {
...proxyDispatcher.requestInit,
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(payload),
@ -1136,6 +1165,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:ollama] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end();
} finally {
await proxyDispatcher?.close();
}
});
@ -1231,12 +1262,15 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
? byokImageModel
: undefined;
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
const toolCtx: BYOKToolContext = {
projectRoot: ctx.paths.PROJECT_ROOT,
projectsRoot: ctx.paths.PROJECTS_DIR,
projectId,
upstreamApiKey: apiKey,
upstreamBaseUrl: effectiveBaseUrl,
requestInit: {},
// Spread-conditional because tsconfig's exactOptionalPropertyTypes
// forbids `field: undefined` on an optional slot. The byok-tools
// executor reads `ctx.defaultImageModel` with `isSenseAudioImageModel`
@ -1265,6 +1299,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
tool_choice: 'auto',
};
const response = await fetch(url, {
...toolCtx.requestInit,
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -1408,8 +1443,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
};
const sse = createSseResponse(res);
sse.send('start', { model });
// SenseAudio's gateway issues one API key that works for both
// /v1/chat/completions and the image / TTS surfaces. Mirror the
// BYOK key into media-config so the CLI agent path (`od media
@ -1436,6 +1469,9 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
});
try {
proxyDispatcher = proxyDispatcherRequestInit();
toolCtx.requestInit = proxyDispatcher.requestInit;
sse.send('start', { model });
for (let loop = 0; loop < MAX_BYOK_TOOL_LOOPS; loop++) {
const turn = await runSenseAudioTurn(sse, workingMessages);
if (turn.kind === 'error') return sse.end();
@ -1495,6 +1531,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
console.error(`[proxy:senseaudio] internal error: ${err.message}`);
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
sse.end();
} finally {
await proxyDispatcher?.close();
}
});

View file

@ -21,13 +21,19 @@ import { promises as dnsPromises } from 'node:dns';
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Agent, EnvHttpProxyAgent, Socks5ProxyAgent } from 'undici';
import type { Dispatcher, Pool } from 'undici';
import {
applyAgentLaunchEnv,
getAgentDef,
resolveAgentLaunch,
spawnEnvForAgent,
} from './agents.js';
import { createCommandInvocation } from '@open-design/platform';
import {
createCommandInvocation,
mergeProxyAwareEnv,
resolveSystemProxyEnv,
} from '@open-design/platform';
import { attachAcpSession } from './acp.js';
import { attachPiRpcSession } from './pi-rpc.js';
import { createClaudeStreamHandler } from './claude-stream.js';
@ -168,6 +174,7 @@ export async function assertExternalAssetUrl(
// Override with OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS for slow networks
// or distant providers; invalid values fall back to the default.
const DEFAULT_PROVIDER_TIMEOUT_MS = 12_000;
const LOOPBACK_NO_PROXY_TOKENS = ['localhost', '127.0.0.1', '[::1]'] as const;
// CLI boot time is dominated by adapter auth/session restore; the heavy
// adapters (Codex, Cursor Agent) regularly take 510 s on a cold first
// run, so 45 s leaves headroom without making a hung child invisible.
@ -211,6 +218,336 @@ function agentTimeoutMs(): number {
DEFAULT_AGENT_TIMEOUT_MS,
);
}
export function mergeNoProxyWithLoopbackDefaults(noProxy: string | undefined): string | null {
if (noProxy?.split(/[\s,]+/).some((token) => token.trim() === '*')) return '*';
const seen = new Set<string>();
const values: string[] = [];
for (const rawToken of [
...(noProxy ? noProxy.split(/[\s,]+/) : []),
...LOOPBACK_NO_PROXY_TOKENS,
]) {
const token = rawToken.trim() === '::1' ? '[::1]' : rawToken.trim();
if (!token || seen.has(token)) continue;
seen.add(token);
values.push(token);
}
return values.length > 0 ? values.join(',') : null;
}
function defaultPortForProtocol(protocol: string): string {
if (protocol === 'http:') return '80';
if (protocol === 'https:') return '443';
return '';
}
function splitNoProxyHostAndPort(token: string): { host: string; port: string } {
const trimmed = token.trim();
if (!trimmed) return { host: '', port: '' };
if (trimmed.startsWith('[')) {
const closingBracket = trimmed.indexOf(']');
if (closingBracket === -1) return { host: trimmed.toLowerCase(), port: '' };
const host = trimmed.slice(0, closingBracket + 1).toLowerCase();
const port = trimmed.slice(closingBracket + 1).replace(/^:/, '');
return { host, port };
}
const firstColon = trimmed.indexOf(':');
const lastColon = trimmed.lastIndexOf(':');
if (firstColon !== -1 && firstColon === lastColon) {
return {
host: trimmed.slice(0, firstColon).toLowerCase(),
port: trimmed.slice(firstColon + 1),
};
}
return { host: trimmed.toLowerCase(), port: '' };
}
function noProxyTokenMatchesUrl(token: string, url: URL): boolean {
const trimmed = token.trim();
if (!trimmed) return false;
if (trimmed === '*') return true;
if (trimmed === '<local>') return !url.hostname.includes('.') && !url.hostname.includes(':');
const { host, port } = splitNoProxyHostAndPort(trimmed.replace(/^\*\./, '.'));
if (!host) return false;
const normalizedHost = host === '::1' ? '[::1]' : host;
const hostname = url.hostname.toLowerCase();
const matchesHost = normalizedHost.startsWith('.')
? hostname === normalizedHost.slice(1) || hostname.endsWith(normalizedHost)
: hostname === normalizedHost || hostname.endsWith(`.${normalizedHost}`);
if (!matchesHost) return false;
if (!port) return true;
return (url.port || defaultPortForProtocol(url.protocol)) === port;
}
function shouldBypassProxyForUrl(target: string | URL, noProxy: string | null): boolean {
if (!noProxy) return false;
let url: URL;
try {
url = target instanceof URL ? target : new URL(target);
} catch {
return false;
}
return noProxy.split(/[\s,]+/).some((token) => noProxyTokenMatchesUrl(token, url));
}
function socksProxyAgentOptions(
options: Pool.Options,
): ConstructorParameters<typeof Socks5ProxyAgent>[1] {
return {
...(options.bodyTimeout === undefined ? {} : { bodyTimeout: options.bodyTimeout }),
...(options.headersTimeout === undefined ? {} : { headersTimeout: options.headersTimeout }),
};
}
class NoProxyAwareSocksProxyAgent {
private readonly directAgent: Agent;
private readonly socksAgent: Socks5ProxyAgent;
private readonly socksDispatchTimeouts: Pick<Dispatcher.DispatchOptions, 'bodyTimeout' | 'headersTimeout'>;
constructor(
private readonly noProxy: string | null,
socksProxy: string,
options: Pool.Options,
) {
this.directAgent = new Agent(options as ConstructorParameters<typeof Agent>[0]);
this.socksAgent = new Socks5ProxyAgent(socksProxy, socksProxyAgentOptions(options));
this.socksDispatchTimeouts = {
...(options.bodyTimeout === undefined ? {} : { bodyTimeout: options.bodyTimeout }),
...(options.headersTimeout === undefined
? {}
: { headersTimeout: options.headersTimeout }),
};
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const origin = options.origin;
const targetUrl =
typeof origin === 'string' || origin instanceof URL
? new URL(options.path, origin)
: null;
const dispatcher =
targetUrl && shouldBypassProxyForUrl(targetUrl, this.noProxy)
? this.directAgent
: this.socksAgent;
return dispatcher.dispatch(
dispatcher === this.socksAgent ? { ...this.socksDispatchTimeouts, ...options } : options,
handler,
);
}
async close(): Promise<void> {
await Promise.all([this.directAgent.close(), this.socksAgent.close()]);
}
async destroy(error?: Error | null): Promise<void> {
await Promise.all([
this.directAgent.destroy(error ?? null),
this.socksAgent.destroy(error ?? null),
]);
}
}
class NoProxyAwareEnvProxyAgent {
private readonly directAgent: Agent;
constructor(
private readonly noProxy: string,
private readonly proxyAgent: EnvHttpProxyAgent,
options: Pool.Options,
) {
this.directAgent = new Agent(options as ConstructorParameters<typeof Agent>[0]);
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const origin = options.origin;
const targetUrl =
typeof origin === 'string' || origin instanceof URL
? new URL(options.path, origin)
: null;
return (targetUrl && shouldBypassProxyForUrl(targetUrl, this.noProxy) ? this.directAgent : this.proxyAgent).dispatch(
options,
handler,
);
}
async close(): Promise<void> {
await Promise.all([this.directAgent.close(), this.proxyAgent.close()]);
}
async destroy(error?: Error | null): Promise<void> {
await Promise.all([
this.directAgent.destroy(error ?? null),
this.proxyAgent.destroy(error ?? null),
]);
}
}
class NoProxyAwareMixedProxyAgent {
private readonly directAgent: Agent;
private readonly proxyAgent: EnvHttpProxyAgent;
private readonly socksAgent: Socks5ProxyAgent;
private readonly socksDispatchTimeouts: Pick<Dispatcher.DispatchOptions, 'bodyTimeout' | 'headersTimeout'>;
constructor(
private readonly noProxy: string | null,
private readonly hasHttpProxy: boolean,
private readonly hasHttpsProxy: boolean,
proxyOptions: ConstructorParameters<typeof EnvHttpProxyAgent>[0],
socksProxy: string,
options: Pool.Options,
) {
this.directAgent = new Agent(options as ConstructorParameters<typeof Agent>[0]);
this.proxyAgent = new EnvHttpProxyAgent(proxyOptions);
this.socksAgent = new Socks5ProxyAgent(socksProxy, socksProxyAgentOptions(options));
this.socksDispatchTimeouts = {
...(options.bodyTimeout === undefined ? {} : { bodyTimeout: options.bodyTimeout }),
...(options.headersTimeout === undefined
? {}
: { headersTimeout: options.headersTimeout }),
};
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const origin = options.origin;
const targetUrl =
typeof origin === 'string' || origin instanceof URL
? new URL(options.path, origin)
: null;
if (targetUrl && shouldBypassProxyForUrl(targetUrl, this.noProxy)) {
return this.directAgent.dispatch(options, handler);
}
if (
targetUrl && ((targetUrl.protocol === 'http:' && this.hasHttpProxy) ||
(targetUrl.protocol === 'https:' && this.hasHttpsProxy))
) {
return this.proxyAgent.dispatch(options, handler);
}
return this.socksAgent.dispatch({ ...this.socksDispatchTimeouts, ...options }, handler);
}
async close(): Promise<void> {
await Promise.all([this.directAgent.close(), this.proxyAgent.close(), this.socksAgent.close()]);
}
async destroy(error?: Error | null): Promise<void> {
await Promise.all([
this.directAgent.destroy(error ?? null),
this.proxyAgent.destroy(error ?? null),
this.socksAgent.destroy(error ?? null),
]);
}
}
type ConnectionTestProxyDispatcher =
| EnvHttpProxyAgent
| NoProxyAwareEnvProxyAgent
| NoProxyAwareMixedProxyAgent
| NoProxyAwareSocksProxyAgent;
function envProxyAgentOptions(
options: Pool.Options,
httpProxy: string | undefined,
httpsProxy: string | undefined,
noProxy: string | null,
): ConstructorParameters<typeof EnvHttpProxyAgent>[0] {
return {
...options,
...(httpProxy ? { httpProxy } : {}),
...(httpsProxy ? { httpsProxy } : {}),
...(noProxy ? { noProxy } : {}),
};
}
function buildConnectionTestProxyDispatcher(
env: NodeJS.ProcessEnv = process.env,
options: Pool.Options = {},
): ConnectionTestProxyDispatcher | null {
const proxyEnv = mergeProxyAwareEnv(
process.platform,
resolveSystemProxyEnv(),
env,
);
const allProxy = proxyEnv.ALL_PROXY ?? proxyEnv.all_proxy;
const socksProxy = socksProxyUrl(allProxy);
const httpProxyFromAll = isHttpOrHttpsProxy(allProxy);
const httpProxy = proxyEnv.HTTP_PROXY ?? proxyEnv.http_proxy ?? httpProxyFromAll;
const httpsProxy = proxyEnv.HTTPS_PROXY ?? proxyEnv.https_proxy ?? httpProxyFromAll;
const noProxy = mergeNoProxyWithLoopbackDefaults(proxyEnv.NO_PROXY ?? proxyEnv.no_proxy);
const proxyOptions = envProxyAgentOptions(options, httpProxy, httpsProxy, noProxy);
if (socksProxy && (httpProxy || httpsProxy) && (!httpProxy || !httpsProxy)) {
return new NoProxyAwareMixedProxyAgent(
noProxy,
Boolean(httpProxy),
Boolean(httpsProxy),
proxyOptions,
socksProxy,
options,
);
}
if (!httpProxy && !httpsProxy && socksProxy) {
return new NoProxyAwareSocksProxyAgent(noProxy, socksProxy, options);
}
if (!httpProxy && !httpsProxy) return null;
const proxyAgent = new EnvHttpProxyAgent(proxyOptions);
return noProxy?.split(/[\s,]+/).some((token) => token.trim() === '<local>')
? new NoProxyAwareEnvProxyAgent(noProxy, proxyAgent, options)
: proxyAgent;
}
function isHttpOrHttpsProxy(proxyUrl: string | undefined): string | undefined {
const trimmed = proxyUrl?.trim();
if (!trimmed) return undefined;
try {
const { protocol } = new URL(trimmed);
return protocol === 'http:' || protocol === 'https:' ? trimmed : undefined;
} catch {
return undefined;
}
}
function socksProxyUrl(proxyUrl: string | undefined): string | undefined {
const trimmed = proxyUrl?.trim();
if (!trimmed) return undefined;
try {
const url = new URL(trimmed);
if (url.protocol === 'socks:' || url.protocol === 'socks5:') return trimmed;
if (url.protocol === 'socks5h:') {
url.protocol = 'socks5:';
return url.toString();
}
return undefined;
} catch {
return undefined;
}
}
export function proxyDispatcherRequestInit(
env: NodeJS.ProcessEnv = process.env,
options: Pool.Options = {},
): {
close(): Promise<void>;
requestInit: Pick<RequestInit, 'dispatcher'>;
} {
const dispatcher = buildConnectionTestProxyDispatcher(env, options);
if (dispatcher == null) {
return {
async close() {},
requestInit: {},
};
}
return {
close: () => dispatcher.close(),
requestInit: {
dispatcher: dispatcher as unknown as NonNullable<RequestInit['dispatcher']>,
},
};
}
const AGENT_COMPLETION_DEBOUNCE_MS = 500;
const AGENT_KILL_GRACE_MS = 2_000;
// Truncates the assistant reply we surface in the success copy so a
@ -479,6 +816,7 @@ async function validateLocalOpenAiModel(
parsed: ParsedBaseUrl,
signal: AbortSignal,
start: number,
requestInit: Pick<RequestInit, 'dispatcher'> = {},
): Promise<ConnectionTestResponse | null> {
if (input.protocol !== 'openai' || !isLoopbackApiHost(parsed.hostname)) {
return null;
@ -488,6 +826,7 @@ async function validateLocalOpenAiModel(
let response: Response;
try {
response = await fetch(url, {
...requestInit,
method: 'GET',
headers: { authorization: `Bearer ${String(input.apiKey)}` },
signal,
@ -732,17 +1071,21 @@ export async function testProviderConnection(
input.signal?.addEventListener('abort', abortFromParent, { once: true });
}
const timer = setTimeout(() => controller.abort(), providerTimeoutMs());
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
try {
proxyDispatcher = proxyDispatcherRequestInit();
const modelError = await validateLocalOpenAiModel(
input,
validated.parsed,
controller.signal,
start,
proxyDispatcher.requestInit,
);
if (modelError) return modelError;
const requestInit = {
...proxyDispatcher.requestInit,
method: 'POST',
headers: call.headers,
signal: controller.signal,
@ -939,6 +1282,7 @@ export async function testProviderConnection(
} finally {
clearTimeout(timer);
input.signal?.removeEventListener('abort', abortFromParent);
await proxyDispatcher?.close();
}
}

View file

@ -95,7 +95,10 @@ function cloneVoiceOptions(voices: ElevenLabsVoiceOption[]): ElevenLabsVoiceOpti
export async function listElevenLabsVoiceOptions(
projectRoot: string,
options: { limit?: number } = {},
options: {
limit?: number;
requestInit?: Pick<RequestInit, 'dispatcher'>;
} = {},
): Promise<ElevenLabsVoiceOption[]> {
const credentials = await resolveProviderConfig(projectRoot, 'elevenlabs');
if (!credentials.apiKey) {
@ -122,6 +125,7 @@ export async function listElevenLabsVoiceOptions(
}
const resp = await fetch(`${baseUrl}/v2/voices?page_size=${pageSize}`, {
...options.requestInit,
method: 'GET',
headers: {
'xi-api-key': credentials.apiKey,

View file

@ -1,5 +1,8 @@
import type { Express } from 'express';
import type { RouteDeps } from './server-context.js';
import { proxyDispatcherRequestInit } from './connectionTest.js';
const LONG_MEDIA_PROXY_TIMEOUT_MS = 10 * 60 * 1000;
export interface RegisterMediaRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'ids' | 'media' | 'appConfig' | 'orbit' | 'nativeDialogs' | 'projectStore' | 'projectFiles' | 'conversations' | 'research'> {}
@ -59,8 +62,16 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
try {
const rawLimit = Number(req.query.limit);
const limit = Number.isFinite(rawLimit) ? rawLimit : undefined;
const voices = await listElevenLabsVoiceOptions(PROJECT_ROOT, { limit });
res.json({ voices });
const proxyDispatcher = proxyDispatcherRequestInit(process.env);
try {
const voices = await listElevenLabsVoiceOptions(PROJECT_ROOT, {
limit,
requestInit: proxyDispatcher.requestInit,
});
res.json({ voices });
} finally {
await proxyDispatcher.close();
}
} catch (err: any) {
const message = String(err && err.message ? err.message : err);
const status = message.includes('no ElevenLabs API key') ? 400 : 502;
@ -147,13 +158,14 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
});
}
let task: ReturnType<typeof createMediaTask> | null = null;
try {
const projectId = req.params.id;
const project = getProject(db, projectId);
if (!project) return res.status(404).json({ error: 'project not found' });
const taskId = randomUUID();
const task = createMediaTask(taskId, projectId, {
task = createMediaTask(taskId, projectId, {
surface: req.body?.surface,
model: req.body?.model,
});
@ -164,6 +176,10 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
`compositionDir=${req.body?.compositionDir ? 'yes' : 'no'}`,
);
const proxyDispatcher = proxyDispatcherRequestInit(process.env, {
headersTimeout: LONG_MEDIA_PROXY_TIMEOUT_MS,
bodyTimeout: LONG_MEDIA_PROXY_TIMEOUT_MS,
});
task.status = 'running';
persistMediaTask(task);
generateMedia({
@ -191,6 +207,7 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
compositionDir: req.body?.compositionDir,
image: req.body?.image,
onProgress: (line: any) => appendTaskProgress(task, line),
requestInit: proxyDispatcher.requestInit,
})
.then((meta: any) => {
task.status = 'done';
@ -217,7 +234,8 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
`[task ${taskId.slice(0, 8)}] failed status=${task.error.status} ` +
`message=${(task.error.message || '').slice(0, 240)}`,
);
});
})
.finally(() => proxyDispatcher.close());
res.status(202).json({
taskId,
@ -225,6 +243,17 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
startedAt: task.startedAt,
});
} catch (err: any) {
if (task) {
task.status = 'failed';
task.error = {
message: String(err && err.message ? err.message : err),
status: typeof err?.status === 'number' ? err.status : 400,
code: err?.code,
};
task.endedAt = Date.now();
persistMediaTask(task);
notifyTaskWaiters(task);
}
const status = typeof err?.status === 'number' ? err.status : 400;
const code = err?.code;
const body: any = { error: String(err && err.message ? err.message : err) };
@ -242,18 +271,24 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
}
try {
const result = await searchResearch({
projectRoot: PROJECT_ROOT,
query: req.body?.query,
maxSources:
typeof req.body?.maxSources === 'number'
? req.body.maxSources
const proxyDispatcher = proxyDispatcherRequestInit(process.env);
try {
const result = await searchResearch({
projectRoot: PROJECT_ROOT,
query: req.body?.query,
maxSources:
typeof req.body?.maxSources === 'number'
? req.body.maxSources
: undefined,
providers: Array.isArray(req.body?.providers)
? req.body.providers
: undefined,
providers: Array.isArray(req.body?.providers)
? req.body.providers
: undefined,
});
res.json(result);
requestInit: proxyDispatcher.requestInit,
});
res.json(result);
} finally {
await proxyDispatcher.close();
}
} catch (err: any) {
if (err instanceof ResearchError) {
return res.status(err.status).json({

View file

@ -70,6 +70,7 @@ const execFile = promisify(execFileCb);
type ProviderConfig = { apiKey?: string; baseUrl?: string; model?: string };
type ProgressFn = (message: string) => void;
type ImageRef = { path: string; abs: string; mime: string; size: number; dataUrl: string };
type MediaRequestInit = Pick<RequestInit, 'dispatcher'>;
type MediaContext = {
surface: MediaSurface;
/**
@ -108,6 +109,7 @@ type MediaContext = {
promptInfluence: number | undefined;
compositionDir: string | null;
imageRef: ImageRef | null;
requestInit: MediaRequestInit;
};
type RenderResult = { bytes: Buffer; providerNote: string; suggestedExt?: string };
type JsonRecord = Record<string, unknown>;
@ -285,7 +287,7 @@ export async function generateMedia(args: {
projectRoot: string; projectsRoot: string; projectId: string; surface: MediaSurface; model: string;
prompt?: string; output?: string; aspect?: string; length?: number; duration?: number; voice?: string;
audioKind?: AudioKind; language?: string; loop?: boolean; promptInfluence?: number;
compositionDir?: string; image?: string; onProgress?: ProgressFn;
compositionDir?: string; image?: string; onProgress?: ProgressFn; requestInit?: MediaRequestInit;
}) {
const {
projectRoot,
@ -305,6 +307,7 @@ export async function generateMedia(args: {
promptInfluence,
compositionDir,
image,
requestInit,
} = args;
if (!projectRoot) throw new Error('projectRoot required');
@ -414,6 +417,7 @@ export async function generateMedia(args: {
// Resolved reference image for i2v / image-edit flows. `null` when
// the agent didn't pass --image. See resolveProjectImage below.
imageRef,
requestInit: requestInit || {},
};
const credentials = await resolveProviderConfig(projectRoot, def.provider);
@ -693,6 +697,16 @@ const openAIImageDispatcher = new UndiciAgent({
bodyTimeout: OPENAI_IMAGE_BODY_TIMEOUT_MS,
});
function withMediaRequestInit(
ctx: Pick<MediaContext, 'requestInit'>,
init: RequestInit = {},
): RequestInit {
return {
...ctx.requestInit,
...init,
};
}
async function renderOpenAIImage(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
if (!credentials.apiKey) {
throw new Error('no OpenAI credential — configure an API key in Settings, set OPENAI_API_KEY, or refresh Codex/Hermes OAuth');
@ -737,12 +751,14 @@ async function renderOpenAIImage(ctx: MediaContext, credentials: ProviderConfig)
headers['api-key'] = credentials.apiKey;
}
const resp = await fetch(url, {
const resp = await fetch(url, withMediaRequestInit(ctx, {
method: 'POST',
headers,
body: JSON.stringify(body),
dispatcher: openAIImageDispatcher as unknown as NonNullable<RequestInit['dispatcher']>,
});
dispatcher: ctx.requestInit.dispatcher
?? openAIImageDispatcher as unknown as NonNullable<RequestInit['dispatcher']>,
signal: AbortSignal.timeout(Math.max(OPENAI_IMAGE_HEADERS_TIMEOUT_MS, OPENAI_IMAGE_BODY_TIMEOUT_MS)),
}));
const text = await resp.text();
if (!resp.ok) {
const tag = azure ? 'azure-openai' : 'openai';
@ -760,7 +776,7 @@ async function renderOpenAIImage(ctx: MediaContext, credentials: ProviderConfig)
if (entry.b64_json) {
bytes = Buffer.from(entry.b64_json, 'base64');
} else if (entry.url) {
const imgResp = await fetch(entry.url);
const imgResp = await fetch(entry.url, withMediaRequestInit(ctx));
if (!imgResp.ok) throw new Error(`openai image fetch ${imgResp.status}`);
const arr = await imgResp.arrayBuffer();
bytes = Buffer.from(arr);
@ -794,16 +810,16 @@ async function renderImageRouterImage(ctx: MediaContext, credentials: ProviderCo
output_format: 'png',
};
const resp = await fetch(url, {
const resp = await fetch(url, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
'authorization': `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
}));
const data = await parseOpenAICompatibleJson(resp, 'imagerouter image');
const bytes = await bytesFromOpenAICompatibleData(data, 'imagerouter image');
const bytes = await bytesFromOpenAICompatibleData(data, 'imagerouter image', ctx.requestInit);
return {
bytes,
providerNote: `imagerouter/${wireModel} · ${imageRouterSizeFor(ctx.aspect, 'image')} · ${bytes.length} bytes`,
@ -829,16 +845,16 @@ async function renderImageRouterVideo(ctx: MediaContext, credentials: ProviderCo
response_format: 'b64_json',
};
const resp = await fetch(url, {
const resp = await fetch(url, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
'authorization': `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
}));
const data = await parseOpenAICompatibleJson(resp, 'imagerouter video');
const bytes = await bytesFromOpenAICompatibleData(data, 'imagerouter video');
const bytes = await bytesFromOpenAICompatibleData(data, 'imagerouter video', ctx.requestInit);
return {
bytes,
providerNote: `imagerouter/${wireModel} · ${imageRouterSizeFor(ctx.aspect, 'video')} · ${seconds === 'auto' ? 'auto' : `${seconds}s`} · ${bytes.length} bytes`,
@ -876,13 +892,13 @@ async function renderCustomOpenAIImage(ctx: MediaContext, credentials: ProviderC
size: openaiSizeFor('gpt-image-1', ctx.aspect),
};
const resp = await fetch(buildOpenAIImageUrl(baseUrl, false), {
const resp = await fetch(buildOpenAIImageUrl(baseUrl, false), withMediaRequestInit(ctx, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
}));
const data = await parseOpenAICompatibleJson(resp, 'custom image');
const bytes = await bytesFromOpenAICompatibleData(data, 'custom image');
const bytes = await bytesFromOpenAICompatibleData(data, 'custom image', ctx.requestInit);
return {
bytes,
providerNote: `custom-image/${wireModel} · ${body.size} · ${bytes.length} bytes`,
@ -912,7 +928,7 @@ async function parseOpenAICompatibleJson(resp: Response, providerTag: string): P
}
}
async function bytesFromOpenAICompatibleData(data: any, providerTag: string): Promise<Buffer> {
async function bytesFromOpenAICompatibleData(data: any, providerTag: string, requestInit: MediaRequestInit = {}): Promise<Buffer> {
const entry = data && Array.isArray(data.data) ? data.data[0] : null;
if (!entry) throw new Error(`${providerTag} response had no data[0]`);
if (typeof entry.b64_json === 'string' && entry.b64_json) {
@ -922,7 +938,7 @@ async function bytesFromOpenAICompatibleData(data: any, providerTag: string): Pr
return Buffer.from(raw, 'base64');
}
if (typeof entry.url === 'string' && entry.url) {
const mediaResp = await fetch(entry.url);
const mediaResp = await fetch(entry.url, requestInit);
if (!mediaResp.ok) {
throw new Error(`${providerTag} media fetch ${mediaResp.status}`);
}
@ -1109,11 +1125,11 @@ async function renderOpenAISpeech(ctx: MediaContext, credentials: ProviderConfig
headers['api-key'] = credentials.apiKey;
}
const resp = await fetch(url, {
const resp = await fetch(url, withMediaRequestInit(ctx, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
}));
if (!resp.ok) {
const text = await resp.text();
const tag = azure ? 'azure-openai' : 'openai';
@ -1187,14 +1203,14 @@ async function renderVolcengineVideo(ctx: MediaContext, credentials: ProviderCon
content,
};
const taskResp = await fetch(`${baseUrl}/contents/generations/tasks`, {
const taskResp = await fetch(`${baseUrl}/contents/generations/tasks`, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
'authorization': `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(taskBody),
});
}));
const taskText = await taskResp.text();
if (!taskResp.ok) {
throw new Error(`volcengine task create ${taskResp.status}: ${truncate(taskText, 240)}`);
@ -1231,9 +1247,9 @@ async function renderVolcengineVideo(ctx: MediaContext, credentials: ProviderCon
}
while (Date.now() - startedAt < maxMs) {
await sleep(4000);
const pollResp = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(taskId)}`, {
const pollResp = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(taskId)}`, withMediaRequestInit(ctx, {
headers: { 'authorization': `Bearer ${credentials.apiKey}` },
});
}));
const pollText = await pollResp.text();
if (!pollResp.ok) {
throw new Error(`volcengine poll ${pollResp.status}: ${truncate(pollText, 240)}`);
@ -1266,7 +1282,7 @@ async function renderVolcengineVideo(ctx: MediaContext, credentials: ProviderCon
throw new Error(`volcengine task did not finish in time (last status: ${lastStatus || 'unknown'})`);
}
const dlResp = await fetch(videoUrl);
const dlResp = await fetch(videoUrl, withMediaRequestInit(ctx));
if (!dlResp.ok) throw new Error(`volcengine video fetch ${dlResp.status}`);
const arr = await dlResp.arrayBuffer();
const bytes = Buffer.from(arr);
@ -1305,14 +1321,14 @@ async function renderVolcengineImage(ctx: MediaContext, credentials: ProviderCon
// wire name. lefarcen + codex P2 on PR #1309.
size: openaiSizeFor(ctx.model, ctx.aspect),
};
const resp = await fetch(`${baseUrl}/images/generations`, {
const resp = await fetch(`${baseUrl}/images/generations`, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
'authorization': `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
}));
const text = await resp.text();
if (!resp.ok) {
throw new Error(`volcengine image ${resp.status}: ${truncate(text, 240)}`);
@ -1329,7 +1345,7 @@ async function renderVolcengineImage(ctx: MediaContext, credentials: ProviderCon
if (entry.b64_json) {
bytes = Buffer.from(entry.b64_json, 'base64');
} else if (entry.url) {
const imgResp = await fetch(entry.url);
const imgResp = await fetch(entry.url, withMediaRequestInit(ctx));
if (!imgResp.ok) throw new Error(`volcengine image fetch ${imgResp.status}`);
bytes = Buffer.from(await imgResp.arrayBuffer());
} else {
@ -1376,14 +1392,14 @@ async function renderGrokImage(ctx: MediaContext, credentials: ProviderConfig):
aspect_ratio: aspectRatio,
response_format: 'b64_json',
};
const resp = await fetch(`${baseUrl}/images/generations`, {
const resp = await fetch(`${baseUrl}/images/generations`, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
'authorization': `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
}));
const text = await resp.text();
if (!resp.ok) {
throw new Error(`grok image ${resp.status}: ${truncate(text, 240)}`);
@ -1400,7 +1416,7 @@ async function renderGrokImage(ctx: MediaContext, credentials: ProviderConfig):
if (entry.b64_json) {
bytes = Buffer.from(entry.b64_json, 'base64');
} else if (entry.url) {
const imgResp = await fetch(entry.url);
const imgResp = await fetch(entry.url, withMediaRequestInit(ctx));
if (!imgResp.ok) throw new Error(`grok image fetch ${imgResp.status}`);
bytes = Buffer.from(await imgResp.arrayBuffer());
} else {
@ -1442,11 +1458,11 @@ async function renderNanoBananaImage(ctx: MediaContext, credentials: ProviderCon
},
};
const resp = await fetch(`${baseUrl}/v1beta/models/${encodeURIComponent(wireModel)}:generateContent`, {
const resp = await fetch(`${baseUrl}/v1beta/models/${encodeURIComponent(wireModel)}:generateContent`, withMediaRequestInit(ctx, {
method: 'POST',
headers: nanoBananaHeaders(baseUrl, credentials.apiKey),
body: JSON.stringify(body),
});
}));
const text = await resp.text();
if (!resp.ok) {
throw new Error(`nano-banana image ${resp.status}: ${truncate(text, 240)}`);
@ -1583,14 +1599,14 @@ async function renderLeonardoImage(ctx: MediaContext, credentials: ProviderConfi
...(requiresContrast ? { contrast: 3.5 } : {}),
};
const submitResp = await fetch(`${baseUrl}/generations`, {
const submitResp = await fetch(`${baseUrl}/generations`, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
'authorization': `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
}));
const submitText = await submitResp.text();
if (!submitResp.ok) {
@ -1618,11 +1634,11 @@ async function renderLeonardoImage(ctx: MediaContext, credentials: ProviderConfi
while (Date.now() - startedAt < maxPollMs) {
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
const pollResp = await fetch(`${baseUrl}/generations/${generationId}`, {
const pollResp = await fetch(`${baseUrl}/generations/${generationId}`, withMediaRequestInit(ctx, {
headers: {
'authorization': `Bearer ${credentials.apiKey}`,
},
});
}));
if (!pollResp.ok) {
throw new Error(`leonardo.ai poll ${pollResp.status}`);
@ -1647,7 +1663,7 @@ async function renderLeonardoImage(ctx: MediaContext, credentials: ProviderConfi
}
// Fetch the generated image
const imgResp = await fetch(imageUrl);
const imgResp = await fetch(imageUrl, withMediaRequestInit(ctx));
if (!imgResp.ok) {
throw new Error(`leonardo.ai image fetch ${imgResp.status}`);
}
@ -1691,14 +1707,14 @@ async function renderGrokVideo(ctx: MediaContext, credentials: ProviderConfig, o
body.image = ctx.imageRef.dataUrl;
}
const submitResp = await fetch(`${baseUrl}/videos/generations`, {
const submitResp = await fetch(`${baseUrl}/videos/generations`, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
'authorization': `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
}));
const submitText = await submitResp.text();
if (!submitResp.ok) {
throw new Error(`grok video submit ${submitResp.status}: ${truncate(submitText, 240)}`);
@ -1731,9 +1747,9 @@ async function renderGrokVideo(ctx: MediaContext, credentials: ProviderConfig, o
}
while (Date.now() - startedAt < maxMs) {
await sleep(4000);
const pollResp = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, {
const pollResp = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, withMediaRequestInit(ctx, {
headers: { 'authorization': `Bearer ${credentials.apiKey}` },
});
}));
const pollText = await pollResp.text();
if (!pollResp.ok) {
throw new Error(`grok poll ${pollResp.status}: ${truncate(pollText, 240)}`);
@ -1785,7 +1801,7 @@ async function renderGrokVideo(ctx: MediaContext, credentials: ProviderConfig, o
);
}
const dlResp = await fetch(videoUrl);
const dlResp = await fetch(videoUrl, withMediaRequestInit(ctx));
if (!dlResp.ok) throw new Error(`grok video fetch ${dlResp.status}`);
const arr = await dlResp.arrayBuffer();
const bytes = Buffer.from(arr);
@ -1854,14 +1870,14 @@ async function renderXAITTS(ctx: MediaContext, credentials: ProviderConfig): Pro
language,
};
const resp = await fetch(`${baseUrl}/tts`, {
const resp = await fetch(`${baseUrl}/tts`, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
authorization: `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
}));
if (!resp.ok) {
const errText = await resp.text().catch(() => '');
throw new Error(`xai tts ${resp.status}: ${truncate(errText, 240)}`);
@ -1957,14 +1973,14 @@ async function renderElevenLabsTTS(ctx: MediaContext, credentials: ProviderConfi
const resp = await fetch(
`${baseUrl}/v1/text-to-speech/${encodeURIComponent(voiceId)}?output_format=mp3_44100_128`,
{
withMediaRequestInit(ctx, {
method: 'POST',
headers: {
'xi-api-key': credentials.apiKey,
'content-type': 'application/json',
},
body: JSON.stringify(body),
},
}),
);
if (!resp.ok) {
const errText = await resp.text();
@ -2008,14 +2024,14 @@ async function renderElevenLabsSfx(ctx: MediaContext, credentials: ProviderConfi
const resp = await fetch(
`${baseUrl}/v1/sound-generation?output_format=mp3_44100_128`,
{
withMediaRequestInit(ctx, {
method: 'POST',
headers: {
'xi-api-key': credentials.apiKey,
'content-type': 'application/json',
},
body: JSON.stringify(body),
},
}),
);
if (!resp.ok) {
const errText = await resp.text();
@ -2099,14 +2115,14 @@ async function renderMinimaxTTS(ctx: MediaContext, credentials: ProviderConfig):
},
};
const resp = await fetch(`${baseUrl}/t2a_v2`, {
const resp = await fetch(`${baseUrl}/t2a_v2`, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
authorization: `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
}));
const respText = await resp.text();
if (!resp.ok) {
throw new Error(`minimax tts ${resp.status}: ${truncate(respText, 240)}`);
@ -2204,14 +2220,14 @@ async function renderSenseAudioTTS(ctx: MediaContext, credentials: ProviderConfi
},
};
const resp = await fetch(`${baseUrl}/v1/t2a_v2`, {
const resp = await fetch(`${baseUrl}/v1/t2a_v2`, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
authorization: `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
}));
const respText = await resp.text();
if (!resp.ok) {
throw new Error(`senseaudio tts ${resp.status}: ${truncate(respText, 240)}`);
@ -2315,14 +2331,14 @@ async function renderSenseAudioImage(ctx: MediaContext, credentials: ProviderCon
body.reference = reference;
}
const resp = await fetch(`${baseUrl}/v1/image/sync`, {
const resp = await fetch(`${baseUrl}/v1/image/sync`, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
authorization: `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
}));
const respText = await resp.text();
if (!resp.ok) {
throw new Error(`senseaudio image ${resp.status}: ${truncate(respText, 240)}`);
@ -2358,7 +2374,7 @@ async function renderSenseAudioImage(ctx: MediaContext, credentials: ProviderCon
if (!urlCheck.ok) {
throw new Error(`senseaudio image ${urlCheck.error}`);
}
const imgResp = await fetch(url, { redirect: 'error' });
const imgResp = await fetch(url, withMediaRequestInit(ctx, { redirect: 'error' }));
if (!imgResp.ok) {
throw new Error(`senseaudio image fetch ${imgResp.status}`);
}
@ -2424,14 +2440,14 @@ async function renderFishAudioTTS(ctx: MediaContext, credentials: ProviderConfig
body.reference_id = ctx.voice.trim();
}
const resp = await fetch(`${baseUrl}/v1/tts`, {
const resp = await fetch(`${baseUrl}/v1/tts`, withMediaRequestInit(ctx, {
method: 'POST',
headers: {
authorization: `Bearer ${credentials.apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
}));
if (!resp.ok) {
const errText = await resp.text();
throw new Error(`fishaudio tts ${resp.status}: ${truncate(errText, 240)}`);

View file

@ -11,7 +11,10 @@ import { isLoopbackApiHost } from '@open-design/contracts/api/connectionTest';
import { redactSecrets, validateBaseUrlResolved } from './connectionTest.js';
import { googleProviderModelsUrl, normalizeGoogleModelId } from './google-models.js';
type ProviderModelsInput = ProviderModelsRequest & { signal?: AbortSignal };
type ProviderModelsInput = ProviderModelsRequest & {
signal?: AbortSignal;
requestInit?: Pick<RequestInit, 'dispatcher'>;
};
const PROVIDER_MODELS_TIMEOUT_MS = 12_000;
@ -236,6 +239,7 @@ export async function listProviderModels(
const response = await fetch(url, {
method: 'GET',
headers: providerModelsHeaders(input.protocol, input.apiKey),
...input.requestInit,
signal: controller.signal,
redirect: 'error',
});

View file

@ -25,6 +25,7 @@ export interface SearchResearchInput {
projectRoot: string;
maxSources?: number;
providers?: string[];
requestInit?: Pick<RequestInit, 'dispatcher'>;
signal?: AbortSignal;
}
@ -70,6 +71,7 @@ export async function searchResearch(
maxResults: maxSources,
includeAnswer: true,
...(cfg.baseUrl ? { baseUrl: cfg.baseUrl } : {}),
...(input.requestInit ? { requestInit: input.requestInit } : {}),
...(input.signal ? { signal: input.signal } : {}),
});
answer = out.answer;

View file

@ -11,6 +11,7 @@ export interface TavilySearchInput {
searchDepth?: 'basic' | 'advanced';
maxResults?: number;
includeAnswer?: boolean;
requestInit?: Pick<RequestInit, 'dispatcher'>;
signal?: AbortSignal;
}
@ -69,6 +70,7 @@ export async function tavilySearch(
let resp: Response;
try {
resp = await fetch(`${base}/search`, {
...input.requestInit,
method: 'POST',
headers: {
'content-type': 'application/json',

View file

@ -1,5 +1,6 @@
import path from 'node:path';
import { mergeProxyAwareEnv, resolveSystemProxyEnv } from '@open-design/platform';
import { expandConfiguredEnv } from './paths.js';
import { resolveAmrOpenCodeExecutable } from './executables.js';
import { amrVelaProfileEnv } from '../integrations/vela-profile.js';
@ -36,11 +37,14 @@ export function spawnEnvForAgent(
agentId: string,
baseEnv: RuntimeEnvMap,
configuredEnv: unknown = {},
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...baseEnv,
...expandConfiguredEnv(configuredEnv),
};
const env = mergeProxyAwareEnv(
process.platform,
systemProxyEnv,
baseEnv,
expandConfiguredEnv(configuredEnv),
);
if (agentId === 'amr') {
Object.assign(env, amrVelaProfileEnv(env));
if (!env.OPENCODE_TEST_HOME?.trim() && env.OD_DATA_DIR?.trim()) {

View file

@ -26,6 +26,7 @@
import type { Express } from 'express';
import { proxyDispatcherRequestInit } from './connectionTest.js';
import { mediaConfigDir, resolveProviderConfig } from './media-config.js';
import { PendingAuthCache } from './mcp-oauth.js';
import { beginXAIAuth, completeXAIAuth } from './xai-oauth.js';
@ -44,6 +45,12 @@ import type { RouteDeps } from './server-context.js';
export interface RegisterXaiRoutesDeps extends RouteDeps<'http' | 'paths'> {}
function fetchWithRequestInit(
requestInit: Pick<RequestInit, 'dispatcher'>,
): typeof fetch {
return (input, init) => fetch(input, { ...init, ...requestInit });
}
export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
const { isLocalSameOrigin, resolvedPortRef } = ctx.http;
const { PROJECT_ROOT } = ctx.paths;
@ -76,11 +83,13 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
console.warn(`[xai-oauth] callback failed: ${outcome.error}`);
return;
}
const proxyDispatcher = proxyDispatcherRequestInit(process.env);
try {
const tokenResp = await completeXAIAuth({
pending: pendingAuth,
state: outcome.state,
code: outcome.code,
fetchImpl: fetchWithRequestInit(proxyDispatcher.requestInit),
});
const stored: StoredXAIToken = {
accessToken: tokenResp.access_token,
@ -97,6 +106,8 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[xai-oauth] token exchange failed:', msg);
} finally {
await proxyDispatcher.close();
}
};
@ -149,11 +160,13 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
.status(400)
.json({ error: 'state and code are required' });
}
const proxyDispatcher = proxyDispatcherRequestInit(process.env);
try {
const tokenResp = await completeXAIAuth({
pending: pendingAuth,
state,
code,
fetchImpl: fetchWithRequestInit(proxyDispatcher.requestInit),
});
const stored: StoredXAIToken = {
accessToken: tokenResp.access_token,
@ -178,6 +191,8 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[xai-oauth] manual complete failed:', msg);
res.status(400).json({ error: msg });
} finally {
await proxyDispatcher.close();
}
});
@ -296,6 +311,8 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
};
let resp: Response;
let text: string;
const proxyDispatcher = proxyDispatcherRequestInit(process.env);
try {
resp = await fetch(`${baseUrl}/responses`, {
method: 'POST',
@ -304,13 +321,16 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
'content-type': 'application/json',
},
body: JSON.stringify(requestBody),
...proxyDispatcher.requestInit,
});
text = await resp.text();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return res.status(502).json({ error: `xAI request failed: ${msg}` });
} finally {
await proxyDispatcher.close();
}
const text = await resp.text();
if (!resp.ok) {
return res
.status(502)

View file

@ -61,8 +61,10 @@ describe('executeGenerateImage', () => {
it('calls /v1/image/sync, downloads the URL, persists bytes, and returns a daemon URL', async () => {
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const dispatcher = { dispatch: vi.fn() } as unknown as NonNullable<RequestInit['dispatcher']>;
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
expect(init?.dispatcher).toBe(dispatcher);
if (url === 'https://api.senseaudio.cn/v1/image/sync') {
expect(init?.method).toBe('POST');
expect(init?.headers).toMatchObject({
@ -94,7 +96,7 @@ describe('executeGenerateImage', () => {
const result = await executeGenerateImage(
{ prompt: 'a tabby cat playing with yarn' },
baseCtx(),
{ ...baseCtx(), requestInit: { dispatcher } },
);
expect(result.ok).toBe(true);
@ -402,9 +404,11 @@ describe('executeGenerateSpeech', () => {
it('calls /v1/t2a_v2, persists mp3 bytes, and returns a daemon URL', async () => {
const audioBytes = Buffer.from([0x49, 0x44, 0x33, 0x04]);
const dispatcher = { dispatch: vi.fn() } as unknown as NonNullable<RequestInit['dispatcher']>;
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
expect(String(input)).toBe('https://api.senseaudio.cn/v1/t2a_v2');
expect(init?.method).toBe('POST');
expect(init?.dispatcher).toBe(dispatcher);
expect(init?.redirect).toBe('error');
expect(init?.headers).toMatchObject({
authorization: 'Bearer sa-byok-key',
@ -445,6 +449,7 @@ describe('executeGenerateSpeech', () => {
projectId: PROJECT_ID,
upstreamApiKey: 'sa-byok-key',
upstreamBaseUrl: 'https://api.senseaudio.cn',
requestInit: { dispatcher },
},
);
@ -618,9 +623,11 @@ describe('executeGenerateVideo', () => {
it('creates, polls until completed, downloads, and writes the mp4 into the project folder', async () => {
const mp4Bytes = Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]);
const dispatcher = { dispatch: vi.fn() } as unknown as NonNullable<RequestInit['dispatcher']>;
let pollCount = 0;
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
expect(init?.dispatcher).toBe(dispatcher);
if (url === 'https://api.senseaudio.cn/v1/video/create') {
expect(init?.method).toBe('POST');
@ -687,7 +694,7 @@ describe('executeGenerateVideo', () => {
resolution: '1080p',
generate_audio: true,
},
baseCtx(),
{ ...baseCtx(), requestInit: { dispatcher } },
);
expect(result.ok).toBe(true);

View file

@ -1,15 +1,19 @@
// Coverage for the /api/test/connection route. Hits status mapping for each
// provider protocol and uses fake CLI bins for deterministic agent outcomes.
import type http from 'node:http';
import * as http from 'node:http';
import { promises as dnsPromises } from 'node:dns';
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Socks5ProxyAgent } from 'undici';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import * as platform from '@open-design/platform';
import {
createAgentSink,
isSmokeOkReply,
mergeNoProxyWithLoopbackDefaults,
proxyDispatcherRequestInit,
redactSecrets,
resolveConnectionTestTimeoutMs,
testAgentConnection,
@ -179,6 +183,43 @@ describe('POST /api/provider/models', () => {
});
});
it('routes provider model discovery through the live proxy dispatcher', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({
HTTP_PROXY: 'http://proxy.example.test:8080',
NODE_USE_ENV_PROXY: '1',
NO_PROXY: 'localhost,127.0.0.1,[::1]',
});
const fetchMock = passThroughOrUpstream((_url, init) => {
expect(init?.dispatcher).toBeTruthy();
return jsonResponse({
data: [{ id: 'gpt-4o', object: 'model' }],
});
});
vi.stubGlobal('fetch', fetchMock);
try {
const res = await realFetch(`${baseUrl}/api/provider/models`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
protocol: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-openai',
}),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
ok: true,
kind: 'success',
models: [{ id: 'gpt-4o', label: 'gpt-4o' }],
});
expect(proxySpy).toHaveBeenCalledWith();
} finally {
proxySpy.mockRestore();
}
});
it('lists Anthropic models with display names and a high page limit', async () => {
const fetchMock = passThroughOrUpstream((url, init) => {
expect(url).toBe('https://api.anthropic.com/v1/models?limit=1000');
@ -1505,6 +1546,305 @@ describe('POST /api/test/connection provider mode', () => {
kind: 'timeout',
});
});
it('uses a live system-proxy dispatcher for provider-mode fetches', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({
HTTPS_PROXY: 'http://system-proxy.internal:8443',
NODE_USE_ENV_PROXY: '1',
});
const fetchMock = vi.fn((_input: FetchInput, init?: FetchInit) => {
expect(init?.dispatcher).toBeDefined();
return Promise.resolve(jsonResponse({
choices: [{ message: { role: 'assistant', content: 'ok' } }],
}));
});
vi.stubGlobal('fetch', fetchMock);
try {
await expect(testProviderConnection({
protocol: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-good',
model: 'gpt-4o',
})).resolves.toMatchObject({
ok: true,
kind: 'success',
});
} finally {
proxySpy.mockRestore();
}
});
it.each([
['*', '*'],
['*,.corp.example', '*'],
[' * , .corp.example ', '*'],
['* .corp.example', '*'],
['.corp.example', '.corp.example,localhost,127.0.0.1,[::1]'],
['::1', '[::1],localhost,127.0.0.1'],
[undefined, 'localhost,127.0.0.1,[::1]'],
])('mergeNoProxyWithLoopbackDefaults(%p)', (input, expected) => {
expect(mergeNoProxyWithLoopbackDefaults(input)).toBe(expected);
});
it('uses a SOCKS dispatcher when ALL_PROXY is the only configured proxy', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
try {
const { close, requestInit } = proxyDispatcherRequestInit({
ALL_PROXY: 'socks5://system-socks:1080',
});
expect(requestInit.dispatcher).toBeDefined();
await expect(close()).resolves.toBeUndefined();
} finally {
proxySpy.mockRestore();
}
});
it('forwards timeout options through SOCKS dispatches', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
const dispatchSpy = vi
.spyOn(Socks5ProxyAgent.prototype, 'dispatch')
.mockReturnValue(true as ReturnType<typeof Socks5ProxyAgent.prototype.dispatch>);
try {
const { close, requestInit } = proxyDispatcherRequestInit(
{
ALL_PROXY: 'socks5://system-socks:1080',
},
{
headersTimeout: 1234,
bodyTimeout: 5678,
},
);
const dispatcher = requestInit.dispatcher as unknown as {
dispatch(options: { origin: string; path: string; method: string }, handler: unknown): boolean;
};
expect(dispatcher).toBeDefined();
dispatcher.dispatch(
{
origin: 'https://api.openai.com',
path: '/v1/chat/completions',
method: 'POST',
},
{},
);
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
origin: 'https://api.openai.com',
path: '/v1/chat/completions',
headersTimeout: 1234,
bodyTimeout: 5678,
}),
expect.anything(),
);
await expect(close()).resolves.toBeUndefined();
} finally {
dispatchSpy.mockRestore();
proxySpy.mockRestore();
}
});
it('resolves system proxy env for each HTTP proxy dispatcher request', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
try {
const { close, requestInit } = proxyDispatcherRequestInit();
expect(proxySpy).toHaveBeenCalledWith();
expect(requestInit).toEqual({});
await expect(close()).resolves.toBeUndefined();
} finally {
proxySpy.mockRestore();
}
});
it('reports malformed proxy env without leaking the connection-test timer', async () => {
const originalHttpProxy = process.env.HTTP_PROXY;
const originalHttpsProxy = process.env.HTTPS_PROXY;
const originalAllProxy = process.env.ALL_PROXY;
process.env.HTTP_PROXY = 'not a valid proxy url';
delete process.env.HTTPS_PROXY;
delete process.env.ALL_PROXY;
try {
await expect(testProviderConnection({
protocol: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-good',
model: 'gpt-4o',
})).resolves.toMatchObject({
ok: false,
kind: 'unknown',
});
} finally {
if (originalHttpProxy === undefined) delete process.env.HTTP_PROXY;
else process.env.HTTP_PROXY = originalHttpProxy;
if (originalHttpsProxy === undefined) delete process.env.HTTPS_PROXY;
else process.env.HTTPS_PROXY = originalHttpsProxy;
if (originalAllProxy === undefined) delete process.env.ALL_PROXY;
else process.env.ALL_PROXY = originalAllProxy;
}
});
it('keeps loopback provider probes off the proxy when user NO_PROXY omits localhost', async () => {
const providerServer = http.createServer((req, res) => {
if (req.url === '/v1/models') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ data: [{ id: 'google/gemma-4-e4b', object: 'model' }] }));
return;
}
if (req.url === '/v1/chat/completions') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
choices: [{ message: { role: 'assistant', content: 'ok' } }],
}));
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => providerServer.listen(0, '127.0.0.1', () => resolve()));
const address = providerServer.address();
if (!address || typeof address === 'string') {
providerServer.close();
throw new Error('Expected an IPv4 provider test server address');
}
const originalNoProxy = process.env.NO_PROXY;
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({
HTTP_PROXY: 'http://127.0.0.1:9',
NO_PROXY: 'localhost,127.0.0.1,[::1]',
NODE_USE_ENV_PROXY: '1',
});
process.env.NO_PROXY = '*.corp.com';
try {
await expect(testProviderConnection({
protocol: 'openai',
baseUrl: `http://127.0.0.1:${address.port}/v1`,
apiKey: 'lm-studio',
model: 'google/gemma-4-e4b',
})).resolves.toMatchObject({
ok: true,
kind: 'success',
});
} finally {
if (originalNoProxy === undefined) delete process.env.NO_PROXY;
else process.env.NO_PROXY = originalNoProxy;
proxySpy.mockRestore();
await new Promise<void>((resolve, reject) =>
providerServer.close((error) => (error ? reject(error) : resolve())),
);
}
});
it('keeps loopback provider probes off the proxy when inherited proxy env omits NO_PROXY', async () => {
const providerServer = http.createServer((req, res) => {
if (req.url === '/v1/models') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ data: [{ id: 'llama3.2', object: 'model' }] }));
return;
}
if (req.url === '/v1/chat/completions') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
choices: [{ message: { role: 'assistant', content: 'ok' } }],
}));
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => providerServer.listen(0, '127.0.0.1', () => resolve()));
const address = providerServer.address();
if (!address || typeof address === 'string') {
providerServer.close();
throw new Error('Expected an IPv4 provider test server address');
}
const originalHttpProxy = process.env.HTTP_PROXY;
const originalHttpsProxy = process.env.HTTPS_PROXY;
const originalNoProxy = process.env.NO_PROXY;
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
process.env.HTTP_PROXY = 'http://127.0.0.1:9';
process.env.HTTPS_PROXY = 'http://127.0.0.1:9';
delete process.env.NO_PROXY;
try {
await expect(testProviderConnection({
protocol: 'openai',
baseUrl: `http://localhost:${address.port}/v1`,
apiKey: 'ollama',
model: 'llama3.2',
})).resolves.toMatchObject({
ok: true,
kind: 'success',
});
} finally {
if (originalHttpProxy === undefined) delete process.env.HTTP_PROXY;
else process.env.HTTP_PROXY = originalHttpProxy;
if (originalHttpsProxy === undefined) delete process.env.HTTPS_PROXY;
else process.env.HTTPS_PROXY = originalHttpsProxy;
if (originalNoProxy === undefined) delete process.env.NO_PROXY;
else process.env.NO_PROXY = originalNoProxy;
proxySpy.mockRestore();
await new Promise<void>((resolve, reject) =>
providerServer.close((error) => (error ? reject(error) : resolve())),
);
}
});
it('keeps loopback provider probes off a SOCKS-only proxy', async () => {
const providerServer = http.createServer((req, res) => {
if (req.url === '/v1/models') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ data: [{ id: 'llama3.2', object: 'model' }] }));
return;
}
if (req.url === '/v1/chat/completions') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
choices: [{ message: { role: 'assistant', content: 'ok' } }],
}));
return;
}
res.writeHead(404).end();
});
await new Promise<void>((resolve) => providerServer.listen(0, '127.0.0.1', () => resolve()));
const address = providerServer.address();
if (!address || typeof address === 'string') {
providerServer.close();
throw new Error('Expected an IPv4 provider test server address');
}
const originalAllProxy = process.env.ALL_PROXY;
const originalNoProxy = process.env.NO_PROXY;
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
process.env.ALL_PROXY = 'socks5://127.0.0.1:9';
delete process.env.NO_PROXY;
try {
await expect(testProviderConnection({
protocol: 'openai',
baseUrl: `http://localhost:${address.port}/v1`,
apiKey: 'ollama',
model: 'llama3.2',
})).resolves.toMatchObject({
ok: true,
kind: 'success',
});
} finally {
if (originalAllProxy === undefined) delete process.env.ALL_PROXY;
else process.env.ALL_PROXY = originalAllProxy;
if (originalNoProxy === undefined) delete process.env.NO_PROXY;
else process.env.NO_PROXY = originalNoProxy;
proxySpy.mockRestore();
await new Promise<void>((resolve, reject) =>
providerServer.close((error) => (error ? reject(error) : resolve())),
);
}
});
});
describe('POST /api/test/connection agent mode', () => {

View file

@ -110,6 +110,47 @@ describe('OpenAI-compatible media providers', () => {
expect(bytes.length).toBeGreaterThan(0);
});
it('forwards requestInit.dispatcher through custom-image submit and asset fetches', async () => {
await writeConfig({
providers: {
'custom-image': {
baseUrl: 'https://images.example.test/v1',
model: 'acme-image-model',
},
},
});
const dispatcher = {} as NonNullable<RequestInit['dispatcher']>;
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
if (String(input) === 'https://images.example.test/v1/images/generations') {
expect(init?.dispatcher).toBe(dispatcher);
return new Response(JSON.stringify({
data: [{ url: 'https://cdn.example.test/generated.png' }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
expect(String(input)).toBe('https://cdn.example.test/generated.png');
expect(init?.dispatcher).toBe(dispatcher);
return new Response(Buffer.from(PNG_BASE64, 'base64'));
});
vi.stubGlobal('fetch', fetchMock);
await generateMedia({
projectRoot,
projectsRoot,
projectId: 'project-1',
surface: 'image',
model: 'custom-image',
prompt: 'A product render on white seamless paper',
output: 'custom-dispatcher.png',
requestInit: { dispatcher },
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('routes matching OpenAI image catalog ids through the configured custom provider', async () => {
await writeConfig({
providers: {

View file

@ -214,6 +214,50 @@ describe('senseaudio media generation', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('forwards requestInit.dispatcher through SenseAudio image submit and download fetches', async () => {
await writeConfig({
providers: {
senseaudio: {
apiKey: 'sense-test-key',
baseUrl: TEST_SENSEAUDIO_BASE_URL,
},
},
});
const dispatcher = {} as NonNullable<RequestInit['dispatcher']>;
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
if (String(input) === `${TEST_SENSEAUDIO_BASE_URL}/v1/image/sync`) {
expect(init?.dispatcher).toBe(dispatcher);
return new Response(JSON.stringify({
url: 'https://cdn.example.test/senseaudio-image.png',
base_resp: { status_code: 0, status_msg: 'success' },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
expect(String(input)).toBe('https://cdn.example.test/senseaudio-image.png');
expect(init?.dispatcher).toBe(dispatcher);
expect(init?.redirect).toBe('error');
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
});
vi.stubGlobal('fetch', fetchMock);
const result = await generateMedia({
projectRoot,
projectsRoot,
projectId: 'project-1',
surface: 'image',
model: 'senseaudio-image-2.0-260319',
prompt: 'A reference render.',
output: 'senseaudio-image.png',
requestInit: { dispatcher },
});
expect(result.providerId).toBe('senseaudio');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('errors when no API key is configured', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

View file

@ -2,7 +2,7 @@ import type http from 'node:http';
import { afterEach, describe, expect, it } from 'vitest';
import { randomUUID } from 'node:crypto';
import { closeDatabase, insertProject, openDatabase } from '../src/db.js';
import { insertMediaTask } from '../src/media-tasks.js';
import { insertMediaTask, listMediaTasksByProject } from '../src/media-tasks.js';
import { startServer } from '../src/server.js';
describe('media task route recovery', () => {
@ -65,4 +65,66 @@ describe('media task route recovery', () => {
message: 'media task interrupted by daemon restart',
});
});
it('marks the media task failed when proxy setup throws before generation starts', async () => {
const dataDir = process.env.OD_DATA_DIR;
const originalHttpProxy = process.env.HTTP_PROXY;
const originalHttpsProxy = process.env.HTTPS_PROXY;
const originalAllProxy = process.env.ALL_PROXY;
const db = openDatabase(process.cwd(), dataDir === undefined ? {} : { dataDir });
const projectId = `project_${randomUUID()}`;
const now = Date.now() - 5_000;
insertProject(db, {
id: projectId,
name: 'Proxy failure media project',
createdAt: now,
updatedAt: now,
});
process.env.HTTP_PROXY = 'not a valid proxy url';
delete process.env.HTTPS_PROXY;
delete process.env.ALL_PROXY;
const started = await startServer({ port: 0, returnServer: true }) as {
url: string;
server: http.Server;
};
server = started.server;
try {
const response = await fetch(`${started.url}/api/projects/${encodeURIComponent(projectId)}/media/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
surface: 'image',
model: 'custom-image',
prompt: 'A proxy failure should not leave a stuck task',
output: 'proxy-failure.png',
}),
});
const body = await response.json() as { error?: string };
expect(response.status).toBe(400);
expect(body.error).toBeTruthy();
expect(listMediaTasksByProject(db, projectId, { includeTerminal: true })).toMatchObject([
{
error: { status: 400 },
file: null,
model: 'custom-image',
progress: [],
projectId,
status: 'failed',
surface: 'image',
},
]);
} finally {
if (originalHttpProxy === undefined) delete process.env.HTTP_PROXY;
else process.env.HTTP_PROXY = originalHttpProxy;
if (originalHttpsProxy === undefined) delete process.env.HTTPS_PROXY;
else process.env.HTTPS_PROXY = originalHttpsProxy;
if (originalAllProxy === undefined) delete process.env.ALL_PROXY;
else process.env.ALL_PROXY = originalAllProxy;
}
});
});

View file

@ -0,0 +1,377 @@
import * as platform from '@open-design/platform';
import { afterEach, describe, expect, it, vi } from 'vitest';
const envHttpProxyAgentConstructor = vi.fn();
const envHttpProxyAgentDispatch = vi.fn();
const directAgentConstructor = vi.fn();
const socks5ProxyAgentConstructor = vi.fn();
const directAgentDispatch = vi.fn();
const socks5ProxyAgentDispatch = vi.fn();
vi.mock('undici', async () => {
const actual = await vi.importActual<typeof import('undici')>('undici');
class MockEnvHttpProxyAgent {
constructor(options?: unknown) {
envHttpProxyAgentConstructor(options);
}
dispatch(options: unknown, handler: unknown) {
envHttpProxyAgentDispatch(options, handler);
return true;
}
async close() {}
async destroy() {}
}
class MockAgent {
constructor(options?: unknown) {
directAgentConstructor(options);
}
dispatch(options: unknown, handler: unknown) {
directAgentDispatch(options, handler);
return true;
}
async close() {}
async destroy() {}
}
class MockSocks5ProxyAgent {
constructor(proxyUrl: string, options?: unknown) {
socks5ProxyAgentConstructor(proxyUrl, options);
}
dispatch(options: unknown, handler: unknown) {
socks5ProxyAgentDispatch(options, handler);
return true;
}
async close() {}
async destroy() {}
}
return {
...actual,
Agent: MockAgent,
EnvHttpProxyAgent: MockEnvHttpProxyAgent,
Socks5ProxyAgent: MockSocks5ProxyAgent,
};
});
describe('proxyDispatcherRequestInit', () => {
afterEach(() => {
directAgentConstructor.mockReset();
directAgentDispatch.mockReset();
envHttpProxyAgentConstructor.mockReset();
envHttpProxyAgentDispatch.mockReset();
socks5ProxyAgentDispatch.mockReset();
socks5ProxyAgentConstructor.mockReset();
vi.resetModules();
});
it('forwards agent timeout options into EnvHttpProxyAgent construction', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
const { proxyDispatcherRequestInit } = await import('../src/connectionTest.js');
try {
const { close, requestInit } = proxyDispatcherRequestInit(
{
HTTP_PROXY: 'http://proxy.example.test:8080',
},
{
headersTimeout: 10 * 60 * 1000,
bodyTimeout: 10 * 60 * 1000,
},
);
expect(requestInit.dispatcher).toBeTruthy();
expect(envHttpProxyAgentConstructor).toHaveBeenCalledWith(expect.objectContaining({
bodyTimeout: 10 * 60 * 1000,
headersTimeout: 10 * 60 * 1000,
httpProxy: 'http://proxy.example.test:8080',
noProxy: 'localhost,127.0.0.1,[::1]',
}));
await expect(close()).resolves.toBeUndefined();
} finally {
proxySpy.mockRestore();
}
});
it('uses Socks5ProxyAgent when only ALL_PROXY carries a SOCKS proxy', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
const { proxyDispatcherRequestInit } = await import('../src/connectionTest.js');
try {
const { close, requestInit } = proxyDispatcherRequestInit({
ALL_PROXY: 'socks5://proxy.example.test:1080',
});
expect(requestInit.dispatcher).toBeTruthy();
expect(socks5ProxyAgentConstructor).toHaveBeenCalledWith(
'socks5://proxy.example.test:1080',
{},
);
expect(envHttpProxyAgentConstructor).not.toHaveBeenCalled();
await expect(close()).resolves.toBeUndefined();
} finally {
proxySpy.mockRestore();
}
});
it('normalizes socks5h ALL_PROXY values for Socks5ProxyAgent', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
const { proxyDispatcherRequestInit } = await import('../src/connectionTest.js');
try {
const { close, requestInit } = proxyDispatcherRequestInit({
ALL_PROXY: 'socks5h://proxy.example.test:1080',
});
expect(requestInit.dispatcher).toBeTruthy();
expect(socks5ProxyAgentConstructor).toHaveBeenCalledWith(
'socks5://proxy.example.test:1080',
{},
);
expect(envHttpProxyAgentConstructor).not.toHaveBeenCalled();
await expect(close()).resolves.toBeUndefined();
} finally {
proxySpy.mockRestore();
}
});
it('forwards agent timeout options into Socks5ProxyAgent construction', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
const { proxyDispatcherRequestInit } = await import('../src/connectionTest.js');
try {
const { close, requestInit } = proxyDispatcherRequestInit(
{
ALL_PROXY: 'socks5://proxy.example.test:1080',
},
{
headersTimeout: 10 * 60 * 1000,
bodyTimeout: 10 * 60 * 1000,
},
);
expect(requestInit.dispatcher).toBeTruthy();
expect(socks5ProxyAgentConstructor).toHaveBeenCalledWith(
'socks5://proxy.example.test:1080',
{
bodyTimeout: 10 * 60 * 1000,
headersTimeout: 10 * 60 * 1000,
},
);
await expect(close()).resolves.toBeUndefined();
} finally {
proxySpy.mockRestore();
}
});
it.each([
{
label: 'HTTP_PROXY only',
systemProxyEnv: {
ALL_PROXY: 'socks5://system-socks.example.test:1080',
HTTP_PROXY: 'http://system-http.example.test:8080',
},
specificOrigin: 'http://api.example.test',
socksOrigin: 'https://api.example.test',
expectedProxyOptions: {
httpProxy: 'http://system-http.example.test:8080',
},
},
{
label: 'HTTPS_PROXY only',
systemProxyEnv: {
ALL_PROXY: 'socks5://system-socks.example.test:1080',
HTTPS_PROXY: 'http://system-https.example.test:8443',
},
specificOrigin: 'https://api.example.test',
socksOrigin: 'http://api.example.test',
expectedProxyOptions: {
httpsProxy: 'http://system-https.example.test:8443',
},
},
])('uses SOCKS ALL_PROXY for the missing scheme when system proxy has $label', async ({
systemProxyEnv,
specificOrigin,
socksOrigin,
expectedProxyOptions,
}) => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
const { proxyDispatcherRequestInit } = await import('../src/connectionTest.js');
try {
const { close, requestInit } = proxyDispatcherRequestInit(systemProxyEnv);
expect(requestInit.dispatcher).toBeTruthy();
expect(envHttpProxyAgentConstructor).toHaveBeenCalledWith(expect.objectContaining({
...expectedProxyOptions,
noProxy: 'localhost,127.0.0.1,[::1]',
}));
expect(socks5ProxyAgentConstructor).toHaveBeenCalledWith(
'socks5://system-socks.example.test:1080',
{},
);
const dispatcher = requestInit.dispatcher as {
dispatch(options: { origin: string; path: string }, handler: unknown): boolean;
};
dispatcher.dispatch(
{
origin: socksOrigin,
path: '/v1/models',
},
{},
);
expect(socks5ProxyAgentDispatch).toHaveBeenCalled();
expect(envHttpProxyAgentDispatch).not.toHaveBeenCalled();
socks5ProxyAgentDispatch.mockClear();
envHttpProxyAgentDispatch.mockClear();
dispatcher.dispatch(
{
origin: specificOrigin,
path: '/v1/models',
},
{},
);
expect(envHttpProxyAgentDispatch).toHaveBeenCalled();
expect(socks5ProxyAgentDispatch).not.toHaveBeenCalled();
await expect(close()).resolves.toBeUndefined();
} finally {
proxySpy.mockRestore();
}
});
it('bypasses SOCKS proxy dispatch for loopback targets from NO_PROXY defaults', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
const { proxyDispatcherRequestInit } = await import('../src/connectionTest.js');
try {
const { close, requestInit } = proxyDispatcherRequestInit({
ALL_PROXY: 'socks5://proxy.example.test:1080',
});
expect(requestInit.dispatcher).toBeTruthy();
const dispatcher = requestInit.dispatcher as {
dispatch(options: { origin: string; path: string }, handler: unknown): boolean;
};
expect(
dispatcher.dispatch(
{
origin: 'http://localhost:11434',
path: '/v1/chat/completions',
},
{},
),
).toBe(true);
expect(directAgentDispatch).toHaveBeenCalledWith(
expect.objectContaining({
origin: 'http://localhost:11434',
path: '/v1/chat/completions',
}),
{},
);
expect(socks5ProxyAgentDispatch).not.toHaveBeenCalled();
await expect(close()).resolves.toBeUndefined();
} finally {
proxySpy.mockRestore();
}
});
it('bypasses SOCKS proxy dispatch for explicit NO_PROXY hosts', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
const { proxyDispatcherRequestInit } = await import('../src/connectionTest.js');
try {
const { close, requestInit } = proxyDispatcherRequestInit({
ALL_PROXY: 'socks5://proxy.example.test:1080',
NO_PROXY: '.corp.test',
});
expect(requestInit.dispatcher).toBeTruthy();
const dispatcher = requestInit.dispatcher as {
dispatch(options: { origin: string; path: string }, handler: unknown): boolean;
};
dispatcher.dispatch(
{
origin: 'https://api.corp.test',
path: '/v1/models',
},
{},
);
expect(directAgentDispatch).toHaveBeenCalled();
expect(socks5ProxyAgentDispatch).not.toHaveBeenCalled();
await expect(close()).resolves.toBeUndefined();
} finally {
proxySpy.mockRestore();
}
});
it('keeps SOCKS proxy dispatch for hosts outside NO_PROXY', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
const { proxyDispatcherRequestInit } = await import('../src/connectionTest.js');
try {
const { close, requestInit } = proxyDispatcherRequestInit({
ALL_PROXY: 'socks5://proxy.example.test:1080',
NO_PROXY: '.corp.test',
});
expect(requestInit.dispatcher).toBeTruthy();
const dispatcher = requestInit.dispatcher as {
dispatch(options: { origin: string; path: string }, handler: unknown): boolean;
};
dispatcher.dispatch(
{
origin: 'https://api.openai.com',
path: '/v1/chat/completions',
},
{},
);
expect(socks5ProxyAgentDispatch).toHaveBeenCalled();
expect(directAgentDispatch).not.toHaveBeenCalled();
await expect(close()).resolves.toBeUndefined();
} finally {
proxySpy.mockRestore();
}
});
it('bypasses HTTP proxy dispatch for simple hosts when NO_PROXY includes <local>', async () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({});
const { proxyDispatcherRequestInit } = await import('../src/connectionTest.js');
try {
const { close, requestInit } = proxyDispatcherRequestInit({
HTTP_PROXY: 'http://proxy.example.test:8080',
NO_PROXY: '<local>,localhost,127.0.0.1,[::1],.local',
});
expect(requestInit.dispatcher).toBeTruthy();
const dispatcher = requestInit.dispatcher as {
dispatch(options: { origin: string; path: string }, handler: unknown): boolean;
};
dispatcher.dispatch(
{
origin: 'http://ollama:11434',
path: '/api/tags',
},
{},
);
expect(directAgentDispatch).toHaveBeenCalled();
expect(envHttpProxyAgentDispatch).not.toHaveBeenCalled();
await expect(close()).resolves.toBeUndefined();
} finally {
proxySpy.mockRestore();
}
});
});

View file

@ -1,5 +1,9 @@
import type http from 'node:http';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeAll, afterAll, describe, expect, it, vi } from 'vitest';
import * as platform from '@open-design/platform';
import { startServer } from '../src/server.js';
type FetchInput = Parameters<typeof fetch>[0];
@ -7,6 +11,7 @@ type FetchInit = Parameters<typeof fetch>[1];
describe('API proxy routes', () => {
const realFetch = globalThis.fetch;
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
let server: http.Server;
let baseUrl: string;
@ -23,6 +28,11 @@ describe('API proxy routes', () => {
vi.unstubAllGlobals();
});
afterEach(async () => {
if (originalMediaConfigDir == null) delete process.env.OD_MEDIA_CONFIG_DIR;
else process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
it('converts OpenAI-compatible CRLF SSE chunks into proxy delta/end events', async () => {
@ -60,6 +70,257 @@ describe('API proxy routes', () => {
);
});
it.each([
{
provider: 'anthropic',
path: '/api/proxy/anthropic/stream',
body: {
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant',
model: 'claude-test',
messages: [{ role: 'user', content: 'hello' }],
},
response: sseResponse('event: message_stop\ndata: {}\n\n'),
},
{
provider: 'openai',
path: '/api/proxy/openai/stream',
body: {
baseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-openai',
model: 'gpt-test',
messages: [{ role: 'user', content: 'hello' }],
},
response: sseResponse('data: [DONE]\n\n'),
},
{
provider: 'azure',
path: '/api/proxy/azure/stream',
body: {
baseUrl: 'https://resource.openai.azure.com',
apiKey: 'azure-key',
model: 'deployment-one',
apiVersion: '2024-10-21',
messages: [{ role: 'user', content: 'hello' }],
},
response: sseResponse('data: [DONE]\n\n'),
},
{
provider: 'google',
path: '/api/proxy/google/stream',
body: {
baseUrl: 'https://generativelanguage.googleapis.com',
apiKey: 'google-key',
model: 'gemini-2.0-flash',
messages: [{ role: 'user', content: 'hello' }],
},
response: sseResponse('data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n'),
},
{
provider: 'ollama',
path: '/api/proxy/ollama/stream',
body: {
baseUrl: 'https://ollama.example.com',
apiKey: 'ollama-key',
model: 'llama3',
messages: [{ role: 'user', content: 'hello' }],
},
response: new Response(new TextEncoder().encode('{"done":true}\n'), {
status: 200,
headers: { 'content-type': 'application/x-ndjson' },
}),
},
{
provider: 'senseaudio',
path: '/api/proxy/senseaudio/stream',
body: {
baseUrl: 'https://api.senseaudio.cn',
apiKey: 'sa-key',
model: 'senseaudio-s2',
projectId: 'test-project',
messages: [{ role: 'user', content: 'hello' }],
},
response: sseResponse('data: [DONE]\n\n'),
},
])('uses the live proxy dispatcher for $provider proxy requests', async ({ path, body, response }) => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({
HTTPS_PROXY: 'http://system-proxy.internal:8443',
NODE_USE_ENV_PROXY: '1',
});
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
const url = String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
expect(init?.dispatcher).toBeDefined();
return Promise.resolve(response.clone());
});
vi.stubGlobal('fetch', fetchMock);
try {
const res = await realFetch(`${baseUrl}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
expect(res.status).toBe(200);
await res.text();
expect(
fetchMock.mock.calls.some(
([input, init]) => !String(input).startsWith(baseUrl) && init?.dispatcher,
),
).toBe(true);
} finally {
proxySpy.mockRestore();
}
});
it('uses the live proxy dispatcher for ElevenLabs voice discovery', async () => {
const configDir = await mkdtemp(path.join(tmpdir(), 'od-elevenlabs-proxy-route-'));
process.env.OD_MEDIA_CONFIG_DIR = configDir;
await mkdir(configDir, { recursive: true });
await writeFile(path.join(configDir, 'media-config.json'), JSON.stringify({
providers: {
elevenlabs: {
apiKey: 'eleven-test-key',
baseUrl: 'https://elevenlabs-gateway.example.test',
},
},
}), 'utf8');
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({
HTTPS_PROXY: 'http://system-proxy.internal:8443',
NODE_USE_ENV_PROXY: '1',
});
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
const url = String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
expect(url).toBe('https://elevenlabs-gateway.example.test/v2/voices?page_size=100');
expect(init?.dispatcher).toBeDefined();
return Promise.resolve(Response.json({
voices: [{ voice_id: 'voice-1', name: 'Rachel' }],
}));
});
vi.stubGlobal('fetch', fetchMock);
try {
const res = await realFetch(`${baseUrl}/api/media/providers/elevenlabs/voices?limit=100`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
voices: [{ voiceId: 'voice-1', name: 'Rachel' }],
});
expect(
fetchMock.mock.calls.some(
([input, init]) => input === 'https://elevenlabs-gateway.example.test/v2/voices?page_size=100' && init?.dispatcher,
),
).toBe(true);
} finally {
proxySpy.mockRestore();
await rm(configDir, { recursive: true, force: true });
}
});
it('uses the live proxy dispatcher for Tavily research search', async () => {
const configDir = await mkdtemp(path.join(tmpdir(), 'od-tavily-proxy-route-'));
process.env.OD_MEDIA_CONFIG_DIR = configDir;
await mkdir(configDir, { recursive: true });
await writeFile(path.join(configDir, 'media-config.json'), JSON.stringify({
providers: {
tavily: {
apiKey: 'tavily-test-key',
baseUrl: 'https://tavily-gateway.example.test',
},
},
}), 'utf8');
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({
HTTPS_PROXY: 'http://system-proxy.internal:8443',
NODE_USE_ENV_PROXY: '1',
});
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
const url = String(input);
if (url.startsWith(baseUrl)) return realFetch(input, init);
expect(url).toBe('https://tavily-gateway.example.test/search');
expect(init?.dispatcher).toBeDefined();
return Promise.resolve(Response.json({
answer: 'Proxy-safe summary',
results: [
{
title: 'Proxy-safe source',
url: 'https://example.test/source',
content: 'Snippet',
},
],
}));
});
vi.stubGlobal('fetch', fetchMock);
try {
const res = await realFetch(`${baseUrl}/api/research/search`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
query: 'proxy-aware research',
providers: ['tavily'],
}),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual(expect.objectContaining({
query: 'proxy-aware research',
provider: 'tavily',
summary: 'Proxy-safe summary',
sources: [
expect.objectContaining({
title: 'Proxy-safe source',
url: 'https://example.test/source',
}),
],
}));
expect(
fetchMock.mock.calls.some(
([input, init]) => input === 'https://tavily-gateway.example.test/search' && init?.dispatcher,
),
).toBe(true);
} finally {
proxySpy.mockRestore();
await rm(configDir, { recursive: true, force: true });
}
});
it('reports malformed proxy env before sending the start event on Anthropic streams', async () => {
const originalHttpProxy = process.env.HTTP_PROXY;
const originalHttpsProxy = process.env.HTTPS_PROXY;
const originalAllProxy = process.env.ALL_PROXY;
process.env.HTTP_PROXY = 'not a valid proxy url';
delete process.env.HTTPS_PROXY;
delete process.env.ALL_PROXY;
try {
const res = await realFetch(`${baseUrl}/api/proxy/anthropic/stream`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant',
model: 'claude-test',
messages: [{ role: 'user', content: 'hello' }],
}),
});
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toContain('event: error');
expect(text).toContain('INTERNAL_ERROR');
expect(text).not.toContain('event: start');
} finally {
if (originalHttpProxy === undefined) delete process.env.HTTP_PROXY;
else process.env.HTTP_PROXY = originalHttpProxy;
if (originalHttpsProxy === undefined) delete process.env.HTTPS_PROXY;
else process.env.HTTPS_PROXY = originalHttpsProxy;
if (originalAllProxy === undefined) delete process.env.ALL_PROXY;
else process.env.ALL_PROXY = originalAllProxy;
}
});
// Regression: appendVersionedApiPath needs to thread three shapes:
// * bare host → inject /v1 (api.openai.com)
// * sub-path containing /vN → no inject (api.deepinfra.com/v1/openai)

View file

@ -1,6 +1,7 @@
import { symlinkSync } from 'node:fs';
import { test } from 'vitest';
import { test, vi } from 'vitest';
import { homedir } from 'node:os';
import * as platform from '@open-design/platform';
import {
assert, chmodSync, detectAgents, inspectAgentExecutableResolution, join, minimalAgentDef, mkdirSync, mkdtempSync, opencode, resolveAgentExecutable, rmSync, spawnEnvForAgent, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
} from './helpers/test-helpers.js';
@ -54,6 +55,86 @@ test('spawnEnvForAgent applies configured Codex env without mutating the base en
assert.equal('CODEX_BIN' in base, false);
});
test('spawnEnvForAgent applies system proxy env to all agent runtimes before base env overrides', () => {
const env = spawnEnvForAgent(
'gemini',
{
HTTPS_PROXY: 'http://user-env:9000',
PATH: '/usr/bin',
},
{},
{
HTTP_PROXY: 'http://system-http:7890',
HTTPS_PROXY: 'http://system-https:7891',
ALL_PROXY: 'socks5://system-socks:1080',
NO_PROXY: '.local,localhost',
NODE_USE_ENV_PROXY: '1',
},
);
assert.equal(env.HTTP_PROXY, 'http://system-http:7890');
assert.equal(env.HTTPS_PROXY, 'http://user-env:9000');
assert.equal(env.ALL_PROXY, 'socks5://system-socks:1080');
assert.equal(env.NO_PROXY, '.local,localhost');
assert.equal(env.NODE_USE_ENV_PROXY, '1');
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent resolves system proxy env for each default agent launch', () => {
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({
HTTPS_PROXY: 'http://system-https:7891',
NODE_USE_ENV_PROXY: '1',
});
try {
const env = spawnEnvForAgent('gemini', { PATH: '/usr/bin' });
assert.deepEqual(proxySpy.mock.calls, [[]]);
assert.equal(env.HTTPS_PROXY, 'http://system-https:7891');
assert.equal(env.PATH, '/usr/bin');
} finally {
proxySpy.mockRestore();
}
});
test('spawnEnvForAgent lets explicit lowercase proxy env override system uppercase proxy env', () => {
const env = spawnEnvForAgent(
'gemini',
{
https_proxy: 'http://user-lowercase:9000',
PATH: '/usr/bin',
},
{},
{
HTTPS_PROXY: 'http://system-uppercase:7891',
NODE_USE_ENV_PROXY: '1',
},
);
assert.equal(env.HTTPS_PROXY, 'http://user-lowercase:9000');
if (process.platform !== 'win32') {
assert.equal(env.https_proxy, 'http://user-lowercase:9000');
}
});
test('spawnEnvForAgent enables Node env proxy support for inherited lowercase proxy env', () => {
const env = spawnEnvForAgent(
'gemini',
{
http_proxy: 'http://user-lowercase:9000',
PATH: '/usr/bin',
},
{},
{},
);
assert.equal(env.HTTP_PROXY, 'http://user-lowercase:9000');
assert.equal(env.NODE_USE_ENV_PROXY, '1');
if (process.platform !== 'win32') {
assert.equal(env.http_proxy, 'http://user-lowercase:9000');
}
});
test('spawnEnvForAgent expands configured env home paths', () => {
const env = spawnEnvForAgent('codex', { PATH: '/usr/bin' }, {
CODEX_HOME: '~/.codex-alt',

View file

@ -25,6 +25,24 @@ const { onCallbackHolder, stopMock, startMock } = vi.hoisted(() => {
return { onCallbackHolder: holder, stopMock: stop, startMock: start };
});
const {
proxyDispatcherCloseMock,
proxyDispatcherFactoryMock,
proxyDispatcherToken,
} = vi.hoisted(() => {
const dispatcher = { tag: 'xai-test-dispatcher' };
const close = vi.fn(async () => {});
const factory = vi.fn(() => ({
close,
requestInit: { dispatcher },
}));
return {
proxyDispatcherCloseMock: close,
proxyDispatcherFactoryMock: factory,
proxyDispatcherToken: dispatcher,
};
});
vi.mock('../src/xai-oauth-server.js', () => ({
XAI_CALLBACK_HOST: '127.0.0.1',
XAI_CALLBACK_PORT: 56121,
@ -32,6 +50,10 @@ vi.mock('../src/xai-oauth-server.js', () => ({
startCallbackListener: startMock,
}));
vi.mock('../src/connectionTest.js', () => ({
proxyDispatcherRequestInit: proxyDispatcherFactoryMock,
}));
import {
extractAnswerText,
extractUrlCitations,
@ -121,6 +143,8 @@ describe('xai-routes', () => {
onCallbackHolder.current = null;
startMock.mockClear();
stopMock.mockClear();
proxyDispatcherCloseMock.mockClear();
proxyDispatcherFactoryMock.mockClear();
app = await startTestApp(projectRoot);
});
@ -183,6 +207,7 @@ describe('xai-routes', () => {
globalThis.fetch = vi.fn(async (input: any, init?: any) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === XAI_OAUTH_TOKEN_ENDPOINT) {
expect(init?.dispatcher).toBe(proxyDispatcherToken);
return new Response(
JSON.stringify({
access_token: 'fresh-bearer',
@ -209,6 +234,8 @@ describe('xai-routes', () => {
expect(status.scope).toBe('openid profile');
expect(status.listening).toBe(false); // listener cleared after handleCallback
expect(typeof status.expiresAt).toBe('number');
expect(proxyDispatcherFactoryMock).toHaveBeenCalledTimes(1);
expect(proxyDispatcherCloseMock).toHaveBeenCalledTimes(1);
});
it('POST /api/xai/oauth/complete (paste-back) exchanges code and stores token', async () => {
@ -220,6 +247,7 @@ describe('xai-routes', () => {
globalThis.fetch = vi.fn(async (input: any, init?: any) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === XAI_OAUTH_TOKEN_ENDPOINT) {
expect(init?.dispatcher).toBe(proxyDispatcherToken);
return new Response(
JSON.stringify({
access_token: 'pasted-bearer',
@ -253,6 +281,8 @@ describe('xai-routes', () => {
expect(status.listening).toBe(false);
// Paste-back must stop the loopback listener so it doesn't dangle.
expect(stopMock).toHaveBeenCalled();
expect(proxyDispatcherFactoryMock).toHaveBeenCalledTimes(1);
expect(proxyDispatcherCloseMock).toHaveBeenCalledTimes(1);
});
it('POST /api/xai/oauth/complete rejects empty state or code', async () => {
@ -340,11 +370,16 @@ describe('xai-routes', () => {
);
let xaiHit = 0;
let bodyConsumed = false;
proxyDispatcherCloseMock.mockImplementationOnce(async () => {
expect(bodyConsumed).toBe(true);
});
globalThis.fetch = vi.fn(async (input: any, init?: any) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('xai.example.test')) {
xaiHit += 1;
expect(url).toBe('https://xai.example.test/v1/responses');
expect(init?.dispatcher).toBe(proxyDispatcherToken);
const headers = init?.headers as Record<string, string>;
expect(headers.authorization).toBe('Bearer stored-test-bearer');
expect(headers['content-type']).toBe('application/json');
@ -358,34 +393,38 @@ describe('xai-routes', () => {
allowed_x_handles: ['NousResearch', 'xai'],
from_date: '2026-05-01',
});
return new Response(
JSON.stringify({
output: [
{
content: [
{
text: 'Hermes 0.11 shipped xAI integration on 5/15.',
annotations: [
{
type: 'url_citation',
url: 'https://x.com/NousResearch/status/123',
start_index: 0,
end_index: 7,
},
{
type: 'url_citation',
url: 'https://x.com/xai/status/456',
start_index: 8,
end_index: 15,
},
],
},
],
},
],
return {
ok: true,
status: 200,
text: vi.fn(async () => {
bodyConsumed = true;
return JSON.stringify({
output: [
{
content: [
{
text: 'Hermes 0.11 shipped xAI integration on 5/15.',
annotations: [
{
type: 'url_citation',
url: 'https://x.com/NousResearch/status/123',
start_index: 0,
end_index: 7,
},
{
type: 'url_citation',
url: 'https://x.com/xai/status/456',
start_index: 8,
end_index: 15,
},
],
},
],
},
],
});
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
} as unknown as Response;
}
// Pass through anything that isn't an xAI call (the test's own
// request to the local express server).
@ -410,6 +449,8 @@ describe('xai-routes', () => {
]);
expect(body.model).toBe('grok-4.20-reasoning');
expect(xaiHit).toBe(1);
expect(proxyDispatcherFactoryMock).toHaveBeenCalledTimes(1);
expect(proxyDispatcherCloseMock).toHaveBeenCalledTimes(1);
});
it('POST /api/xai/search surfaces upstream errors as 502', async () => {

View file

@ -23,6 +23,8 @@ import {
} from "@open-design/sidecar";
import {
createProcessStampArgs,
mergeProxyAwareEnv,
resolveSystemProxyEnv,
stopProcesses,
waitForProcessExit,
wellKnownUserToolchainBins,
@ -39,11 +41,13 @@ const PACKAGED_CHILD_ENV_ALLOWLIST = [
"LANG",
"LC_ALL",
"LOGNAME",
"ALL_PROXY",
"NODE_USE_ENV_PROXY",
"NO_PROXY",
"TMPDIR",
"USER",
"VP_HOME",
"all_proxy",
"http_proxy",
"https_proxy",
"no_proxy",
@ -248,14 +252,18 @@ export function resolvePackagedPathEnv(basePath = process.env.PATH ?? ""): strin
export function resolvePackagedChildBaseEnv(
env: NodeJS.ProcessEnv = process.env,
includeProviderSecrets = false,
systemProxyEnv: NodeJS.ProcessEnv = resolveSystemProxyEnv(),
includeSystemProxyEnv = true,
): NodeJS.ProcessEnv {
const baseEnv: NodeJS.ProcessEnv = {};
const forwardedEnv: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(env)) {
if (value != null && value.length > 0 && shouldForwardPackagedChildEnv(key, includeProviderSecrets)) {
baseEnv[key] = value;
forwardedEnv[key] = value;
}
}
return baseEnv;
return includeSystemProxyEnv
? mergeProxyAwareEnv(process.platform, systemProxyEnv, forwardedEnv)
: mergeProxyAwareEnv(process.platform, forwardedEnv);
}
function createPackagedDaemonManagedPathEnv(
@ -369,7 +377,12 @@ async function spawnSidecarChild(options: {
base: options.paths.runtimeRoot,
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
extraEnv: {
...resolvePackagedChildBaseEnv(process.env, options.app === APP_KEYS.DAEMON),
...resolvePackagedChildBaseEnv(
process.env,
options.app === APP_KEYS.DAEMON,
resolveSystemProxyEnv(),
options.app !== APP_KEYS.DAEMON,
),
...options.env,
NODE_ENV: "production",
PATH: resolvePackagedPathEnv(),

View file

@ -84,23 +84,27 @@ describe('packaged child Vite+ environment forwarding', () => {
it('forwards standard Node proxy variables to packaged sidecars', () => {
const env = resolvePackagedChildBaseEnv({
ALL_PROXY: 'socks5://127.0.0.1:1080',
HOME: '/Users/tester',
HTTP_PROXY: 'http://127.0.0.1:7890',
HTTPS_PROXY: 'http://127.0.0.1:7890',
NODE_USE_ENV_PROXY: '1',
NO_PROXY: 'localhost,127.0.0.1',
RANDOM_INTERNAL_FLAG: 'drop-me',
all_proxy: 'socks5://127.0.0.1:1081',
http_proxy: 'http://127.0.0.1:7891',
https_proxy: 'http://127.0.0.1:7891',
no_proxy: 'localhost,127.0.0.1,::1',
});
expect(env).toMatchObject({
ALL_PROXY: 'socks5://127.0.0.1:1081',
HOME: '/Users/tester',
HTTP_PROXY: 'http://127.0.0.1:7890',
HTTPS_PROXY: 'http://127.0.0.1:7890',
HTTP_PROXY: 'http://127.0.0.1:7891',
HTTPS_PROXY: 'http://127.0.0.1:7891',
NODE_USE_ENV_PROXY: '1',
NO_PROXY: 'localhost,127.0.0.1',
NO_PROXY: 'localhost,127.0.0.1,::1',
all_proxy: 'socks5://127.0.0.1:1081',
http_proxy: 'http://127.0.0.1:7891',
https_proxy: 'http://127.0.0.1:7891',
no_proxy: 'localhost,127.0.0.1,::1',
@ -108,6 +112,89 @@ describe('packaged child Vite+ environment forwarding', () => {
expect(env.RANDOM_INTERNAL_FLAG).toBeUndefined();
});
it('merges system proxy env when the packaged app was GUI-launched without shell proxy vars', () => {
const env = resolvePackagedChildBaseEnv(
{
HOME: '/Users/tester',
},
false,
{
HTTP_PROXY: 'http://system-proxy:8080',
HTTPS_PROXY: 'http://system-proxy:8443',
ALL_PROXY: 'socks5://system-proxy:1080',
NO_PROXY: '.local,localhost',
NODE_USE_ENV_PROXY: '1',
},
);
expect(env).toMatchObject({
HOME: '/Users/tester',
HTTP_PROXY: 'http://system-proxy:8080',
HTTPS_PROXY: 'http://system-proxy:8443',
ALL_PROXY: 'socks5://system-proxy:1080',
NO_PROXY: '.local,localhost',
NODE_USE_ENV_PROXY: '1',
});
});
it('lets forwarded lowercase proxy env override system uppercase proxy env', () => {
const env = resolvePackagedChildBaseEnv(
{
HOME: '/Users/tester',
https_proxy: 'http://user-lowercase:9443',
},
false,
{
HTTPS_PROXY: 'http://system-uppercase:8443',
NODE_USE_ENV_PROXY: '1',
},
);
expect(env.HTTPS_PROXY).toBe('http://user-lowercase:9443');
if (process.platform !== 'win32') {
expect(env.https_proxy).toBe('http://user-lowercase:9443');
}
});
it('enables Node env proxy support for forwarded lowercase proxy env', () => {
const env = resolvePackagedChildBaseEnv(
{
HOME: '/Users/tester',
https_proxy: 'http://user-lowercase:9443',
},
false,
{},
);
expect(env.HTTPS_PROXY).toBe('http://user-lowercase:9443');
expect(env.NODE_USE_ENV_PROXY).toBe('1');
if (process.platform !== 'win32') {
expect(env.https_proxy).toBe('http://user-lowercase:9443');
}
});
it('can skip injecting system proxy env into the packaged daemon base env', () => {
const env = resolvePackagedChildBaseEnv(
{
HOME: '/Users/tester',
},
true,
{
HTTP_PROXY: 'http://system-proxy:8080',
HTTPS_PROXY: 'http://system-proxy:8443',
NODE_USE_ENV_PROXY: '1',
},
false,
);
expect(env).toMatchObject({
HOME: '/Users/tester',
});
expect(env.HTTP_PROXY).toBeUndefined();
expect(env.HTTPS_PROXY).toBeUndefined();
expect(env.NODE_USE_ENV_PROXY).toBeUndefined();
});
it('adds custom VP_HOME/bin to the packaged PATH builder', () => {
const vpHome = mkdtempSync(join(tmpdir(), 'od-packaged-vp-home-'));
const originalVpHome = process.env.VP_HOME;

View file

@ -1,4 +1,4 @@
import { execFile, spawn, type ChildProcess, type StdioOptions } from "node:child_process";
import { execFile, execFileSync, spawn, type ChildProcess, type StdioOptions } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { copyFile, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
import { homedir } from "node:os";
@ -79,12 +79,358 @@ export type RemovePathBestEffortResult = {
removed: boolean;
};
export type SystemProxyCommandRunner = (command: string, args: string[]) => string;
export type ResolveSystemProxyEnvOptions = {
platform?: NodeJS.Platform;
runCommand?: SystemProxyCommandRunner;
};
type WindowsProcessRecord = {
CommandLine?: string | null;
ParentProcessId?: number | string | null;
ProcessId?: number | string | null;
};
const CANONICAL_PROXY_ENV_KEYS = new Map<string, "ALL_PROXY" | "HTTP_PROXY" | "HTTPS_PROXY" | "NODE_USE_ENV_PROXY" | "NO_PROXY">([
["all_proxy", "ALL_PROXY"],
["http_proxy", "HTTP_PROXY"],
["https_proxy", "HTTPS_PROXY"],
["node_use_env_proxy", "NODE_USE_ENV_PROXY"],
["no_proxy", "NO_PROXY"],
]);
function defaultSystemProxyCommandRunner(command: string, args: string[]): string {
return execFileSync(command, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 2_000,
windowsHide: true,
});
}
function canonicalProxyEnvKey(
key: string,
): "ALL_PROXY" | "HTTP_PROXY" | "HTTPS_PROXY" | "NODE_USE_ENV_PROXY" | "NO_PROXY" | null {
return CANONICAL_PROXY_ENV_KEYS.get(key.toLowerCase()) ?? null;
}
function deleteProxyEnvVariants(env: NodeJS.ProcessEnv, canonicalKey: string): void {
for (const existingKey of Object.keys(env)) {
if (existingKey.toLowerCase() === canonicalKey.toLowerCase()) delete env[existingKey];
}
}
function setCanonicalProxyEnvValue(
env: NodeJS.ProcessEnv,
canonicalKey: "ALL_PROXY" | "HTTP_PROXY" | "HTTPS_PROXY" | "NODE_USE_ENV_PROXY" | "NO_PROXY",
value: string,
platform: NodeJS.Platform,
): void {
deleteProxyEnvVariants(env, canonicalKey);
if (canonicalKey === "NODE_USE_ENV_PROXY") {
env.NODE_USE_ENV_PROXY = value;
return;
}
addProxyEnvValue(env, canonicalKey, value, platform);
}
export function mergeProxyAwareEnv(
platform: NodeJS.Platform,
...sources: Array<NodeJS.ProcessEnv | Record<string, string | undefined>>
): NodeJS.ProcessEnv {
const merged: NodeJS.ProcessEnv = {};
for (const source of sources) {
const proxyEntries = new Map<
"ALL_PROXY" | "HTTP_PROXY" | "HTTPS_PROXY" | "NODE_USE_ENV_PROXY" | "NO_PROXY",
{ preferLowercase: boolean; value: string }
>();
for (const [key, value] of Object.entries(source)) {
if (value == null) continue;
const canonicalKey = canonicalProxyEnvKey(key);
if (canonicalKey) {
const current = proxyEntries.get(canonicalKey);
const preferLowercase = key === key.toLowerCase();
if (!current || preferLowercase || !current.preferLowercase) {
proxyEntries.set(canonicalKey, { preferLowercase, value });
}
continue;
}
merged[key] = value;
}
for (const [canonicalKey, entry] of proxyEntries) {
setCanonicalProxyEnvValue(merged, canonicalKey, entry.value, platform);
}
}
if (hasProxyEndpointEnv(merged) && !hasCanonicalProxyEnv(merged, "NODE_USE_ENV_PROXY")) {
merged.NODE_USE_ENV_PROXY = "1";
}
return merged;
}
function hasCanonicalProxyEnv(
env: NodeJS.ProcessEnv,
canonicalKey: "ALL_PROXY" | "HTTP_PROXY" | "HTTPS_PROXY" | "NODE_USE_ENV_PROXY" | "NO_PROXY",
): boolean {
return Object.keys(env).some((key) => key.toLowerCase() === canonicalKey.toLowerCase());
}
function hasProxyEndpointEnv(env: NodeJS.ProcessEnv): boolean {
return ["ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY"].some((key) => {
for (const [envKey, value] of Object.entries(env)) {
if (envKey.toLowerCase() === key.toLowerCase() && value?.trim()) return true;
}
return false;
});
}
function addProxyEnvValue(
env: NodeJS.ProcessEnv,
key: "HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" | "NO_PROXY",
value: string,
platform: NodeJS.Platform,
): void {
const trimmed = value.trim();
if (!trimmed) return;
env[key] = trimmed;
if (platform !== "win32") env[key.toLowerCase()] = trimmed;
}
function normalizeBypassToken(token: string): string[] {
const trimmed = token.trim();
if (!trimmed) return [];
if (trimmed === "<local>") return ["<local>", "localhost", "127.0.0.1", "[::1]", ".local"];
if (trimmed === "::1") return ["[::1]"];
if (trimmed.startsWith("*.")) return [`.${trimmed.slice(2)}`];
return [trimmed];
}
function buildNoProxyValue(tokens: Iterable<string>): string | null {
const seen = new Set<string>();
const values: string[] = [];
for (const token of tokens) {
for (const normalized of normalizeBypassToken(token)) {
if (!seen.has(normalized)) {
seen.add(normalized);
values.push(normalized);
}
}
}
return values.length > 0 ? values.join(",") : null;
}
function preserveWildcardNoProxyValue(noProxy: string | null | undefined): string | undefined {
return noProxy?.split(",").some((token) => token.trim() === "*") ? "*" : undefined;
}
function normalizeProxyUrl(raw: string, scheme: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `${scheme}://${trimmed}`;
}
function bracketIpv6Authority(authority: string): string {
if (authority.startsWith("[") || !authority.includes(":")) return authority;
const portSeparatorIndex = authority.lastIndexOf(":");
if (portSeparatorIndex <= 0) return authority;
const host = authority.slice(0, portSeparatorIndex);
const port = authority.slice(portSeparatorIndex + 1);
if (!host.includes(":") || !/^\d+$/.test(port)) return authority;
return `[${host}]:${port}`;
}
function normalizeAuthorityProxyUrl(raw: string, scheme: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed;
return `${scheme}://${bracketIpv6Authority(trimmed)}`;
}
function normalizeHostPortProxyUrl(
host: string | undefined,
port: string | undefined,
scheme: string,
): string | null {
const trimmedHost = host?.trim() ?? "";
const trimmedPort = port?.trim() ?? "";
if (!trimmedHost || !trimmedPort) return null;
const normalizedHost =
trimmedHost.includes(":") && !trimmedHost.startsWith("[") && !trimmedHost.endsWith("]")
? `[${trimmedHost}]`
: trimmedHost;
return normalizeProxyUrl(`${normalizedHost}:${trimmedPort}`, scheme);
}
function finalizeSystemProxyEnv(
values: {
allProxy?: string | null;
httpProxy?: string | null;
httpsProxy?: string | null;
noProxy?: string | null;
},
platform: NodeJS.Platform,
): NodeJS.ProcessEnv {
const hasProxy = Boolean(values.httpProxy || values.httpsProxy || values.allProxy);
const noProxy = hasProxy
? preserveWildcardNoProxyValue(values.noProxy) ??
buildNoProxyValue([
...(values.noProxy ? values.noProxy.split(",") : []),
"localhost",
"127.0.0.1",
"[::1]",
])
: null;
const env: NodeJS.ProcessEnv = {};
if (values.httpProxy) addProxyEnvValue(env, "HTTP_PROXY", values.httpProxy, platform);
if (values.httpsProxy) addProxyEnvValue(env, "HTTPS_PROXY", values.httpsProxy, platform);
if (values.allProxy) addProxyEnvValue(env, "ALL_PROXY", values.allProxy, platform);
if (noProxy) addProxyEnvValue(env, "NO_PROXY", noProxy, platform);
if (hasProxy) env.NODE_USE_ENV_PROXY = "1";
return env;
}
export function parseMacosScutilProxyOutput(
stdout: string,
platform: NodeJS.Platform = "darwin",
): NodeJS.ProcessEnv {
const scalars = new Map<string, string>();
const exceptions: string[] = [];
let inExceptions = false;
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (/^ExceptionsList\s*:\s*<array>\s*\{$/.test(line)) {
inExceptions = true;
continue;
}
if (inExceptions) {
if (line === "}") {
inExceptions = false;
continue;
}
const match = line.match(/^\d+\s*:\s*(.+)$/);
if (match) exceptions.push(match[1].trim());
continue;
}
const match = line.match(/^([A-Za-z][A-Za-z0-9]*)\s*:\s*(.+)$/);
if (match) scalars.set(match[1], match[2].trim());
}
const httpProxy =
scalars.get("HTTPEnable") === "1"
? normalizeHostPortProxyUrl(scalars.get("HTTPProxy"), scalars.get("HTTPPort"), "http")
: null;
const httpsProxy =
scalars.get("HTTPSEnable") === "1"
? normalizeHostPortProxyUrl(scalars.get("HTTPSProxy"), scalars.get("HTTPSPort"), "http")
: null;
const allProxy =
scalars.get("SOCKSEnable") === "1"
? normalizeHostPortProxyUrl(scalars.get("SOCKSProxy"), scalars.get("SOCKSPort"), "socks5")
: null;
return finalizeSystemProxyEnv(
{
allProxy,
httpProxy,
httpsProxy,
noProxy: buildNoProxyValue([
...exceptions,
...(scalars.get("ExcludeSimpleHostnames") === "1" ? ["<local>"] : []),
]),
},
platform,
);
}
function parseRegistryValue(stdout: string, valueName: string): string | null {
const match = stdout.match(new RegExp(`^\\s*${valueName}\\s+REG_\\w+\\s+(.+)$`, "m"));
return match ? match[1].trim() : null;
}
export function parseWindowsInternetSettingsProxyOutput(
input: { proxyEnable: string; proxyOverride?: string; proxyServer?: string },
platform: NodeJS.Platform = "win32",
): NodeJS.ProcessEnv {
const enabled = parseRegistryValue(input.proxyEnable, "ProxyEnable");
if (enabled == null || !/^(1|0x1)$/i.test(enabled)) return {};
const proxyServer = parseRegistryValue(input.proxyServer ?? "", "ProxyServer") ?? "";
const proxyOverride = parseRegistryValue(input.proxyOverride ?? "", "ProxyOverride") ?? "";
if (!proxyServer.trim()) return {};
let httpProxy: string | null = null;
let httpsProxy: string | null = null;
let allProxy: string | null = null;
if (proxyServer.includes("=")) {
for (const segment of proxyServer.split(";")) {
const [kind, rawValue] = segment.split("=", 2);
const value = rawValue?.trim();
if (!kind || !value) continue;
const lowerKind = kind.trim().toLowerCase();
if (lowerKind === "http") httpProxy = normalizeAuthorityProxyUrl(value, "http");
else if (lowerKind === "https") httpsProxy = normalizeAuthorityProxyUrl(value, "http");
else if (lowerKind === "socks") allProxy = normalizeAuthorityProxyUrl(value, "socks5");
}
} else {
const shared = normalizeAuthorityProxyUrl(proxyServer, "http");
httpProxy = shared;
httpsProxy = shared;
}
return finalizeSystemProxyEnv(
{
allProxy,
httpProxy,
httpsProxy,
noProxy: buildNoProxyValue(proxyOverride.split(/[;,]/)),
},
platform,
);
}
export function resolveSystemProxyEnv(options: ResolveSystemProxyEnvOptions = {}): NodeJS.ProcessEnv {
const platform = options.platform ?? process.platform;
const runCommand = options.runCommand ?? defaultSystemProxyCommandRunner;
const tryRun = (command: string, args: string[]): string => {
try {
return runCommand(command, args);
} catch {
return "";
}
};
try {
if (platform === "darwin") {
return parseMacosScutilProxyOutput(tryRun("scutil", ["--proxy"]), platform);
}
if (platform === "win32") {
return parseWindowsInternetSettingsProxyOutput(
{
proxyEnable: tryRun("reg", [
"query",
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
"/v",
"ProxyEnable",
]),
proxyOverride: tryRun("reg", [
"query",
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
"/v",
"ProxyOverride",
]),
proxyServer: tryRun("reg", [
"query",
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
"/v",
"ProxyServer",
]),
},
platform,
);
}
} catch {
return {};
}
return {};
}
export function createProcessStampArgs<TStamp extends ProcessStampShape>(
stamp: TStamp,
contract: ProcessStampContract<TStamp>,

View file

@ -9,10 +9,14 @@ import {
createCommandInvocation,
createPackageManagerInvocation,
createProcessStampArgs,
mergeProxyAwareEnv,
matchesStampedProcess,
parseMacosScutilProxyOutput,
parseWindowsInternetSettingsProxyOutput,
pathContains,
readProcessStampFromCommand,
removePathBestEffort,
resolveSystemProxyEnv,
wellKnownUserToolchainBins,
type ProcessStampContract,
} from "../src/index.js";
@ -149,6 +153,314 @@ describe("generic filesystem primitives", () => {
});
});
describe("system proxy env resolution", () => {
it("enables Node env proxy support when merging user proxy variables", () => {
const env = mergeProxyAwareEnv("darwin", {
http_proxy: "http://user-proxy:7890",
});
expect(env).toMatchObject({
HTTP_PROXY: "http://user-proxy:7890",
NODE_USE_ENV_PROXY: "1",
http_proxy: "http://user-proxy:7890",
});
});
it("preserves an explicit NODE_USE_ENV_PROXY value when merging user proxy variables", () => {
const env = mergeProxyAwareEnv("darwin", {
HTTPS_PROXY: "http://user-proxy:7891",
NODE_USE_ENV_PROXY: "0",
});
expect(env.HTTPS_PROXY).toBe("http://user-proxy:7891");
expect(env.NODE_USE_ENV_PROXY).toBe("0");
});
it("parses macOS scutil output into standard proxy env vars", () => {
const env = parseMacosScutilProxyOutput(`
<dictionary> {
ExceptionsList : <array> {
0 : *.local
1 : localhost
}
HTTPEnable : 1
HTTPPort : 7890
HTTPProxy : 127.0.0.1
HTTPSEnable : 1
HTTPSPort : 7891
HTTPSProxy : corp-proxy.internal
SOCKSEnable : 1
SOCKSPort : 1080
SOCKSProxy : 127.0.0.1
}
`);
expect(env).toMatchObject({
HTTP_PROXY: "http://127.0.0.1:7890",
HTTPS_PROXY: "http://corp-proxy.internal:7891",
ALL_PROXY: "socks5://127.0.0.1:1080",
NO_PROXY: ".local,localhost,127.0.0.1,[::1]",
NODE_USE_ENV_PROXY: "1",
http_proxy: "http://127.0.0.1:7890",
https_proxy: "http://corp-proxy.internal:7891",
all_proxy: "socks5://127.0.0.1:1080",
no_proxy: ".local,localhost,127.0.0.1,[::1]",
});
});
it("brackets IPv6 system proxy hosts before composing proxy URLs", () => {
const env = parseMacosScutilProxyOutput(`
<dictionary> {
HTTPEnable : 1
HTTPPort : 7890
HTTPProxy : ::1
HTTPSEnable : 1
HTTPSPort : 7891
HTTPSProxy : 2001:db8::10
SOCKSEnable : 1
SOCKSPort : 1080
SOCKSProxy : fe80::1
}
`);
expect(env).toMatchObject({
HTTP_PROXY: "http://[::1]:7890",
HTTPS_PROXY: "http://[2001:db8::10]:7891",
ALL_PROXY: "socks5://[fe80::1]:1080",
http_proxy: "http://[::1]:7890",
https_proxy: "http://[2001:db8::10]:7891",
all_proxy: "socks5://[fe80::1]:1080",
});
});
it("parses Windows Internet Settings proxy registry values", () => {
const env = parseWindowsInternetSettingsProxyOutput({
proxyEnable: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x1
`,
proxyServer: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyServer REG_SZ http=10.0.0.2:8080;https=10.0.0.3:8443;socks=10.0.0.4:1080
`,
proxyOverride: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyOverride REG_SZ localhost;<local>;*.corp
`,
});
expect(env).toEqual({
HTTP_PROXY: "http://10.0.0.2:8080",
HTTPS_PROXY: "http://10.0.0.3:8443",
ALL_PROXY: "socks5://10.0.0.4:1080",
NO_PROXY: "localhost,<local>,127.0.0.1,[::1],.local,.corp",
NODE_USE_ENV_PROXY: "1",
});
});
it("brackets Windows IPv6 proxy hosts before composing proxy URLs", () => {
const segmented = parseWindowsInternetSettingsProxyOutput({
proxyEnable: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x1
`,
proxyServer: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyServer REG_SZ http=::1:8080;https=2001:db8::10:8443;socks=fe80::1:1080
`,
});
const shared = parseWindowsInternetSettingsProxyOutput({
proxyEnable: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x1
`,
proxyServer: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyServer REG_SZ ::1:8080
`,
});
expect(segmented).toMatchObject({
HTTP_PROXY: "http://[::1]:8080",
HTTPS_PROXY: "http://[2001:db8::10]:8443",
ALL_PROXY: "socks5://[fe80::1]:1080",
});
expect(shared).toMatchObject({
HTTP_PROXY: "http://[::1]:8080",
HTTPS_PROXY: "http://[::1]:8080",
});
});
it("normalizes bare IPv6 loopback bypass entries to bracketed form", () => {
const env = parseWindowsInternetSettingsProxyOutput({
proxyEnable: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x1
`,
proxyServer: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyServer REG_SZ http=10.0.0.2:8080
`,
proxyOverride: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyOverride REG_SZ ::1;localhost
`,
});
expect(env.NO_PROXY).toBe("[::1],localhost,127.0.0.1");
});
it("preserves a wildcard macOS bypass list", () => {
const env = parseMacosScutilProxyOutput(`
<dictionary> {
ExceptionsList : <array> {
0 : *
}
HTTPEnable : 1
HTTPPort : 7890
HTTPProxy : 127.0.0.1
}
`);
expect(env.NO_PROXY).toBe("*");
expect(env.no_proxy).toBe("*");
});
it("preserves a wildcard macOS bypass list when other entries are present", () => {
const env = parseMacosScutilProxyOutput(`
<dictionary> {
ExceptionsList : <array> {
0 : *
1 : <local>
}
HTTPEnable : 1
HTTPPort : 7890
HTTPProxy : 127.0.0.1
}
`);
expect(env.NO_PROXY).toBe("*");
expect(env.no_proxy).toBe("*");
});
it("adds <local> to the macOS bypass list when simple hostnames are excluded", () => {
const env = parseMacosScutilProxyOutput(`
<dictionary> {
ExcludeSimpleHostnames : 1
HTTPEnable : 1
HTTPPort : 7890
HTTPProxy : 127.0.0.1
}
`);
expect(env.NO_PROXY).toBe("<local>,localhost,127.0.0.1,[::1],.local");
expect(env.no_proxy).toBe("<local>,localhost,127.0.0.1,[::1],.local");
});
it("preserves a wildcard Windows bypass list", () => {
const env = parseWindowsInternetSettingsProxyOutput({
proxyEnable: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x1
`,
proxyServer: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyServer REG_SZ http=10.0.0.2:8080
`,
proxyOverride: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyOverride REG_SZ *
`,
});
expect(env.NO_PROXY).toBe("*");
});
it("preserves a wildcard Windows bypass list when other entries are present", () => {
const env = parseWindowsInternetSettingsProxyOutput({
proxyEnable: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyEnable REG_DWORD 0x1
`,
proxyServer: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyServer REG_SZ http=10.0.0.2:8080
`,
proxyOverride: `
HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings
ProxyOverride REG_SZ *;<local>
`,
});
expect(env.NO_PROXY).toBe("*");
});
it("resolves macOS system proxy env through the command runner", () => {
const env = resolveSystemProxyEnv({
platform: "darwin",
runCommand(command, args) {
expect(command).toBe("scutil");
expect(args).toEqual(["--proxy"]);
return `
<dictionary> {
HTTPEnable : 1
HTTPPort : 8888
HTTPProxy : 127.0.0.1
}
`;
},
});
expect(env.HTTP_PROXY).toBe("http://127.0.0.1:8888");
expect(env.NODE_USE_ENV_PROXY).toBe("1");
});
it("returns an empty object when the platform has no system proxy adapter", () => {
expect(resolveSystemProxyEnv({ platform: "linux" })).toEqual({});
});
it("does not cache system proxy resolution across calls", () => {
const values = [
"\n<dictionary> {\n HTTPEnable : 1\n HTTPPort : 8001\n HTTPProxy : 127.0.0.1\n}\n",
"\n<dictionary> {\n HTTPEnable : 1\n HTTPPort : 8002\n HTTPProxy : 127.0.0.1\n}\n",
];
let callCount = 0;
const runCommand = () => values[callCount++] ?? values.at(-1) ?? "";
const first = resolveSystemProxyEnv({ platform: "darwin", runCommand });
const second = resolveSystemProxyEnv({ platform: "darwin", runCommand });
expect(first.HTTP_PROXY).toBe("http://127.0.0.1:8001");
expect(second.HTTP_PROXY).toBe("http://127.0.0.1:8002");
expect(callCount).toBe(2);
});
it("makes the last proxy env source win case-insensitively", () => {
const env = mergeProxyAwareEnv(
"linux",
{ HTTPS_PROXY: "http://system:8443", https_proxy: "http://system:8443" },
{ https_proxy: "http://user:9443" },
);
expect(env.HTTPS_PROXY).toBe("http://user:9443");
expect(env.https_proxy).toBe("http://user:9443");
});
it("makes lowercase proxy vars win within a single POSIX source", () => {
const env = mergeProxyAwareEnv("linux", {
http_proxy: "http://new:8080",
HTTP_PROXY: "http://old:8080",
HTTPS_PROXY: "http://older:8443",
https_proxy: "http://newer:8443",
});
expect(env.HTTP_PROXY).toBe("http://new:8080");
expect(env.http_proxy).toBe("http://new:8080");
expect(env.HTTPS_PROXY).toBe("http://newer:8443");
expect(env.https_proxy).toBe("http://newer:8443");
});
});
// `createCommandInvocation` makes a platform-conditional choice based on
// `process.platform`. These tests stub it both ways so we exercise the
// Windows .cmd / .bat shim path on every CI runner, not just Windows.