fix(channels): apply proxy settings to channel start command (#3136)

`qwen channel start` never calls `loadCliConfig`, so the proxy
configured via `--proxy` or `HTTPS_PROXY`/`HTTP_PROXY` env vars
was not applied. This caused Telegram's `getMe` (and all other
channel HTTP traffic) to bypass the proxy entirely.

The fix has two parts:

1. Resolve proxy in `start.ts` bootstrap and call
   `setGlobalDispatcher(new ProxyAgent(...))` for native fetch()
   calls (file downloads, other channels). This mirrors the same
   pattern used by Config constructor in the main CLI path.

2. Thread the proxy URL through `ChannelBaseOptions` so adapters
   can configure their own HTTP clients. TelegramAdapter passes
   an `HttpsProxyAgent` to grammy's `baseFetchConfig.agent` since
   grammy uses node-fetch which ignores undici's global dispatcher.

Fixes #3122
This commit is contained in:
tanzhenxin 2026-04-11 16:44:14 +08:00 committed by GitHub
parent e216ab35fc
commit 7219469285
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 52 additions and 8 deletions

1
package-lock.json generated
View file

@ -16907,6 +16907,7 @@
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"grammy": "^1.41.1",
"https-proxy-agent": "^7.0.6",
"telegram-markdown-formatter": "^0.1.2"
},
"devDependencies": {

View file

@ -8,6 +8,7 @@ import type { AcpBridge, ToolCallEvent } from './AcpBridge.js';
export interface ChannelBaseOptions {
router?: SessionRouter;
proxy?: string;
}
/** Handler for a slash command. Return true if handled, false to forward to agent. */
@ -20,6 +21,8 @@ export abstract class ChannelBase {
protected gate: SenderGate;
protected router: SessionRouter;
protected name: string;
/** Resolved proxy URL, available to subclasses for adapter-specific clients. */
protected proxy?: string;
private instructedSessions: Set<string> = new Set();
private commands: Map<string, CommandHandler> = new Map();
/** Per-session promise chain to serialize prompt + send (followup mode). */
@ -45,6 +48,7 @@ export abstract class ChannelBase {
this.name = name;
this.config = config;
this.bridge = bridge;
this.proxy = options?.proxy;
this.groupGate = new GroupGate(config.groupPolicy, config.groups);

View file

@ -20,6 +20,7 @@
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"grammy": "^1.41.1",
"https-proxy-agent": "^7.0.6",
"telegram-markdown-formatter": "^0.1.2"
},
"devDependencies": {

View file

@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
import { basename, join } from 'node:path';
import { tmpdir } from 'node:os';
import { Bot } from 'grammy';
import { HttpsProxyAgent } from 'https-proxy-agent';
import {
telegramFormat,
splitHtmlForTelegram,
@ -27,7 +28,14 @@ export class TelegramChannel extends ChannelBase {
options?: ChannelBaseOptions,
) {
super(name, config, bridge, options);
this.bot = new Bot(config.token);
const botConfig = this.proxy
? {
client: {
baseFetchConfig: { agent: new HttpsProxyAgent(this.proxy) },
},
}
: undefined;
this.bot = new Bot(config.token, botConfig);
}
private getFileUrl(filePath: string): string {

View file

@ -1,6 +1,8 @@
import * as path from 'node:path';
import * as os from 'node:os';
import type { CommandModule } from 'yargs';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import { normalizeProxyUrl } from '@qwen-code/qwen-code-core';
import { loadSettings } from '../../config/settings.js';
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
import { AcpBridge, SessionRouter } from '@qwen-code/channel-base';
@ -22,6 +24,31 @@ const MAX_CRASH_RESTARTS = 3;
const CRASH_WINDOW_MS = 5 * 60 * 1000; // 5-minute window for counting crashes
const RESTART_DELAY_MS = 3000;
/**
* Resolve and apply proxy settings for the channel service process.
*
* 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).
*/
function resolveProxy(cliProxy?: string): string | undefined {
const proxyUrl = normalizeProxyUrl(
cliProxy ||
process.env['HTTPS_PROXY'] ||
process.env['https_proxy'] ||
process.env['HTTP_PROXY'] ||
process.env['http_proxy'],
);
if (proxyUrl) {
setGlobalDispatcher(new ProxyAgent(proxyUrl));
}
return proxyUrl;
}
function sessionsPath(): string {
return path.join(os.homedir(), '.qwen', 'channels', 'sessions.json');
}
@ -100,7 +127,7 @@ function createChannel(
name: string,
config: ReturnType<typeof parseChannelConfig>,
bridge: AcpBridge,
options?: { router?: SessionRouter },
options?: { router?: SessionRouter; proxy?: string },
): ChannelBase {
const channelPlugin = getPlugin(config.type);
if (!channelPlugin) {
@ -138,7 +165,7 @@ function checkDuplicateInstance(): void {
}
/** Start a single channel with its own bridge + crash recovery. */
async function startSingle(name: string): Promise<void> {
async function startSingle(name: string, proxy?: string): Promise<void> {
checkDuplicateInstance();
const channelsConfig = loadChannelsConfig();
@ -180,7 +207,7 @@ async function startSingle(name: string): Promise<void> {
);
const channels: Map<string, ChannelBase> = new Map();
const channel = createChannel(name, config, bridge, { router });
const channel = createChannel(name, config, bridge, { router, proxy });
channels.set(name, channel);
registerToolCallDispatch(bridge, router, channels);
@ -256,7 +283,7 @@ async function startSingle(name: string): Promise<void> {
}
/** Start all configured channels with a shared bridge + crash recovery. */
async function startAll(): Promise<void> {
async function startAll(proxy?: string): Promise<void> {
checkDuplicateInstance();
const channelsConfig = loadChannelsConfig();
@ -323,7 +350,7 @@ async function startAll(): Promise<void> {
);
for (const { name, config } of parsed) {
channels.set(name, createChannel(name, config, bridge, { router }));
channels.set(name, createChannel(name, config, bridge, { router, proxy }));
}
registerToolCallDispatch(bridge, router, channels);
@ -433,10 +460,13 @@ export const startCommand: CommandModule<object, { name?: string }> = {
describe: 'Channel name (omit to start all configured channels)',
}),
handler: async (argv) => {
const proxy = resolveProxy(
(argv as Record<string, unknown>)['proxy'] as string | undefined,
);
if (argv.name) {
await startSingle(argv.name);
await startSingle(argv.name, proxy);
} else {
await startAll();
await startAll(proxy);
}
},
};