Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/users/support/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ This guide provides solutions to common issues and debugging tips, including top
- If you are behind a proxy, set it via `qwen --proxy <url>` (or the `proxy` setting in `settings.json`).
- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` as described above.

- **Self-signed model endpoint: `[API Error: Connection error. (cause: fetch failed)]`**
- **Cause:** A self-signed or otherwise untrusted TLS certificate on the model server (common in dev / lab / homelab setups). Setting `NODE_TLS_REJECT_UNAUTHORIZED=0` alone does **not** fix this for `fetch`-based code paths because Node's bundled HTTP client (`undici`) ignores that env var by design.
- **Solution:** Pass `--insecure` on the command line, set `QWEN_TLS_INSECURE=1`, or set `NODE_TLS_REJECT_UNAUTHORIZED=0` (Qwen Code now also honors the latter for parity with Claude Code / Node's legacy http stack). All three configure undici with `rejectUnauthorized: false` for outbound model API and MCP traffic. Only use this on networks you trust — it disables certificate verification for the entire process.

- **Issue: Unable to display UI after authentication failure**
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/auth/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export async function handleQwenAuth(
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
insecure: undefined,
includeDirectories: undefined,
screenReader: undefined,
inputFormat: undefined,
Expand Down
79 changes: 79 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,85 @@ describe('loadCliConfig', () => {
expect(config.getProxy()).toBe('http://localhost:7890');
});
});

describe('Insecure / TLS-skip configuration (#3535)', () => {
const insecureEnvVars = [
'QWEN_TLS_INSECURE',
'NODE_TLS_REJECT_UNAUTHORIZED',
];
const original: { [key: string]: string | undefined } = {};

beforeEach(() => {
for (const key of insecureEnvVars) {
original[key] = process.env[key];
delete process.env[key];
}
});

afterEach(() => {
for (const key of insecureEnvVars) {
if (original[key] !== undefined) {
process.env[key] = original[key];
} else {
delete process.env[key];
}
}
});

it('defaults to false', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv);
expect(config.getInsecure()).toBe(false);
});

it('honors --insecure flag', async () => {
process.argv = ['node', 'script.js', '--insecure'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv);
expect(config.getInsecure()).toBe(true);
});

it('honors QWEN_TLS_INSECURE=1', async () => {
vi.stubEnv('QWEN_TLS_INSECURE', '1');
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv);
expect(config.getInsecure()).toBe(true);
});

it('honors NODE_TLS_REJECT_UNAUTHORIZED=0 (matches Claude Code/Node convention)', async () => {
vi.stubEnv('NODE_TLS_REJECT_UNAUTHORIZED', '0');
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv);
expect(config.getInsecure()).toBe(true);
});

it('ignores QWEN_TLS_INSECURE=0 / arbitrary values', async () => {
vi.stubEnv('QWEN_TLS_INSECURE', '0');
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv);
expect(config.getInsecure()).toBe(false);
});

it('ignores NODE_TLS_REJECT_UNAUTHORIZED=1', async () => {
vi.stubEnv('NODE_TLS_REJECT_UNAUTHORIZED', '1');
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv);
expect(config.getInsecure()).toBe(false);
});

it('--insecure overrides explicit env=0', async () => {
vi.stubEnv('QWEN_TLS_INSECURE', '0');
process.argv = ['node', 'script.js', '--insecure'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv);
expect(config.getInsecure()).toBe(true);
});
});
});

describe('loadCliConfig telemetry', () => {
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export interface CliArgs {
openaiBaseUrl: string | undefined;
openaiLoggingDir: string | undefined;
proxy: string | undefined;
insecure: boolean | undefined;
includeDirectories: string[] | undefined;
screenReader: boolean | undefined;
inputFormat?: string | undefined;
Expand Down Expand Up @@ -163,6 +164,30 @@ export interface CliArgs {
inputFile?: string | undefined;
}

/**
* Resolve the effective TLS-insecure setting from (in order):
* 1. ``--insecure`` CLI flag,
* 2. ``QWEN_TLS_INSECURE`` env var (truthy: ``1``, ``true``, ``yes``,
* case-insensitive),
* 3. ``NODE_TLS_REJECT_UNAUTHORIZED=0`` for parity with Node's legacy
* ``http`` stack and Claude Code -- undici (used by ``fetch``)
* otherwise ignores this env var, leaving users surprised that the
* flag they set "for everything" silently does nothing here (#3535).
*/
function resolveInsecureFlag(cliFlag: boolean | undefined): boolean {
if (cliFlag) {
return true;
}
const qwenEnv = process.env['QWEN_TLS_INSECURE']?.trim().toLowerCase();
if (qwenEnv === '1' || qwenEnv === 'true' || qwenEnv === 'yes') {
return true;
}
if (process.env['NODE_TLS_REJECT_UNAUTHORIZED']?.trim() === '0') {
return true;
}
return false;
}
Comment thread
JahanzaibTayyab marked this conversation as resolved.
Resolved

function normalizeOutputFormat(
format: string | OutputFormat | undefined,
): OutputFormat | undefined {
Expand Down Expand Up @@ -272,6 +297,14 @@ export async function parseArguments(): Promise<CliArgs> {
'proxy',
'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.',
)
.option('insecure', {
type: 'boolean',
description:
'Skip TLS certificate verification for outbound HTTPS requests. ' +
'Use for self-signed dev/lab endpoints. Equivalent env vars: ' +
'QWEN_TLS_INSECURE=1 or NODE_TLS_REJECT_UNAUTHORIZED=0.',
default: false,
})
.option('chat-recording', {
type: 'boolean',
description:
Expand Down Expand Up @@ -1158,6 +1191,7 @@ export async function loadCliConfig(
process.env['https_proxy'] ||
process.env['HTTP_PROXY'] ||
process.env['http_proxy'],
insecure: resolveInsecureFlag(argv.insecure),
cwd,
fileDiscoveryService: fileService,
bugCommand: settings.advanced?.bugCommand,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,7 @@ describe('gemini.tsx main function kitty protocol', () => {
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
insecure: undefined,
includeDirectories: undefined,
screenReader: undefined,
inputFormat: undefined,
Expand Down
41 changes: 39 additions & 2 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as path from 'node:path';
import process from 'node:process';

// External dependencies
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';

// Types
import type {
Expand Down Expand Up @@ -372,6 +372,13 @@ export interface ConfigParameters {
};
checkpointing?: boolean;
proxy?: string;
/**
* Disable TLS certificate verification for outbound HTTPS requests
* (model APIs, MCP servers reached over HTTPS, and similar). Intended for
* self-signed dev/lab endpoints. See ``getInsecure`` for the resolution
* order applied at the CLI layer (#3535).
*/
insecure?: boolean;
cwd: string;
fileDiscoveryService?: FileDiscoveryService;
includeDirectories?: string[];
Expand Down Expand Up @@ -613,6 +620,7 @@ export class Config {
private chatRecordingService: ChatRecordingService | undefined = undefined;
private readonly checkpointing: boolean;
private readonly proxy: string | undefined;
private readonly insecure: boolean;
private readonly cwd: string;
private readonly explicitIncludeDirectories: string[];
private readonly bugCommand: BugCommandSettings | undefined;
Expand Down Expand Up @@ -759,6 +767,7 @@ export class Config {
};
this.checkpointing = params.checkpointing ?? false;
this.proxy = params.proxy;
this.insecure = params.insecure ?? false;
this.cwd = params.cwd ?? process.cwd();
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand;
Expand Down Expand Up @@ -850,8 +859,22 @@ export class Config {
}

const proxyUrl = this.getProxy();
// The global dispatcher backs every ``fetch`` call that does not provide
// its own dispatcher (MCP transports, streaming endpoints, telemetry).
// Apply both proxy and insecure-TLS here so they take effect uniformly,
// not only for the SDK clients we control directly (#3535).
const connect = this.insecure ? { rejectUnauthorized: false } : undefined;
if (proxyUrl) {
setGlobalDispatcher(new ProxyAgent(proxyUrl));
setGlobalDispatcher(
new ProxyAgent({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Correctness] The global ProxyAgent is constructed without headersTimeout: 0 and bodyTimeout: 0, while the per-SDK ProxyAgent instances created in buildFetchOptionsWithDispatcher (in runtimeFetchOptions.ts) explicitly set both to 0. This is inconsistent.

The global dispatcher backs MCP transports, streaming endpoints, and telemetry -- all of which can involve long-lived or idle connections. Undici's default timeouts (300 s for headers, 300 s for body) will apply here, which can silently kill MCP long-polls or streaming reads that exceed the default. Adding headersTimeout: 0, bodyTimeout: 0 to match the per-SDK ProxyAgent configuration would prevent this.

uri: proxyUrl,
...(connect ? { connect } : {}),
}),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The global proxy dispatcher has the same issue: ProxyAgent needs upstream TLS options in requestTls, not connect. As written, MCP/streaming/telemetry fetches that rely on the global dispatcher will still fail against self-signed HTTPS targets when a proxy is configured, despite insecure being enabled.

Suggested change
}),
...(connect ? { requestTls: connect } : {}),

— gpt-5.5 via Qwen Code /review

);
} else if (this.insecure) {
setGlobalDispatcher(
new Agent({ connect: { rejectUnauthorized: false } }),
);
}
this.geminiClient = new GeminiClient(this);
this.chatRecordingService = this.chatRecordingEnabled
Expand Down Expand Up @@ -2035,6 +2058,20 @@ export class Config {
return normalizeProxyUrl(this.proxy);
}

/**
* Whether outbound HTTPS connections should skip TLS certificate
* verification. Useful for self-signed dev/lab model endpoints (#3535).
*
* Resolution order is applied by the CLI layer:
* 1. ``--insecure`` flag
* 2. ``QWEN_TLS_INSECURE`` env var (truthy: ``1``, ``true``, ``yes``)
* 3. ``NODE_TLS_REJECT_UNAUTHORIZED=0`` for parity with Node's
* legacy http stack and Claude Code.
*/
getInsecure(): boolean {
return this.insecure;
}

getWorkingDir(): string {
return this.cwd;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe('AnthropicContentGenerator', () => {
mockConfig = {
getCliVersion: vi.fn().mockReturnValue('1.2.3'),
getProxy: vi.fn().mockReturnValue(undefined),
getInsecure: vi.fn().mockReturnValue(false),
} as unknown as Config;
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ export class AnthropicContentGenerator implements ContentGenerator {
const baseURL = contentGeneratorConfig.baseUrl;
// Configure runtime options to ensure user-configured timeout works as expected
// bodyTimeout is always disabled (0) to let Anthropic SDK timeout control the request
const runtimeOptions = buildRuntimeFetchOptions(
'anthropic',
this.cliConfig.getProxy(),
);
const runtimeOptions = buildRuntimeFetchOptions('anthropic', {
proxyUrl: this.cliConfig.getProxy(),
insecure: this.cliConfig.getInsecure(),
});

this.client = new Anthropic({
apiKey: contentGeneratorConfig.apiKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe('DashScopeOpenAICompatibleProvider', () => {
enableCacheControl: true,
}),
getProxy: vi.fn().mockReturnValue(undefined),
getInsecure: vi.fn().mockReturnValue(false),
} as unknown as Config;

provider = new DashScopeOpenAICompatibleProvider(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
const defaultHeaders = this.buildHeaders();
// Configure fetch options to ensure user-configured timeout works as expected
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
const runtimeOptions = buildRuntimeFetchOptions(
'openai',
this.cliConfig.getProxy(),
);
const runtimeOptions = buildRuntimeFetchOptions('openai', {
proxyUrl: this.cliConfig.getProxy(),
insecure: this.cliConfig.getInsecure(),
});
return new OpenAI({
apiKey,
baseURL: baseUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ describe('DefaultOpenAICompatibleProvider', () => {
mockCliConfig = {
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getProxy: vi.fn().mockReturnValue(undefined),
getInsecure: vi.fn().mockReturnValue(false),
} as unknown as Config;

provider = new DefaultOpenAICompatibleProvider(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ export class DefaultOpenAICompatibleProvider
const defaultHeaders = this.buildHeaders();
// Configure fetch options to ensure user-configured timeout works as expected
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
const runtimeOptions = buildRuntimeFetchOptions(
'openai',
this.cliConfig.getProxy(),
);
const runtimeOptions = buildRuntimeFetchOptions('openai', {
proxyUrl: this.cliConfig.getProxy(),
insecure: this.cliConfig.getInsecure(),
});
return new OpenAI({
apiKey,
baseURL: baseUrl,
Expand Down
Loading
Loading