From 46c6278572fed7bf6daaf028a9225fba4b1cb5fc Mon Sep 17 00:00:00 2001 From: qqqys Date: Wed, 22 Jul 2026 22:39:17 +0800 Subject: [PATCH 1/4] feat(serve): persist workspace channel configuration --- packages/channels/base/src/index.ts | 3 + packages/channels/base/src/types.ts | 24 + packages/channels/dingtalk/src/index.ts | 18 + packages/channels/feishu/src/index.ts | 18 + packages/channels/wecom/src/index.ts | 24 + .../commands/channel/channel-registry.test.ts | 29 + .../src/commands/channel/channel-registry.ts | 26 +- packages/cli/src/config/settingsSchema.ts | 11 + .../src/serve/channel-settings-store.test.ts | 601 ++++++++++++++++++ .../cli/src/serve/channel-settings-store.ts | 429 +++++++++++++ .../schemas/settings.schema.json | 5 + 11 files changed, 1187 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/channel/channel-registry.test.ts create mode 100644 packages/cli/src/serve/channel-settings-store.test.ts create mode 100644 packages/cli/src/serve/channel-settings-store.ts diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 36e706930bd..8bc1440d6ce 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -78,7 +78,10 @@ export type { BlockStreamingChunkConfig, BlockStreamingCoalesceConfig, ChannelConfig, + ChannelConfigFieldDescriptor, + ChannelConfigFieldKind, ChannelIdentityConfig, + ChannelManagementDescriptor, ChannelMemoryIntentClassifier, ChannelMemoryIntentClassifierResult, ChannelMemoryScopeConfig, diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 877d3dc349b..ee62e56e15a 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -301,6 +301,27 @@ export interface ChannelMemoryIntentClassifier { ): Promise; } +export type ChannelConfigFieldKind = + | 'string' + | 'secret' + | 'boolean' + | 'number' + | 'enum'; + +export interface ChannelConfigFieldDescriptor { + key: string; + label: string; + kind: ChannelConfigFieldKind; + required?: boolean; + envResolvable?: boolean; + options?: ReadonlyArray<{ value: string; label: string }>; + description?: string; +} + +export interface ChannelManagementDescriptor { + fields: readonly ChannelConfigFieldDescriptor[]; +} + /** * A channel plugin registers a channel type and provides a factory * to create adapter instances. Both built-in adapters and external @@ -322,6 +343,9 @@ export interface ChannelPlugin { /** Optional config fields whose string values may reference environment vars. */ envResolvableConfigFields?: string[]; + /** Serializable metadata for safe configuration management. */ + management?: ChannelManagementDescriptor; + /** Create a channel adapter instance. */ createChannel( name: string, diff --git a/packages/channels/dingtalk/src/index.ts b/packages/channels/dingtalk/src/index.ts index 4ea26fd7d20..739881957c5 100644 --- a/packages/channels/dingtalk/src/index.ts +++ b/packages/channels/dingtalk/src/index.ts @@ -8,6 +8,24 @@ export const plugin: ChannelPlugin = { channelType: 'dingtalk', displayName: 'DingTalk', requiredConfigFields: ['clientId', 'clientSecret'], + management: { + fields: [ + { + key: 'clientId', + label: 'Client ID', + kind: 'string', + required: true, + envResolvable: true, + }, + { + key: 'clientSecret', + label: 'Client Secret', + kind: 'secret', + required: true, + envResolvable: true, + }, + ], + }, createChannel: (name, config, bridge, options) => new DingtalkChannel(name, config, bridge, options), }; diff --git a/packages/channels/feishu/src/index.ts b/packages/channels/feishu/src/index.ts index ec506cef388..8b4255e93b6 100644 --- a/packages/channels/feishu/src/index.ts +++ b/packages/channels/feishu/src/index.ts @@ -8,6 +8,24 @@ export const plugin: ChannelPlugin = { channelType: 'feishu', displayName: 'Feishu', requiredConfigFields: ['clientId', 'clientSecret'], + management: { + fields: [ + { + key: 'clientId', + label: 'App ID', + kind: 'string', + required: true, + envResolvable: true, + }, + { + key: 'clientSecret', + label: 'App Secret', + kind: 'secret', + required: true, + envResolvable: true, + }, + ], + }, createChannel: (name, config, bridge, options) => new FeishuChannel(name, config, bridge, options), }; diff --git a/packages/channels/wecom/src/index.ts b/packages/channels/wecom/src/index.ts index 35ac9fb617a..418b5c4141f 100644 --- a/packages/channels/wecom/src/index.ts +++ b/packages/channels/wecom/src/index.ts @@ -8,6 +8,30 @@ export const plugin: ChannelPlugin = { displayName: 'WeCom', requiredConfigFields: ['botId', 'secret'], envResolvableConfigFields: ['wsUrl'], + management: { + fields: [ + { + key: 'botId', + label: 'Bot ID', + kind: 'string', + required: true, + envResolvable: true, + }, + { + key: 'secret', + label: 'Bot Secret', + kind: 'secret', + required: true, + envResolvable: true, + }, + { + key: 'wsUrl', + label: 'WebSocket URL', + kind: 'string', + envResolvable: true, + }, + ], + }, createChannel: (name, config, bridge, options) => new WeComChannel(name, config, bridge, options), }; diff --git a/packages/cli/src/commands/channel/channel-registry.test.ts b/packages/cli/src/commands/channel/channel-registry.test.ts new file mode 100644 index 00000000000..87d725d8e69 --- /dev/null +++ b/packages/cli/src/commands/channel/channel-registry.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { supportedChannelCatalog } from './channel-registry.js'; + +describe('channel registry', () => { + it('only marks the manually configurable built-in types as manageable', async () => { + const catalog = await supportedChannelCatalog(); + expect(catalog.map((entry) => entry.type)).toEqual([ + 'telegram', + 'weixin', + 'dingtalk', + 'wecom', + 'feishu', + 'qq', + ]); + expect( + catalog.filter((entry) => entry.manageable).map((entry) => entry.type), + ).toEqual(['dingtalk', 'wecom', 'feishu']); + expect( + catalog.find((entry) => entry.type === 'dingtalk')?.fields, + ).toContainEqual( + expect.objectContaining({ + key: 'clientSecret', + kind: 'secret', + required: true, + }), + ); + expect(JSON.stringify(catalog)).not.toContain('createChannel'); + }); +}); diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts index 469e2131c66..a3c5ff6f10a 100644 --- a/packages/cli/src/commands/channel/channel-registry.ts +++ b/packages/cli/src/commands/channel/channel-registry.ts @@ -1,4 +1,14 @@ -import type { ChannelPlugin } from '@qwen-code/channel-base'; +import type { + ChannelConfigFieldDescriptor, + ChannelPlugin, +} from '@qwen-code/channel-base'; + +export interface ChannelTypeDescriptor { + type: string; + displayName: string; + manageable: boolean; + fields: readonly ChannelConfigFieldDescriptor[]; +} const registry = new Map(); let builtinsPromise: Promise | null = null; @@ -52,3 +62,17 @@ export async function supportedTypes(): Promise { await ensureBuiltins(); return [...registry.keys()]; } + +export async function supportedChannelCatalog(): Promise< + ChannelTypeDescriptor[] +> { + await ensureBuiltins(); + return [...registry.values()].map( + ({ channelType, displayName, management }) => ({ + type: channelType, + displayName, + manageable: management !== undefined, + fields: management?.fields ?? [], + }), + ); +} diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index eca8d0e9bee..110f25df167 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -305,6 +305,17 @@ const SETTINGS_SCHEMA = { mergeStrategy: MergeStrategy.SHALLOW_MERGE, }, + serve: { + type: 'object', + label: 'Serve', + category: 'Advanced', + requiresRestart: true, + default: {} as { channels?: string[] }, + description: 'Persistent qwen serve settings.', + showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + }, + // Model providers configuration grouped by authType modelProviders: { type: 'object', diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts new file mode 100644 index 00000000000..5d67bbffdf0 --- /dev/null +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -0,0 +1,601 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import stripJsonComments from 'strip-json-comments'; +import { registerPlugin } from '../commands/channel/channel-registry.js'; +import { resetHomeEnvBootstrapForTesting } from '../config/settings.js'; +import { WorkspaceChannelSettingsStore } from './channel-settings-store.js'; + +describe('WorkspaceChannelSettingsStore', () => { + let testRoot: string; + let workspace: string; + let settingsPath: string; + let originalQwenHome: string | undefined; + + const writeWorkspaceSettings = (contents: string) => { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, contents); + }; + + const readWorkspaceSettings = (): Record => + JSON.parse( + stripJsonComments(fs.readFileSync(settingsPath, 'utf8')), + ) as Record; + + beforeAll(() => { + registerPlugin({ + channelType: 'management-validation-test', + displayName: 'Management validation test', + management: { + fields: [ + { + key: 'clientId', + label: 'Client ID', + kind: 'string', + required: true, + envResolvable: true, + }, + { + key: 'clientSecret', + label: 'Client Secret', + kind: 'secret', + required: true, + envResolvable: true, + }, + { + key: 'optionalSecret', + label: 'Optional Secret', + kind: 'secret', + }, + { key: 'enabled', label: 'Enabled', kind: 'boolean' }, + { key: 'retries', label: 'Retries', kind: 'number' }, + { + key: 'mode', + label: 'Mode', + kind: 'enum', + options: [ + { value: 'safe', label: 'Safe' }, + { value: 'fast', label: 'Fast' }, + ], + }, + { key: 'literalOnly', label: 'Literal only', kind: 'string' }, + ], + }, + createChannel() { + throw new Error('not used'); + }, + }); + }); + + beforeEach(() => { + originalQwenHome = process.env['QWEN_HOME']; + testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'channel-settings-')); + workspace = path.join(testRoot, 'workspace'); + settingsPath = path.join(workspace, '.qwen', 'settings.json'); + process.env['QWEN_HOME'] = path.join(testRoot, 'home'); + resetHomeEnvBootstrapForTesting(); + writeWorkspaceSettings(`{ + // Keep this comment and unrelated setting. + "$version": 4, + "general": { "vimMode": true }, + "channels": { + "bot": { + "type": "management-validation-test", + "clientId": "client-id", + "clientSecret": "$BOT_TOKEN", + "senderPolicy": "open", + "legacyField": true + } + }, + "serve": { "port": 4123 } +}\n`); + }); + + afterEach(() => { + if (originalQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = originalQwenHome; + } + resetHomeEnvBootstrapForTesting(); + fs.rmSync(testRoot, { recursive: true, force: true }); + }); + + it('preserves an existing secret unless replace or clear is explicit', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + const first = store.snapshot(); + + await store.upsert('bot', { + expectedRevision: first.revision, + config: { + type: 'management-validation-test', + clientId: 'client-id', + senderPolicy: 'pairing', + }, + secrets: { clientSecret: { operation: 'preserve' } }, + }); + + expect( + ( + readWorkspaceSettings()['channels'] as Record< + string, + Record + > + )['bot'], + ).toEqual({ + type: 'management-validation-test', + clientId: 'client-id', + senderPolicy: 'pairing', + clientSecret: '$BOT_TOKEN', + }); + }); + + it('preserves an existing secret when its operation is omitted', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + + await store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'client-id', + senderPolicy: 'pairing', + }, + }); + + expect( + ( + readWorkspaceSettings()['channels'] as Record< + string, + Record + > + )['bot']?.['clientSecret'], + ).toBe('$BOT_TOKEN'); + }); + + it('replaces and clears secrets only through explicit operations', async () => { + writeWorkspaceSettings(`{ + "$version": 4, + "channels": { "bot": { + "type": "management-validation-test", + "clientId": "client-id", + "clientSecret": "required-secret", + "optionalSecret": "old-secret" + } } +}\n`); + const store = new WorkspaceChannelSettingsStore(workspace); + const replaced = await store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { type: 'management-validation-test', clientId: 'client-id' }, + secrets: { + optionalSecret: { operation: 'replace', value: 'new-secret' }, + }, + }); + + expect( + ( + readWorkspaceSettings()['channels'] as Record< + string, + Record + > + )['bot']?.['optionalSecret'], + ).toBe('new-secret'); + + await store.upsert('bot', { + expectedRevision: replaced.revision, + config: { type: 'management-validation-test', clientId: 'client-id' }, + secrets: { optionalSecret: { operation: 'clear' } }, + }); + + expect( + ( + readWorkspaceSettings()['channels'] as Record< + string, + Record + > + )['bot'], + ).not.toHaveProperty('optionalSecret'); + }); + + it('rejects blank replacements and secret keys not declared by the plugin', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + const revision = store.snapshot().revision; + + await expect( + store.upsert('bot', { + expectedRevision: revision, + config: { type: 'dingtalk', clientId: 'client-id' }, + secrets: { clientSecret: { operation: 'replace', value: '' } }, + }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_secret' }); + await expect( + store.upsert('bot', { + expectedRevision: revision, + config: { type: 'dingtalk', clientId: 'client-id' }, + secrets: { secret: { operation: 'clear' } }, + }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_secret' }); + }); + + it('rejects malformed direct secret updates without writing', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + const revision = store.snapshot().revision; + const before = fs.readFileSync(settingsPath, 'utf8'); + const entryName = 'clientSecret'; + const invalidMaps: unknown[] = [ + null, + [], + { [entryName]: { operation: 'rotate', value: 'new-secret' } }, + { [entryName]: null }, + { [entryName]: { operation: 'replace', value: '' } }, + { [entryName]: [] }, + { [entryName]: { operation: 'preserve', value: 'unexpected' } }, + ...['__proto__', 'constructor', 'prototype'].map((key) => + Object.fromEntries([[key, { operation: 'preserve' }]]), + ), + ]; + + for (const invalidMap of invalidMaps) { + const options = { + expectedRevision: revision, + config: { type: 'dingtalk', clientId: 'client-id' }, + secrets: invalidMap, + }; + await expect(store.upsert('bot', options as never)).rejects.toMatchObject( + { code: 'channel_settings_invalid_secret' }, + ); + expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before); + } + }); + + it('rejects channel types without management descriptors', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + + await expect( + store.upsert('custom', { + expectedRevision: store.snapshot().revision, + config: { type: 'unmanaged-extension' }, + }), + ).rejects.toMatchObject({ code: 'channel_settings_unmanageable' }); + }); + + it.each([ + { + label: 'missing required DingTalk client ID', + config: { type: 'dingtalk' }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'missing required DingTalk client secret', + config: { type: 'dingtalk', clientId: 'client-id' }, + secrets: { clientSecret: { operation: 'preserve' } as const }, + }, + { + label: 'cleared required DingTalk client secret', + config: { type: 'dingtalk', clientId: 'client-id' }, + secrets: { clientSecret: { operation: 'clear' } as const }, + }, + { + label: 'wrong string kind', + config: { type: 'management-validation-test', clientId: 42 }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'wrong boolean kind', + config: { + type: 'management-validation-test', + clientId: 'client-id', + enabled: 'yes', + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'wrong number kind', + config: { + type: 'management-validation-test', + clientId: 'client-id', + retries: '3', + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'invalid enum option', + config: { + type: 'management-validation-test', + clientId: 'client-id', + mode: 'turbo', + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'environment reference on a non-resolvable field', + config: { + type: 'management-validation-test', + clientId: 'client-id', + literalOnly: '$LITERAL_ONLY', + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'unknown config field', + config: { + type: 'management-validation-test', + clientId: 'client-id', + unexpected: true, + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + { + label: 'wrong shared field kind', + config: { + type: 'management-validation-test', + clientId: 'client-id', + allowedUsers: 'user-1', + }, + secrets: { + clientSecret: { operation: 'replace', value: 'secret' } as const, + }, + }, + ])('rejects $label without writing', async ({ config, secrets }) => { + const store = new WorkspaceChannelSettingsStore(workspace); + const before = fs.readFileSync(settingsPath, 'utf8'); + + await expect( + store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: config as Record & { type: string }, + secrets, + }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_config' }); + + expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before); + }); + + it('accepts env-resolvable descriptor fields and typed shared fields', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: '$CLIENT_ID', + enabled: true, + retries: 3, + mode: 'safe', + senderPolicy: 'open', + allowedUsers: ['user-1'], + groupHistoryLimit: 25, + blockStreaming: 'on', + identity: { id: 'ops', displayName: 'Ops' }, + }, + secrets: { + clientSecret: { + operation: 'replace', + value: '$CLIENT_SECRET', + }, + }, + }); + + expect(next.channels['bot']).toMatchObject({ + clientId: '$CLIENT_ID', + clientSecret: '$CLIENT_SECRET', + enabled: true, + retries: 3, + mode: 'safe', + senderPolicy: 'open', + allowedUsers: ['user-1'], + groupHistoryLimit: 25, + blockStreaming: 'on', + identity: { id: 'ops', displayName: 'Ops' }, + }); + }); + + it('rejects clearing an existing required secret without writing', async () => { + writeWorkspaceSettings(`{ + "$version": 4, + "channels": { "bot": { + "type": "dingtalk", + "clientId": "client-id", + "clientSecret": "existing-secret" + } } +}\n`); + const store = new WorkspaceChannelSettingsStore(workspace); + const before = fs.readFileSync(settingsPath, 'utf8'); + + await expect( + store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { type: 'dingtalk', clientId: 'client-id' }, + secrets: { clientSecret: { operation: 'clear' } }, + }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_config' }); + + expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before); + }); + + it('allows unknown legacy fields only when preserved unchanged', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'client-id', + senderPolicy: 'pairing', + legacyField: true, + }, + secrets: { clientSecret: { operation: 'preserve' } }, + }); + + expect(next.channels['bot']?.['legacyField']).toBe(true); + }); + + it('rejects a stale revision without writing', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + const before = fs.readFileSync(settingsPath, 'utf8'); + + await expect( + store.remove('bot', { expectedRevision: 'stale' }), + ).rejects.toMatchObject({ code: 'channel_settings_conflict' }); + + expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before); + }); + + it('does not infer startup from existing channel config', () => { + const store = new WorkspaceChannelSettingsStore(workspace); + + expect(store.snapshot().startupNames).toEqual([]); + }); + + it('writes startup names separately while preserving settings and formatting', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.setStartupNames(['bot'], { + expectedRevision: store.snapshot().revision, + }); + + const settings = readWorkspaceSettings(); + expect(settings['serve']).toEqual({ port: 4123, channels: ['bot'] }); + expect(settings['general']).toEqual({ vimMode: true }); + expect(fs.readFileSync(settingsPath, 'utf8')).toContain( + '// Keep this comment and unrelated setting.', + ); + expect(next.startupNames).toEqual(['bot']); + }); + + it('rejects stale startup names without changing workspace settings', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + const before = fs.readFileSync(settingsPath, 'utf8'); + + await expect( + store.setStartupNames(['bot'], { expectedRevision: 'stale' }), + ).rejects.toMatchObject({ code: 'channel_settings_conflict' }); + + expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before); + }); + + it('removes the channel and its startup selection together', async () => { + writeWorkspaceSettings(`{ + "$version": 4, + "channels": { "bot": { "type": "telegram", "token": "$BOT_TOKEN" } }, + "serve": { "channels": ["other", "bot"] } +}\n`); + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.remove('bot', { + expectedRevision: store.snapshot().revision, + }); + + expect(next.channels).toEqual({}); + expect(next.startupNames).toEqual(['other']); + expect(readWorkspaceSettings()).toEqual({ + $version: 4, + channels: {}, + serve: { channels: ['other'] }, + }); + }); + + it('preserves the all sentinel when removing a legacy all config beside other instances', async () => { + writeWorkspaceSettings(`{ + "$version": 4, + "channels": { + "all": { "type": "telegram", "token": "$ALL_TOKEN" }, + "bot": { "type": "telegram", "token": "$BOT_TOKEN" } + }, + "serve": { "channels": ["all"] } +}\n`); + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.remove('all', { + expectedRevision: store.snapshot().revision, + }); + + expect(next.channels).toEqual({ + bot: { type: 'telegram', token: '$BOT_TOKEN' }, + }); + expect(next.startupNames).toEqual(['all']); + }); + + it('clears the all sentinel when removing the only legacy all config', async () => { + writeWorkspaceSettings(`{ + "$version": 4, + "channels": { + "all": { "type": "telegram", "token": "$ALL_TOKEN" } + }, + "serve": { "channels": ["all"] } +}\n`); + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.remove('all', { + expectedRevision: store.snapshot().revision, + }); + + expect(next.channels).toEqual({}); + expect(next.startupNames).toEqual([]); + }); + + it('canonicalizes a whitespace all sentinel when removing its legacy config', async () => { + writeWorkspaceSettings(`{ + "$version": 4, + "channels": { + " all ": { "type": "telegram", "token": "$ALL_TOKEN" }, + "bot": { "type": "telegram", "token": "$BOT_TOKEN" } + }, + "serve": { "channels": [" all ", "bot"] } +}\n`); + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.remove(' all ', { + expectedRevision: store.snapshot().revision, + }); + + expect(next.channels).toEqual({ + bot: { type: 'telegram', token: '$BOT_TOKEN' }, + }); + expect(next.startupNames).toEqual(['all']); + }); + + it('clears a whitespace all sentinel when no selectable configs remain', async () => { + writeWorkspaceSettings(`{ + "$version": 4, + "channels": { + " all ": { "type": "telegram", "token": "$ALL_TOKEN" } + }, + "serve": { "channels": [" all "] } +}\n`); + const store = new WorkspaceChannelSettingsStore(workspace); + + const next = await store.remove(' all ', { + expectedRevision: store.snapshot().revision, + }); + + expect(next.channels).toEqual({}); + expect(next.startupNames).toEqual([]); + }); + + it('produces the same revision for unchanged persisted values', () => { + const store = new WorkspaceChannelSettingsStore(workspace); + + expect(store.snapshot().revision).toBe(store.snapshot().revision); + }); +}); diff --git a/packages/cli/src/serve/channel-settings-store.ts b/packages/cli/src/serve/channel-settings-store.ts new file mode 100644 index 00000000000..7cebd938a4a --- /dev/null +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -0,0 +1,429 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import type { ChannelConfigFieldDescriptor } from '@qwen-code/channel-base'; +import { getPlugin } from '../commands/channel/channel-registry.js'; +import { loadSettings, saveSettings } from '../config/settings.js'; + +export type ChannelSecretUpdate = + | { operation: 'preserve' } + | { operation: 'replace'; value: string } + | { operation: 'clear' }; + +export interface ChannelSettingsSnapshot { + revision: string; + channels: Record>; + startupNames: string[]; +} + +export interface ChannelSettingsMutationOptions { + expectedRevision: string; +} + +export interface ChannelSettingsUpsertOptions + extends ChannelSettingsMutationOptions { + config: Record & { type: string }; + secrets?: Record; +} + +export class ChannelSettingsError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = 'ChannelSettingsError'; + } +} + +function revisionOf( + channels: unknown, + startupNames: readonly string[], +): string { + return createHash('sha256') + .update(JSON.stringify({ channels, startupNames })) + .digest('hex'); +} + +function applySecretUpdate( + current: unknown, + update: ChannelSecretUpdate, +): unknown { + validateSecretUpdate(update); + if (update.operation === 'preserve') return current; + if (update.operation === 'clear') return undefined; + if (typeof update.value !== 'string' || update.value.length === 0) { + throw invalidSecret('Secret replacements must be non-empty strings.'); + } + return update.value; +} + +function invalidSecret(message: string): ChannelSettingsError { + return new ChannelSettingsError('channel_settings_invalid_secret', message); +} + +function invalidConfig(message: string): ChannelSettingsError { + return new ChannelSettingsError('channel_settings_invalid_config', message); +} + +const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function isAllStartupName(name: string): boolean { + return name.trim() === 'all'; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isEnvironmentReference(value: string): boolean { + return /^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value); +} + +function assertStringRecord( + key: string, + value: unknown, + allowedKeys: ReadonlySet, +): void { + if (!isRecord(value)) { + throw invalidConfig(`Channel field "${key}" must be an object.`); + } + for (const [nestedKey, nestedValue] of Object.entries(value)) { + if (!allowedKeys.has(nestedKey) || typeof nestedValue !== 'string') { + throw invalidConfig(`Channel field "${key}.${nestedKey}" is invalid.`); + } + } +} + +function assertNumberRecord( + key: string, + value: unknown, + allowedKeys: ReadonlySet, +): void { + if (!isRecord(value)) { + throw invalidConfig(`Channel field "${key}" must be an object.`); + } + for (const [nestedKey, nestedValue] of Object.entries(value)) { + if ( + !allowedKeys.has(nestedKey) || + typeof nestedValue !== 'number' || + !Number.isFinite(nestedValue) + ) { + throw invalidConfig(`Channel field "${key}.${nestedKey}" is invalid.`); + } + } +} + +function assertSharedField(key: string, value: unknown): boolean { + const enumValues: Record> = { + senderPolicy: new Set(['allowlist', 'pairing', 'open']), + dmPolicy: new Set(['open', 'disabled']), + groupPolicy: new Set(['disabled', 'allowlist', 'open']), + sessionScope: new Set(['user', 'thread', 'single']), + dispatchMode: new Set(['steer', 'followup', 'collect']), + blockStreaming: new Set(['on', 'off']), + }; + if (Object.hasOwn(enumValues, key)) { + if (typeof value !== 'string' || !enumValues[key]!.has(value)) { + throw invalidConfig(`Channel field "${key}" has an invalid value.`); + } + return true; + } + if (['model', 'cwd', 'approvalMode', 'instructions'].includes(key)) { + if (typeof value !== 'string') { + throw invalidConfig(`Channel field "${key}" must be a string.`); + } + return true; + } + if (key === 'allowedUsers') { + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== 'string') + ) { + throw invalidConfig(`Channel field "${key}" must be a string array.`); + } + return true; + } + if (key === 'groupHistoryLimit') { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw invalidConfig(`Channel field "${key}" must be a number.`); + } + return true; + } + if (key === 'identity') { + assertStringRecord( + key, + value, + new Set(['id', 'displayName', 'description']), + ); + return true; + } + if (key === 'blockStreamingChunk') { + assertNumberRecord(key, value, new Set(['minChars', 'maxChars'])); + return true; + } + if (key === 'blockStreamingCoalesce') { + assertNumberRecord(key, value, new Set(['idleMs'])); + return true; + } + if (key === 'memoryScope') { + if (!isRecord(value)) { + throw invalidConfig(`Channel field "${key}" must be an object.`); + } + for (const [nestedKey, nestedValue] of Object.entries(value)) { + const valid = + (nestedKey === 'namespace' && typeof nestedValue === 'string') || + (nestedKey === 'mode' && nestedValue === 'metadata-only'); + if (!valid) { + throw invalidConfig(`Channel field "${key}.${nestedKey}" is invalid.`); + } + } + return true; + } + return false; +} + +function assertDescriptorValue( + field: ChannelConfigFieldDescriptor, + value: unknown, +): void { + const invalidEnvironment = + typeof value === 'string' && + isEnvironmentReference(value) && + field.envResolvable !== true; + if (invalidEnvironment) { + throw invalidConfig( + `Channel field "${field.key}" does not support environment references.`, + ); + } + const valid = + ((field.kind === 'string' || field.kind === 'secret') && + typeof value === 'string' && + value.length > 0) || + (field.kind === 'boolean' && typeof value === 'boolean') || + (field.kind === 'number' && + typeof value === 'number' && + Number.isFinite(value)) || + (field.kind === 'enum' && + typeof value === 'string' && + field.options?.some((option) => option.value === value) === true); + if (!valid) { + throw invalidConfig(`Channel field "${field.key}" has an invalid value.`); + } +} + +function assertManagedConfig( + config: Record, + previous: Record, + fields: readonly ChannelConfigFieldDescriptor[], +): void { + const descriptorFields = new Map(fields.map((field) => [field.key, field])); + for (const [key, value] of Object.entries(config)) { + if (key === 'type') continue; + const field = descriptorFields.get(key); + if (field) { + assertDescriptorValue(field, value); + continue; + } + if (assertSharedField(key, value)) continue; + if ( + !Object.hasOwn(previous, key) || + !isDeepStrictEqual(previous[key], value) + ) { + throw invalidConfig(`Channel field "${key}" is not manageable.`); + } + } + for (const field of fields) { + if (!field.required) continue; + const value = config[field.key]; + if (value === undefined || value === null || value === '') { + throw invalidConfig(`Channel field "${field.key}" is required.`); + } + } +} + +function validateSecretUpdate( + update: unknown, +): asserts update is ChannelSecretUpdate { + if (!isRecord(update)) { + throw invalidSecret('Secret updates must be objects.'); + } + const operation = update['operation']; + const keys = Object.keys(update).sort(); + const valid = + ((operation === 'preserve' || operation === 'clear') && + keys.length === 1 && + keys[0] === 'operation') || + (operation === 'replace' && + keys.length === 2 && + keys[0] === 'operation' && + keys[1] === 'value' && + typeof update['value'] === 'string' && + update['value'].length > 0); + if (!valid) { + throw invalidSecret('Secret updates contain an invalid operation.'); + } +} + +export function assertValidChannelSecretUpdates( + updates: unknown, +): asserts updates is Record { + if (!isRecord(updates)) { + throw invalidSecret('Secret updates must be objects.'); + } + for (const [key, update] of Object.entries(updates)) { + if (UNSAFE_OBJECT_KEYS.has(key)) { + throw invalidSecret(`Secret update ${JSON.stringify(key)} is invalid.`); + } + validateSecretUpdate(update); + } +} + +function workspaceValues(workspaceCwd: string): { + channels: Record>; + startupNames: string[]; +} { + const settings = loadSettings(workspaceCwd, { skipLoadEnvironment: true }) + .workspace.settings; + const channels = + typeof settings.channels === 'object' && + settings.channels !== null && + !Array.isArray(settings.channels) + ? (settings.channels as Record>) + : {}; + const startupNames = Array.isArray(settings.serve?.channels) + ? settings.serve.channels.filter( + (name): name is string => typeof name === 'string', + ) + : []; + return { channels, startupNames }; +} + +export class WorkspaceChannelSettingsStore { + constructor(private readonly workspaceCwd: string) {} + + snapshot(): ChannelSettingsSnapshot { + const { channels, startupNames } = workspaceValues(this.workspaceCwd); + return { + revision: revisionOf(channels, startupNames), + channels: { ...channels }, + startupNames: [...startupNames], + }; + } + + async upsert( + name: string, + options: ChannelSettingsUpsertOptions, + ): Promise { + const secretUpdates: unknown = + options.secrets === undefined ? {} : options.secrets; + assertValidChannelSecretUpdates(secretUpdates); + const plugin = await getPlugin(options.config.type); + if (!plugin?.management) { + throw new ChannelSettingsError( + 'channel_settings_unmanageable', + `Channel type "${options.config.type}" does not provide safe management metadata.`, + ); + } + const secretKeys = new Set( + plugin.management.fields + .filter((field) => field.kind === 'secret') + .map((field) => field.key), + ); + for (const key of Object.keys(secretUpdates)) { + if (!secretKeys.has(key)) { + throw invalidSecret( + `Channel type "${options.config.type}" does not declare "${key}" as a secret.`, + ); + } + } + for (const key of secretKeys) { + if (Object.hasOwn(options.config, key)) { + throw invalidSecret( + `Secret "${key}" must use an explicit preserve, replace, or clear operation.`, + ); + } + } + + const current = this.assertRevision(options.expectedRevision); + const storedPrevious = current.channels[name] ?? {}; + const previous = + storedPrevious['type'] === options.config.type ? storedPrevious : {}; + const nextConfig: Record = { ...options.config }; + for (const key of secretKeys) { + const update = secretUpdates[key] ?? { operation: 'preserve' }; + const value = applySecretUpdate(previous[key], update); + if (value !== undefined) nextConfig[key] = value; + } + assertManagedConfig(nextConfig, previous, plugin.management.fields); + + const channels = { ...current.channels, [name]: nextConfig }; + const workspaceFile = loadSettings(this.workspaceCwd, { + skipLoadEnvironment: true, + }).workspace; + saveSettings(workspaceFile, { channels }, ['channels'], { + throwOnWriteFailure: true, + }); + return this.snapshot(); + } + + async remove( + name: string, + options: ChannelSettingsMutationOptions, + ): Promise { + const current = this.assertRevision(options.expectedRevision); + const channels = { ...current.channels }; + delete channels[name]; + const hasAllSentinel = current.startupNames.some(isAllStartupName); + const startupNames = hasAllSentinel + ? Object.keys(channels).some( + (channelName) => !isAllStartupName(channelName), + ) + ? ['all'] + : [] + : current.startupNames.filter((startupName) => startupName !== name); + const workspaceFile = loadSettings(this.workspaceCwd, { + skipLoadEnvironment: true, + }).workspace; + saveSettings( + workspaceFile, + { channels, serve: { channels: startupNames } }, + ['channels'], + { throwOnWriteFailure: true }, + ); + return this.snapshot(); + } + + async setStartupNames( + names: readonly string[], + options: ChannelSettingsMutationOptions, + ): Promise { + this.assertRevision(options.expectedRevision); + const workspaceFile = loadSettings(this.workspaceCwd, { + skipLoadEnvironment: true, + }).workspace; + saveSettings( + workspaceFile, + { serve: { channels: [...names] } }, + ['serve', 'channels'], + { throwOnWriteFailure: true }, + ); + return this.snapshot(); + } + + private assertRevision(expectedRevision: string): ChannelSettingsSnapshot { + const current = this.snapshot(); + if (current.revision !== expectedRevision) { + throw new ChannelSettingsError( + 'channel_settings_conflict', + 'Channel settings changed; reload before trying again.', + ); + } + return current; + } +} diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index e118984dfe0..e289910eb8b 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -13,6 +13,11 @@ "type": "object", "additionalProperties": true }, + "serve": { + "description": "Persistent qwen serve settings.", + "type": "object", + "additionalProperties": true + }, "modelProviders": { "description": "Model providers configuration keyed by provider id (a built-in AuthType such as \"openai\" or \"gemini\", or a custom id mapped via providerProtocol). Each entry is an array of model configurations.", "type": "object", From 2348189668a18f8afbb6d59bb049e26fd4444427 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 23 Jul 2026 00:09:26 +0800 Subject: [PATCH 2/4] fix(serve): harden channel settings snapshots --- .../src/serve/channel-settings-store.test.ts | 38 +++++++++++++++++++ .../cli/src/serve/channel-settings-store.ts | 22 ++++++++--- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts index 5d67bbffdf0..30fe11bb20f 100644 --- a/packages/cli/src/serve/channel-settings-store.test.ts +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -467,6 +467,44 @@ describe('WorkspaceChannelSettingsStore', () => { expect(store.snapshot().startupNames).toEqual([]); }); + it('ignores invalid stored channel values in snapshots', () => { + writeWorkspaceSettings(`{ + "channels": { + "valid": { "type": "management-validation-test" }, + "null": null, + "scalar": 42, + "array": [] + } +}\n`); + + expect( + new WorkspaceChannelSettingsStore(workspace).snapshot().channels, + ).toEqual({ + valid: { type: 'management-validation-test' }, + }); + }); + + it('rejects unsafe channel names without writing', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + const before = fs.readFileSync(settingsPath, 'utf8'); + + for (const name of ['__proto__', 'constructor', 'prototype']) { + await expect( + store.upsert(name, { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'client-id', + }, + }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_name' }); + await expect( + store.remove(name, { expectedRevision: store.snapshot().revision }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_name' }); + expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before); + } + }); + it('writes startup names separately while preserving settings and formatting', async () => { const store = new WorkspaceChannelSettingsStore(workspace); diff --git a/packages/cli/src/serve/channel-settings-store.ts b/packages/cli/src/serve/channel-settings-store.ts index 7cebd938a4a..e3f8db45eb7 100644 --- a/packages/cli/src/serve/channel-settings-store.ts +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -73,6 +73,15 @@ function invalidConfig(message: string): ChannelSettingsError { const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +function assertSafeChannelName(name: string): void { + if (UNSAFE_OBJECT_KEYS.has(name)) { + throw new ChannelSettingsError( + 'channel_settings_invalid_name', + `Channel name ${JSON.stringify(name)} is not allowed.`, + ); + } +} + function isAllStartupName(name: string): boolean { return name.trim() === 'all'; } @@ -290,12 +299,11 @@ function workspaceValues(workspaceCwd: string): { } { const settings = loadSettings(workspaceCwd, { skipLoadEnvironment: true }) .workspace.settings; - const channels = - typeof settings.channels === 'object' && - settings.channels !== null && - !Array.isArray(settings.channels) - ? (settings.channels as Record>) - : {}; + const rawChannels = isRecord(settings.channels) ? settings.channels : {}; + const channels: Record> = {}; + for (const [name, config] of Object.entries(rawChannels)) { + if (isRecord(config)) channels[name] = config; + } const startupNames = Array.isArray(settings.serve?.channels) ? settings.serve.channels.filter( (name): name is string => typeof name === 'string', @@ -320,6 +328,7 @@ export class WorkspaceChannelSettingsStore { name: string, options: ChannelSettingsUpsertOptions, ): Promise { + assertSafeChannelName(name); const secretUpdates: unknown = options.secrets === undefined ? {} : options.secrets; assertValidChannelSecretUpdates(secretUpdates); @@ -376,6 +385,7 @@ export class WorkspaceChannelSettingsStore { name: string, options: ChannelSettingsMutationOptions, ): Promise { + assertSafeChannelName(name); const current = this.assertRevision(options.expectedRevision); const channels = { ...current.channels }; delete channels[name]; From f704a4c1049d8049e2c3b4d1ea658b746cd115e0 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 23 Jul 2026 01:11:53 +0800 Subject: [PATCH 3/4] fix(serve): validate startup channel names --- .../cli/src/serve/channel-settings-store.test.ts | 14 ++++++++++++++ packages/cli/src/serve/channel-settings-store.ts | 3 +++ 2 files changed, 17 insertions(+) diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts index 30fe11bb20f..271773a5473 100644 --- a/packages/cli/src/serve/channel-settings-store.test.ts +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -521,6 +521,20 @@ describe('WorkspaceChannelSettingsStore', () => { expect(next.startupNames).toEqual(['bot']); }); + it('rejects unsafe startup names without writing', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + const before = fs.readFileSync(settingsPath, 'utf8'); + + for (const name of ['__proto__', 'constructor', 'prototype']) { + await expect( + store.setStartupNames([name], { + expectedRevision: store.snapshot().revision, + }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_name' }); + expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before); + } + }); + it('rejects stale startup names without changing workspace settings', async () => { const store = new WorkspaceChannelSettingsStore(workspace); const before = fs.readFileSync(settingsPath, 'utf8'); diff --git a/packages/cli/src/serve/channel-settings-store.ts b/packages/cli/src/serve/channel-settings-store.ts index e3f8db45eb7..c0c265fb940 100644 --- a/packages/cli/src/serve/channel-settings-store.ts +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -413,6 +413,9 @@ export class WorkspaceChannelSettingsStore { names: readonly string[], options: ChannelSettingsMutationOptions, ): Promise { + for (const name of names) { + assertSafeChannelName(name); + } this.assertRevision(options.expectedRevision); const workspaceFile = loadSettings(this.workspaceCwd, { skipLoadEnvironment: true, From 6d020c09826509e44b6214bc6c6de908bbc39ba2 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 23 Jul 2026 13:12:05 +0800 Subject: [PATCH 4/4] fix(serve): reserve all channel name --- .../src/serve/channel-settings-store.test.ts | 18 ++++++++++++++++++ .../cli/src/serve/channel-settings-store.ts | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts index 271773a5473..5ec5b6c9e01 100644 --- a/packages/cli/src/serve/channel-settings-store.test.ts +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -505,6 +505,24 @@ describe('WorkspaceChannelSettingsStore', () => { } }); + it('rejects names reserved for the all startup sentinel without writing', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + const before = fs.readFileSync(settingsPath, 'utf8'); + + for (const name of ['all', ' all ']) { + await expect( + store.upsert(name, { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'client-id', + }, + }), + ).rejects.toMatchObject({ code: 'channel_settings_invalid_name' }); + expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before); + } + }); + it('writes startup names separately while preserving settings and formatting', async () => { const store = new WorkspaceChannelSettingsStore(workspace); diff --git a/packages/cli/src/serve/channel-settings-store.ts b/packages/cli/src/serve/channel-settings-store.ts index c0c265fb940..6f1268f8f5a 100644 --- a/packages/cli/src/serve/channel-settings-store.ts +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -82,6 +82,16 @@ function assertSafeChannelName(name: string): void { } } +function assertUpsertChannelName(name: string): void { + assertSafeChannelName(name); + if (isAllStartupName(name)) { + throw new ChannelSettingsError( + 'channel_settings_invalid_name', + `Channel name ${JSON.stringify(name)} is reserved for startup selection.`, + ); + } +} + function isAllStartupName(name: string): boolean { return name.trim() === 'all'; } @@ -328,7 +338,7 @@ export class WorkspaceChannelSettingsStore { name: string, options: ChannelSettingsUpsertOptions, ): Promise { - assertSafeChannelName(name); + assertUpsertChannelName(name); const secretUpdates: unknown = options.secrets === undefined ? {} : options.secrets; assertValidChannelSecretUpdates(secretUpdates);