mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
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
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:
parent
6efcf2b877
commit
0b7a569ac7
8 changed files with 290 additions and 12 deletions
202
packages/cli/src/commands/channel/start.test.ts
Normal file
202
packages/cli/src/commands/channel/start.test.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'] ||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue