fix(auth): address PR #4287 review (critical + suggestion)

vscode AuthMessageHandler (Critical):
- Add the missing protocol-selection step so custom-provider users can
  pick Anthropic/Gemini instead of being silently locked to OpenAI.
- Validate free-form base URL with the same /^https?:\/\// check the
  CLI uses; reject file:/javascript: schemes.

vscode AuthMessageHandler (Suggestion):
- Stop filtering separator entries from the provider QuickPick so
  groups (Alibaba Cloud / Third Party / Custom) actually show as
  headers instead of a flat list.
- Treat a null authInteractiveHandler as an error: surface an
  authError + cancellation notification instead of silently dropping
  the user's input.
- Call notifyAuthCancelled when validateApiKey rejects so the
  webview state resets and the user can retry.

core/providers/presets/openrouter.ts (Critical):
- Replace the substring includes() in ownsModel with a URL-hostname
  match so paths like https://api.example.com/openrouter.ai/v1 stop
  being misidentified as OpenRouter models (and getting removed on
  re-install).

vscode/services/settingsWriter.ts (Critical):
- stripTrailingCommas() so JSONC files with trailing commas (VSCode's
  default style) parse instead of silently returning {} and then
  overwriting the entire settings file.
- readSettings() distinguishes ENOENT (return {}) from parse errors
  (log + rethrow) so a malformed file never gets clobbered.
- writeSettings() writes through a temp file + fs.renameSync atomic
  rename, eliminating the half-written file window on EACCES /
  disk-full / crash.
- setValue() refuses to overwrite a scalar at an intermediate path
  segment (would have silently destroyed e.g. {"env": "legacy-string"}).

core/providers/install.ts (Suggestion):
- Move settings.backup?.() inside the try block so a backup failure
  still triggers the env-rollback path in catch.

cli/config/loadedSettingsAdapter.ts (Suggestion):
- Add the same UNSAFE_KEY_PARTS guard the vscode adapter has, so
  __proto__/constructor/prototype segments are rejected before
  reaching the underlying setNestedPropertySafe walker. Defense in
  depth: not exploitable today but the utility has no built-in guard.

vscode/webview/providers/WebViewProvider.ts (Suggestion):
- Hoist buildInstallPlan / applyProviderInstallPlanToFile to static
  imports (both modules already top-level imported); drops two
  per-call await import() round-trips.

cli/utils/doctorChecks.ts (Suggestion):
- Whitespace nit before the comma in the qwen-code-core import.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
pomelo-nwu 2026-05-19 13:58:25 +08:00
parent e5191b885a
commit f4e01a409e
7 changed files with 145 additions and 37 deletions

View file

