Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
95 changes: 95 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,101 @@ 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);
});

it('--no-insecure forces verification on even with QWEN_TLS_INSECURE=1', async () => {
vi.stubEnv('QWEN_TLS_INSECURE', '1');
process.argv = ['node', 'script.js', '--no-insecure'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv);
expect(config.getInsecure()).toBe(false);
});

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

describe('loadCliConfig telemetry', () => {
Expand Down
44 changes: 44 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,35 @@ export interface CliArgs {
inputFile?: string | undefined;
}

/**
* Resolve the effective TLS-insecure setting from (in order):
* 1. ``--insecure`` / ``--no-insecure`` CLI flag (any explicit value wins),
* 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).
*
* ``cliFlag`` is tri-state: ``undefined`` means the user passed neither
* ``--insecure`` nor ``--no-insecure``, in which case we fall through to
* the env-var checks. An explicit ``false`` (``--no-insecure``) forces
* verification on regardless of env, since the CLI is documented to win.
*/
function resolveInsecureFlag(cliFlag: boolean | undefined): boolean {
if (cliFlag !== undefined) {
return cliFlag;
}
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.

function normalizeOutputFormat(
format: string | OutputFormat | undefined,
): OutputFormat | undefined {
Expand Down Expand Up @@ -272,6 +302,19 @@ 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. ' +
'Pass --no-insecure to force verification on, overriding env vars. ' +
'Equivalent env vars (lower precedence): ' +
'QWEN_TLS_INSECURE=1 or NODE_TLS_REJECT_UNAUTHORIZED=0.',
// No default so yargs reports ``undefined`` when neither --insecure
// nor --no-insecure is passed. That preserves three distinct states
// (true / false / undefined), which lets the resolver below treat
// an explicit ``--no-insecure`` as the highest-precedence override.
})
.option('chat-recording', {
type: 'boolean',
description:
Expand Down Expand Up @@ -1158,6 +1201,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