mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
9de5ecd87c
commit
338cb4d423
24 changed files with 2641 additions and 146 deletions
|
|
@ -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}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 5–10 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
377
apps/daemon/tests/proxy-dispatcher-options.test.ts
Normal file
377
apps/daemon/tests/proxy-dispatcher-options.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue