Skip to content

Commit 7a0cdba

Browse files
authored
Merge pull request #2883 from guantw/fix/acp-remove-auto-reject
fix(acp): remove automatic rejection from permission settings
2 parents c330b32 + 2ad01b8 commit 7a0cdba

5 files changed

Lines changed: 160 additions & 24 deletions

File tree

‎src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx‎

Lines changed: 123 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -97,21 +97,6 @@ vi.mock('@openbitfun/ui', async importOriginal => ({
9797
onChange?: React.ChangeEventHandler<HTMLInputElement>;
9898
placeholder?: string;
9999
}) => <input value={value} onChange={onChange} placeholder={placeholder} />,
100-
Select: ({
101-
value,
102-
onChange,
103-
options,
104-
}: {
105-
value?: string;
106-
onChange?: (value: string) => void;
107-
options?: Array<{ value: string; label: string }>;
108-
}) => (
109-
<select value={value} onChange={(event) => onChange?.(event.target.value)}>
110-
{(options ?? []).map((option) => (
111-
<option key={option.value} value={option.value}>{option.label}</option>
112-
))}
113-
</select>
114-
),
115100
TabGroup: ({
116101
items,
117102
onValueChange,
@@ -244,6 +229,19 @@ async function openView(container: HTMLElement, label: string): Promise<void> {
244229
});
245230
}
246231

232+
async function selectPermission(container: HTMLElement, value: string): Promise<void> {
233+
const trigger = container.querySelector<HTMLButtonElement>(
234+
'[data-openbitfun-part="confirmation"] button[role="combobox"]',
235+
);
236+
expect(trigger).not.toBeNull();
237+
await act(async () => trigger!.click());
238+
const options = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]'));
239+
expect(options.map(option => option.dataset.value)).toEqual(['ask', 'allow_once']);
240+
const selected = options.find(option => option.dataset.value === value);
241+
expect(selected).toBeTruthy();
242+
await act(async () => selected!.click());
243+
}
244+
247245
describe('AcpAgentsConfig', () => {
248246
let container: HTMLDivElement;
249247
let root: Root;
@@ -300,6 +298,116 @@ describe('AcpAgentsConfig', () => {
300298
vi.clearAllMocks();
301299
});
302300

301+
it.each([
302+
['ask', 'ask'],
303+
['allow_once', 'allow_once'],
304+
])('loads and saves permission mode %s as %s with only supported choices', async (storedMode, expectedMode) => {
305+
loadJsonConfigMock.mockResolvedValue(JSON.stringify({
306+
acpClients: {
307+
opencode: { command: 'opencode', args: ['acp'], permissionMode: storedMode },
308+
},
309+
}));
310+
311+
await act(async () => {
312+
root.render(<AcpAgentsConfig />);
313+
});
314+
315+
expect(container.textContent).not.toContain('permissionMode.legacyRejectWarning');
316+
await selectPermission(container, expectedMode);
317+
expect(saveJsonConfigMock).not.toHaveBeenCalled();
318+
319+
await openView(container, 'views.json');
320+
const editor = container.querySelector<HTMLTextAreaElement>('textarea')!;
321+
await act(async () => {
322+
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
323+
?.call(editor, `${editor.value}\n`);
324+
editor.dispatchEvent(new Event('input', { bubbles: true }));
325+
});
326+
const saveButton = Array.from(container.querySelectorAll('button'))
327+
.find(button => button.textContent === 'actions.saveJson');
328+
expect(saveButton?.disabled).toBe(false);
329+
await act(async () => {
330+
saveButton!.click();
331+
});
332+
333+
const savedConfig = JSON.parse(saveJsonConfigMock.mock.calls[0][0]);
334+
expect(savedConfig.acpClients.opencode).toMatchObject({
335+
command: 'opencode', args: ['acp'], permissionMode: expectedMode,
336+
});
337+
});
338+
339+
it.each([
340+
['local', true],
341+
['ssh', true],
342+
['json', true],
343+
['local', false],
344+
])('explicitly applies a legacy permission from %s (wrapped config: %s)', async (view, wrapped) => {
345+
const legacyClient = {
346+
command: 'opencode', args: ['acp'], env: { ACP_TEST: 'preserved' }, permissionMode: 'reject_once',
347+
};
348+
const otherClient = { command: 'custom-agent', args: [], permissionMode: 'allow_once' };
349+
const acpClients = { opencode: legacyClient, custom: otherClient };
350+
loadJsonConfigMock.mockResolvedValue(JSON.stringify(wrapped ? { acpClients } : acpClients));
351+
saveJsonConfigMock.mockImplementation(async (rawConfig: string) => {
352+
loadJsonConfigMock.mockResolvedValue(rawConfig);
353+
window.dispatchEvent(new Event('openbitfun:acp-clients-changed'));
354+
});
355+
356+
await act(async () => root.render(<AcpAgentsConfig />));
357+
expect(container.querySelector('[data-openbitfun-part="confirmation"]')?.textContent)
358+
.toContain('permissionMode.ask');
359+
expect(container.querySelector('[role="alert"]')?.textContent)
360+
.toContain('permissionMode.legacyRejectWarning');
361+
expect(saveJsonConfigMock).not.toHaveBeenCalled();
362+
363+
// The real Select does not emit a change when Ask is already selected.
364+
await selectPermission(container, 'ask');
365+
expect(Array.from(container.querySelectorAll('button'))
366+
.find(button => button.textContent === 'actions.save')).toBeUndefined();
367+
if (view !== 'local') await openView(container, `views.${view}`);
368+
expect(saveJsonConfigMock).not.toHaveBeenCalled();
369+
370+
const applyButton = Array.from(container.querySelectorAll('button'))
371+
.find(button => button.textContent === 'permissionMode.saveAndApply');
372+
expect(applyButton?.disabled).toBe(false);
373+
await act(async () => applyButton!.click());
374+
375+
expect(saveJsonConfigMock).toHaveBeenCalledTimes(1);
376+
const savedConfig = JSON.parse(saveJsonConfigMock.mock.calls[0][0]);
377+
expect(savedConfig.acpClients.opencode).toMatchObject({ ...legacyClient, permissionMode: 'ask' });
378+
expect(savedConfig.acpClients.custom).toMatchObject(otherClient);
379+
expect(container.textContent).not.toContain('permissionMode.legacyRejectWarning');
380+
expect(container.textContent).not.toContain('permissionMode.saveAndApply');
381+
382+
await act(async () => {
383+
window.dispatchEvent(new Event('openbitfun:acp-clients-changed'));
384+
});
385+
expect(container.textContent).not.toContain('permissionMode.legacyRejectWarning');
386+
expect(saveJsonConfigMock).toHaveBeenCalledTimes(1);
387+
});
388+
389+
it('keeps the migration action after a failed save and applies the current selection on retry', async () => {
390+
loadJsonConfigMock.mockResolvedValue(JSON.stringify({
391+
acpClients: { opencode: { command: 'opencode', permissionMode: 'reject_once' } },
392+
}));
393+
saveJsonConfigMock.mockRejectedValueOnce(new Error('Save failed'));
394+
await act(async () => root.render(<AcpAgentsConfig />));
395+
await selectPermission(container, 'allow_once');
396+
const applyButton = () => Array.from(container.querySelectorAll('button'))
397+
.find(button => button.textContent === 'permissionMode.saveAndApply');
398+
399+
await act(async () => applyButton()!.click());
400+
expect(notifyErrorMock).toHaveBeenCalledWith('Save failed', expect.anything());
401+
expect(container.textContent).toContain('permissionMode.legacyRejectWarning');
402+
expect(applyButton()?.disabled).toBe(false);
403+
404+
await act(async () => applyButton()!.click());
405+
expect(saveJsonConfigMock).toHaveBeenCalledTimes(2);
406+
expect(JSON.parse(saveJsonConfigMock.mock.calls[1][0]).acpClients.opencode.permissionMode)
407+
.toBe('allow_once');
408+
expect(container.textContent).not.toContain('permissionMode.legacyRejectWarning');
409+
});
410+
303411
it('probes requirements when opened and does not treat missing probe data as invalid config', async () => {
304412
await act(async () => {
305413
root.render(<AcpAgentsConfig />);

‎src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { OverflowText,
2+
Alert,
23
Button,
34
ConfirmDialog,
45
Icon,
@@ -210,7 +211,10 @@ function defaultConfigForPreset(preset: AcpClientPreset): AcpClientConfig {
210211
};
211212
}
212213

213-
function normalizeConfigValue(value: unknown): AcpClientConfigFile {
214+
function normalizeConfigValue(value: unknown): {
215+
config: AcpClientConfigFile;
216+
hasLegacyPermissionModes: boolean;
217+
} {
214218
const candidate = value && typeof value === 'object' ? value as Record<string, unknown> : {};
215219
const rawClients = (
216220
candidate.acpClients && typeof candidate.acpClients === 'object' && !Array.isArray(candidate.acpClients)
@@ -219,6 +223,7 @@ function normalizeConfigValue(value: unknown): AcpClientConfigFile {
219223
: candidate;
220224

221225
const acpClients: Record<string, AcpClientConfig> = {};
226+
let hasLegacyPermissionModes = false;
222227
for (const [id, rawConfig] of Object.entries(rawClients)) {
223228
if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
224229
continue;
@@ -230,6 +235,7 @@ function normalizeConfigValue(value: unknown): AcpClientConfigFile {
230235
continue;
231236
}
232237

238+
hasLegacyPermissionModes ||= item.permissionMode === 'reject_once';
233239
acpClients[id] = {
234240
name: typeof item.name === 'string' ? item.name : undefined,
235241
command,
@@ -242,7 +248,7 @@ function normalizeConfigValue(value: unknown): AcpClientConfigFile {
242248
};
243249
}
244250

245-
return { acpClients };
251+
return { config: { acpClients }, hasLegacyPermissionModes };
246252
}
247253

248254
function normalizeEnvObject(value: unknown): Record<string, string> {
@@ -253,7 +259,7 @@ function normalizeEnvObject(value: unknown): Record<string, string> {
253259
}
254260

255261
function normalizePermissionMode(value: unknown): AcpClientPermissionMode {
256-
return value === 'allow_once' || value === 'reject_once' ? value : 'ask';
262+
return value === 'allow_once' ? value : 'ask';
257263
}
258264

259265
function normalizeSubagentConfig(value: unknown): AcpClientSubagentConfig {
@@ -483,6 +489,7 @@ const AcpAgentsConfig: React.FC<AcpAgentsConfigProps> = ({
483489
const [loadFailed, setLoadFailed] = useState(false);
484490
const [saving, setSaving] = useState(false);
485491
const [dirty, setDirty] = useState(false);
492+
const [pendingPermissionMigration, setPendingPermissionMigration] = useState(false);
486493
const [jsonConfig, setJsonConfig] = useState('');
487494
const [jsonBaseline, setJsonBaseline] = useState(formatConfig({ acpClients: {} }));
488495
const [jsonDirty, setJsonDirty] = useState(false);
@@ -713,8 +720,9 @@ const AcpAgentsConfig: React.FC<AcpAgentsConfigProps> = ({
713720
log.warn('Failed to load saved SSH connections for ACP remote overrides', error);
714721
return [] as SavedConnection[];
715722
});
716-
const parsed = normalizeConfigValue(JSON.parse(rawConfig || '{}'));
723+
const { config: parsed, hasLegacyPermissionModes } = normalizeConfigValue(JSON.parse(rawConfig || '{}'));
717724
setConfig(parsed);
725+
setPendingPermissionMigration(hasLegacyPermissionModes);
718726
const formattedConfig = formatConfig(parsed);
719727
setJsonConfig(formattedConfig);
720728
setJsonBaseline(formattedConfig);
@@ -959,6 +967,7 @@ const AcpAgentsConfig: React.FC<AcpAgentsConfigProps> = ({
959967
setJsonBaseline(formattedConfig);
960968
setDirty(false);
961969
setJsonDirty(false);
970+
setPendingPermissionMigration(false);
962971
await refreshRequirementProbes({ force: true, notifyOnError: false });
963972
loadedRemoteProbeIdsRef.current.clear();
964973
setRemoteProbeRefreshNonce(prev => prev + 1);
@@ -1010,7 +1019,7 @@ const AcpAgentsConfig: React.FC<AcpAgentsConfigProps> = ({
10101019

10111020
const saveJsonConfig = async (): Promise<boolean> => {
10121021
try {
1013-
const parsed = normalizeConfigValue(JSON.parse(jsonConfig));
1022+
const { config: parsed } = normalizeConfigValue(JSON.parse(jsonConfig));
10141023
const saved = await saveConfig(parsed, { mergeEnvDrafts: false });
10151024
if (!saved) return false;
10161025
setConfig(parsed);
@@ -1056,7 +1065,6 @@ const AcpAgentsConfig: React.FC<AcpAgentsConfigProps> = ({
10561065
const permissionOptions = useMemo(() => [
10571066
{ value: 'ask', label: t('permissionMode.ask') },
10581067
{ value: 'allow_once', label: t('permissionMode.allowOnce') },
1059-
{ value: 'reject_once', label: t('permissionMode.rejectOnce') },
10601068
], [t]);
10611069

10621070
const registryFilterOptions = useMemo(() => [
@@ -1348,6 +1356,23 @@ const AcpAgentsConfig: React.FC<AcpAgentsConfigProps> = ({
13481356
onValueChange={handleViewChange}
13491357
value={activeView}
13501358
/>
1359+
{pendingPermissionMigration && (
1360+
<Alert
1361+
tone="warning"
1362+
message={t('permissionMode.legacyRejectWarning')}
1363+
description={(
1364+
<Button
1365+
variant="fill"
1366+
size="sm"
1367+
disabled={saving}
1368+
loading={saving}
1369+
onClick={() => { void (activeView === 'json' ? saveJsonConfig() : saveConfig()); }}
1370+
>
1371+
{t('permissionMode.saveAndApply')}
1372+
</Button>
1373+
)}
1374+
/>
1375+
)}
13511376
{activeView === 'json' && (
13521377
<ConfigMessage message={{ type: 'warning', text: t('security.secretWarning') }} />
13531378
)}

‎src/web-ui/src/locales/en-US/settings/acp-agents.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@
112112
"permissionMode": {
113113
"ask": "Ask",
114114
"allowOnce": "Auto approve",
115-
"rejectOnce": "Auto reject"
115+
"legacyRejectWarning": "Some agents still have Auto reject saved. The editor replaces it with Ask. Save and apply your current choices to make them take effect; until then, those agents will continue to reject requests automatically.",
116+
"saveAndApply": "Save and apply"
116117
},
117118
"requirements": {
118119
"tool": "CLI",

‎src/web-ui/src/locales/zh-CN/settings/acp-agents.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@
112112
"permissionMode": {
113113
"ask": "询问",
114114
"allowOnce": "自动通过",
115-
"rejectOnce": "自动拒绝"
115+
"legacyRejectWarning": "部分 Agent 的已保存设置仍为“自动拒绝”,编辑器已将其替换为“询问”。请保存并应用当前选择;保存前,这些 Agent 仍会自动拒绝请求。",
116+
"saveAndApply": "保存并应用"
116117
},
117118
"requirements": {
118119
"tool": "CLI",

‎src/web-ui/src/locales/zh-TW/settings/acp-agents.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@
112112
"permissionMode": {
113113
"ask": "詢問",
114114
"allowOnce": "自動通過",
115-
"rejectOnce": "自動拒絕"
115+
"legacyRejectWarning": "部分 Agent 的已儲存設定仍為「自動拒絕」,編輯器已將其替換為「詢問」。請儲存並套用目前選擇;儲存前,這些 Agent 仍會自動拒絕請求。",
116+
"saveAndApply": "儲存並套用"
116117
},
117118
"requirements": {
118119
"tool": "CLI",

0 commit comments

Comments
 (0)