diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 8ef92c17b76..1a2fb7df289 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -355,6 +355,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c | `tools.callCommand` | string | Defines a custom shell command for calling a specific tool that was discovered using `tools.discoveryCommand`. The shell command must meet the following criteria: It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). | `undefined` | | | `tools.useRipgrep` | boolean | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | | | `tools.useBuiltinRipgrep` | boolean | Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`. | `true` | | +| `tools.workflowsEnabled` | boolean | Enable the Workflow tool, which lets the model author and run a script that orchestrates subagents in parallel. Off by default; a run can dispatch many subagents and spend tokens accordingly. | `false` | User, System, and SystemDefaults scopes only; workspace values are ignored. Requires restart: Yes. Env overrides: `QWEN_CODE_ENABLE_WORKFLOWS=1` forces on; `QWEN_CODE_DISABLE_WORKFLOWS=1` forces off (disable wins). | | `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes | | `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes | | `tools.computerUse.enabled` | boolean | Enable the built-in Computer Use tools (cua-driver native desktop automation). When `true` (default), the `computer_use__*` tools are registered as deferred built-ins; the first invocation downloads the pinned, signed cua-driver binary into `~/.qwen/computer-use/` and walks through macOS Accessibility / Screen Recording permissions. | `true` | Requires restart: Yes | diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 41a5e44ea8d..4559d783ae7 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -133,7 +133,7 @@ Commands for managing AI tools and models. > [!note] > -> `/workflows`, `/lsp`, and `/trust` are registered only when their feature is enabled — via the `QWEN_CODE_ENABLE_WORKFLOWS=1` env var, the `--experimental-lsp` CLI flag, and the `security.folderTrust.enabled` setting respectively. When disabled they won't appear and will report an unknown command. Similarly, `/dream` and `/forget` are registered only when managed auto-memory is available; without it they won't appear. +> `/workflows`, `/lsp`, and `/trust` are registered only when their feature is enabled — via the user/system-scoped `tools.workflowsEnabled` setting or `QWEN_CODE_ENABLE_WORKFLOWS=1` env var, the `--experimental-lsp` CLI flag, and the `security.folderTrust.enabled` setting respectively. Workspace values for `tools.workflowsEnabled` are ignored. When disabled these commands won't appear and will report an unknown command. Similarly, `/dream` and `/forget` are registered only when managed auto-memory is available; without it they won't appear. ### 1.5 Built-in Skills diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c995ac20bc2..2e06ec97626 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -3925,6 +3925,62 @@ describe('loadCliConfig useBuiltinRipgrep', () => { }); }); +describe('loadCliConfig workflowsEnabled', () => { + const originalArgv = process.argv; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.stubEnv('QWEN_CODE_ENABLE_WORKFLOWS', undefined); + vi.stubEnv('QWEN_CODE_DISABLE_WORKFLOWS', undefined); + }); + + afterEach(() => { + process.argv = originalArgv; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should be disabled by default when workflowsEnabled is not set in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = {}; + const config = await loadCliConfig(settings, argv, undefined, []); + expect(config.isWorkflowsEnabled()).toBe(false); + }); + + // The regression this whole setting exists to prevent: `workflowsEnabled` + // was declared on ConfigParameters and read by isWorkflowsEnabled(), but + // loadCliConfig never wrote it — so the setting was a dead switch and the + // env var was the only way in. + it('should be enabled when workflowsEnabled is set to true in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { tools: { workflowsEnabled: true } }; + const config = await loadCliConfig(settings, argv, undefined, []); + expect(config.isWorkflowsEnabled()).toBe(true); + }); + + it('should let the QWEN_CODE_DISABLE_WORKFLOWS kill switch override a true setting', async () => { + vi.stubEnv('QWEN_CODE_DISABLE_WORKFLOWS', '1'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { tools: { workflowsEnabled: true } }; + const config = await loadCliConfig(settings, argv, undefined, []); + expect(config.isWorkflowsEnabled()).toBe(false); + }); + + it('should let QWEN_CODE_ENABLE_WORKFLOWS enable workflows when the setting is false', async () => { + vi.stubEnv('QWEN_CODE_ENABLE_WORKFLOWS', '1'); + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { tools: { workflowsEnabled: false } }; + const config = await loadCliConfig(settings, argv, undefined, []); + expect(config.isWorkflowsEnabled()).toBe(true); + }); +}); + describe('screenReader configuration', () => { const originalArgv = process.argv; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d27226420ef..6a74a6f3eb7 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2299,6 +2299,7 @@ export async function loadCliConfig( trustedFolder, useRipgrep: settings.tools?.useRipgrep, useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep, + workflowsEnabled: settings.tools?.workflowsEnabled, shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell, shellDefaultTimeoutMs: settings.tools?.shell?.defaultTimeoutMs, shellHeartbeatIntervalMs: settings.tools?.shell?.heartbeatIntervalMs, diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 87db8628a0a..466d8819cc9 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -71,6 +71,10 @@ import { ENV_CORRUPTED_PATH, ENV_WAS_RECOVERED, } from './settings.js'; +import { + WORKSPACE_RESTRICTED_SETTINGS, + WORKSPACE_RESTRICTED_SETTING_KEYS, +} from '../utils/settingsUtils.js'; import { needsMigration } from './migration/index.js'; import { QWEN_DIR } from '@qwen-code/qwen-code-core'; @@ -3333,6 +3337,162 @@ describe('Settings Loading and Merging', () => { }); }); + describe('workflowsEnabled scope handling', () => { + it.each([ + ['system defaults', getSystemDefaultsPath()], + ['system', getSystemSettingsPath()], + ])('should honor %s scope settings', (_scope, settingsPath) => { + (mockFsExistsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === settingsPath) + return JSON.stringify({ tools: { workflowsEnabled: true } }); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.tools?.workflowsEnabled).toBe(true); + }); + + it('should ignore workspace scope while preserving the user value', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify({ tools: { workflowsEnabled: false } }); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify({ + tools: { workflowsEnabled: true, useRipgrep: false }, + }); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.tools?.workflowsEnabled).toBe(false); + expect(settings.merged.tools?.useRipgrep).toBe(false); + }); + + it('should ignore an explicit workspace false and preserve a user opt-in', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify({ tools: { workflowsEnabled: true } }); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify({ tools: { workflowsEnabled: false } }); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.tools?.workflowsEnabled).toBe(true); + expect( + getSettingsWarnings(settings).some((warning) => + warning.includes('tools.workflowsEnabled'), + ), + ).toBe(true); + }); + + it('should ignore workspace env overrides for workflow enablement', () => { + delete process.env['QWEN_CODE_ENABLE_WORKFLOWS']; + delete process.env['QWEN_CODE_DISABLE_WORKFLOWS']; + (mockFsExistsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify({ tools: { workflowsEnabled: true } }); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify({ + env: { + QWEN_CODE_ENABLE_WORKFLOWS: '1', + QWEN_CODE_DISABLE_WORKFLOWS: '1', + }, + }); + return '{}'; + }, + ); + + try { + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.tools?.workflowsEnabled).toBe(true); + expect(process.env['QWEN_CODE_ENABLE_WORKFLOWS']).toBeUndefined(); + expect(process.env['QWEN_CODE_DISABLE_WORKFLOWS']).toBeUndefined(); + } finally { + delete process.env['QWEN_CODE_ENABLE_WORKFLOWS']; + delete process.env['QWEN_CODE_DISABLE_WORKFLOWS']; + } + }); + + it('should warn when workspace settings define workflowsEnabled', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify({ tools: { workflowsEnabled: true } }); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.tools?.workflowsEnabled).toBeUndefined(); + expect( + getSettingsWarnings(settings).some((warning) => + warning.includes('tools.workflowsEnabled'), + ), + ).toBe(true); + }); + }); + + describe('WORKSPACE_RESTRICTED_SETTINGS as the single source', () => { + // R4-3: the strip, the warning and the dialog filter all derive from this + // list. A key present here but unstripped would be honored from a repo's + // settings while the warning claimed it was ignored — the exact drift the + // hand-maintained trio allowed. + it('strips and warns for every listed key, driven by the list itself', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const workspacePayload: Record> = {}; + for (const { section, key } of WORKSPACE_RESTRICTED_SETTINGS) { + workspacePayload[section] ??= {}; + workspacePayload[section][key] = + key === 'allowedInsecureVoiceBaseUrls' + ? ['http://voice.example/v1'] + : true; + } + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspacePayload); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + const warnings = getSettingsWarnings(settings); + for (const { section, key } of WORKSPACE_RESTRICTED_SETTINGS) { + const merged = settings.merged[section] as + | Record + | undefined; + expect(merged?.[key]).toBeUndefined(); + expect( + warnings.some((warning) => warning.includes(`${section}.${key}`)), + ).toBe(true); + } + }); + + it('exposes every key in dotted form for the dialog filter', () => { + expect(WORKSPACE_RESTRICTED_SETTING_KEYS).toEqual( + WORKSPACE_RESTRICTED_SETTINGS.map( + ({ section, key }) => `${section}.${key}`, + ), + ); + expect(WORKSPACE_RESTRICTED_SETTING_KEYS).toContain( + 'tools.workflowsEnabled', + ); + }); + }); + describe('allowedInsecureVoiceBaseUrls scope handling', () => { it('should honor the allowlist from user scope', () => { (mockFsExistsSync as Mock).mockReturnValue(true); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index dde69e27621..3ecf75b03fe 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -31,7 +31,10 @@ import { getSettingsSchema, } from './settingsSchema.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; -import { setNestedPropertySafe } from '../utils/settingsUtils.js'; +import { + setNestedPropertySafe, + WORKSPACE_RESTRICTED_SETTINGS, +} from '../utils/settingsUtils.js'; import { customDeepMerge } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/jsonc-editor.js'; import { runMigrations, needsMigration } from './migration/index.js'; @@ -358,26 +361,21 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] { warningSet.add(warning); } - // security.allowPrivateNetworkHooks is stripped from Workspace scope during + // Settings restricted to trusted scopes are stripped from Workspace during // the merge; warn so the user knows their workspace setting has no effect. + // Driven by WORKSPACE_RESTRICTED_SETTINGS so the warning cannot drift from + // the strip that produces it. const workspaceFile = loadedSettings.forScope(SettingScope.Workspace); - if ( - workspaceFile.rawJson !== undefined && - workspaceFile.originalSettings.security?.allowPrivateNetworkHooks !== - undefined - ) { - warningSet.add( - `Warning: security.allowPrivateNetworkHooks in workspace settings (${workspaceFile.path}) is ignored. This setting is only honored from User, System, or SystemDefaults scope settings.`, - ); - } - if ( - workspaceFile.rawJson !== undefined && - workspaceFile.originalSettings.security?.allowedInsecureVoiceBaseUrls !== - undefined - ) { - warningSet.add( - `Warning: security.allowedInsecureVoiceBaseUrls in workspace settings (${workspaceFile.path}) is ignored. This setting is only honored from User, System, or SystemDefaults scope settings.`, - ); + if (workspaceFile.rawJson !== undefined) { + for (const { section, key } of WORKSPACE_RESTRICTED_SETTINGS) { + const sectionValue = workspaceFile.originalSettings[section] as + | Record + | undefined; + if (sectionValue?.[key] === undefined) continue; + warningSet.add( + `Warning: ${section}.${key} in workspace settings (${workspaceFile.path}) is ignored. This setting is only honored from User, System, or SystemDefaults scope settings.`, + ); + } } return [...warningSet]; @@ -408,24 +406,21 @@ function tagMcpServerScope( } /** - * Network security bypasses must never be honored from Workspace scope — - * otherwise a malicious repository could self-grant access to private - * infrastructure. Strip them from workspace settings before merging. - * Returns a shallow copy — never mutates input. + * Strip the workspace-restricted settings before merging so a repository + * cannot opt the user into those capabilities. Returns a shallow copy, and + * the input unchanged when it carries none of them. */ -function stripWorkspaceSecurityBypasses(settings: Settings): Settings { - if ( - settings.security?.allowPrivateNetworkHooks === undefined && - settings.security?.allowedInsecureVoiceBaseUrls === undefined - ) { - return settings; +function stripWorkspaceRestrictedSettings(settings: Settings): Settings { + let stripped: Settings | undefined; + for (const { section, key } of WORKSPACE_RESTRICTED_SETTINGS) { + const source = (stripped ?? settings)[section] as + | Record + | undefined; + if (source?.[key] === undefined) continue; + const { [key]: _restricted, ...rest } = source; + stripped = { ...(stripped ?? settings), [section]: rest } as Settings; } - const { - allowPrivateNetworkHooks: _privateHooks, - allowedInsecureVoiceBaseUrls: _insecureVoice, - ...restSecurity - } = settings.security; - return { ...settings, security: restSecurity }; + return stripped ?? settings; } function mergeSettings( @@ -436,7 +431,10 @@ function mergeSettings( isTrusted: boolean, ): Settings { const safeWorkspace = isTrusted - ? tagMcpServerScope(stripWorkspaceSecurityBypasses(workspace), 'workspace') + ? tagMcpServerScope( + stripWorkspaceRestrictedSettings(workspace), + 'workspace', + ) : ({} as Settings); // Settings are merged with the following precedence (last one wins for diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 2b0dbf6ce15..23ef4e8128c 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -139,6 +139,22 @@ describe('SettingsSchema', () => { }); }); + // requiresRestart is load-bearing, not decorative: the Workflow tool is + // registered once while the tool registry is built, so a mid-session + // toggle would leave the dialog claiming the feature is on while the + // tool is absent from the registry. + it('should keep dynamic workflows opt-in and restart-scoped', () => { + expect( + getSettingsSchema().tools.properties.workflowsEnabled, + ).toMatchObject({ + type: 'boolean', + default: false, + requiresRestart: true, + showInDialog: true, + category: 'Tools', + }); + }); + it('should expose cumulative tool result threshold in clearContextOnIdle', () => { const threshold = getSettingsSchema().context.properties.clearContextOnIdle.properties diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e8c92f65bbb..a0d53d37575 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2702,6 +2702,21 @@ const SETTINGS_SCHEMA = { 'Use the bundled ripgrep binary. When set to false, the system-level "rg" command will be used instead. This setting is only effective when useRipgrep is true.', showInDialog: false, }, + workflowsEnabled: { + type: 'boolean', + label: 'Dynamic Workflows', + category: 'Tools', + // The Workflow tool is registered once while building the tool + // registry and /workflows is gated when commands load. Keyword + // steering reads the same startup-built Config on each submission, + // so changing the settings file mid-session cannot update any of the + // three surfaces until the next launch. + requiresRestart: true, + default: false, + description: + 'Enable the Workflow tool, which lets the model author and run a script that orchestrates subagents in parallel. Off by default; a run can dispatch many subagents and spend tokens accordingly. The QWEN_CODE_ENABLE_WORKFLOWS=1 and QWEN_CODE_DISABLE_WORKFLOWS=1 environment variables override this setting (disable wins). Unrelated to the Session Workflow plan-and-review view; to stop the "workflow" keyword from steering a turn, see Disable Workflow Keyword Trigger.', + showInDialog: true, + }, truncateToolOutputThreshold: { type: 'number', label: 'Tool Output Truncation Threshold', diff --git a/packages/cli/src/config/shared-env-keys.ts b/packages/cli/src/config/shared-env-keys.ts index 0dd7e641b16..5ff25714ff4 100644 --- a/packages/cli/src/config/shared-env-keys.ts +++ b/packages/cli/src/config/shared-env-keys.ts @@ -39,6 +39,10 @@ export const PROJECT_ENV_HARDCODED_EXCLUSIONS = [ // Project memory routing is frozen daemon-wide before workspace env files // load, so only the operator's launch environment or CLI flag may set it. 'QWEN_CODE_MEMORY_PROJECT_SCOPE', + // Workflow execution is an explicit user opt-in. A project must not enable + // it or override a user opt-in through settings.env or a project .env. + 'QWEN_CODE_ENABLE_WORKFLOWS', + 'QWEN_CODE_DISABLE_WORKFLOWS', // QWEN_TLS_INSECURE (and NODE_TLS_REJECT_UNAUTHORIZED, which it mirrors) // disable TLS certificate verification for all outbound API connections. A // project `.env` must never enable either — that would let an untrusted repo diff --git a/packages/cli/src/serve/routes/workspace-settings.test.ts b/packages/cli/src/serve/routes/workspace-settings.test.ts index 212afb7db03..aac6da4fd5e 100644 --- a/packages/cli/src/serve/routes/workspace-settings.test.ts +++ b/packages/cli/src/serve/routes/workspace-settings.test.ts @@ -7,7 +7,10 @@ import { beforeEach, describe, it, expect, vi } from 'vitest'; import express from 'express'; import request from 'supertest'; -import { registerWorkspaceSettingsRoutes } from './workspace-settings.js'; +import { + registerWorkspaceQualifiedSettingsRoutes, + registerWorkspaceSettingsRoutes, +} from './workspace-settings.js'; import { loadSettings } from '../../config/settings.js'; import { WorkspaceGenerationClosedError } from '../workspace-registry.js'; @@ -55,6 +58,43 @@ function makeApp( return { app, persistSetting, broadcastSettingsChanged }; } +/** Minimal registry for the workspace-qualified routes: one active, trusted entry. */ +function makeQualifiedApp() { + const app = express(); + app.use(express.json()); + const persistSetting = vi.fn(async () => {}); + const registry = { + getEntryByWorkspaceId: (selector: string) => + selector === 'primary' + ? { + state: 'active', + current: { + runtime: { + trusted: true, + workspaceCwd: '/workspace', + bridge: {}, + generationGuard: undefined, + }, + }, + } + : undefined, + }; + + registerWorkspaceQualifiedSettingsRoutes(app, { + mutate: () => (_req, _res, next) => next(), + safeBody: (req) => + req.body && typeof req.body === 'object' ? req.body : {}, + persistSetting, + workspaceRegistry: + registry as unknown as Parameters< + typeof registerWorkspaceQualifiedSettingsRoutes + >[1]['workspaceRegistry'], + invalidateServeFeaturesCache: () => {}, + }); + + return { app, persistSetting }; +} + describe('POST /workspace/settings', () => { it('exposes the Live shortcut as user-global and rejects generic writes', async () => { vi.mocked(loadSettings).mockReturnValue({ @@ -232,6 +272,39 @@ describe('POST /workspace/settings', () => { expect(persistSetting).not.toHaveBeenCalled(); }); + // R8-1: `stripWorkspaceRestrictedSettings` drops these before every merge, so + // a workspace-scope write persists a committable dead entry into the repo's + // .qwen/settings.json and answers 200 + requiresRestart while the feature + // never turns on. The TUI dialog already filters them; the API did not. + it('rejects a workspace-restricted key at workspace scope', async () => { + const { app, persistSetting } = makeApp(); + + const res = await request(app).post('/workspace/settings').send({ + scope: 'workspace', + key: 'tools.workflowsEnabled', + value: true, + }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'workspace_restricted_setting' }); + expect(persistSetting).not.toHaveBeenCalled(); + }); + + it('still accepts the same key at user scope', async () => { + // User scope honors the setting — the guard must not reach beyond + // workspace scope, or this PR's whole enablement path dies with it. + const { app, persistSetting } = makeApp(); + + const res = await request(app).post('/workspace/settings').send({ + scope: 'user', + key: 'tools.workflowsEnabled', + value: true, + }); + + expect(res.status).toBe(200); + expect(persistSetting).toHaveBeenCalled(); + }); + it('rejects a security-sensitive key even at user scope', async () => { // Enabling user-scope writes must not expose SECURITY_SENSITIVE_SETTINGS // (e.g. tools.approvalMode) — getAllowedKeys() filters them out regardless @@ -478,3 +551,22 @@ describe('POST /workspace/settings', () => { }, ); }); + +describe('POST /workspaces/:workspace/settings', () => { + // R8-1, second call site: the qualified route accepts workspace scope only, + // so without the guard it is the easier of the two paths to write a dead + // entry through. Fixing the sibling route does not fix this one. + it('rejects a workspace-restricted key', async () => { + const { app, persistSetting } = makeQualifiedApp(); + + const res = await request(app).post('/workspaces/primary/settings').send({ + scope: 'workspace', + key: 'tools.workflowsEnabled', + value: true, + }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'workspace_restricted_setting' }); + expect(persistSetting).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/serve/routes/workspace-settings.ts b/packages/cli/src/serve/routes/workspace-settings.ts index 2adbec738e6..32bcb60043b 100644 --- a/packages/cli/src/serve/routes/workspace-settings.ts +++ b/packages/cli/src/serve/routes/workspace-settings.ts @@ -20,6 +20,7 @@ import { getNestedProperty, getSettingDefinition, validateSettingValue, + WORKSPACE_RESTRICTED_SETTING_KEYS, } from '../../utils/settingsUtils.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { parseAndValidateWorkspaceClientId } from '../server/request-helpers.js'; @@ -102,6 +103,37 @@ interface SettingsResponse { const SECURITY_SENSITIVE_SETTINGS = new Set(['tools.approvalMode']); +/** + * Refuse a workspace-scope write of a setting the merge strips anyway. + * + * R8-1: `stripWorkspaceRestrictedSettings` drops these before every merge, so + * persisting one at workspace scope writes a committable dead entry into the + * repo's `.qwen/settings.json` and answers 200 + `requiresRestart: true` while + * the feature never turns on — GET then reports `workspace: true` beside + * `effective: false`, and the warnings channel carries only `corrupted`, so the + * client never learns the write was inert. `tools.workflowsEnabled` is the + * first restricted key with `showInDialog: true`, which is what puts it in + * `getAllowedKeys()` and made this reachable. The TUI dialog already filters + * these; this is the same trap one layer over. + * + * User scope is untouched — that scope honors the key. + * + * Returns true when the request was answered and the caller must stop. + */ +function rejectWorkspaceRestrictedWrite( + res: Response, + scope: string, + key: string, +): boolean { + if (scope !== 'workspace' || !WORKSPACE_RESTRICTED_SETTING_KEYS.includes(key)) + return false; + res.status(400).json({ + error: `Setting "${key}" is not honored from workspace scope; set it at user scope instead`, + code: 'workspace_restricted_setting', + }); + return true; +} + function getAllowedKeys(includeLiveVoice = false): Set { const keys = new Set( getDialogSettingKeys().filter( @@ -389,6 +421,8 @@ export function registerWorkspaceSettingsRoutes( return; } + if (rejectWorkspaceRestrictedWrite(res, scope, key)) return; + if (LIVE_MANAGED_SETTINGS.has(key)) { res.status(400).json({ error: `Setting "${key}" must be changed through the Live setup API`, @@ -599,6 +633,8 @@ export function registerWorkspaceQualifiedSettingsRoutes( }); return; } + + if (rejectWorkspaceRestrictedWrite(res, scope, key)) return; if (LIVE_MANAGED_SETTINGS.has(key)) { res.status(400).json({ error: `Setting "${key}" must be changed through the Live setup API`, diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index e284218b92d..18692cf995f 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -253,6 +253,24 @@ describe('BuiltinCommandLoader', () => { expect(enabledCommands.find((c) => c.name === 'lsp')).toBeDefined(); }); + it('should include workflows command only when workflows are enabled', async () => { + const disabledLoader = new BuiltinCommandLoader(mockConfig); + const disabledCommands = await disabledLoader.loadCommands( + new AbortController().signal, + ); + expect( + disabledCommands.find((c) => c.name === 'workflows'), + ).toBeUndefined(); + + (mockConfig.isWorkflowsEnabled as Mock).mockReturnValue(true); + const enabledLoader = new BuiltinCommandLoader(mockConfig); + const enabledCommands = await enabledLoader.loadCommands( + new AbortController().signal, + ); + + expect(enabledCommands.find((c) => c.name === 'workflows')).toBeDefined(); + }); + it('should still load all other commands when ideCommand() throws', async () => { // Simulate ideCommand() failure (e.g., platform-specific process detection fails) const { ideCommand: ideCommandMock } = await import( diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 79c6ee2bf4b..648ef856b2e 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -111,10 +111,10 @@ export class BuiltinCommandLoader implements ICommandLoader { agentsCommand, tasksCommand, // Gated behind isWorkflowsEnabled — feature flag honors - // QWEN_CODE_ENABLE_WORKFLOWS (opt-in) and QWEN_CODE_DISABLE_WORKFLOWS - // (kill switch). When the flag is off the command vanishes entirely - // from typeahead and help, matching the established convention for - // experimental builtins. + // tools.workflowsEnabled, QWEN_CODE_ENABLE_WORKFLOWS (opt-in), and + // QWEN_CODE_DISABLE_WORKFLOWS (kill switch). When the flag is off the + // command vanishes entirely from typeahead and help, matching the + // established convention for experimental builtins. this.config?.isWorkflowsEnabled() ? workflowsCommand : null, arenaCommand, approvalModeCommand, diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index b650e3c8b41..e1764afa60d 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -217,7 +217,15 @@ export function SettingsDialog({ }, [selectedScope, settings, globalPendingChanges]); const generateSettingsItems = () => { - const settingKeys = getDialogSettingKeys(); + // Workspace-restricted settings are stripped before the merge, so offering + // them under Workspace scope is a trap: the dialog renders from the raw + // scope file, so a toggle here would keep displaying the value it wrote + // while the feature stayed at its merged value, leaving a dead entry in + // the repo's .qwen/settings.json. They stay listed under the scopes that + // do honor them. + const settingKeys = getDialogSettingKeys({ + excludeWorkspaceRestricted: selectedScope === SettingScope.Workspace, + }); return settingKeys.map((key: string) => { const definition = getSettingDefinition(key); diff --git a/packages/cli/src/utils/settingsUtils.test.ts b/packages/cli/src/utils/settingsUtils.test.ts index 035d34e64c1..3a07b4ce298 100644 --- a/packages/cli/src/utils/settingsUtils.test.ts +++ b/packages/cli/src/utils/settingsUtils.test.ts @@ -22,6 +22,7 @@ import { getDialogSettingsByCategory, getDialogSettingsByType, getDialogSettingKeys, + WORKSPACE_RESTRICTED_SETTING_KEYS, // Business logic utilities getSettingValue, isSettingModified, @@ -525,6 +526,68 @@ describe('SettingsUtils', () => { }); describe('getDialogSettingKeys', () => { + // R4-2: Workspace scope strips these before the merge, so listing them + // there lets a user toggle a setting that silently never takes effect + // and writes a dead entry into the repo's .qwen/settings.json. + describe('workspace-restricted filtering', () => { + const restrictedKey = WORKSPACE_RESTRICTED_SETTING_KEYS[0]!; + const [section, leaf] = restrictedKey.split('.') as [string, string]; + + beforeEach(() => { + vi.mocked(getSettingsSchema).mockReturnValue({ + [section]: { + type: 'object', + label: section, + category: 'General', + requiresRestart: false, + default: {}, + description: 'section', + showInDialog: false, + properties: { + [leaf]: { + type: 'boolean', + label: leaf, + category: 'General', + requiresRestart: true, + default: false, + description: 'restricted', + showInDialog: true, + }, + unrestricted: { + type: 'boolean', + label: 'Unrestricted', + category: 'General', + requiresRestart: false, + default: false, + description: 'plain', + showInDialog: true, + }, + }, + }, + } as unknown as ReturnType); + }); + + it('drops the restricted key when asked to, and nothing else', () => { + // Guard against a vacuous pass: it must be dialog-visible first. + expect(getDialogSettingKeys()).toContain(restrictedKey); + + const workspaceKeys = getDialogSettingKeys({ + excludeWorkspaceRestricted: true, + }); + expect(workspaceKeys).not.toContain(restrictedKey); + expect(workspaceKeys).toContain(`${section}.unrestricted`); + }); + + it('keeps it when the option is absent or false', () => { + const allKeys = getDialogSettingKeys(); + expect(getDialogSettingKeys({})).toEqual(allKeys); + expect( + getDialogSettingKeys({ excludeWorkspaceRestricted: false }), + ).toEqual(allKeys); + expect(allKeys).toContain(restrictedKey); + }); + }); + it('should return only settings marked for dialog display', () => { const dialogKeys = getDialogSettingKeys(); diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index 1404afa9b07..91127fba237 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -359,12 +359,54 @@ export function validateSettingValue( } /** - * Get all setting keys that should be shown in the dialog, sorted by display order + * Settings that can grant sensitive or costly capabilities must never be + * honored from Workspace scope. + * + * This is the ONE list. It drives the Workspace strip + * (`stripWorkspaceRestrictedSettings`), the warning that tells the user their + * workspace value was ignored, and the settings dialog's scope filter — so + * adding a restricted setting is a single edit here, and the three surfaces + * cannot drift apart. Previously each was hand-maintained: forgetting the + * warning discarded a workspace value silently, and forgetting the strip + * honored a value the warning said was ignored. + * + * It lives here, not in `settings.ts`: that module already value-imports + * this one, so defining it there and importing it back would close a + * runtime import cycle. */ -export function getDialogSettingKeys(): string[] { +export const WORKSPACE_RESTRICTED_SETTINGS = [ + { section: 'tools', key: 'workflowsEnabled' }, + { section: 'security', key: 'allowPrivateNetworkHooks' }, + { section: 'security', key: 'allowedInsecureVoiceBaseUrls' }, +] as const satisfies ReadonlyArray<{ + readonly section: keyof Settings; + readonly key: string; +}>; + +/** The restricted settings as flattened dotted keys, e.g. `tools.workflowsEnabled`. */ +export const WORKSPACE_RESTRICTED_SETTING_KEYS: readonly string[] = + WORKSPACE_RESTRICTED_SETTINGS.map(({ section, key }) => `${section}.${key}`); + +/** + * Get all setting keys that should be shown in the dialog, sorted by display order. + * + * `excludeWorkspaceRestricted` drops the settings that are stripped before the + * merge — the caller passes it when the dialog's selected scope is Workspace, + * where offering them would only write a dead entry into the repo's settings + * file. The scope comparison stays with the caller so this module keeps its + * type-only dependency on `settings.ts`. + */ +export function getDialogSettingKeys(options?: { + excludeWorkspaceRestricted?: boolean; +}): string[] { const dialogSettings = Object.values(getFlattenedSchema()) .filter((definition) => definition.showInDialog === true) - .map((definition) => definition.key); + .map((definition) => definition.key) + .filter( + (key) => + !options?.excludeWorkspaceRestricted || + !WORKSPACE_RESTRICTED_SETTING_KEYS.includes(key), + ); // Sort by explicit order; settings not in the order array appear at the end return dialogSettings.sort((a, b) => { diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 02e47a81148..f88b5de0f2a 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -1288,6 +1288,11 @@ "type": "boolean", "default": true }, + "workflowsEnabled": { + "description": "Enable the Workflow tool, which lets the model author and run a script that orchestrates subagents in parallel. Off by default; a run can dispatch many subagents and spend tokens accordingly. The QWEN_CODE_ENABLE_WORKFLOWS=1 and QWEN_CODE_DISABLE_WORKFLOWS=1 environment variables override this setting (disable wins). Unrelated to the Session Workflow plan-and-review view; to stop the \"workflow\" keyword from steering a turn, see Disable Workflow Keyword Trigger.", + "type": "boolean", + "default": false + }, "truncateToolOutputThreshold": { "description": "Truncate tool output if it is larger than this many characters. Set to -1 to disable.", "type": "number",