Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_approval_mode_control',
'workspace_tool_toggle',
'workspace_settings',
'workspace_permissions',
'workspace_init',
'workspace_mcp_restart',
'session_recap',
Expand Down
204 changes: 198 additions & 6 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,20 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
Storage: {
getGlobalQwenDir: vi.fn(() => '/tmp/qwen-global-test'),
},
parseRule: vi.fn((raw: string) => {
const trimmed = raw.trim();
const openParen = trimmed.indexOf('(');
if (openParen === -1) {
return { raw: trimmed, toolName: trimmed };
}
return {
raw: trimmed,
toolName: trimmed.slice(0, openParen).trim(),
...(trimmed.endsWith(')')
? { specifier: trimmed.slice(openParen + 1, -1) }
: { invalid: true }),
};
}),
parse: vi.fn((yaml: string) => {
const record: Record<string, unknown> = {};
for (const line of yaml.split('\n')) {
Expand Down Expand Up @@ -535,7 +549,7 @@ import {
} from '@qwen-code/qwen-code-core';
import type { McpServer } from '@agentclientprotocol/sdk';
import { AgentSideConnection } from '@agentclientprotocol/sdk';
import { loadSettings } from '../config/settings.js';
import { loadSettings, SettingScope } from '../config/settings.js';
import { loadCliConfig } from '../config/config.js';
import { Session, buildAvailableCommandsSnapshot } from './session/Session.js';
import {
Expand Down Expand Up @@ -1317,13 +1331,73 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
}

function makeCoreSettings(outputLanguage = 'English') {
const userSettings = { general: { outputLanguage } };
const workspaceSettings = {};
const mergedSettings = { general: { outputLanguage } };
type PermissionRules = { allow: string[]; ask: string[]; deny: string[] };

const userGeneral = { outputLanguage };
const mergedGeneral = { outputLanguage };
const userSettings: Record<string, unknown> = { general: userGeneral };
const workspaceSettings: Record<string, unknown> = {};
const mergedSettings: Record<string, unknown> = { general: mergedGeneral };
const emptyRules = (): PermissionRules => ({
allow: [],
ask: [],
deny: [],
});
const readPermissionRules = (
settings: Record<string, unknown>,
): PermissionRules => {
const permissions = settings['permissions'];
if (
typeof permissions !== 'object' ||
permissions === null ||
Array.isArray(permissions)
) {
return emptyRules();
}
const permissionRecord = permissions as Record<string, unknown>;
const readList = (key: keyof PermissionRules) =>
Array.isArray(permissionRecord[key])
? permissionRecord[key].filter(
(value): value is string => typeof value === 'string',
)
: [];
return {
allow: readList('allow'),
ask: readList('ask'),
deny: readList('deny'),
};
};
const updateMergedPermissions = () => {
const userRules = readPermissionRules(userSettings);
const workspaceRules = readPermissionRules(workspaceSettings);
mergedSettings['permissions'] = {
allow: [...userRules.allow, ...workspaceRules.allow],
ask: [...userRules.ask, ...workspaceRules.ask],
deny: [...userRules.deny, ...workspaceRules.deny],
};
};
const setValue = vi.fn((_scope: string, key: string, value: unknown) => {
const target = _scope === 'Workspace' ? workspaceSettings : userSettings;
if (key.startsWith('permissions.')) {
const ruleType = key.slice('permissions.'.length);
if (ruleType !== 'allow' && ruleType !== 'ask' && ruleType !== 'deny') {
return;
}
const existing = target['permissions'];
const permissions =
typeof existing === 'object' &&
existing !== null &&
!Array.isArray(existing)
? { ...(existing as Record<string, unknown>) }
: {};
permissions[ruleType] = value;
target['permissions'] = permissions;
updateMergedPermissions();
return;
}
if (key !== 'general.outputLanguage') return;
userSettings.general.outputLanguage = value as string;
mergedSettings.general.outputLanguage = value as string;
userGeneral.outputLanguage = value as string;
mergedGeneral.outputLanguage = value as string;
});
return {
merged: mergedSettings,
Expand Down Expand Up @@ -2625,6 +2699,51 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
return { agent, agentPromise };
}

it('qwen/permissions/getSettings returns user workspace merged and trust state', async () => {
const settings = makeCoreSettings();
settings.setValue(SettingScope.User, 'permissions.allow', [
'ShellTool(git status)',
]);
settings.setValue(SettingScope.Workspace, 'permissions.allow', [
'ShellTool(npm test)',
]);
settings.setValue(SettingScope.Workspace, 'permissions.deny', [
'ReadFileTool(**/.env)',
]);
const { agent, agentPromise } = await bootCoreSettingsAgent(settings);

const result = await agent.extMethod('qwen/permissions/getSettings', {});

expect(result).toEqual({
v: 1,
user: {
path: '/home/test/.qwen/settings.json',
rules: {
allow: ['ShellTool(git status)'],
ask: [],
deny: [],
},
},
workspace: {
path: '/work/.qwen/settings.json',
rules: {
allow: ['ShellTool(npm test)'],
ask: [],
deny: ['ReadFileTool(**/.env)'],
},
},
merged: {
allow: ['ShellTool(git status)', 'ShellTool(npm test)'],
ask: [],
deny: ['ReadFileTool(**/.env)'],
},
isTrusted: true,
});

mockConnectionState.resolve();
await agentPromise;
});

it('qwen/settings/getCore returns user, workspace, and merged views', async () => {
const settings = makeCoreSettings();
const { agent, agentPromise } = await bootCoreSettingsAgent(settings);
Expand Down Expand Up @@ -3276,6 +3395,23 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

it('qwen/permissions/setRules rejects malformed permission rules', async () => {
const settings = makeCoreSettings();
const { agent, agentPromise } = await bootCoreSettingsAgent(settings);

await expect(
agent.extMethod('qwen/permissions/setRules', {
scope: 'user',
ruleType: 'allow',
rules: ['ShellTool(git status'],
}),
).rejects.toThrowError(/Malformed permission rule/);
expect(settings.setValue).not.toHaveBeenCalled();

mockConnectionState.resolve();
await agentPromise;
});

it('qwen/permissions/setRules persists normalized rules for the requested scope', async () => {
const settings = makeCoreSettings();
const { agent, agentPromise } = await bootCoreSettingsAgent(settings);
Expand All @@ -3301,6 +3437,62 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

it('qwen/permissions/setRules syncs live permission managers after replacement', async () => {
const settings = makeCoreSettings();
settings.setValue(SettingScope.User, 'permissions.allow', [
'ShellTool(git status)',
'ShellTool(git diff)',
]);
vi.mocked(settings.setValue).mockClear();
const addPersistentRule = vi.fn();
const removePersistentRule = vi.fn();
const permissionManager = {
addPersistentRule,
removePersistentRule,
};
const innerConfig = await setupSessionMocks('test-session-id');
(
innerConfig as ReturnType<typeof makeInnerConfig> & {
getPermissionManager: () => typeof permissionManager;
}
).getPermissionManager = vi.fn(() => permissionManager);
vi.mocked(loadSettings).mockReturnValue(settings);
const agentPromise = runAcpAgent(mockConfig, settings, mockArgv);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await agent.newSession({ cwd: '/tmp', mcpServers: [] });

await agent.extMethod('qwen/permissions/setRules', {
scope: 'user',
ruleType: 'allow',
rules: ['ShellTool(git diff)', 'ShellTool(npm test)'],
});

expect(removePersistentRule).toHaveBeenCalledWith(
'ShellTool(git status)',
'allow',
);
expect(removePersistentRule).not.toHaveBeenCalledWith(
'ShellTool(git diff)',
'allow',
);
expect(addPersistentRule).toHaveBeenCalledWith(
'ShellTool(npm test)',
'allow',
);
expect(addPersistentRule).not.toHaveBeenCalledWith(
'ShellTool(git diff)',
'allow',
);

mockConnectionState.resolve();
await agentPromise;
});

const VALID_SESSION_ID = '12345678-1234-1234-1234-1234567890ab';

function mockSessionServiceLoad(result: unknown) {
Expand Down
Loading
Loading