Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/sdk-java.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand All @@ -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/**'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/sdk-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ on:
- 'release/**'
paths:
- 'packages/sdk-python/**'
- 'packages/core/src/config/approval-modes.json'
- '.github/workflows/sdk-python.yml'
push:
branches:
- 'main'
- 'release/**'
paths:
- 'packages/sdk-python/**'
- 'packages/core/src/config/approval-modes.json'
- '.github/workflows/sdk-python.yml'

jobs:
Expand Down
36 changes: 36 additions & 0 deletions docs/design/2026-08-23-approval-mode-contract.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 6 additions & 10 deletions packages/acp-bridge/src/bridgeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,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;
Expand Down Expand Up @@ -7745,7 +7749,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({
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1362,7 +1362,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' },
Expand Down
8 changes: 2 additions & 6 deletions packages/cli/src/acp-integration/session/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import type {
ApprovalModeValue,
Config,
ToolArtifact,
ToolResultBoundaryArtifact,
Expand All @@ -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.
Expand Down
9 changes: 2 additions & 7 deletions packages/cli/src/commands/channel/config-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/channel/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/commands/review/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.',
Expand Down
37 changes: 34 additions & 3 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' },
Comment thread
yiliang114 marked this conversation as resolved.
} 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);
});
Expand Down
43 changes: 16 additions & 27 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import {
ApprovalMode,
APPROVAL_MODE_INFO,
APPROVAL_MODES,
AuthType,
Config,
DEFAULT_QWEN_EMBEDDING_MODEL,
Expand Down Expand Up @@ -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',
Expand All @@ -121,30 +115,23 @@ 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(
', ',
)}`,
);
}

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'
Comment thread
yiliang114 marked this conversation as resolved.
? ApprovalMode.AUTO_EDIT
: normalized;
Comment thread
yiliang114 marked this conversation as resolved.
const approvalMode = APPROVAL_MODES.find((mode) => mode === canonical);
Comment thread
yiliang114 marked this conversation as resolved.
if (approvalMode === undefined) {
throw formatApprovalModeError(value);
}
return approvalMode;
}

export interface CliArgs {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { describe, expect, it, vi } from 'vitest';
import {
ApprovalMode,
InputFormat,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading