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
2 changes: 1 addition & 1 deletion packages/core/src/providers/all-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ export {

/** All known providers, in display order. */
export const ALL_PROVIDERS: readonly ProviderConfig[] = [
codingPlanProvider,
tokenPlanProvider,
codingPlanProvider,
alibabaStandardProvider,
deepseekProvider,
minimaxProvider,
Expand Down
12 changes: 7 additions & 5 deletions packages/vscode-ide-companion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,21 +206,23 @@
"order": 0,
"type": "string",
"enum": [
"token-plan",
"coding-plan",
"api-key"
],
"enumDescriptions": [
"Alibaba Cloud Coding Plan — configurable from VS Code Settings",
"Configured via Qwen Code: Auth or the onboarding button"
"Alibaba Cloud Token Plan — for teams and companies",
"Alibaba Cloud Coding Plan — for individual developers",
"API Key — ModelStudio or OpenAI-compatible providers"
],
"default": "coding-plan",
"markdownDescription": "**Coding Plan**: enter API Key + Region here to sync `~/.qwen/settings.json`.\n\n**API Key**: use **Qwen Code: Auth** or the onboarding button to configure ModelStudio or custom OpenAI-compatible providers."
"default": "token-plan",
"markdownDescription": "**Token Plan**: enter API Key to sync `~/.qwen/settings.json`.\n\n**Coding Plan**: enter API Key + Region to sync `~/.qwen/settings.json`.\n\n**API Key**: use **Qwen Code: Auth** or the onboarding button to configure ModelStudio or custom OpenAI-compatible providers."
},
"qwen-code.apiKey": {
"order": 1,
"type": "string",
"default": "",
"markdownDescription": "API key used for **Coding Plan** settings sync. For **API Key** providers, configure the full provider details through **Qwen Code: Auth**."
"markdownDescription": "API key for **Coding Plan** or **Token Plan** settings sync. For **API Key** providers, configure the full provider details through **Qwen Code: Auth**."
},
"qwen-code.codingPlanRegion": {
"order": 2,
Expand Down
164 changes: 134 additions & 30 deletions packages/vscode-ide-companion/src/services/settingsWriter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
});

