From f57b926110a5d66d1cc418a9a09c162d87a0ca10 Mon Sep 17 00:00:00 2001 From: QZP Date: Thu, 13 Aug 2026 18:06:03 +0800 Subject: [PATCH] feat(agents): add agent permission-policy control (OpenCode pilot, AionUi #4018) Add a permission-level selector to the Agent settings page for external agents (OpenCode pilot). It reads the backend's /api/agents/permission-policy read-model and write-throughs the selected level (ask / auto_edit / full_auto) to the agent's own config, then re-polls so the echoed level stays in sync. - agentPermissionPolicy model: normalized levels, read-model types, and an actionable check (supported + installed). - AgentPermissionControl: renders only when the backend reports the agent is supported and installed; clear option resets to agent default. - LocalAgents wiring: fetch policy read-models, pass per-agent to the control, re-poll on change. - ipcBridge.permissionPolicy: list / setLevel / clear routes. - i18n: 13 locales + generated key types. - Vitest: unit + DOM tests for model, control, and LocalAgents wiring. --- .../desktop/src/common/adapter/ipcBridge.ts | 24 ++++ .../AgentSettings/AgentPermissionControl.tsx | 81 +++++++++++ .../settings/AgentSettings/LocalAgents.tsx | 47 +++++-- .../src/renderer/services/i18n/i18n-keys.d.ts | 8 ++ .../services/i18n/locales/de-DE/settings.json | 8 ++ .../services/i18n/locales/en-US/settings.json | 8 ++ .../services/i18n/locales/es-ES/settings.json | 8 ++ .../services/i18n/locales/fa-IR/settings.json | 8 ++ .../services/i18n/locales/fr-FR/settings.json | 8 ++ .../services/i18n/locales/ja-JP/settings.json | 8 ++ .../services/i18n/locales/ko-KR/settings.json | 8 ++ .../services/i18n/locales/pt-BR/settings.json | 8 ++ .../services/i18n/locales/ru-RU/settings.json | 8 ++ .../services/i18n/locales/tr-TR/settings.json | 8 ++ .../services/i18n/locales/uk-UA/settings.json | 8 ++ .../services/i18n/locales/zh-CN/settings.json | 8 ++ .../services/i18n/locales/zh-TW/settings.json | 8 ++ .../utils/model/agentPermissionPolicy.ts | 41 ++++++ tests/unit/renderer/LocalAgents.dom.test.tsx | 128 +++++++++++++++++ .../agentPermissionControl.dom.test.tsx | 129 ++++++++++++++++++ .../renderer/agentPermissionPolicy.test.ts | 45 ++++++ 21 files changed, 597 insertions(+), 10 deletions(-) create mode 100644 packages/desktop/src/renderer/pages/settings/AgentSettings/AgentPermissionControl.tsx create mode 100644 packages/desktop/src/renderer/utils/model/agentPermissionPolicy.ts create mode 100644 tests/unit/renderer/agentPermissionControl.dom.test.tsx create mode 100644 tests/unit/renderer/agentPermissionPolicy.test.ts diff --git a/packages/desktop/src/common/adapter/ipcBridge.ts b/packages/desktop/src/common/adapter/ipcBridge.ts index b43b6f9b3f..e54be8f955 100644 --- a/packages/desktop/src/common/adapter/ipcBridge.ts +++ b/packages/desktop/src/common/adapter/ipcBridge.ts @@ -1082,6 +1082,30 @@ export const acpConversation = { ), }; +// --------------------------------------------------------------------------- +// Agent permission policy — routed to /api/agents/permission-policy/* +// (OpenCode pilot for iOfficeAI/AionUi#4018) +// --------------------------------------------------------------------------- + +export const permissionPolicy = { + /** List all known agents' permission-policy read-models. */ + list: httpGet( + '/api/agents/permission-policy' + ), + /** Write-through a permission level to the agent's own config file. */ + setLevel: httpPut< + import('@/renderer/utils/model/agentPermissionPolicy').AgentPermissionPolicy, + { agent: string; level: import('@/renderer/utils/model/agentPermissionPolicy').AgentPermissionLevel } + >( + (p) => `/api/agents/permission-policy/${encodeURIComponent(p.agent)}`, + (p) => ({ level: p.level }) + ), + /** Remove the agent-side policy so the agent uses its default behaviour. */ + clear: httpPost( + (p) => `/api/agents/permission-policy/${encodeURIComponent(p.agent)}/clear` + ), +}; + // --------------------------------------------------------------------------- // MCP Service — routed to /api/mcp/* // --------------------------------------------------------------------------- diff --git a/packages/desktop/src/renderer/pages/settings/AgentSettings/AgentPermissionControl.tsx b/packages/desktop/src/renderer/pages/settings/AgentSettings/AgentPermissionControl.tsx new file mode 100644 index 0000000000..8fcb8b752f --- /dev/null +++ b/packages/desktop/src/renderer/pages/settings/AgentSettings/AgentPermissionControl.tsx @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Message, Select, Typography } from '@arco-design/web-react'; +import { useTranslation } from 'react-i18next'; +import { ipcBridge } from '@/common'; +import { + AGENT_PERMISSION_LEVEL_OPTIONS, + type AgentPermissionPolicy, + isPermissionPolicyActionable, +} from '@/renderer/utils/model/agentPermissionPolicy'; + +const UNMANAGED = '__unmanaged__'; + +interface AgentPermissionControlProps { + /** Read-model for this agent's permission policy. */ + policy: AgentPermissionPolicy | undefined; + /** Called after a write-through succeeds so the parent can refresh the read-model. */ + onChanged: () => void; +} + +/** + * Write-through permission-level selector for an external agent (OpenCode pilot). + * + * Renders nothing when the backend reports the agent is unsupported or not + * installed. `onChanged` lets the parent re-poll the policy so the echoed + * `current_level` stays in sync with the agent's config file. + */ +export const AgentPermissionControl: React.FC = ({ policy, onChanged }) => { + const { t } = useTranslation(); + + if (!isPermissionPolicyActionable(policy)) { + return null; + } + + const apply = async (level: string) => { + const agent = policy.agent; + try { + if (level === UNMANAGED) { + await ipcBridge.permissionPolicy.clear.invoke({ agent }); + Message.success(t('settings.agentManagement.permissionCleared')); + } else { + await ipcBridge.permissionPolicy.setLevel.invoke({ agent, level: level as 'ask' | 'auto_edit' | 'full_auto' }); + Message.success(t('settings.agentManagement.permissionSaved')); + } + onChanged(); + } catch { + Message.error(t('settings.agentManagement.permissionSaveFailed')); + } + }; + + const current = policy.current_level ?? UNMANAGED; + + return ( +
+ + {t('settings.agentManagement.permissionPolicyLabel')} + + onChange?.(e.target.value)} + > + {(options ?? []).map((o) => ( + + ))} + + ), + }; +}); + +import AgentPermissionControl from '@/renderer/pages/settings/AgentSettings/AgentPermissionControl'; + +const actionablePolicy = { + agent: 'opencode', + supported: true, + installed: true, + current_level: null, + config_path: '/home/user/.config/opencode/opencode.json', +}; + +describe('AgentPermissionControl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders nothing when the agent is not installed', () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing for an unsupported agent', () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when the policy is undefined', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a selector reflecting the current level', () => { + render(); + const select = screen.getByTestId('agent-permission-select') as HTMLSelectElement; + expect(select).toBeTruthy(); + expect(select.value).toBe('full_auto'); + expect(Array.from(select.options).map((o) => o.value)).toEqual(['__unmanaged__', 'ask', 'auto_edit', 'full_auto']); + }); + + it('writes through a level on change and calls onChanged + success toast', async () => { + const onChanged = vi.fn(); + render(); + const select = screen.getByTestId('agent-permission-select') as HTMLSelectElement; + fireEvent.change(select, { target: { value: 'auto_edit' } }); + expect(mocks.permissionPolicy.setLevel.invoke).toHaveBeenCalledWith({ agent: 'opencode', level: 'auto_edit' }); + await vi.waitFor(() => expect(mocks.messageSuccess).toHaveBeenCalled()); + expect(onChanged).toHaveBeenCalled(); + }); + + it('clears the policy for the unmanaged option', async () => { + const onChanged = vi.fn(); + render(); + const select = screen.getByTestId('agent-permission-select') as HTMLSelectElement; + fireEvent.change(select, { target: { value: '__unmanaged__' } }); + expect(mocks.permissionPolicy.clear.invoke).toHaveBeenCalledWith({ agent: 'opencode' }); + await vi.waitFor(() => expect(mocks.messageSuccess).toHaveBeenCalled()); + expect(onChanged).toHaveBeenCalled(); + }); + + it('shows an error toast when the write-through fails', async () => { + mocks.permissionPolicy.setLevel.invoke.mockRejectedValueOnce(new Error('no config')); + render(); + const select = screen.getByTestId('agent-permission-select') as HTMLSelectElement; + fireEvent.change(select, { target: { value: 'full_auto' } }); + await vi.waitFor(() => expect(mocks.messageError).toHaveBeenCalled()); + }); +}); diff --git a/tests/unit/renderer/agentPermissionPolicy.test.ts b/tests/unit/renderer/agentPermissionPolicy.test.ts new file mode 100644 index 0000000000..f79b9bf213 --- /dev/null +++ b/tests/unit/renderer/agentPermissionPolicy.test.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + * + * Unit tests for the permission-policy normalization helpers. + */ + +import { describe, expect, it } from 'vitest'; + +import { + AGENT_PERMISSION_LEVEL_OPTIONS, + isPermissionPolicyActionable, + type AgentPermissionPolicy, +} from '@/renderer/utils/model/agentPermissionPolicy'; + +const basePolicy: AgentPermissionPolicy = { + agent: 'opencode', + supported: true, + installed: true, + current_level: null, + config_path: '/home/user/.config/opencode/opencode.json', +}; + +describe('isPermissionPolicyActionable', () => { + it('is true only when supported AND installed', () => { + expect(isPermissionPolicyActionable(basePolicy)).toBe(true); + expect(isPermissionPolicyActionable({ ...basePolicy, installed: false })).toBe(false); + expect(isPermissionPolicyActionable({ ...basePolicy, supported: false })).toBe(false); + expect(isPermissionPolicyActionable({ ...basePolicy, supported: false, installed: false })).toBe(false); + }); + + it('is false for undefined policy', () => { + expect(isPermissionPolicyActionable(undefined)).toBe(false); + }); +}); + +describe('AGENT_PERMISSION_LEVEL_OPTIONS', () => { + it('offers the three normalized levels in display order with label keys', () => { + expect(AGENT_PERMISSION_LEVEL_OPTIONS.map((o) => o.value)).toEqual(['ask', 'auto_edit', 'full_auto']); + for (const opt of AGENT_PERMISSION_LEVEL_OPTIONS) { + expect(opt.labelKey.startsWith('settings.agentManagement.permission')).toBe(true); + } + }); +});