Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/users/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 56 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2299,6 +2299,7 @@ export async function loadCliConfig(
trustedFolder,
useRipgrep: settings.tools?.useRipgrep,
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
workflowsEnabled: settings.tools?.workflowsEnabled,
Comment thread
qqqys marked this conversation as resolved.
Comment thread
qqqys marked this conversation as resolved.
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
shellDefaultTimeoutMs: settings.tools?.shell?.defaultTimeoutMs,
shellHeartbeatIntervalMs: settings.tools?.shell?.heartbeatIntervalMs,
Expand Down
108 changes: 108 additions & 0 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3333,6 +3333,114 @@ 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', () => {
Comment thread
qqqys marked this conversation as resolved.
(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('allowedInsecureVoiceBaseUrls scope handling', () => {
it('should honor the allowlist from user scope', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
Expand Down
55 changes: 40 additions & 15 deletions packages/cli/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,17 @@ 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.
const workspaceFile = loadedSettings.forScope(SettingScope.Workspace);
if (
workspaceFile.rawJson !== undefined &&
workspaceFile.originalSettings.tools?.workflowsEnabled !== undefined
) {
Comment thread
qqqys marked this conversation as resolved.
Outdated
warningSet.add(
`Warning: tools.workflowsEnabled 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?.allowPrivateNetworkHooks !==
Expand Down Expand Up @@ -408,24 +416,38 @@ 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.
* Settings that can grant sensitive or costly capabilities must never be
* honored from Workspace scope. Strip them before merging so a repository
* cannot opt the user into those capabilities. Returns a shallow copy.
Comment thread
qqqys marked this conversation as resolved.
Outdated
*/
function stripWorkspaceSecurityBypasses(settings: Settings): Settings {
function stripWorkspaceRestrictedSettings(settings: Settings): Settings {
const tools = settings.tools;
let safeTools = tools;
if (tools?.workflowsEnabled !== undefined) {
const { workflowsEnabled: _workflowsEnabled, ...restTools } = tools;
Comment thread
qqqys marked this conversation as resolved.
Outdated
safeTools = restTools;
}
const security = settings.security;
let safeSecurity = security;
if (
settings.security?.allowPrivateNetworkHooks === undefined &&
settings.security?.allowedInsecureVoiceBaseUrls === undefined
security?.allowPrivateNetworkHooks !== undefined ||
security?.allowedInsecureVoiceBaseUrls !== undefined
) {
const {
allowPrivateNetworkHooks: _privateHooks,
allowedInsecureVoiceBaseUrls: _insecureVoice,
...restSecurity
} = security;
safeSecurity = restSecurity;
}
if (safeTools === tools && safeSecurity === security) {
return settings;
}
const {
allowPrivateNetworkHooks: _privateHooks,
allowedInsecureVoiceBaseUrls: _insecureVoice,
...restSecurity
} = settings.security;
return { ...settings, security: restSecurity };
return {
...settings,
...(safeTools !== tools ? { tools: safeTools } : {}),
...(safeSecurity !== security ? { security: safeSecurity } : {}),
};
}

function mergeSettings(
Expand All @@ -436,7 +458,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
Expand Down
Loading
Loading