fix(cli): honor proxy setting (#3753)
Some checks failed
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
SDK Python / SDK Python (3.10) (push) Has been cancelled
SDK Python / SDK Python (3.11) (push) Has been cancelled
SDK Python / SDK Python (3.12) (push) Has been cancelled

* fix(cli): honor proxy setting

* fix(cli): apply settings proxy to channel start

* test(cli): cover channel start settings proxy

---------

Co-authored-by: cyphercodes <cyphercodes@users.noreply.github.com>
This commit is contained in:
Rayan Salhab 2026-04-30 13:24:59 +03:00 committed by GitHub
parent 6efcf2b877
commit 0b7a569ac7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 290 additions and 12 deletions

View file

@ -0,0 +1,202 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockSetGlobalDispatcher = vi.hoisted(() => vi.fn());
const mockProxyAgent = vi.hoisted(() =>
vi.fn((url: string) => ({ proxyUrl: url })),
);
const mockLoadSettings = vi.hoisted(() => vi.fn());
const mockGetExtensionManager = vi.hoisted(() => vi.fn());
const mockReadServiceInfo = vi.hoisted(() => vi.fn());
const mockWriteServiceInfo = vi.hoisted(() => vi.fn());
const mockRemoveServiceInfo = vi.hoisted(() => vi.fn());
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
const mockFindCliEntryPath = vi.hoisted(() => vi.fn());
const mockParseChannelConfig = vi.hoisted(() => vi.fn());
const mockGetPlugin = vi.hoisted(() => vi.fn());
const mockRegisterPlugin = vi.hoisted(() => vi.fn());
const mockChannelConnect = vi.hoisted(() => vi.fn());
const mockChannelDisconnect = vi.hoisted(() => vi.fn());
const mockChannelSetBridge = vi.hoisted(() => vi.fn());
const mockChannelOnToolCall = vi.hoisted(() => vi.fn());
const mockCreateChannel = vi.hoisted(() => vi.fn());
const mockBridgeStart = vi.hoisted(() => vi.fn());
const mockBridgeStop = vi.hoisted(() => vi.fn());
const mockBridgeOn = vi.hoisted(() => vi.fn());
const mockAcpBridge = vi.hoisted(() =>
vi.fn(() => ({
on: mockBridgeOn,
start: mockBridgeStart,
stop: mockBridgeStop,
})),
);
const mockRouterClearAll = vi.hoisted(() => vi.fn());
const mockRouterGetTarget = vi.hoisted(() => vi.fn());
const mockRouterRestoreSessions = vi.hoisted(() => vi.fn());
const mockRouterSetBridge = vi.hoisted(() => vi.fn());
const mockRouterSetChannelScope = vi.hoisted(() => vi.fn());
const mockSessionRouter = vi.hoisted(() =>
vi.fn(() => ({
clearAll: mockRouterClearAll,
getTarget: mockRouterGetTarget,
restoreSessions: mockRouterRestoreSessions,
setBridge: mockRouterSetBridge,
setChannelScope: mockRouterSetChannelScope,
})),
);
vi.mock('undici', () => ({
ProxyAgent: mockProxyAgent,
setGlobalDispatcher: mockSetGlobalDispatcher,
}));
vi.mock('../../config/settings.js', () => ({
loadSettings: mockLoadSettings,
}));
vi.mock('../extensions/utils.js', () => ({
getExtensionManager: mockGetExtensionManager,
}));
vi.mock('./pidfile.js', () => ({
readServiceInfo: mockReadServiceInfo,
removeServiceInfo: mockRemoveServiceInfo,
writeServiceInfo: mockWriteServiceInfo,
}));
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStderrLine: mockWriteStderrLine,
writeStdoutLine: mockWriteStdoutLine,
}));
vi.mock('./config-utils.js', () => ({
findCliEntryPath: mockFindCliEntryPath,
parseChannelConfig: mockParseChannelConfig,
}));
vi.mock('./channel-registry.js', () => ({
getPlugin: mockGetPlugin,
registerPlugin: mockRegisterPlugin,
}));
vi.mock('@qwen-code/channel-base', () => ({
AcpBridge: mockAcpBridge,
SessionRouter: mockSessionRouter,
}));
import { resolveProxy, startCommand } from './start.js';
type StartCommandArgs = Parameters<NonNullable<typeof startCommand.handler>>[0];
const invokeStartHandler = async (
args: Partial<StartCommandArgs>,
): Promise<void> => {
const handler = startCommand.handler;
if (!handler) {
throw new Error('startCommand handler is missing');
}
await handler({ _: [], $0: 'qwen', ...args } as StartCommandArgs);
};
const mockParsedChannelConfig = {
cwd: '/tmp/qwen-channel-test',
model: 'qwen-test-model',
sessionScope: 'user',
type: 'telegram',
};
const mockChannel = {
connect: mockChannelConnect,
disconnect: mockChannelDisconnect,
onToolCall: mockChannelOnToolCall,
setBridge: mockChannelSetBridge,
};
beforeEach(() => {
vi.clearAllMocks();
mockBridgeStart.mockResolvedValue(undefined);
mockChannelConnect.mockRejectedValue(new Error('stop after channel setup'));
mockCreateChannel.mockReturnValue(mockChannel);
mockFindCliEntryPath.mockReturnValue('/tmp/qwen-cli-entry.js');
mockGetExtensionManager.mockResolvedValue({ getLoadedExtensions: () => [] });
mockGetPlugin.mockResolvedValue({ createChannel: mockCreateChannel });
mockLoadSettings.mockReturnValue({ merged: { channels: {} } });
mockParseChannelConfig.mockResolvedValue(mockParsedChannelConfig);
mockReadServiceInfo.mockReturnValue(null);
mockRouterGetTarget.mockReturnValue(undefined);
mockRouterRestoreSessions.mockResolvedValue({ failed: 0, restored: 0 });
delete process.env['HTTPS_PROXY'];
delete process.env['https_proxy'];
delete process.env['HTTP_PROXY'];
delete process.env['http_proxy'];
});
describe('resolveProxy', () => {
it('prefers the CLI proxy over settings and environment proxies', () => {
process.env['HTTPS_PROXY'] = 'http://env.example.com:8080';
const proxy = resolveProxy(
'http://cli.example.com:8080',
'http://settings.example.com:8080',
);
expect(proxy).toBe('http://cli.example.com:8080');
expect(mockProxyAgent).toHaveBeenCalledWith('http://cli.example.com:8080');
expect(mockSetGlobalDispatcher).toHaveBeenCalledWith({
proxyUrl: 'http://cli.example.com:8080',
});
});
it('prefers settings.proxy over environment proxies', () => {
process.env['HTTPS_PROXY'] = 'http://env.example.com:8080';
const proxy = resolveProxy(undefined, 'http://settings.example.com:8080');
expect(proxy).toBe('http://settings.example.com:8080');
expect(mockProxyAgent).toHaveBeenCalledWith(
'http://settings.example.com:8080',
);
});
it('falls back to proxy environment variables', () => {
process.env['HTTP_PROXY'] = 'http://env.example.com:8080';
const proxy = resolveProxy();
expect(proxy).toBe('http://env.example.com:8080');
expect(mockProxyAgent).toHaveBeenCalledWith('http://env.example.com:8080');
});
});
describe('startCommand.handler', () => {
it('loads settings.merged.proxy when no CLI proxy is provided', async () => {
const settingsProxy = 'http://settings.example.com:8080';
const envProxy = 'http://env.example.com:8080';
const channels = { telegram: { type: 'telegram' } };
mockLoadSettings.mockReturnValue({
merged: { channels, proxy: settingsProxy },
});
process.env['HTTPS_PROXY'] = envProxy;
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit: ${String(code)}`);
});
try {
await expect(invokeStartHandler({ name: 'telegram' })).rejects.toThrow(
'process.exit: 1',
);
} finally {
exitSpy.mockRestore();
}
expect(mockLoadSettings).toHaveBeenCalledWith(process.cwd());
expect(mockProxyAgent).toHaveBeenCalledWith(settingsProxy);
expect(mockProxyAgent).not.toHaveBeenCalledWith(envProxy);
expect(mockCreateChannel).toHaveBeenCalledWith(
'telegram',
mockParsedChannelConfig,
expect.any(Object),
expect.objectContaining({ proxy: settingsProxy }),
);
});
});

View file

@ -29,15 +29,19 @@ const RESTART_DELAY_MS = 3000;
*
* The normal CLI path applies proxy via loadCliConfig Config constructor
* setGlobalDispatcher, but `channel start` never calls loadCliConfig. This
* replicates the same resolution logic (--proxy flag HTTPS_PROXY
* HTTP_PROXY) and applies the global dispatcher for native fetch() calls.
* The resolved URL is also passed to channels via ChannelBaseOptions so
* adapters can configure their own HTTP clients (e.g. grammy uses node-fetch
* which needs a separate agent).
* replicates the same resolution logic (--proxy flag settings.proxy
* HTTPS_PROXY HTTP_PROXY) and applies the global dispatcher for native
* fetch() calls. The resolved URL is also passed to channels via
* ChannelBaseOptions so adapters can configure their own HTTP clients (e.g.
* grammy uses node-fetch which needs a separate agent).
*/
function resolveProxy(cliProxy?: string): string | undefined {
export function resolveProxy(
cliProxy?: string,
settingsProxy?: string,
): string | undefined {
const proxyUrl = normalizeProxyUrl(
cliProxy ||
settingsProxy ||
process.env['HTTPS_PROXY'] ||
process.env['https_proxy'] ||
process.env['HTTP_PROXY'] ||
@ -471,8 +475,10 @@ export const startCommand: CommandModule<object, { name?: string }> = {
describe: 'Channel name (omit to start all configured channels)',
}),
handler: async (argv) => {
const settings = loadSettings(process.cwd());
const proxy = resolveProxy(
(argv as Record<string, unknown>)['proxy'] as string | undefined,
settings.merged.proxy as string | undefined,
);
if (argv.name) {
await startSingle(argv.name, proxy);

View file

@ -791,6 +791,31 @@ describe('loadCliConfig', () => {
expect(config.getProxy()).toBe('http://localhost:7890');
});
it('should set proxy from settings when present', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { proxy: 'http://localhost:7890' };
const config = await loadCliConfig(settings, argv);
expect(config.getProxy()).toBe('http://localhost:7890');
});
it('should normalize proxy from settings when scheme is omitted', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { proxy: 'localhost:7890' };
const config = await loadCliConfig(settings, argv);
expect(config.getProxy()).toBe('http://localhost:7890');
});
it('should prioritize settings proxy over environment variable', async () => {
vi.stubEnv('HTTPS_PROXY', 'http://localhost:7891');
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { proxy: 'http://localhost:7890' };
const config = await loadCliConfig(settings, argv);
expect(config.getProxy()).toBe('http://localhost:7890');
});
it('should prioritize CLI flag over environment variable for proxy (CLI http://localhost:7890, environment variable http://localhost:7891)', async () => {
vi.stubEnv('http_proxy', 'http://localhost:7891');
process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890'];
@ -799,6 +824,14 @@ describe('loadCliConfig', () => {
const config = await loadCliConfig(settings, argv);
expect(config.getProxy()).toBe('http://localhost:7890');
});
it('should prioritize CLI flag over settings proxy', async () => {
process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890'];
const argv = await parseArguments();
const settings: Settings = { proxy: 'http://localhost:7891' };
const config = await loadCliConfig(settings, argv);
expect(config.getProxy()).toBe('http://localhost:7890');
});
});
});

View file

@ -1220,11 +1220,13 @@ export async function loadCliConfig(
: settings.tools?.discoveryCommand,
toolCallCommand: bareMode ? undefined : settings.tools?.callCommand,
mcpServerCommand: bareMode ? undefined : settings.mcp?.serverCommand,
mcpServers: bareMode ? {} : (() => {
const base = settings.mcpServers || {};
const cliMcpServers = parseMcpConfig(argv.mcpConfig);
return cliMcpServers ? { ...base, ...cliMcpServers } : base;
})(),
mcpServers: bareMode
? {}
: (() => {
const base = settings.mcpServers || {};
const cliMcpServers = parseMcpConfig(argv.mcpConfig);
return cliMcpServers ? { ...base, ...cliMcpServers } : base;
})(),
allowedMcpServers: allowedMcpServers
? Array.from(allowedMcpServers)
: undefined,
@ -1244,6 +1246,7 @@ export async function loadCliConfig(
argv.checkpointing || settings.general?.checkpointing?.enabled,
proxy:
argv.proxy ||
settings.proxy ||
process.env['HTTPS_PROXY'] ||
process.env['https_proxy'] ||
process.env['HTTP_PROXY'] ||

View file

@ -22,6 +22,7 @@ describe('SettingsSchema', () => {
'ide',
'privacy',
'telemetry',
'proxy',
'model',
'context',
'tools',
@ -119,6 +120,15 @@ describe('SettingsSchema', () => {
);
});
it('should have top-level proxy setting in schema', () => {
expect(getSettingsSchema().proxy).toBeDefined();
expect(getSettingsSchema().proxy.type).toBe('string');
expect(getSettingsSchema().proxy.category).toBe('Advanced');
expect(getSettingsSchema().proxy.requiresRestart).toBe(true);
expect(getSettingsSchema().proxy.default).toBe(undefined);
expect(getSettingsSchema().proxy.showInDialog).toBe(false);
});
it('should have unique categories', () => {
const categories = new Set();
@ -227,12 +237,14 @@ describe('SettingsSchema', () => {
includeDirectories: ['/path/to/dir'],
loadFromIncludeDirectories: true,
},
proxy: 'http://localhost:7890',
};
// TypeScript should not complain about these properties
expect(settings.ui?.theme).toBe('dark');
expect(settings.context?.includeDirectories).toEqual(['/path/to/dir']);
expect(settings.context?.loadFromIncludeDirectories).toBe(true);
expect(settings.proxy).toBe('http://localhost:7890');
});
it('should have includeDirectories setting in schema', () => {

View file

@ -287,6 +287,17 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
},
proxy: {
type: 'string',
label: 'Proxy',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description:
'Proxy URL for CLI HTTP requests. Takes precedence over proxy environment variables when --proxy is not provided.',
showInDialog: false,
},
general: {
type: 'object',
label: 'General',

View file

@ -33,6 +33,10 @@
"type": "object",
"additionalProperties": true
},
"proxy": {
"description": "Proxy URL for CLI HTTP requests. Takes precedence over proxy environment variables when --proxy is not provided.",
"type": "string"
},
"general": {
"description": "General application settings.",
"type": "object",