import { AuthType, type ProviderInstallPlan } from '@qwen-code/qwen-code-core';
import { CODING_PLAN_ENV_KEY } from './subscriptionPlanDefinitions.js';
import {
CODING_PLAN_ENV_KEY,
TOKEN_PLAN_ENV_KEY,
getSubscriptionPlanConfig,
} from './subscriptionPlanDefinitions.js';
import {
applyProviderInstallPlanToFile,
clearPersistedAuth,
Expand All @@ -35,6 +39,7 @@ import {
snapshotSettingsForRollback,
writeCodingPlanConfig,
writeModelProvidersConfig,
writeTokenPlanConfig,
} from './settingsWriter.js';

describe('settingsWriter', () => {
Expand Down Expand Up @@ -104,7 +109,133 @@ describe('settingsWriter', () => {
expect(readQwenSettingsForVSCode()).toEqual({
provider: 'api-key',
apiKey: 'manual-key',
codingPlanRegion: 'china',
});
});

it('writes Token Plan config with the CLI Token Plan model template', () => {
writeTokenPlanConfig('token-plan-key');

const settings = JSON.parse(
fs.readFileSync(settingsPath, 'utf-8'),
) as Record<string, unknown>;
const env = settings.env as Record<string, string>;
const modelProviders = settings.modelProviders as Record<string, unknown>;
const openaiModels = modelProviders[AuthType.USE_OPENAI] as Array<
Record<string, string>
>;
const providerMetadata = settings.providerMetadata as Record<
string,
Record<string, string>
>;
const expectedModelIds = [
'qwen3.6-plus',
'deepseek-v3.2',
'glm-5',
'MiniMax-M2.5',
];

expect(env[TOKEN_PLAN_ENV_KEY]).toBe('token-plan-key');
expect(settings.model).toEqual({ name: 'qwen3.6-plus' });
expect(openaiModels.map((model) => model.id)).toEqual(expectedModelIds);
expect(
openaiModels.every((model) => model.envKey === TOKEN_PLAN_ENV_KEY),
).toBe(true);
// qwen3.6-plus must keep the CLI's image/video modalities so the
// VS Code-configured Token Plan advertises the same multimodal
// support as the CLI provider entry.
const qwen36 = openaiModels.find(
(model) => model.id === 'qwen3.6-plus',
) as unknown as { generationConfig?: Record<string, unknown> };
expect(qwen36.generationConfig?.modalities).toEqual({
image: true,
video: true,
});
const deepseek = openaiModels.find(
(model) => model.id === 'deepseek-v3.2',
) as unknown as { generationConfig?: Record<string, unknown> };
expect(deepseek.generationConfig?.modalities).toBeUndefined();
expect(providerMetadata['token-plan']).toMatchObject({
baseUrl: getSubscriptionPlanConfig('token').baseUrl,
version: expect.any(String),
});
expect(settings.tokenPlan).toBeUndefined();
});

it('reads Token Plan config without overwriting Coding Plan region', () => {
writeTokenPlanConfig('token-plan-key');

expect(readQwenSettingsForVSCode()).toEqual({
provider: 'token-plan',
apiKey: 'token-plan-key',
});
});

it('preserves api-key credentials and custom models when writing Token Plan', () => {
writeModelProvidersConfig({
apiKey: 'manual-key',
modelProviders: {
'gpt-4o': 'https://api.openai.com/v1',
},
activeModel: 'gpt-4o',
});

writeTokenPlanConfig('token-plan-key');

const settings = JSON.parse(
fs.readFileSync(settingsPath, 'utf-8'),
) as Record<string, unknown>;
const env = settings.env as Record<string, string>;
const modelProviders = settings.modelProviders as Record<string, unknown>;
const openaiModels = modelProviders[AuthType.USE_OPENAI] as Array<
Record<string, string>
>;

// The preserved custom model still references OPENAI_API_KEY, so the
// key must survive the plan switch (otherwise it breaks silently).
expect(env.OPENAI_API_KEY).toBe('manual-key');
expect(env[TOKEN_PLAN_ENV_KEY]).toBe('token-plan-key');
expect(openaiModels.map((model) => model.id)).toContain('gpt-4o');
expect(openaiModels.find((model) => model.id === 'gpt-4o')).toMatchObject({
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
});
});

it('clears stale sibling subscription plan credentials when switching plans', () => {
writeCodingPlanConfig('global', 'coding-plan-key');
writeTokenPlanConfig('token-plan-key');

let settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record<
string,
unknown
>;
let env = settings.env as Record<string, string>;
let providerMetadata = settings.providerMetadata as Record<string, unknown>;

expect(env[CODING_PLAN_ENV_KEY]).toBeUndefined();
expect(env[TOKEN_PLAN_ENV_KEY]).toBe('token-plan-key');
expect(providerMetadata['coding-plan']).toBeUndefined();
expect(providerMetadata['token-plan']).toMatchObject({
baseUrl: getSubscriptionPlanConfig('token').baseUrl,
version: expect.any(String),
});

writeCodingPlanConfig('china', 'new-coding-plan-key');

settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record<
string,
unknown
>;
env = settings.env as Record<string, string>;
providerMetadata = settings.providerMetadata as Record<string, unknown>;

expect(env[TOKEN_PLAN_ENV_KEY]).toBeUndefined();
expect(env[CODING_PLAN_ENV_KEY]).toBe('new-coding-plan-key');
expect(providerMetadata['token-plan']).toBeUndefined();
expect(providerMetadata['coding-plan']).toMatchObject({
baseUrl: getSubscriptionPlanConfig('coding').baseUrl,
region: 'china',
version: expect.any(String),
});
});

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.

[Suggestion] Missing test: writeModelProvidersConfig clearing token-plan state when switching to api-key.

The existing tests cover coding-plan → api-key and token-plan ↔ coding-plan, but the token-plan → api-key direction via writeModelProvidersConfig is untested. This direction exercises the refactored SUBSCRIPTION_PROVIDER_METADATA_KEYS loop.

A symmetric test would verify:

writeTokenPlanConfig('key');
writeModelProvidersConfig({ apiKey: 'ak', modelProviders: { 'gpt-4': 'https://api.openai.com/v1' }, activeModel: 'gpt-4' });
// expect env[TOKEN_PLAN_ENV_KEY] undefined
// expect providerMetadata['token-plan'] undefined

— qwen-latest-series-invite-beta-v28 via Qwen Code /review


Expand Down Expand Up @@ -137,9 +268,6 @@ describe('settingsWriter', () => {
});

it('rejects __proto__ in install-plan env keys (prototype-pollution guard)', async () => {
// {__proto__: 'x'} literal sets the object's prototype rather than a
// real property, so build the env via defineProperty to land an actual
// "__proto__" own-property that survives Object.entries.
const env: Record<string, string> = {};
Object.defineProperty(env, '__proto__', {
value: 'polluted',
Expand All @@ -156,12 +284,10 @@ describe('settingsWriter', () => {
await expect(applyProviderInstallPlanToFile(plan)).rejects.toThrow(
/reserved segment/,
);
// Ensure prototype was not polluted by the failed call
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});

it('rejects writes that would overwrite an intermediate scalar segment', async () => {
// Hand-edited settings with `env` as a string (legacy / mistake).
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(
settingsPath,
Expand All @@ -177,14 +303,12 @@ describe('settingsWriter', () => {
await expect(applyProviderInstallPlanToFile(plan)).rejects.toThrow(
/segment "env" is a string/,
);
// Original scalar must be untouched
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(after.env).toBe('legacy-string');
});

it('throws on malformed settings file instead of silently overwriting it', async () => {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
// Note the broken bracket — neither comments nor trailing commas fix it.
fs.writeFileSync(settingsPath, '{ "broken": [1, 2', 'utf-8');
const plan: ProviderInstallPlan = {
providerId: 'test',
Expand All @@ -193,13 +317,11 @@ describe('settingsWriter', () => {
};

await expect(applyProviderInstallPlanToFile(plan)).rejects.toThrow();
// Bad file is preserved, not silently clobbered with {}
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ "broken": [1, 2');
});

it('parses JSONC with trailing commas (and preserves comma inside strings)', async () => {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
// Comments + trailing commas + a string containing a literal ",]".
const jsonc = `{
// hand-edited
"preserveMe": ",]",
Expand All @@ -215,18 +337,12 @@ describe('settingsWriter', () => {
await applyProviderInstallPlanToFile(plan);

const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(after.preserveMe).toBe(',]'); // literal preserved, not corrupted
expect(after.preserveMe).toBe(',]');
expect(after.list).toEqual([1, 2]);
expect(after.env.K).toBe('v');
});

it('treats \\uXXXX as a 6-char escape (no parser differential / key injection)', async () => {
// If the JSONC string scanner stepped past the backslash with j+=2 for
// every escape, `"` would leave `0022` in the buffer and the next
// `"` would close the string early — letting an attacker inject extra
// top-level keys (e.g. env.NODE_OPTIONS) into settings.json.
// The corrected scanner consumes \uXXXX as 6 chars, so the value stays
// a single string with a literal `"` in the middle.
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
const jsonc = `{
// attempted injection
Expand Down Expand Up @@ -267,9 +383,6 @@ describe('settingsWriter', () => {
describe('clearPersistedAuth', () => {
it('wipes preset, custom, and subscription-plan env keys without touching unrelated env', () => {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
// Pre-populate a settings file representing a user who has used
// multiple providers (so each preset's envKey is set) plus a
// hand-set NODE_OPTIONS the clear must leave alone.
const initial = {
env: {
OPENAI_API_KEY: 'sk-openai',
Expand Down Expand Up @@ -299,19 +412,14 @@ describe('settingsWriter', () => {
clearPersistedAuth();

const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
// Every preset + subscription + OPENAI + every QWEN_CUSTOM_API_KEY_*
// is gone; NODE_OPTIONS survives.
expect(after.env).toEqual({ NODE_OPTIONS: '--max-old-space-size=8192' });
// selectedType is wiped.
expect(after.security?.auth?.selectedType).toBeUndefined();
// providerMetadata is empty (or only holds keys that weren't ours).
expect(after.providerMetadata['coding-plan']).toBeUndefined();
expect(after.providerMetadata['deepseek']).toBeUndefined();
expect(after.providerMetadata['openrouter']).toBeUndefined();
});

it('is a no-op when no settings file exists', () => {
// No settings file written — clear must not throw.
expect(() => clearPersistedAuth()).not.toThrow();
});
});
Expand All @@ -332,7 +440,6 @@ describe('settingsWriter', () => {
const snapshot = snapshotSettingsForRollback();
expect(snapshot).not.toBeNull();

// Simulate a bad-credential install writing over the file.
fs.writeFileSync(
settingsPath,
JSON.stringify({ env: { OPENAI_API_KEY: 'sk-bad' } }, null, 2),
Expand All @@ -352,14 +459,11 @@ describe('settingsWriter', () => {
const snapshot = snapshotSettingsForRollback();
expect(snapshot).toBeNull();

// No-op restore must not throw and must not clobber the file.
expect(() => restoreSettingsSnapshot(snapshot)).not.toThrow();
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ "broken": [1, 2');
});

it('snapshot returns {} (not null) when no settings file exists', () => {
// ENOENT → readSettings returns {}, so we get a valid empty snapshot
// that restore can write (creating the file).
const snapshot = snapshotSettingsForRollback();
expect(snapshot).toEqual({});
});
Expand Down
Loading
Loading