@ -24,6 +24,14 @@ import {
getNestedProperty,
} from '../utils/settingsUtils.js';
/**
* Reserved object keys that would let a caller climb into the prototype chain
* when walking a dotted key path. Defense in depth current install-plan keys
* are derived from static provider config, but the underlying setNestedProperty
* helper does not guard against them.
*/
const UNSAFE_KEY_PARTS = new Set(['__proto__', 'constructor', 'prototype']);
export function createLoadedSettingsAdapter(
settings: LoadedSettings,
scope?: SettingScope,
@ -40,6 +48,11 @@ export function createLoadedSettingsAdapter(
},
setValue(key: string, value: unknown): void {
if (key.split('.').some((p) => UNSAFE_KEY_PARTS.has(p))) {
throw new Error(
`Refusing to write settings key with reserved segment: ${key}`,
);
}
settings.setValue(persistScope, key, value);
},

View file

@ -8,7 +8,8 @@ import process from 'node:process';
import os from 'node:os';
import { getNpmVersion, getGitVersion } from './systemInfo.js';
import { validateAuthMethod } from '../config/auth.js';
import { findProviderByCredentials ,
import {
findProviderByCredentials,
canUseRipgrep,
getMCPServerStatus,
MCPServerStatus,

View file

@ -87,10 +87,13 @@ export async function applyProviderInstallPlan(
doRefreshAuth = true,
} = options;
settings.backup?.();
const previousEnvValues = new Map<string, string | undefined>();
try {
// backup() inside the try so a failure here (e.g. structuredClone on a
// non-serializable adapter) still triggers the catch + env rollback.
settings.backup?.();
// Set environment variables (snapshot previous values for rollback)
for (const [key, value] of Object.entries(plan.env ?? {})) {
previousEnvValues.set(key, process.env[key]);

View file

@ -24,7 +24,14 @@ export const openRouterProvider: ProviderConfig = {
],
modelsEditable: true,
modelNamePrefix: 'OpenRouter',
ownsModel: (model) => (model.baseUrl ?? '').includes('openrouter.ai'),
ownsModel: (model) => {
try {
const host = new URL(model.baseUrl ?? '').hostname;
return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');
} catch {
return false;
}
},
documentationUrl: 'https://openrouter.ai/docs',
uiGroup: 'third-party',
};

View file

@ -101,21 +101,49 @@ function stripJsonComments(text: string): string {
}
/**
* Read ~/.qwen/settings.json. Returns {} if missing or invalid.
* Handles JSONC (JSON with comments) which is common in hand-edited
* settings files.
* Strip trailing commas inside arrays/objects (`,]` / `,}`) VSCode's own
* settings.json allows them and they crash strict JSON.parse. Operates on the
* already-comment-stripped text, so a literal `,]` inside a string is safe
* (string-literal copy in stripJsonComments preserves contents).
*/
function stripTrailingCommas(text: string): string {
return text.replace(/,(\s*[}\]])/g, '$1');
}
/**
* Read ~/.qwen/settings.json. Returns {} if the file is missing.
* Parse errors are logged and re-thrown so callers don't silently destroy a
* malformed file by treating it as empty and then overwriting it.
* Handles JSONC (JSON with comments + trailing commas) for hand-edited files.
*/
function readSettings(): Record<string, unknown> {
const settingsPath = Storage.getGlobalSettingsPath();
let content: string;
try {
const content = fs.readFileSync(Storage.getGlobalSettingsPath(), 'utf-8');
return JSON.parse(stripJsonComments(content)) as Record<string, unknown>;
} catch {
return {};
content = fs.readFileSync(settingsPath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
throw err;
}
try {
return JSON.parse(
stripTrailingCommas(stripJsonComments(content)),
) as Record<string, unknown>;
} catch (err) {
console.error(
`[settingsWriter] Failed to parse ${settingsPath}; refusing to overwrite a malformed file.`,
err,
);
throw err;
}
}
/**
* Write ~/.qwen/settings.json (creates dir if needed).
* Write ~/.qwen/settings.json atomically (temp file + rename), creating the
* directory if needed. Atomic rename prevents leaving a half-written file
* behind on EACCES / disk-full / process crash mid-write.
*/
function writeSettings(settings: Record<string, unknown>): void {
const settingsPath = Storage.getGlobalSettingsPath();
@ -123,7 +151,9 @@ function writeSettings(settings: Record<string, unknown>): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
const tmpPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2), 'utf-8');
fs.renameSync(tmpPath, settingsPath);
}
/**
@ -326,12 +356,18 @@ function createFileSettingsAdapter(): ProviderSettingsAdapter {
let current = data;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
if (
!Object.prototype.hasOwnProperty.call(current, part) ||
!current[part] ||
typeof current[part] !== 'object'
) {
const existing = Object.prototype.hasOwnProperty.call(current, part)
? current[part]
: undefined;
if (existing == null) {
current[part] = {};
} else if (typeof existing !== 'object') {
// Refuse to silently overwrite a scalar at an intermediate segment —
// would destroy user data (e.g. {"env": "legacy-string"} losing the
// string when env.NEW_KEY is written).
throw new Error(
`Cannot write settings key "${key}": segment "${part}" is a ${typeof existing}, not an object`,
);
}
current = current[part] as Record<string, unknown>;
}

View file

@ -14,6 +14,7 @@ import {
shouldShowStep,
resolveBaseUrl,
getDefaultModelIds,
type AuthType,
type ProviderConfig,
type ProviderSetupInputs,
type BaseUrlOption,
@ -101,9 +102,17 @@ export class AuthMessageHandler extends BaseMessageHandler {
/**
* Helper: show a QuickPick and return the selected item's `value`.
* Returns undefined if the user cancels.
*
* Items with `kind: Separator` are rendered by VSCode as non-selectable
* group headers; they should be left in `items` to preserve grouping.
*/
private async pick<T extends string>(
items: Array<{ label: string; description?: string; value: T }>,
items: Array<{
label: string;
description?: string;
value: T;
kind?: vscode.QuickPickItemKind;
}>,
title: string,
placeHolder: string,
): Promise<T | undefined> {
@ -111,7 +120,7 @@ export class AuthMessageHandler extends BaseMessageHandler {
title,
placeHolder,
});
if (!choice) {
if (!choice || choice.kind === vscode.QuickPickItemKind.Separator) {
this.notifyAuthCancelled();
return undefined;
}
@ -194,14 +203,10 @@ export class AuthMessageHandler extends BaseMessageHandler {
addGroup('Custom', customProviders);
}
// Pass items including separators; VSCode QuickPick renders separator
// entries as non-selectable group headers (mirrors the CLI grouping).
const selectedId = await this.pick(
items.filter(
(i) => i.kind !== vscode.QuickPickItemKind.Separator,
) as Array<{
label: string;
description?: string;
value: string;
}>,
items,
'Qwen Code: Select Provider',
'Choose how to connect',
);
@ -233,6 +238,25 @@ export class AuthMessageHandler extends BaseMessageHandler {
const flowTitle =
provider.uiLabels?.flowTitle ?? `Qwen Code: ${provider.label}`;
// Step 0: Protocol (only for providers offering multiple, e.g. custom)
let protocol: AuthType | undefined;
if (
shouldShowStep(provider, 'protocol') &&
provider.protocolOptions &&
provider.protocolOptions.length > 1
) {
const selected = await this.pick(
provider.protocolOptions.map((p) => ({
label: String(p),
value: String(p),
})),
`${flowTitle}: Protocol`,
'Select API protocol',
);
if (!selected) return;
protocol = selected as AuthType;
}
// Step 1: Base URL (if needed)
let baseUrl: string;
if (shouldShowStep(provider, 'baseUrl')) {
@ -260,6 +284,16 @@ export class AuthMessageHandler extends BaseMessageHandler {
});
if (urlInput === undefined) return;
baseUrl = urlInput;
if (!/^https?:\/\//i.test(baseUrl)) {
this.sendToWebView({
type: 'authError',
data: {
message: 'Base URL must start with http:// or https://.',
},
});
this.notifyAuthCancelled();
return;
}
}
} else {
baseUrl = resolveBaseUrl(provider);
@ -283,6 +317,7 @@ export class AuthMessageHandler extends BaseMessageHandler {
type: 'authError',
data: { message: validationError },
});
this.notifyAuthCancelled();
return;
}
}
@ -334,13 +369,26 @@ export class AuthMessageHandler extends BaseMessageHandler {
}
// Submit
if (this.authInteractiveHandler) {
await this.authInteractiveHandler(provider, {
baseUrl,
apiKey,
modelIds,
advancedConfig,
if (!this.authInteractiveHandler) {
console.error(
'[AuthMessageHandler] authInteractiveHandler not set; cannot apply provider config.',
);
this.sendToWebView({
type: 'authError',
data: {
message:
'Auth handler not initialized. Please reopen the panel and try again.',
},
});
this.notifyAuthCancelled();
return;
}
await this.authInteractiveHandler(provider, {
protocol,
baseUrl,
apiKey,
modelIds,
advancedConfig,
});
}
}

View file

@ -28,11 +28,15 @@ import { type ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import { isAuthenticationRequiredError } from '../../utils/authErrors.js';
import { getErrorMessage } from '../../utils/errorMessage.js';
import {
applyProviderInstallPlanToFile,
writeCodingPlanConfig,
readQwenSettingsForVSCode,
clearPersistedAuth,
} from '../../services/settingsWriter.js';
import { parseInsightMessage } from '@qwen-code/qwen-code-core';
import {
buildInstallPlan,
parseInsightMessage,
} from '@qwen-code/qwen-code-core';
/** Threshold (ms) before a completed task triggers a notification. */
const LONG_TASK_THRESHOLD_MS = 20_000;
@ -1315,10 +1319,6 @@ export class WebViewProvider {
try {
// Use core's buildInstallPlan to create a standardized install plan,
// then apply it via the VSCode settings adapter.
const { buildInstallPlan } = await import('@qwen-code/qwen-code-core');
const { applyProviderInstallPlanToFile } = await import(
'../../services/settingsWriter.js'
);
const plan = buildInstallPlan(providerConfig, inputs);
await applyProviderInstallPlanToFile(plan);