refactor: centralize approval mode contracts (#9796)

* refactor: centralize approval mode contracts

* fix: align Python SDK import grouping

* test: restore approval mode exports in CLI mocks

* fix: close approval mode drift gaps

* test(cli): preserve core exports in serve mocks

* fix: close approval mode review gaps

* test(cli): complete permission request fixture

* test(sdk): match approval mode route

* test(approval): close review coverage gaps

* test(sdk): cover approval mode global scope path
This commit is contained in:
易良 authored and GitHub committed 2026-08-24 07:46:50 +00:00
1 parent a60cbbc54a
commit 27285a5243
39 files changed
+421 -255

No files matched your search

+2
View File
@@ -7,6 +7,7 @@ on:
- 'release/**'
paths:
- 'packages/sdk-java/**'
- 'packages/core/src/config/approval-modes.json'
- 'packages/cli/src/commands/serve.ts'
- 'packages/cli/src/serve/**'
- 'packages/cli/src/acp-integration/**'
@@ -25,6 +26,7 @@ on:
- 'release/**'
paths:
- 'packages/sdk-java/**'
- 'packages/core/src/config/approval-modes.json'
- 'packages/cli/src/commands/serve.ts'
- 'packages/cli/src/serve/**'
- 'packages/cli/src/acp-integration/**'
+2
View File
@@ -10,6 +10,7 @@ on:
- 'release/**'
paths:
- 'packages/sdk-python/**'
- 'packages/core/src/config/approval-modes.json'
- '.github/workflows/sdk-python.yml'
push:
branches:
@@ -17,6 +18,7 @@ on:
- 'release/**'
paths:
- 'packages/sdk-python/**'
- 'packages/core/src/config/approval-modes.json'
- '.github/workflows/sdk-python.yml'
jobs:
@@ -0,0 +1,36 @@
# Approval Mode Contract
## Decision
Keep `ApprovalMode` and `APPROVAL_MODES` in core as the runtime source of
truth. TypeScript packages derive their local types and validators from either
that core contract or the TypeScript SDK's checked tuple. Python and Java keep
native public types, with their accepted values checked against a small JSON
fixture that is also checked against core.
This avoids adding a runtime dependency from the published SDKs to core and
does not introduce code generation for one five-value domain.
## Changes
- Export the string-union form of core's `ApprovalMode`.
- Replace repeated TypeScript unions and validation arrays with the core or
SDK contract.
- Type the CLI and TypeScript SDK system-message `permission_mode` fields with
the shared union.
- Check core, TypeScript, Python, and Java accepted values against one fixture
in their existing test suites.
- Trigger the Python and Java SDK workflows when the fixture changes.
Adjacent value domains such as channel modes, hook permission decisions, and
desktop cycling preferences remain independent because their supported values
and semantics intentionally differ.
## Verification
- Core approval-mode tests.
- CLI ACP and non-interactive type checking plus focused tests.
- TypeScript SDK drift and query-option tests.
- Python validation tests.
- Java permission-mode tests.
- Relevant lint, typecheck, and build commands.
+6 -10
View File
@@ -20,6 +20,7 @@ import type {
WriteTextFileResponse,
} from '@agentclientprotocol/sdk';
import { RequestError } from '@agentclientprotocol/sdk';
import { APPROVAL_MODES } from '@qwen-code/qwen-code-core';
import type { BridgeEvent, EventBus } from './eventBus.js';
// Wire constants shared with the child-side caller (`Session.ts`) and, for the
// SSE event type, the SDK validator + browser consumer — single sources of truth
@@ -539,17 +540,12 @@ const MAX_SUGGESTION_LENGTH = 500;
const EARLY_EVENT_TTL_MS = 60_000;
// Known approval-mode ids accepted on the in-session `current_mode_update`
// demux path. Mirrors the `modeMap` keys in `Session.setMode` (CLI); an id
// outside this set is dropped before it fans out to SSE clients / the SDK
// reducer. Keep the two in lockstep. Exported so the bridge's reconcile and
// demux path. An id outside this set is dropped before it fans out to SSE
// clients / the SDK reducer. Exported so the bridge's reconcile and
// snapshot-seed paths apply the same enum backstop to agent-supplied mode ids.
export const KNOWN_APPROVAL_MODES: ReadonlySet<string> = new Set([
'plan',
'default',
'auto-edit',
'auto',
'yolo',
]);
export const KNOWN_APPROVAL_MODES: ReadonlySet<string> = new Set(
APPROVAL_MODES,
);
/**
* Human-readable label for a `fs.Stats` object's kind, used in the
@@ -296,8 +296,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
})),
dispose: vi.fn(),
})),
APPROVAL_MODE_INFO: {},
APPROVAL_MODES: [],
APPROVAL_MODE_INFO: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).APPROVAL_MODE_INFO,
APPROVAL_MODES: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).APPROVAL_MODES,
applyReasoningEffort: (
config: {
setReasoningEffort(effort: string | undefined): void;
@@ -7979,7 +7983,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
workspaceCwd: '/tmp',
state: {
models: { currentModelId: 'm(api-key)', availableModels: [] },
modes: { currentModeId: 'default', availableModes: [] },
modes: {
currentModeId: 'default',
availableModes: APPROVAL_MODES.map((id) => ({ id })),
},
},
});
expect(supportedCommands).toEqual({
+1 -1
View File
@@ -1321,7 +1321,7 @@ const QWEN_CORE_SETTING_DEFINITIONS = {
'general.language': { type: 'string' },
'tools.approvalMode': {
type: 'enum',
values: ['plan', 'default', 'auto-edit', 'auto', 'yolo'],
values: APPROVAL_MODES,
},
'general.vimMode': { type: 'boolean' },
'general.enableAutoUpdate': { type: 'boolean' },
@@ -5,6 +5,7 @@
*/
import type {
ApprovalModeValue,
Config,
ToolArtifact,
ToolResultBoundaryArtifact,
@@ -17,12 +18,7 @@ import type {
} from '@agentclientprotocol/sdk';
import type { MessageRewriteMiddleware } from './rewrite/index.js';
export type ApprovalModeValue =
| 'plan'
| 'default'
| 'auto-edit'
| 'auto'
| 'yolo';
export type { ApprovalModeValue };
/**
* Interface for sending session updates to the ACP client.
@@ -4,17 +4,12 @@ import type {
ChannelWebhookSourceConfig,
ChannelWebhookTargetConfig,
} from '@qwen-code/channel-base';
import { APPROVAL_MODES } from '@qwen-code/qwen-code-core';
import { resolveChannelCwd } from './channel-cwd.js';
import { getPlugin, supportedTypes } from './channel-registry.js';
const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
const CHANNEL_APPROVAL_MODES = new Set([
'plan',
'default',
'auto-edit',
'auto',
'yolo',
]);
const CHANNEL_APPROVAL_MODES = new Set<string>(APPROVAL_MODES);
export { findCliEntryPath } from './cli-entry-path.js';
@@ -14,7 +14,10 @@ import {
sessionsPath,
} from './runtime.js';
vi.mock('@qwen-code/qwen-code-core', () => ({
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
APPROVAL_MODES: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).APPROVAL_MODES,
Storage: { getGlobalQwenDir: () => '/tmp/qwen' },
hashDaemonWorkspace: (workspace: string) =>
workspace === '/workspace' ? 'workspace-hash' : 'other-hash',
+5 -2
View File
@@ -26,7 +26,10 @@
// reached a verdict" from "blocking verdict" (opt-in via --fail-on).
import type { CommandModule } from 'yargs';
import { isUnusableScriptEntry } from '@qwen-code/qwen-code-core';
import {
APPROVAL_MODES,
isUnusableScriptEntry,
} from '@qwen-code/qwen-code-core';
import { spawn, execFileSync } from 'node:child_process';
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
@@ -677,7 +680,7 @@ export const runCommand: CommandModule = {
.option('approval-mode', {
type: 'string',
default: 'yolo',
choices: ['plan', 'default', 'auto-edit', 'auto', 'yolo'],
choices: APPROVAL_MODES,
describe:
'Approval mode for the child CLI. The default is yolo: headless runs cannot answer ' +
'confirmation prompts, and anything still unapproved would be auto-denied mid-review.',
+34 -3
View File
@@ -290,6 +290,27 @@ describe('parseArguments', () => {
process.argv = originalArgv;
});
it('includes every approval mode description in --help', async () => {
process.argv = ['node', 'script.js', '--help'];
const output: string[] = [];
const log = vi.spyOn(console, 'log').mockImplementation((...args) => {
output.push(args.join(' '));
});
try {
await expect(parseArguments()).rejects.toThrow(
'process.exit unexpectedly called with "0"',
);
const help = output.join('');
for (const mode of ServerConfig.APPROVAL_MODES) {
expect(help).toContain(
`${mode} (${ServerConfig.APPROVAL_MODE_INFO[mode].description})`,
);
}
} finally {
log.mockRestore();
}
});
it('should throw an error when both --prompt and --prompt-interactive are used together', async () => {
process.argv = [
'node',
@@ -4478,9 +4499,19 @@ describe('loadCliConfig approval mode', () => {
it('should normalize approval mode values from settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {
tools: { approvalMode: ServerConfig.ApprovalMode.AUTO_EDIT },
};
const settings = {
tools: { approvalMode: 'auto_edit' },
} as unknown as Settings;
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT);
});
it('should normalize legacy autoedit approval mode from settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings = {
tools: { approvalMode: 'autoedit' },
} as unknown as Settings;
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT);
});
+16 -27
View File
@@ -6,6 +6,8 @@
import {
ApprovalMode,
APPROVAL_MODE_INFO,
APPROVAL_MODES,
AuthType,
Config,
DEFAULT_QWEN_EMBEDDING_MODEL,
@@ -100,14 +102,6 @@ function resolveLocaleForExtensions(settings: Settings): string {
return detectSystemLanguage();
}
const VALID_APPROVAL_MODE_VALUES = [
'plan',
'default',
'auto-edit',
'auto',
'yolo',
] as const;
const SKILL_LEVELS: readonly SkillLevel[] = [
'project',
'user',
@@ -121,7 +115,7 @@ function isSkillLevel(value: unknown): value is SkillLevel {
function formatApprovalModeError(value: string): Error {
return new Error(
`Invalid approval mode: ${value}. Valid values are: ${VALID_APPROVAL_MODE_VALUES.join(
`Invalid approval mode: ${value}. Valid values are: ${APPROVAL_MODES.join(
', ',
)}`,
);
@@ -129,22 +123,15 @@ function formatApprovalModeError(value: string): Error {
function parseApprovalModeValue(value: string): ApprovalMode {
const normalized = value.trim().toLowerCase();
switch (normalized) {
case 'plan':
return ApprovalMode.PLAN;
case 'default':
return ApprovalMode.DEFAULT;
case 'yolo':
return ApprovalMode.YOLO;
case 'auto_edit':
case 'autoedit':
case 'auto-edit':
return ApprovalMode.AUTO_EDIT;
case 'auto':
return ApprovalMode.AUTO;
default:
throw formatApprovalModeError(value);
const canonical =
normalized === 'auto_edit' || normalized === 'autoedit'
? ApprovalMode.AUTO_EDIT
: normalized;
const approvalMode = APPROVAL_MODES.find((mode) => mode === canonical);
if (approvalMode === undefined) {
throw formatApprovalModeError(value);
}
return approvalMode;
}
export interface CliArgs {
@@ -547,6 +534,9 @@ function normalizeOutputFormat(
export async function parseArguments(): Promise<CliArgs> {
let rawArgv = hideBin(process.argv);
const approvalModeDescription = APPROVAL_MODES.map(
(mode) => `${mode} (${APPROVAL_MODE_INFO[mode].description})`,
).join(', ');
// hack: if the first argument is the CLI entry point, remove it
if (
@@ -715,9 +705,8 @@ export async function parseArguments(): Promise<CliArgs> {
})
.option('approval-mode', {
type: 'string',
choices: ['plan', 'default', 'auto-edit', 'auto', 'yolo'],
description:
'Set the approval mode: plan (plan only), default (prompt for approval), auto-edit (auto-approve edit tools), auto (LLM classifier auto-approves safe actions, blocks risky ones), yolo (auto-approve all tools)',
choices: APPROVAL_MODES,
description: `Set the approval mode: ${approvalModeDescription}`,
})
.option('acp', {
type: 'boolean',
@@ -6,6 +6,7 @@
import { describe, expect, it, vi } from 'vitest';
import {
ApprovalMode,
InputFormat,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
@@ -50,6 +51,42 @@ function createRegistry(): IPendingRequestRegistry {
}
describe('PermissionController', () => {
it.each([
[ApprovalMode.PLAN, 'allow'],
[ApprovalMode.DEFAULT, 'deny'],
[ApprovalMode.AUTO_EDIT, 'allow'],
[ApprovalMode.AUTO, 'allow'],
[ApprovalMode.YOLO, 'allow'],
] as const)(
'checks %s permission mode for can_use_tool',
async (mode, behavior) => {
const context = createContext();
context.permissionMode = mode;
const controller = new PermissionController(
context,
createRegistry(),
'PermissionController',
);
await expect(
controller.handleRequest(
{
subtype: 'can_use_tool',
tool_name: 'read_file',
tool_use_id: `tool-${mode}`,
input: {},
permission_suggestions: null,
blocked_path: null,
},
`request-${mode}`,
),
).resolves.toMatchObject({
subtype: 'can_use_tool',
behavior,
});
},
);
it('round-trips workflow approval through can_use_tool with updated input', async () => {
const context = createContext(120_000);
const resolvePendingApproval = vi.fn().mockResolvedValue(true);
@@ -18,12 +18,13 @@ import type {
WaitingToolCall,
ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails,
ApprovalMode,
TeammateApprovalRequestEvent,
ToolConfirmationPayload,
WorkflowApproval,
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
APPROVAL_MODES,
InputFormat,
ToolConfirmationOutcome,
ToolNames,
@@ -146,22 +147,23 @@ export class PermissionController extends BaseController {
private checkPermissionMode(): { allowed: boolean; message?: string } {
const mode = this.context.permissionMode;
// Map permission modes to approval logic (aligned with VALID_APPROVAL_MODE_VALUES)
switch (mode) {
case 'yolo': // Allow all tools
case 'auto-edit': // Auto-approve edit operations
case 'auto': // Auto-approve via LLM classifier — coreToolScheduler enforces the gate
case 'plan': // Auto-approve planning operations
return { allowed: true };
case 'default': // TODO: allow all tools for test
default:
return {
allowed: false,
message:
'Tool execution requires manual approval. Update permission mode or approve via host.',
};
if (mode === ApprovalMode.DEFAULT) {
return {
allowed: false,
message:
'Tool execution requires manual approval. Update permission mode or approve via host.',
};
}
const validModes = APPROVAL_MODES as readonly PermissionMode[];
if (validModes.includes(mode)) {
return { allowed: true };
}
return {
allowed: false,
message: `Invalid permission mode: ${mode}. Valid values are: ${validModes.join(', ')}`,
};
}
/**
@@ -217,13 +219,7 @@ export class PermissionController extends BaseController {
}
const mode = payload.mode;
const validModes: PermissionMode[] = [
'default',
'plan',
'auto-edit',
'auto',
'yolo',
];
const validModes = APPROVAL_MODES as readonly PermissionMode[];
if (!validModes.includes(mode)) {
throw new Error(
+3 -2
View File
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
ActiveGoal,
ApprovalModeValue,
GoalSnapshotV2,
SubagentConfig,
McpToolProgressData,
@@ -145,7 +146,7 @@ export interface CLISystemMessage {
status: string;
}>;
model?: string;
permission_mode?: string;
permission_mode?: PermissionMode;
slash_commands?: string[];
qwen_code_version?: string;
output_style?: string;
@@ -278,7 +279,7 @@ export interface CLIPartialAssistantMessage {
parent_tool_use_id: string | null;
}
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'auto' | 'yolo';
export type PermissionMode = ApprovalModeValue;
/**
* Permission suggestion for tool use requests
@@ -24,7 +24,8 @@ import {
registerWorkspaceQualifiedGitDiffRoutes,
} from './workspace-git-diff.js';
vi.mock('@qwen-code/qwen-code-core', () => ({
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
...(await importOriginal<typeof import('@qwen-code/qwen-code-core')>()),
fetchGitDiff: vi.fn(),
fetchGitDiffHunksForFile: vi.fn(),
}));
@@ -35,7 +35,8 @@ const { mockDebugLogger } = vi.hoisted(() => ({
},
}));
vi.mock('@qwen-code/qwen-code-core', () => ({
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
...(await importOriginal<typeof import('@qwen-code/qwen-code-core')>()),
createDebugLogger: () => mockDebugLogger,
}));
@@ -7,7 +7,7 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { Content, Part } from '@google/genai';
import type { Config } from '../config/config.js';
import type { ApprovalModeValue, Config } from '../config/config.js';
import * as jsonl from '../utils/jsonl-utils.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import {
@@ -107,8 +107,6 @@ const WORKTREE_ISOLATION_BLOCKED_REASON =
const INCOMPATIBLE_ISOLATION_BLOCKED_REASON =
'Background task isolation metadata is incompatible.';
type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo';
/**
* Returns true when the subagent's effective tool surface will include the
* Skill tool. Mirrors `AgentCore.willHaveSkillTool()` for the resume path
@@ -12,4 +12,6 @@ export enum ApprovalMode {
YOLO = 'yolo',
}
export type ApprovalModeValue = `${ApprovalMode}`;
export const APPROVAL_MODES = Object.values(ApprovalMode);
@@ -0,0 +1 @@
["plan", "default", "auto-edit", "auto", "yolo"]
+5 -1
View File
@@ -325,7 +325,11 @@ export {
export type ModelInvocableCommandExecutorResult = string | { error: string };
export { ApprovalMode, APPROVAL_MODES } from './approval-mode.js';
export {
ApprovalMode,
APPROVAL_MODES,
type ApprovalModeValue,
} from './approval-mode.js';
/**
* Thrown by `Config.setApprovalMode` when the requested mode would grant
@@ -1,6 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { PermissionMode } from '@qwen-code/sdk';
export type { PermissionMode };
export interface Annotation {
type: string;
value: string;
@@ -120,7 +124,7 @@ export interface SDKSystemMessage {
status: string;
}>;
model?: string;
permission_mode?: string;
permission_mode?: PermissionMode;
slash_commands?: string[];
qwen_code_version?: string;
output_style?: string;
@@ -230,8 +234,6 @@ export interface SDKPartialAssistantMessage {
parent_tool_use_id: string | null;
}
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'auto' | 'yolo';
/**
* TODO: Align with `ToolCallConfirmationDetails`
*/
@@ -1,11 +1,20 @@
package com.alibaba.qwen.code.cli.transport;
import com.alibaba.qwen.code.cli.protocol.data.PermissionMode;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.alibaba.fastjson2.JSON;
import com.alibaba.qwen.code.cli.protocol.data.PermissionMode;
import com.alibaba.qwen.code.daemon.DaemonApprovalMode;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
public class PermissionModeTest {
@Test
@@ -17,4 +26,32 @@ public class PermissionModeTest {
assertEquals("yolo", PermissionMode.YOLO.getValue());
}
@Test
public void permissionModesMatchCoreContract() throws IOException {
Set<String> expected = readCoreContract();
Set<String> cliModes = Arrays.stream(PermissionMode.values())
.map(PermissionMode::getValue)
.collect(Collectors.toSet());
Set<String> daemonModes = Arrays.stream(DaemonApprovalMode.values())
.map(DaemonApprovalMode::getWireValue)
.collect(Collectors.toSet());
assertEquals(expected, cliModes);
assertEquals(expected, daemonModes);
}
private static Set<String> readCoreContract() throws IOException {
Path directory = Path.of("").toAbsolutePath();
while (directory != null) {
Path contract = directory.resolve(
"packages/core/src/config/approval-modes.json");
if (Files.exists(contract)) {
return new HashSet<>(JSON.parseArray(
Files.readString(contract), String.class));
}
directory = directory.getParent();
}
throw new IOException("Cannot find approval-modes.json");
}
}
@@ -1,11 +1,13 @@
from __future__ import annotations
import json
import re
from typing import Any, cast
from pathlib import Path
from typing import Any, cast, get_args
import pytest
from qwen_code_sdk.errors import ValidationError
from qwen_code_sdk.types import QueryOptions, TimeoutOptions
from qwen_code_sdk.types import PermissionMode, QueryOptions, TimeoutOptions
from qwen_code_sdk.validation import validate_query_options
VALID_UUID = "123e4567-e89b-12d3-a456-426614174000"
@@ -77,12 +79,21 @@ def test_accepts_canonical_session_id_in_either_case() -> None:
@pytest.mark.parametrize(
"mode",
["default", "plan", "auto-edit", "auto", "yolo"],
get_args(PermissionMode),
)
def test_accepts_valid_permission_modes(mode: str) -> None:
validate_query_options(QueryOptions.from_mapping({"permission_mode": mode}))
def test_permission_modes_match_core_contract() -> None:
contract_path = (
Path(__file__).parents[3] / "core" / "src" / "config" / "approval-modes.json"
)
expected = set(json.loads(contract_path.read_text(encoding="utf-8")))
assert set(get_args(PermissionMode)) == expected
def test_rejects_invalid_permission_mode() -> None:
with pytest.raises(
ValidationError,
@@ -9,6 +9,13 @@ import { tool } from '../../tool.js';
import { formatJsonResult, formatToolError } from '../../formatters.js';
import type { BridgeState } from '../types.js';
import { handler, resolveSessionId } from '../helpers.js';
import { PERMISSION_MODES } from '../../../types/permission-mode.js';
const SAFE_LOCAL_APPROVAL_MODES = new Set(['plan', 'default']);
const GLOBAL_SCOPE_APPROVAL_MODES = PERMISSION_MODES.filter(
(mode) => !SAFE_LOCAL_APPROVAL_MODES.has(mode),
);
const PERMISSION_MODE_LIST = PERMISSION_MODES.join(', ');
/* eslint-disable @typescript-eslint/no-explicit-any */
export function workspaceWriteTools(state: BridgeState): any[] {
@@ -80,11 +87,9 @@ export function workspaceWriteTools(state: BridgeState): any[] {
tool(
'session_set_approval_mode',
'Change the approval mode of a session (plan, default, auto-edit, auto, yolo).',
`Change the approval mode of a session (${PERMISSION_MODE_LIST}).`,
{
mode: z
.enum(['plan', 'default', 'auto-edit', 'auto', 'yolo'])
.describe('Approval mode.'),
mode: z.enum(PERMISSION_MODES).describe('Approval mode.'),
persist: z
.boolean()
.optional()
@@ -97,10 +102,9 @@ export function workspaceWriteTools(state: BridgeState): any[] {
handler(async (args) => {
// Block dangerous modes and persistent changes without explicit opt-in
if (!state.allowGlobalScope) {
const dangerousModes = ['yolo', 'auto', 'auto-edit'];
if (dangerousModes.includes(args.mode)) {
if (GLOBAL_SCOPE_APPROVAL_MODES.includes(args.mode)) {
return formatToolError(
`Approval modes '${dangerousModes.join("', '")}' are restricted for security. Set QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE=true to enable.`,
`Approval modes '${GLOBAL_SCOPE_APPROVAL_MODES.join("', '")}' are restricted for security. Set QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE=true to enable.`,
);
}
if (args.persist) {
+7 -8
View File
@@ -13,6 +13,11 @@
* signals breaking wire changes (per the design doc).
*/
import {
PERMISSION_MODES,
type PermissionMode,
} from '../types/permission-mode.js';
export type DaemonMode = 'http-bridge' | 'native';
/** Goal v2 wire types, duplicated here to keep the SDK independent of Core. */
@@ -2782,14 +2787,8 @@ export interface SetSessionLanguageResult {
* Order matters for diagnostic UIs that render the modes in the
* advertised sequence.
*/
export const DAEMON_APPROVAL_MODES = [
'plan',
'default',
'auto-edit',
'auto',
'yolo',
] as const;
export type DaemonApprovalMode = (typeof DAEMON_APPROVAL_MODES)[number];
export const DAEMON_APPROVAL_MODES = PERMISSION_MODES;
export type DaemonApprovalMode = PermissionMode;
/**
* Result body of `POST /session/:id/approval-mode`. `previous` and
+1
View File
@@ -2,6 +2,7 @@ export { query } from './query/createQuery.js';
export { AbortError, isAbortError } from './types/errors.js';
export { Query } from './query/Query.js';
export { SdkLogger } from './utils/logger.js';
export { PERMISSION_MODES } from './types/permission-mode.js';
// Daemon HTTP client (talks to `qwen serve`)
export {
+2 -1
View File
@@ -50,6 +50,7 @@ import {
type SdkControlServerTransportOptions,
} from '../daemon-mcp/SdkControlServerTransport.js';
import { ControlRequestType } from '../types/protocol.js';
import type { PermissionMode } from '../types/permission-mode.js';
interface PendingControlRequest {
resolve: (response: Record<string, unknown> | null) => void;
@@ -1004,7 +1005,7 @@ export class Query implements AsyncIterable<SDKMessage> {
return this.sendControlRequest(ControlRequestType.CONTINUE_LAST_TURN);
}
async setPermissionMode(mode: string): Promise<void> {
async setPermissionMode(mode: PermissionMode): Promise<void> {
await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, {
mode,
});
@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export const PERMISSION_MODES = [
'plan',
'default',
'auto-edit',
'auto',
'yolo',
] as const;
export type PermissionMode = (typeof PERMISSION_MODES)[number];
@@ -1,6 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { PermissionMode } from './permission-mode.js';
export type { PermissionMode } from './permission-mode.js';
export interface Annotation {
type: string;
value: string;
@@ -120,7 +123,7 @@ export interface SDKSystemMessage {
status: string;
}>;
model?: string;
permission_mode?: string;
permission_mode?: PermissionMode;
slash_commands?: string[];
qwen_code_version?: string;
output_style?: string;
@@ -230,8 +233,6 @@ export interface SDKPartialAssistantMessage {
parent_tool_use_id: string | null;
}
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'auto' | 'yolo';
/**
* Authentication types supported by the CLI.
* Aligns with CLI's --auth-type parameter.
@@ -1,6 +1,7 @@
import { z } from 'zod';
import type { CanUseTool } from './types.js';
import type { SubagentConfig } from './protocol.js';
import { PERMISSION_MODES } from './permission-mode.js';
const RESERVED_CLI_FLAGS = new Set([
'--input-format',
@@ -201,9 +202,7 @@ export const QueryOptionsSchema = z
QuerySystemPromptPresetSchema,
])
.optional(),
permissionMode: z
.enum(['default', 'plan', 'auto-edit', 'auto', 'yolo'])
.optional(),
permissionMode: z.enum(PERMISSION_MODES).optional(),
canUseTool: z
.custom<CanUseTool>((val) => typeof val === 'function', {
message: 'canUseTool must be a function',
+1 -1
View File
@@ -307,7 +307,7 @@ export interface QueryOptions {
* @see allowedTools For auto-approving specific tools
* @see excludeTools For blocking specific tools
*/
permissionMode?: 'default' | 'plan' | 'auto-edit' | 'auto' | 'yolo';
permissionMode?: PermissionMode;
/**
* Custom permission handler for tool execution approval.
@@ -4,41 +4,24 @@
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Drift detector for the SDK ↔ core approval-mode contract.
*
* Two sources of truth converge here:
* 1. core's `APPROVAL_MODES` const array (= `Object.values(ApprovalMode)`,
* consumed by `Config.setApprovalMode` and the daemon route's body
* validator)
* 2. SDK's `DAEMON_APPROVAL_MODES` literal tuple (mirrored for SDK
* consumers; backs the `DaemonApprovalMode` union)
*
* The third source — the `ApprovalMode` enum itself — is structurally
* tied to `APPROVAL_MODES` via `Object.values(ApprovalMode)` at the
* core's definition site, so there's no separate assertion needed for
* it: `APPROVAL_MODES` is always a 1:1 mirror by construction.
*
* If `DAEMON_APPROVAL_MODES` drifts away from `APPROVAL_MODES` (e.g. a
* future fifth mode added to the enum but not the SDK list), this test
* fires before runtime and before the protocol docs go out of sync.
*
* Lives in the SDK package (not the CLI) because the SDK already
* depends on `@qwen-code/qwen-code-core`, so all three identifiers are
* visible — and this is the package whose contract the test is
* actually pinning.
*
* #4175 Wave 4 PR 17 (#4282 fold-in 1, wenshao review).
*/
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { KNOWN_APPROVAL_MODES } from '@qwen-code/acp-bridge/bridgeClient';
import { APPROVAL_MODES } from '@qwen-code/qwen-code-core';
import { DAEMON_APPROVAL_MODES } from '../../src/index.js';
import { DAEMON_APPROVAL_MODES, PERMISSION_MODES } from '../../src/index.js';
const crossLanguageContract = JSON.parse(
readFileSync(
new URL('../../../core/src/config/approval-modes.json', import.meta.url),
'utf8',
),
) as string[];
describe('approval-mode SDK ↔ core drift detection', () => {
it('DAEMON_APPROVAL_MODES (SDK) mirrors core APPROVAL_MODES exactly', () => {
// Order matters — diagnostic UIs that render modes in registration
// order stay stable across SDK / daemon versions only when the two
// tuples are sequence-equal, not just set-equal.
expect([...DAEMON_APPROVAL_MODES]).toEqual([...APPROVAL_MODES]);
it('keeps core and SDK contracts synchronized', () => {
expect([...APPROVAL_MODES]).toEqual(crossLanguageContract);
expect([...PERMISSION_MODES]).toEqual(crossLanguageContract);
expect([...KNOWN_APPROVAL_MODES]).toEqual(crossLanguageContract);
expect(DAEMON_APPROVAL_MODES).toBe(PERMISSION_MODES);
});
});
@@ -577,49 +577,30 @@ describe('serve-bridge', () => {
expect(result.content[0].text).toContain('Global scope is disabled');
});
it('should reject yolo approval mode without allowGlobalScope', async () => {
const { state } = makeMockState({
defaultSessionId: 'test-session',
});
state.allowGlobalScope = false;
it.each(['auto-edit', 'auto', 'yolo'] as const)(
'should reject %s approval mode without allowGlobalScope',
async (mode) => {
const { state } = makeMockState({
defaultSessionId: 'test-session',
});
state.allowGlobalScope = false;
const { workspaceWriteTools } = await import(
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
);
const tools = workspaceWriteTools(state);
const approvalTool = tools.find(
(t: { name: string }) => t.name === 'session_set_approval_mode',
);
const { workspaceWriteTools } = await import(
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
);
const tools = workspaceWriteTools(state);
const approvalTool = tools.find(
(t: { name: string }) => t.name === 'session_set_approval_mode',
);
const result = await approvalTool.handler(
{ mode: 'yolo', session_id: 'test-session' },
{},
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('restricted for security');
});
it('should reject auto-edit approval mode without allowGlobalScope', async () => {
const { state } = makeMockState({
defaultSessionId: 'test-session',
});
state.allowGlobalScope = false;
const { workspaceWriteTools } = await import(
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
);
const tools = workspaceWriteTools(state);
const approvalTool = tools.find(
(t: { name: string }) => t.name === 'session_set_approval_mode',
);
const result = await approvalTool.handler(
{ mode: 'auto-edit', session_id: 'test-session' },
{},
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('restricted for security');
});
const result = await approvalTool.handler(
{ mode, session_id: 'test-session' },
{},
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('restricted for security');
},
);
it('should reject persistent approval mode change without allowGlobalScope', async () => {
const { state } = makeMockState({
@@ -643,6 +624,62 @@ describe('serve-bridge', () => {
expect(result.content[0].text).toContain('restricted for security');
});
it.each(['default', 'plan'] as const)(
'should allow local %s approval mode changes without allowGlobalScope',
async (mode) => {
const { state, calls } = makeMockState({
defaultSessionId: 'default-session',
});
state.allowGlobalScope = false;
const { workspaceWriteTools } = await import(
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
);
const tools = workspaceWriteTools(state);
const approvalTool = tools.find(
(t: { name: string }) => t.name === 'session_set_approval_mode',
);
const result = await approvalTool.handler(
{ mode, session_id: 'other-session' },
{},
);
expect(result.isError).toBeUndefined();
expect(calls[0]?.url).toBe(
'http://127.0.0.1:4170/session/other-session/approval-mode',
);
expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ mode });
},
);
it('should allow persistent elevated approval mode changes with allowGlobalScope', async () => {
const { state, calls } = makeMockState({
defaultSessionId: 'test-session',
});
state.allowGlobalScope = true;
const { workspaceWriteTools } = await import(
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
);
const tools = workspaceWriteTools(state);
const approvalTool = tools.find(
(t: { name: string }) => t.name === 'session_set_approval_mode',
);
const result = await approvalTool.handler(
{ mode: 'auto', persist: true, session_id: 'test-session' },
{},
);
expect(result.isError).toBeUndefined();
expect(calls[0]?.url).toBe(
'http://127.0.0.1:4170/session/test-session/approval-mode',
);
expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({
mode: 'auto',
persist: true,
});
});
it('should allow read-only agents_manage actions with global scope', async () => {
const { state } = makeMockState({
defaultSessionId: 'test-session',
@@ -291,14 +291,9 @@ export class QwenAgentManager {
const obj = (init || {}) as Record<string, unknown>;
const modes = obj['modes'] as
| {
currentModeId?:
| 'plan'
| 'default'
| 'auto-edit'
| 'auto'
| 'yolo';
currentModeId?: ApprovalModeValue;
availableModes?: Array<{
id: 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo';
id: ApprovalModeValue;
name: string;
description: string;
}>;
@@ -1409,9 +1404,9 @@ export class QwenAgentManager {
*/
onModeInfo(
callback: (info: {
currentModeId?: 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo';
currentModeId?: ApprovalModeValue;
availableModes?: Array<{
id: 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo';
id: ApprovalModeValue;
name: string;
description: string;
}>;
@@ -1424,11 +1419,7 @@ export class QwenAgentManager {
/**
* Register mode changed callback
*/
onModeChanged(
callback: (
modeId: 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo',
) => void,
): void {
onModeChanged(callback: (modeId: ApprovalModeValue) => void): void {
this.callbacks.onModeChanged = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
@@ -4,29 +4,34 @@
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Enum for approval modes with UI-friendly labels
* Represents the different approval modes available in the ACP protocol
* with their corresponding user-facing display names
*/
export enum ApprovalMode {
PLAN = 'plan',
DEFAULT = 'default',
AUTO_EDIT = 'auto-edit',
AUTO = 'auto',
YOLO = 'yolo',
}
import {
DAEMON_APPROVAL_MODES,
type DaemonApprovalMode,
} from '@qwen-code/sdk/daemon';
type ApprovalModeByValue = {
[Mode in DaemonApprovalMode]: Mode;
};
const APPROVAL_MODE_BY_VALUE = Object.fromEntries(
DAEMON_APPROVAL_MODES.map((mode) => [mode, mode]),
) as ApprovalModeByValue;
export const ApprovalMode = {
PLAN: APPROVAL_MODE_BY_VALUE.plan,
DEFAULT: APPROVAL_MODE_BY_VALUE.default,
AUTO_EDIT: APPROVAL_MODE_BY_VALUE['auto-edit'],
AUTO: APPROVAL_MODE_BY_VALUE.auto,
YOLO: APPROVAL_MODE_BY_VALUE.yolo,
} as const;
export type ApprovalMode = DaemonApprovalMode;
/**
* Mapping from string values to enum values for runtime conversion
*/
export const APPROVAL_MODE_MAP: Record<string, ApprovalMode> = {
plan: ApprovalMode.PLAN,
default: ApprovalMode.DEFAULT,
'auto-edit': ApprovalMode.AUTO_EDIT,
auto: ApprovalMode.AUTO,
yolo: ApprovalMode.YOLO,
};
export const APPROVAL_MODE_MAP: Record<string, ApprovalMode> =
APPROVAL_MODE_BY_VALUE;
/**
* UI display information for each approval mode
@@ -4,13 +4,4 @@
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Type for approval mode values
* Used in ACP protocol for controlling agent behavior
*/
export type ApprovalModeValue =
| 'plan'
| 'default'
| 'auto-edit'
| 'auto'
| 'yolo';
export type { DaemonApprovalMode as ApprovalModeValue } from '@qwen-code/sdk/daemon';
@@ -6,6 +6,7 @@
import type { ModelInfo } from '@agentclientprotocol/sdk';
import { knownTokenLimit } from '@qwen-code/qwen-code-core';
import { DAEMON_APPROVAL_MODES } from '@qwen-code/sdk/daemon';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
type AcpMeta = Record<string, unknown>;
@@ -114,17 +115,9 @@ export interface SessionModeState {
}>;
}
const APPROVAL_MODE_VALUES: ApprovalModeValue[] = [
'plan',
'default',
'auto-edit',
'auto',
'yolo',
];
const isApprovalModeValue = (value: unknown): value is ApprovalModeValue =>
typeof value === 'string' &&
APPROVAL_MODE_VALUES.includes(value as ApprovalModeValue);
DAEMON_APPROVAL_MODES.includes(value as ApprovalModeValue);
/**
* Extract complete model state from ACP `session/new` result.
@@ -362,12 +362,7 @@ export class WebViewProvider {
// Surface available modes and current mode (from ACP initialize)
this.agentManager.onModeInfo((info) => {
try {
const current = (info?.currentModeId || null) as
| 'plan'
| 'default'
| 'auto-edit'
| 'yolo'
| null;
const current = info?.currentModeId ?? null;
this.currentModeId = current;
} catch (_error) {
// Ignore error when parsing mode info