diff --git a/src/renderer/src/components/settings/AgentCatalogRow.tsx b/src/renderer/src/components/settings/AgentCatalogRow.tsx index 231dac9c39d..e7bfb99c070 100644 --- a/src/renderer/src/components/settings/AgentCatalogRow.tsx +++ b/src/renderer/src/components/settings/AgentCatalogRow.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { Check, ChevronDown, ExternalLink } from 'lucide-react' import type { TuiAgent } from '../../../../shared/tui-agent' import { AgentIcon } from '@/lib/agent-catalog' @@ -8,7 +8,13 @@ import { Button } from '../ui/button' import { SettingsBadge, SettingsSegmentedControl } from './SettingsFormControls' import type { AgentSessionSourceHomeControl } from './codex-session-source-home-control' import { AgentSessionSourceHomeInput } from './codex-session-source-home-control' +import { resolveTuiAgentPermissionMode } from '../../../../shared/tui-agent-permissions' import { stringifyAgentDefaultEnvDraft } from './agent-default-env-draft' +import { + AgentPermissionExceptionBadge, + AgentRowPermissionControl, + type AgentRowPermission +} from './AgentRowPermissions' import { AgentCommandOverrideInput, AgentDefaultArgsInput, @@ -75,6 +81,8 @@ export type AgentCatalogRowProps = { onSaveEnv: (value: Record) => void sessionSourceHome?: AgentSessionSourceHomeControl environmentNames?: readonly string[] + /** Absent for agents without a configurable permission mode on this host. */ + permission?: AgentRowPermission environmentEditor?: React.ReactNode } @@ -98,13 +106,33 @@ export function AgentCatalogRow({ onSaveEnv, sessionSourceHome, environmentNames, - environmentEditor + environmentEditor, + permission }: AgentCatalogRowProps): React.JSX.Element { const envSummary = environmentNames?.join(' ') ?? stringifyAgentDefaultEnvDraft(envOverride) const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv) const [cmdOpen, setCmdOpen] = useState( - Boolean(cmdOverride) || argsOverride !== defaultArgs || envSummary !== defaultEnvSummary + Boolean(cmdOverride) || + (argsOverride !== defaultArgs && + resolveTuiAgentPermissionMode({ + agent: agentId, + agentArgs: argsOverride, + agentEnv: envOverride + }) === 'mixed') || + envSummary !== defaultEnvSummary ) + const permissionRef = useRef(null) + const openPermission = (): void => { + setCmdOpen(true) + requestAnimationFrame(() => { + const control = permissionRef.current + ;( + control?.querySelector('[aria-checked="true"]') ?? + control?.querySelector('button') + )?.focus() + }) + } + const expandable = isDetected || permission !== undefined return (
@@ -112,8 +140,8 @@ export function AgentCatalogRow({
-
-
+
+
{label} {!isEnabled && ( @@ -135,118 +163,141 @@ export function AgentCatalogRow({
-
- -
- {isDetected && isEnabled && ( - - )} -
- - - -
- {isDetected && ( - - )} +
+ {permission && permission.mode !== permission.expectedMode && ( + // Right-aligned against the controls so badges line up without reserving width on every row. +
+ +
+ )} +
+ +
+ {isDetected && isEnabled && ( + + )} +
+ + + +
+ {expandable && ( + + )} +
- {isDetected && cmdOpen && ( -
- -
- + {permission && ( + -
- {environmentEditor ?? - ((defaultEnvSummary || envSummary) && ( + )} + {isDetected && ( +
+
-
- ))} - {sessionSourceHome && ( -
- + {environmentEditor ?? + ((defaultEnvSummary || envSummary) && ( +
+ +
+ ))} + {sessionSourceHome && ( +
+ +
+ )} +

+ {translate( + 'auto.components.settings.AgentsPane.f9f127d664', + 'Override the binary path or name, and edit the default launch arguments or environment for this agent.' + )} +

)} -

- {translate( - 'auto.components.settings.AgentsPane.f9f127d664', - 'Override the binary path or name, and edit the default launch arguments or environment for this agent.' - )} -

)}
diff --git a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx index 2e9a9dcd202..d5f2f4aa39c 100644 --- a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx +++ b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx @@ -4,7 +4,9 @@ import type { AgentLaunchSettingsMutation } from '../../../../shared/agent-launch-settings' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { AgentPermissionMode } from '../../../../shared/tui-agent-permissions' +import { AUTO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' +import type { AgentPermissionsSettingProps } from './AgentPermissionsSetting' +import { buildAgentRowPermissions } from './agent-row-permission-model' import { getTuiAgentDefaultArgs, getTuiAgentDefaultEnv, @@ -21,10 +23,7 @@ import { HostAgentEnvironmentEditor } from './HostAgentEnvironmentEditor' import { buildCodexSessionSourceHomeControl } from './codex-session-source-home-control' import { translate } from '@/i18n/i18n' -export type AgentPermissionsRenderer = ( - mode: AgentPermissionMode, - onChange: (mode: Exclude) => void -) => React.ReactNode +export type AgentPermissionsRenderer = (props: AgentPermissionsSettingProps) => React.ReactNode export function AgentLaunchSettingsCatalog({ settings, @@ -33,6 +32,7 @@ export function AgentLaunchSettingsCatalog({ hostName, renderPermissions, refreshSettings, + autoSupported = true, local }: { settings: AgentLaunchSettings @@ -41,6 +41,7 @@ export function AgentLaunchSettingsCatalog({ hostName: string | null renderPermissions: AgentPermissionsRenderer refreshSettings?: () => void + autoSupported?: boolean local?: { settings: GlobalSettings updateSettings: (updates: Partial) => void | Promise @@ -62,6 +63,13 @@ export function AgentLaunchSettingsCatalog({ const save = (mutation: AgentLaunchSettingsMutation): void => { void mutate(mutation).catch(() => {}) } + const permissions = buildAgentRowPermissions({ + agents: catalog.map((agent) => agent.id), + settings, + localEnv: local ? (local.settings.agentDefaultEnv ?? {}) : null, + autoSupported, + save + }) const rowProps = ( agent: (typeof catalog)[number], isDetected: boolean @@ -78,6 +86,7 @@ export function AgentLaunchSettingsCatalog({ cmdOverride: isDetected ? settings.agentCmdOverrides[agent.id] : undefined, argsOverride: resolveTuiAgentLaunchArgs(agent.id, settings.agentDefaultArgs), envOverride: local ? resolveTuiAgentLaunchEnv(agent.id, local.settings.agentDefaultEnv) : {}, + permission: permissions.rows.get(agent.id), onSetDefault: () => save({ type: 'default', @@ -125,7 +134,17 @@ export function AgentLaunchSettingsCatalog({ )} onSetDefault={(agent) => save({ type: 'default', agent })} /> - {renderPermissions(settings.permissionMode, (mode) => save({ type: 'permissions', mode }))} + {renderPermissions({ + mode: permissions.globalMode, + nonAutoMode: settings.nonAutoPermissionMode, + majorityMode: permissions.majorityMode, + autoAgentLabels: catalog + .filter((agent) => agent.id in AUTO_TUI_AGENT_ARGS) + .map((agent) => agent.label), + onChange: (mode) => save({ type: 'permissions', mode }), + onChangeNonAuto: (mode) => save({ type: 'permissions', mode, exceptAuto: true }), + autoSupported + })} + +export type AgentPermissionsSettingProps = { + mode: AgentPermissionMode + /** With Auto on: the combined mode of agents Auto doesn't cover. */ + nonAutoMode?: AgentPermissionMode + /** Most common mode across agents, named when the global state is mixed. */ + majorityMode?: PresetMode | null + autoAgentLabels?: readonly string[] + onChange: (mode: PresetMode) => void + /** Changes only agents Auto doesn't cover. */ + onChangeNonAuto?: (mode: 'manual' | 'yolo') => void + autoSupported?: boolean +} + +function SupportedAgentsTrigger({ + labels, + children +}: { + labels: readonly string[] + children?: React.ReactNode +}): React.JSX.Element { + return ( + + + + + + {new Intl.ListFormat(getIntlLocale(), { type: 'conjunction' }).format(labels)} + + + ) +} + +function AgentPermissionsDescription({ + mode, + majorityMode, + autoAgentLabels +}: AgentPermissionsSettingProps): React.ReactNode { + if (mode === 'auto') { + return ( + Supported agents approve safe actions on their own.' + )} + components={{ agents: }} + /> + ) + } + if (mode === 'mixed') { + return majorityMode + ? translate( + 'settings.agents.permissionsMixed', + 'Most agents use {{mode}}. Choosing a mode resets the marked exceptions, except custom arguments.', + { mode: agentPermissionModeLabel(majorityMode) } + ) + : translate( + 'settings.agents.permissionsMixedNoMajority', + 'Agents use different settings. Choosing a mode applies to all.' + ) + } + return translate( + 'settings.agents.permissionsDescription', + 'How much agents can do without asking you. Agents with custom launch arguments keep them.' + ) +} + +export function AgentPermissionsSetting(props: AgentPermissionsSettingProps): React.JSX.Element { + const { mode, nonAutoMode, onChange, onChangeNonAuto, autoSupported = true } = props + const title = translate( + 'auto.components.settings.AgentsPane.agentPermissions', + 'Agent Permissions' + ) + const showOthers = mode === 'auto' && onChangeNonAuto !== undefined + return ( +
+
+

{title}

+

+ +

+ {!autoSupported ? ( +

+ {translate('settings.agents.autoUnsupported', 'Auto needs a newer Orca on this host.')} +

+ ) : null} +
+
+ + value={mode} + onChange={(next) => { + if (next !== 'mixed') { + onChange(next) + } + }} + ariaLabel={title} + size="sm" + options={[ + { value: 'manual', label: agentPermissionModeLabel('manual') }, + { value: 'auto', label: agentPermissionModeLabel('auto'), disabled: !autoSupported }, + { value: 'yolo', label: agentPermissionModeLabel('yolo') } + ]} + /> + {/* Kept mounted at zero height so it can animate; inert keeps it out of focus and a11y while hidden. */} +
+
+ {/* Fades in after the row has mostly opened and out before it closes, so it never shows half-clipped. */} +
+ + {translate('settings.agents.otherAgentsPermissions', 'Other agents')} + + + value={nonAutoMode ?? 'mixed'} + onChange={(next) => { + if ((next === 'manual' || next === 'yolo') && onChangeNonAuto) { + onChangeNonAuto(next) + } + }} + ariaLabel={translate( + 'settings.agents.otherAgentsPermissionsLabel', + 'Permissions for other agents' + )} + size="sm" + options={[ + { value: 'manual', label: agentPermissionModeLabel('manual') }, + { value: 'yolo', label: agentPermissionModeLabel('yolo') } + ]} + /> +
+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/AgentRowPermissions.tsx b/src/renderer/src/components/settings/AgentRowPermissions.tsx new file mode 100644 index 00000000000..f03ca9924c7 --- /dev/null +++ b/src/renderer/src/components/settings/AgentRowPermissions.tsx @@ -0,0 +1,111 @@ +import type { AgentPermissionMode } from '../../../../shared/tui-agent-permissions' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { SettingsBadge, SettingsSegmentedControl } from './SettingsFormControls' +import { agentPermissionModeLabel } from './agent-permission-mode-label' + +export type AgentRowPermission = { + mode: AgentPermissionMode + /** Mode the global control implies; a different mode is marked as an exception. */ + expectedMode: AgentPermissionMode | null + autoAvailable: boolean + onChange: (mode: Exclude) => void + /** Replaces custom arguments with the expected preset; absent for environment-only agents. */ + reset?: { mode: Exclude; apply: () => void } +} + +export function AgentPermissionExceptionBadge({ + label, + permission, + onSelect +}: { + label: string + permission: AgentRowPermission + onSelect: () => void +}): React.JSX.Element { + const { mode } = permission + const text = mode === 'mixed' ? customLabel() : agentPermissionModeLabel(mode) + const button = ( + + ) + if (mode !== 'mixed') { + return button + } + return ( + + {button} + + {translate('settings.agents.customArgsTooltip', 'Uses its own launch arguments.')} + + + ) +} + +function customLabel(): string { + return translate('settings.agents.customArgsBadge', 'Custom') +} + +export function AgentRowPermissionControl({ + label, + permission, + controlRef +}: { + label: string + permission: AgentRowPermission + controlRef: React.Ref +}): React.JSX.Element { + return ( +
+ + {translate('settings.agents.rowPermissions', 'Permissions')} + + {permission.mode === 'mixed' ? ( + + {translate('settings.agents.customPermissions', 'Custom arguments')} + {permission.reset ? ( + + ) : null} + + ) : ( + + value={permission.mode} + onChange={(next) => { + if (next !== 'mixed') { + permission.onChange(next) + } + }} + ariaLabel={translate('settings.agents.rowPermissionsLabel', '{{agent}} permissions', { + agent: label + })} + size="sm" + options={[ + { value: 'manual', label: agentPermissionModeLabel('manual') }, + ...(permission.autoAvailable + ? [{ value: 'auto' as const, label: agentPermissionModeLabel('auto') }] + : []), + { value: 'yolo', label: agentPermissionModeLabel('yolo') } + ]} + /> + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index c0f9e879691..694207f9fe3 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -503,24 +503,40 @@ describe('AgentsPane', () => { expect(matchesSettingsSearch('permission', getAgentsPaneSearchEntries())).toBe(true) expect(matchesSettingsSearch('yolo', getAgentsPaneSearchEntries())).toBe(true) expect(matchesSettingsSearch('manual', getAgentsPaneSearchEntries())).toBe(true) + expect(matchesSettingsSearch('approve for me', getAgentsPaneSearchEntries())).toBe(true) }) - it('applies the selected agent permission mode from settings without a mixed segment', () => { - const onChange = vi.fn() - const element = AgentPermissionsSetting({ mode: 'mixed', onChange }) - const props = element.props.children.props.action.props as { - value: 'yolo' - onChange: (value: 'yolo' | 'manual' | 'mixed') => void - options: { value: string }[] - } + it.each([ + ['manual', undefined, 'How much agents can do without asking you.', 1], + ['auto', 'yolo', 'Supported agents approve safe actions on their own.', 2], + ['auto', 'mixed', 'Supported agents approve safe actions on their own.', 1], + ['mixed', undefined, 'Most agents use Yolo. Choosing a mode resets the marked exceptions', 0] + ] as const)('describes %s permissions with others %s', (mode, nonAutoMode, copy, checked) => { + const markup = renderToStaticMarkup( + + + + ) + expect(markup).toContain(copy) + expect(markup.includes('inert=""')).toBe(mode !== 'auto') + expect(markup.match(/aria-checked="true"/g)?.length ?? 0).toBe(checked) + }) - expect(props.value).toBe('yolo') - expect(props.options.map((option) => option.value)).toEqual(['yolo', 'manual']) - props.onChange('mixed') - expect(onChange).not.toHaveBeenCalled() - - props.onChange('manual') - expect(onChange).toHaveBeenCalledWith('manual') + it('explains inline when the host cannot run Auto', () => { + const markup = renderToStaticMarkup( + + + + ) + expect(markup).toContain('Auto needs a newer Orca on this host.') + expect(markup).toMatch(/aria-disabled="true"[^>]*>Auto { diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 75525ca4f9d..03b46b582fd 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -1,6 +1,4 @@ -import { Info } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { AgentPermissionMode } from '../../../../shared/tui-agent-permissions' import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' import { useAppStore } from '@/store' import { AgentAwakeSetting } from './AgentAwakeSetting' @@ -16,13 +14,7 @@ import { getAgentWorkspaceTrustDescription, getAgentWorkspaceTrustTitle } from './agent-workspace-trust-copy' -import { - SettingsSegmentedControl, - SettingsSubsectionHeader, - SettingsSwitchRow -} from './SettingsFormControls' -import { translate } from '@/i18n/i18n' -import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { getAgentsPaneSearchEntries } from './agents-search' import { @@ -31,13 +23,18 @@ import { } from './agent-availability-settings' import { AgentAvailabilityControl } from './AgentCatalogRow' import { AgentLaunchSettingsSection } from './AgentLaunchSettingsSection' +import { + AgentPermissionsSetting, + type AgentPermissionsSettingProps +} from './AgentPermissionsSetting' import type { SettingsHostScope } from './settings-host-scope' export { buildAgentAvailabilitySettingsUpdate, createAgentAvailabilityUpdateQueue, getAgentsPaneSearchEntries, - AgentAvailabilityControl + AgentAvailabilityControl, + AgentPermissionsSetting } type AgentSettingProps = { @@ -54,79 +51,6 @@ type AgentsPaneProps = AgentSettingProps & { wslCapabilitiesLoading?: boolean } -export function AgentPermissionsSetting({ - mode, - onChange -}: { - mode: AgentPermissionMode - onChange: (mode: Exclude) => void -}): React.JSX.Element { - const visibleMode: Exclude = mode === 'manual' ? 'manual' : 'yolo' - return ( -
- - {translate('auto.components.settings.AgentsPane.agentPermissions', 'Agent Permissions')} - - - - - - {translate( - 'auto.components.settings.AgentsPane.agentPermissionsTooltip', - "Doesn't apply to agents where you've overridden launch arguments." - )} - - - - } - description={translate( - 'auto.components.settings.AgentsPane.agentPermissionsDescription', - 'Choose whether Orca launches agents with fewer permission prompts or with manual checks.' - )} - action={ - - value={visibleMode} - onChange={(nextMode) => { - if (nextMode !== 'mixed') { - onChange(nextMode) - } - }} - ariaLabel={translate( - 'auto.components.settings.AgentsPane.agentPermissions', - 'Agent Permissions' - )} - size="sm" - options={[ - { - value: 'yolo', - label: translate('auto.components.settings.AgentsPane.agentPermissionsYolo', 'Yolo') - }, - { - value: 'manual', - label: translate( - 'auto.components.settings.AgentsPane.agentPermissionsManual', - 'Manual' - ) - } - ]} - /> - } - /> -
- ) -} - export function AgentsPane({ settings, updateSettings, @@ -176,11 +100,8 @@ export function AgentsPane({ ) } -function renderAgentPermissions( - mode: AgentPermissionMode, - onChange: (mode: Exclude) => void -): React.ReactNode { - return +function renderAgentPermissions(props: AgentPermissionsSettingProps): React.ReactNode { + return } export function AgentStatusHooksSetting({ settings, updateSettings }: AgentSettingProps) { diff --git a/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx b/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx index 2c79a4a5c54..4bca0b107c3 100644 --- a/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx +++ b/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { StrictMode } from 'react' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { projectAgentLaunchSettings, @@ -9,14 +9,21 @@ import { import { HostAgentLaunchSettings } from './HostAgentLaunchSettings' import { TooltipProvider } from '../ui/tooltip' import type { AgentPermissionsRenderer } from './AgentLaunchSettingsCatalog' -import { AgentsPane } from './AgentsPane' +import { AgentPermissionsSetting, AgentsPane } from './AgentsPane' import { useAppStore } from '@/store' +import { applyAgentPermissionMode } from '../../../../shared/tui-agent-permissions' import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' -const transport = vi.hoisted(() => ({ read: vi.fn(), mutate: vi.fn(), detection: vi.fn() })) +const transport = vi.hoisted(() => ({ + read: vi.fn(), + mutate: vi.fn(), + detection: vi.fn(), + auto: vi.fn() +})) vi.mock('./agent-launch-settings-transport', () => ({ readHostAgentLaunchSettings: transport.read, - mutateHostAgentLaunchSettings: transport.mutate + mutateHostAgentLaunchSettings: transport.mutate, + hostSupportsAutoPermissions: transport.auto })) vi.mock('@/hooks/useDetectedAgents', () => ({ useDetectedAgents: (target: unknown) => { @@ -34,8 +41,8 @@ const host = () => agentDefaultArgs: { claude: '--host-marker' }, agentDefaultEnv: { claude: { API_KEY: 'host-secret' } } }) -const renderPermissions: AgentPermissionsRenderer = (_mode, change) => ( - +const renderPermissions: AgentPermissionsRenderer = ({ onChange }) => ( + ) const pane = (revision: number) => ( @@ -54,9 +61,134 @@ beforeEach(() => { transport.read.mockReset().mockResolvedValue(host()) transport.mutate.mockReset().mockResolvedValue(host()) transport.detection.mockClear() + transport.auto.mockReset().mockResolvedValue(true) }) describe('host launch settings form', () => { + it.each([true, false])( + 'offers automatic permissions only when supported: %s', + async (supported) => { + transport.auto.mockResolvedValue(supported) + render( + + } + /> + + ) + await screen.findByDisplayValue('--host-marker') + const auto = screen.getByRole('radio', { name: 'Auto' }) + expect(auto.getAttribute('aria-disabled') === 'true').toBe(!supported) + fireEvent.click(auto) + if (supported) { + await waitFor(() => + expect(transport.mutate).toHaveBeenCalledWith( + { environmentId: 'ssh-host', pairingRevision: 17 }, + { type: 'permissions', mode: 'auto' }, + expect.any(AbortSignal) + ) + ) + } else { + expect(transport.mutate).not.toHaveBeenCalled() + } + } + ) + + const fullPane = () => ( + + } + /> + + ) + + it('changes only the agents Auto does not cover from the Other agents control', async () => { + const automatic = projectAgentLaunchSettings( + applyAgentPermissionMode({ mode: 'auto', ...applyAgentPermissionMode({ mode: 'yolo' }) }) + ) + transport.read.mockResolvedValue(automatic) + transport.mutate.mockResolvedValue(automatic) + render(fullPane()) + const others = await screen.findByRole('radiogroup', { name: 'Permissions for other agents' }) + fireEvent.focus(screen.getByRole('button', { name: 'Supported agents' })) + expect((await screen.findByRole('tooltip')).textContent).toContain( + 'Claude, Claude Agent Teams, and Codex' + ) + expect(within(others).getByRole('radio', { name: 'Yolo' }).getAttribute('aria-checked')).toBe( + 'true' + ) + fireEvent.click(within(others).getByRole('radio', { name: 'Manual' })) + await waitFor(() => + expect(transport.mutate).toHaveBeenCalledWith( + { environmentId: 'ssh-host', pairingRevision: 17 }, + { type: 'permissions', mode: 'manual', exceptAuto: true }, + expect.any(AbortSignal) + ) + ) + }) + + it('marks an exception and opens its own permission control from the badge', async () => { + const yolo = applyAgentPermissionMode({ mode: 'yolo' }) + const settings = projectAgentLaunchSettings({ + ...yolo, + agentDefaultArgs: { ...yolo.agentDefaultArgs, claude: '' } + }) + transport.read.mockResolvedValue(settings) + transport.mutate.mockResolvedValue(settings) + render(fullPane()) + fireEvent.click(await screen.findByRole('button', { name: 'Claude permissions: Manual' })) + const control = await screen.findByRole('radiogroup', { name: 'Claude permissions' }) + await waitFor(() => + expect(document.activeElement).toBe(within(control).getByRole('radio', { name: 'Manual' })) + ) + fireEvent.click(within(control).getByRole('radio', { name: 'Auto' })) + await waitFor(() => + expect(transport.mutate).toHaveBeenCalledWith( + { environmentId: 'ssh-host', pairingRevision: 17 }, + { type: 'permissions', agent: 'claude', mode: 'auto' }, + expect.any(AbortSignal) + ) + ) + }) + + it('replaces custom arguments with the expected mode from the row', async () => { + render(fullPane()) + await screen.findByDisplayValue('--host-marker') + fireEvent.click(screen.getByRole('button', { name: 'Replace with Manual' })) + await waitFor(() => + expect(transport.mutate).toHaveBeenCalledWith( + { environmentId: 'ssh-host', pairingRevision: 17 }, + { type: 'arguments', agent: 'claude', value: '' }, + expect.any(AbortSignal) + ) + ) + }) + + it('offers the most common mode for a custom-argument row', async () => { + const yolo = applyAgentPermissionMode({ mode: 'yolo' }) + const settings = projectAgentLaunchSettings({ + ...yolo, + agentDefaultArgs: { ...yolo.agentDefaultArgs, claude: '--model sonnet', codex: '' } + }) + transport.read.mockResolvedValue(settings) + transport.mutate.mockResolvedValue(settings) + render(fullPane()) + fireEvent.click(await screen.findByRole('button', { name: 'Replace with Yolo' })) + await waitFor(() => + expect(transport.mutate).toHaveBeenCalledWith( + { environmentId: 'ssh-host', pairingRevision: 17 }, + { type: 'arguments', agent: 'claude', value: '--dangerously-skip-permissions' }, + expect.any(AbortSignal) + ) + ) + }) + it.each(['pill', 'row'] as const)( 'clears the selected default through its %s', async (surface) => { @@ -152,11 +284,7 @@ describe('host launch settings form', () => { ) const args = await screen.findByDisplayValue('--host-marker') - expect( - screen.getByText( - 'Choose whether Orca launches agents with fewer permission prompts or with manual checks.' - ) - ).toBeTruthy() + expect(screen.getByText(/How much agents can do without asking you/)).toBeTruthy() expect(screen.queryByDisplayValue('--desktop-marker')).toBeNull() fireEvent.change(args, { target: { value: '--r10-marker' } }) fireEvent.blur(args) @@ -237,7 +365,10 @@ describe('host launch settings form', () => { expect(transport.mutate).toHaveBeenCalledTimes(1) finishArguments(projectAgentLaunchSettings({ agentDefaultArgs: { claude: '--next-marker' } })) await waitFor(() => expect(transport.mutate).toHaveBeenCalledTimes(2)) - expect(transport.mutate.mock.calls[1]?.[1]).toEqual({ type: 'permissions', mode: 'manual' }) + expect(transport.mutate.mock.calls[1]?.[1]).toEqual({ + type: 'permissions', + mode: 'manual' + }) }) it('does not display launch controls or local defaults for an old server', async () => { diff --git a/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx b/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx index ae945c55fa1..513520e497e 100644 --- a/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx +++ b/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx @@ -8,6 +8,7 @@ import { AgentLaunchSettingsCatalog } from './AgentLaunchSettingsCatalog' import { mutateHostAgentLaunchSettings, readHostAgentLaunchSettings, + hostSupportsAutoPermissions, type AgentLaunchSettingsOwner } from './agent-launch-settings-transport' import { Button } from '../ui/button' @@ -15,7 +16,7 @@ import { translate } from '@/i18n/i18n' type ReadState = | { kind: 'loading' | 'unsupported' | 'failed' } - | { kind: 'ready'; settings: AgentLaunchSettings } + | { kind: 'ready'; settings: AgentLaunchSettings; autoSupported: boolean } export function HostAgentLaunchSettings({ environmentId, @@ -44,9 +45,12 @@ export function HostAgentLaunchSettings({ controllerRef.current = controller setRead({ kind: 'loading' }) try { - const settings = await readHostAgentLaunchSettings(owner, controller.signal) + const [settings, autoSupported] = await Promise.all([ + readHostAgentLaunchSettings(owner, controller.signal), + hostSupportsAutoPermissions(owner) + ]) if (!controller.signal.aborted) { - setRead(settings ? { kind: 'ready', settings } : { kind: 'unsupported' }) + setRead(settings ? { kind: 'ready', settings, autoSupported } : { kind: 'unsupported' }) } } catch { if (!controller.signal.aborted) { @@ -73,7 +77,7 @@ export function HostAgentLaunchSettings({ try { const settings = await mutateHostAgentLaunchSettings(owner, mutation, controller.signal) if (!controller.signal.aborted) { - setRead({ kind: 'ready', settings }) + setRead({ kind: 'ready', settings, autoSupported: read.autoSupported }) } } catch { if (!controller.signal.aborted) { @@ -118,6 +122,7 @@ export function HostAgentLaunchSettings({ detectionTarget={detectionTarget} hostName={hostName} renderPermissions={renderPermissions} + autoSupported={read.autoSupported} refreshSettings={() => void refresh()} /> diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index c50706ac122..48b7b65f180 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -216,7 +216,7 @@ export function SettingsBadge({ return ( ({ call: vi.fn(), supports: vi.fn() })) vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: rpc.call, @@ -65,4 +66,48 @@ describe('host agent launch settings transport', () => { expect(rpc.call).toHaveBeenCalledTimes(1) expect(rpc.call.mock.calls[0]?.[0]).toEqual({ kind: 'environment', environmentId: 'offline' }) }) + + it('keeps the new automatic permission value out of an older host strict schema', async () => { + rpc.supports.mockImplementation( + async (_id, capability) => capability !== AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY + ) + await expect( + mutateHostAgentLaunchSettings( + { environmentId: 'old' }, + { type: 'permissions', agent: 'codex', mode: 'auto' } + ) + ).rejects.toThrow('agent_launch_auto_permissions_unsupported') + expect(rpc.call).not.toHaveBeenCalled() + await mutateHostAgentLaunchSettings( + { environmentId: 'old' }, + { type: 'permissions', mode: 'manual' } + ) + expect(rpc.call).toHaveBeenCalledOnce() + }) + + it('keeps scoped Manual out of an older host strict schema too', async () => { + rpc.supports.mockImplementation( + async (_id, capability) => capability !== AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY + ) + await expect( + mutateHostAgentLaunchSettings( + { environmentId: 'old' }, + { type: 'permissions', agent: 'codex', mode: 'manual' } + ) + ).rejects.toThrow('unsupported') + expect(rpc.call).not.toHaveBeenCalled() + }) + + it('sends automatic permissions only to a host that advertises support', async () => { + await mutateHostAgentLaunchSettings( + { environmentId: 'new' }, + { type: 'permissions', agent: 'codex', mode: 'auto' } + ) + expect(rpc.supports).toHaveBeenCalledWith('new', AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY) + expect(rpc.call.mock.calls[0]?.[2]).toEqual({ + type: 'permissions', + agent: 'codex', + mode: 'auto' + }) + }) }) diff --git a/src/renderer/src/components/settings/agent-launch-settings-transport.ts b/src/renderer/src/components/settings/agent-launch-settings-transport.ts index f5c3d2e0211..7ebac63ced1 100644 --- a/src/renderer/src/components/settings/agent-launch-settings-transport.ts +++ b/src/renderer/src/components/settings/agent-launch-settings-transport.ts @@ -2,7 +2,10 @@ import type { AgentLaunchSettings, AgentLaunchSettingsMutation } from '../../../../shared/agent-launch-settings' -import { AGENT_LAUNCH_SETTINGS_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import { + AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY, + AGENT_LAUNCH_SETTINGS_CAPABILITY +} from '../../../../shared/agent-launch-runtime-capability' import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' export type AgentLaunchSettingsOwner = { @@ -44,6 +47,16 @@ export async function mutateHostAgentLaunchSettings( ) { throw new Error('agent_launch_settings_unsupported') } + if ( + mutation.type === 'permissions' && + (mutation.mode === 'auto' || mutation.agent !== undefined || mutation.exceptAuto) && + !(await runtimeEnvironmentSupportsCapability( + owner.environmentId, + AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY + )) + ) { + throw new Error('agent_launch_auto_permissions_unsupported') + } const result = await callRuntimeRpc<{ settings: AgentLaunchSettings }>( { kind: 'environment', environmentId: owner.environmentId }, 'settings.mutateAgentLaunch', @@ -52,3 +65,10 @@ export async function mutateHostAgentLaunchSettings( ) return result.settings } + +export function hostSupportsAutoPermissions(owner: AgentLaunchSettingsOwner): Promise { + return runtimeEnvironmentSupportsCapability( + owner.environmentId, + AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY + ) +} diff --git a/src/renderer/src/components/settings/agent-permission-baseline.ts b/src/renderer/src/components/settings/agent-permission-baseline.ts new file mode 100644 index 00000000000..28f42b21025 --- /dev/null +++ b/src/renderer/src/components/settings/agent-permission-baseline.ts @@ -0,0 +1,46 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { + AUTO_TUI_AGENT_ARGS, + type AgentPermissionMode +} from '../../../../shared/tui-agent-permissions' + +type PresetMode = Exclude + +/** Most common preset mode; ties keep the first seen. Custom-argument agents don't vote. */ +export function majorityPermissionMode(modes: Iterable): PresetMode | null { + const counts = new Map() + for (const mode of modes) { + if (mode !== 'mixed') { + counts.set(mode, (counts.get(mode) ?? 0) + 1) + } + } + let best: PresetMode | null = null + for (const [mode, count] of counts) { + if (best === null || count > (counts.get(best) ?? 0)) { + best = mode + } + } + return best +} + +/** Mode the global control implies per agent; when it implies none, the most common mode is the baseline. */ +export function buildAgentPermissionBaseline( + modes: ReadonlyMap, + globalMode: AgentPermissionMode, + nonAutoMode: AgentPermissionMode | undefined +): (agent: TuiAgent) => PresetMode | null { + if (globalMode === 'manual' || globalMode === 'yolo') { + return () => globalMode + } + if (globalMode === 'auto') { + const others = + nonAutoMode === 'manual' || nonAutoMode === 'yolo' + ? nonAutoMode + : majorityPermissionMode( + [...modes].filter(([agent]) => !(agent in AUTO_TUI_AGENT_ARGS)).map(([, mode]) => mode) + ) + return (agent) => (agent in AUTO_TUI_AGENT_ARGS ? 'auto' : others) + } + const baseline = majorityPermissionMode(modes.values()) + return () => baseline +} diff --git a/src/renderer/src/components/settings/agent-permission-mode-label.ts b/src/renderer/src/components/settings/agent-permission-mode-label.ts new file mode 100644 index 00000000000..965969d4fa6 --- /dev/null +++ b/src/renderer/src/components/settings/agent-permission-mode-label.ts @@ -0,0 +1,10 @@ +import type { AgentPermissionMode } from '../../../../shared/tui-agent-permissions' +import { translate } from '@/i18n/i18n' + +export function agentPermissionModeLabel(mode: Exclude): string { + return mode === 'auto' + ? translate('settings.agents.autoPermissions', 'Auto') + : mode === 'yolo' + ? translate('auto.components.settings.AgentsPane.agentPermissionsYolo', 'Yolo') + : translate('auto.components.settings.AgentsPane.agentPermissionsManual', 'Manual') +} diff --git a/src/renderer/src/components/settings/agent-row-permission-model.ts b/src/renderer/src/components/settings/agent-row-permission-model.ts new file mode 100644 index 00000000000..88ef77516a4 --- /dev/null +++ b/src/renderer/src/components/settings/agent-row-permission-model.ts @@ -0,0 +1,104 @@ +import type { + AgentLaunchSettings, + AgentLaunchSettingsMutation +} from '../../../../shared/agent-launch-settings' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../../../shared/tui-agent-launch-defaults' +import { + AUTO_TUI_AGENT_ARGS, + hasConfigurableTuiAgentPermissions, + resolveTuiAgentPermissionMode, + YOLO_TUI_AGENT_ARGS, + type AgentPermissionMode +} from '../../../../shared/tui-agent-permissions' +import { buildAgentPermissionBaseline, majorityPermissionMode } from './agent-permission-baseline' +import type { AgentRowPermission } from './AgentRowPermissions' + +export function buildAgentRowPermissions({ + agents, + settings, + localEnv, + autoSupported, + save +}: { + agents: readonly TuiAgent[] + settings: AgentLaunchSettings + /** Local env values; hosts publish only names, so env-only agents can't be read there. */ + localEnv: Partial>> | null + autoSupported: boolean + save: (mutation: AgentLaunchSettingsMutation) => void +}): { + globalMode: AgentPermissionMode + majorityMode: Exclude | null + rows: ReadonlyMap +} { + const globalMode = settings.autoPermissionMode ? 'auto' : settings.permissionMode + const modes = new Map() + for (const agent of agents) { + // Older hosts take only argument edits, which can't express env-only presets. + const editable = autoSupported || agent in YOLO_TUI_AGENT_ARGS + if ( + hasConfigurableTuiAgentPermissions(agent) && + editable && + (localEnv !== null || agent in YOLO_TUI_AGENT_ARGS) + ) { + modes.set( + agent, + resolveTuiAgentPermissionMode({ + agent, + agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), + agentEnv: localEnv ? resolveTuiAgentLaunchEnv(agent, localEnv) : undefined + }) + ) + } + } + const baseline = buildAgentPermissionBaseline(modes, globalMode, settings.nonAutoPermissionMode) + const rows = new Map() + for (const [agent, mode] of modes) { + rows.set(agent, { + mode, + expectedMode: baseline(agent), + autoAvailable: autoSupported && agent in AUTO_TUI_AGENT_ARGS, + onChange: (next) => + save( + autoSupported + ? { type: 'permissions', agent, mode: next } + : { + type: 'arguments', + agent, + value: next === 'yolo' ? (YOLO_TUI_AGENT_ARGS[agent] ?? '') : '' + } + ), + ...(agent in YOLO_TUI_AGENT_ARGS + ? { reset: buildPresetReset(agent, presetFor(agent, baseline(agent)), save) } + : {}) + }) + } + return { globalMode, majorityMode: majorityPermissionMode(modes.values()), rows } +} + +// The most common mode can be Auto even for an agent that can't run it. +function presetFor( + agent: TuiAgent, + mode: AgentPermissionMode | null +): Exclude { + return mode === 'yolo' || (mode === 'auto' && agent in AUTO_TUI_AGENT_ARGS) ? mode : 'manual' +} + +function buildPresetReset( + agent: TuiAgent, + mode: Exclude, + save: (mutation: AgentLaunchSettingsMutation) => void +): NonNullable { + // An arguments edit, since a permissions change refuses agents with custom arguments. + const value = + mode === 'yolo' + ? (YOLO_TUI_AGENT_ARGS[agent] ?? '') + : mode === 'auto' + ? (AUTO_TUI_AGENT_ARGS[agent] ?? '') + : '' + return { mode, apply: () => save({ type: 'arguments', agent, value }) } +} diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 78baab279fb..30cb874b32d 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -45,6 +45,8 @@ function buildAgentSettingsKeywords(): string[] { { key: 'auto.components.settings.agents.search.permissions', fallback: 'permissions' }, { key: 'auto.components.settings.agents.search.yolo', fallback: 'yolo', englishOnly: true }, { key: 'auto.components.settings.agents.search.manual', fallback: 'manual' }, + { key: 'settings.agents.autoPermissions', fallback: 'Auto' }, + { key: 'settings.agents.approveForMe', fallback: 'approve for me' }, { key: 'auto.components.settings.agents.search.e2b7c0dcd7', fallback: 'github', @@ -151,8 +153,8 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ 'Agent Permissions' ), description: translate( - 'auto.components.settings.agents.search.agentPermissionsDescription', - 'Switch agent permission defaults between Yolo and Manual.' + 'settings.agents.permissionsSearchDescription', + 'Launch agents with Manual, Auto, or Yolo permissions.' ), keywords: [ ...translateSearchKeyword('auto.components.settings.agents.search.permission', 'permission'), @@ -162,6 +164,7 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ ), ...translateSearchKeyword('auto.components.settings.agents.search.yolo', 'yolo'), ...translateSearchKeyword('auto.components.settings.agents.search.manual', 'manual'), + ...translateSearchKeyword('settings.agents.autoPermissions', 'Auto'), ...translateSearchKeyword('auto.components.settings.agents.search.skip', 'skip'), ...translateSearchKeyword('auto.components.settings.agents.search.checks', 'checks') ] diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 63ead128f12..851e166a396 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -388,7 +388,25 @@ "saveFailed": "Couldn’t save agent settings. Check the host connection and try again.", "hostDefaults": "Defaults for new agents on the selected host.", "save": "Save", - "clearDefault": "Clear default agent" + "clearDefault": "Clear default agent", + "autoPermissions": "Auto", + "autoUnsupported": "Auto needs a newer Orca on this host.", + "permissionsDescription": "How much agents can do without asking you. Agents with custom launch arguments keep them.", + "permissionsAutoDescription": "Supported agents approve safe actions on their own.", + "permissionsMixed": "Most agents use {{mode}}. Choosing a mode resets the marked exceptions, except custom arguments.", + "permissionsMixedNoMajority": "Agents use different settings. Choosing a mode applies to all.", + "permissionsSearchDescription": "Launch agents with Manual, Auto, or Yolo permissions.", + "otherAgentsPermissions": "Other agents", + "otherAgentsPermissionsLabel": "Permissions for other agents", + "customArgsBadge": "Custom", + "customArgsTooltip": "Uses its own launch arguments.", + "editRowPermissions": "{{agent}} permissions: {{mode}}", + "rowPermissions": "Permissions", + "rowPermissionsLabel": "{{agent}} permissions", + "customPermissions": "Custom arguments", + "replaceWithPermissionMode": "Replace with {{mode}}", + "expandAgentSettings": "Expand agent settings", + "collapseAgentSettings": "Collapse agent settings" }, "followSymlinkedDirectoriesKeywords": { "symlink": "symlink", @@ -7201,8 +7219,6 @@ "0d9e293a02": "Refresh", "c9b33eb5c0": "Refreshing…", "13647f9f80": "Re-read your shell PATH and re-detect installed agents", - "dc4a2ffdc0": "Expand command override", - "cea7d97be1": "Collapse command override", "db9e9e5887": "Customize command", "959b67385b": "Set default", "24e032fa34": "Default", @@ -7212,9 +7228,6 @@ "8fbe1f37c1": "Environment", "2d133152fa": "No default environment", "agentPermissions": "Agent Permissions", - "agentPermissionsInfo": "Agent permissions info", - "agentPermissionsTooltip": "Doesn't apply to agents where you've overridden launch arguments.", - "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manual", "3f1bdf3cb4": "Environment text is too large to parse safely.", @@ -9620,7 +9633,6 @@ "a79d266f71": "session", "afbf35be68": "stable session", "agentPermissions": "Agent Permissions", - "agentPermissionsDescription": "Switch agent permission defaults between Yolo and Manual.", "agentRuntime": "Agent Runtime", "agentRuntimeDescription": "Choose whether agents are detected and launched on Windows or in WSL by default.", "runtime": "runtime", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9b62de6f936..e57e14b9d16 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6935,8 +6935,6 @@ "0d9e293a02": "Refrescar", "c9b33eb5c0": "Actualizando…", "13647f9f80": "Vuelve a leer PATH de tu shell y vuelve a detectar los Agents instalados", - "dc4a2ffdc0": "Expandir override del comando", - "cea7d97be1": "Contraer override del comando", "db9e9e5887": "Personalizar comando", "959b67385b": "Establecer predeterminado", "24e032fa34": "Predeterminado", @@ -6946,9 +6944,6 @@ "8fbe1f37c1": "Entorno", "2d133152fa": "Sin entorno predeterminado", "agentPermissions": "Permisos de Agents", - "agentPermissionsInfo": "Información de permisos de Agents", - "agentPermissionsTooltip": "No se aplica a agents con argumentos de lanzamiento sobrescritos.", - "agentPermissionsDescription": "Elige si Orca lanza agents con menos prompts de permisos o con checks manuales.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manual", "3f1bdf3cb4": "El texto de entorno es demasiado grande para analizarlo de forma segura.", @@ -9304,7 +9299,6 @@ "a79d266f71": "sesión", "afbf35be68": "sesión estable", "agentPermissions": "Permisos de agentes", - "agentPermissionsDescription": "Cambia los permisos predeterminados de los agentes entre Yolo y Manual.", "agentRuntime": "Runtime del agente", "agentRuntimeDescription": "Elige si los agentes se detectan y ejecutan en Windows o en WSL de forma predeterminada.", "7e15b89f6e": "servidor", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 53e368f987b..7571c2be53b 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7052,8 +7052,6 @@ "0d9e293a02": "Actualiser", "c9b33eb5c0": "Actualisation…", "13647f9f80": "Relire le PATH de votre shell et redétecter les agents installés", - "dc4a2ffdc0": "Déplier le remplacement de commande", - "cea7d97be1": "Replier le remplacement de commande", "db9e9e5887": "Personnaliser la commande", "959b67385b": "Définir par défaut", "24e032fa34": "Par défaut", @@ -7063,9 +7061,6 @@ "8fbe1f37c1": "Environnement", "2d133152fa": "Aucun environnement par défaut", "agentPermissions": "Permissions des agents", - "agentPermissionsInfo": "Infos permissions des agents", - "agentPermissionsTooltip": "Ne s'applique pas aux agents dont vous avez remplacé les arguments de lancement.", - "agentPermissionsDescription": "Choisissez si Orca lance les agents avec moins de demandes de permission ou avec des vérifications manuelles.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manuel", "3f1bdf3cb4": "Le texte d'environnement est trop volumineux pour être analysé sans risque.", @@ -9459,7 +9454,6 @@ "a79d266f71": "session", "afbf35be68": "session stable", "agentPermissions": "Permissions des agents", - "agentPermissionsDescription": "Basculer les autorisations d'agent par défaut entre Yolo et Manuelle.", "agentRuntime": "Runtime des agents", "agentRuntimeDescription": "Choisissez si les agents sont détectés et lancés par défaut sous Windows ou dans WSL.", "runtime": "durée d'exécution", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 214a8e8d238..4c88d1d2594 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6925,8 +6925,6 @@ "0d9e293a02": "更新", "c9b33eb5c0": "更新中…", "13647f9f80": "シェルの PATH を再読み取り、インストールされている Agent を再検出します", - "dc4a2ffdc0": "展開コマンドのオーバーライド", - "cea7d97be1": "コマンドのオーバーライドを折りたたむ", "db9e9e5887": "カスタマイズコマンド", "959b67385b": "デフォルトを設定する", "24e032fa34": "デフォルト", @@ -6936,9 +6934,6 @@ "8fbe1f37c1": "環境", "2d133152fa": "デフォルト環境なし", "agentPermissions": "Agent の権限", - "agentPermissionsInfo": "Agent 権限情報", - "agentPermissionsTooltip": "起動引数を上書きしている Agent には適用されません。", - "agentPermissionsDescription": "Orca が権限プロンプトの少ない Agent を起動するか、手動チェックを含む Agent を起動するかを選択します。", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手動", "3f1bdf3cb4": "環境テキストが大きすぎるため安全に解析できません。", @@ -9331,7 +9326,6 @@ "a79d266f71": "セッション", "afbf35be68": "安定したセッション", "agentPermissions": "Agent の権限", - "agentPermissionsDescription": "Agent の権限の既定値を Yolo と Manual で切り替えます。", "agentRuntime": "Agent のランタイム", "agentRuntimeDescription": "デフォルトで Agent を Windows または WSL のどちらで検出および起動するかを選択します。", "runtime": "ランタイム", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 28985b0f21d..6bcc6bddc12 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6925,8 +6925,6 @@ "0d9e293a02": "새로고침", "c9b33eb5c0": "새로고침 중…", "13647f9f80": "셸 PATH를 다시 읽고 설치된 에이전트를 다시 감지합니다", - "dc4a2ffdc0": "명령 재정의 펼치기", - "cea7d97be1": "명령 재정의 접기", "db9e9e5887": "명령 사용자 정의", "959b67385b": "기본값 설정", "24e032fa34": "기본", @@ -6936,9 +6934,6 @@ "8fbe1f37c1": "환경", "2d133152fa": "기본 환경 없음", "agentPermissions": "에이전트 권한", - "agentPermissionsInfo": "에이전트 권한 정보", - "agentPermissionsTooltip": "실행 인수를 재정의한 에이전트에는 적용되지 않습니다.", - "agentPermissionsDescription": "Orca가 에이전트를 실행할 때 권한 프롬프트를 줄일지, 수동 확인을 유지할지 선택합니다.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "수동", "3f1bdf3cb4": "환경 텍스트가 너무 커서 안전하게 파싱할 수 없습니다.", @@ -9294,7 +9289,6 @@ "a79d266f71": "세션", "afbf35be68": "안정적인 세션", "agentPermissions": "에이전트 권한", - "agentPermissionsDescription": "에이전트 권한 기본값을 Yolo와 수동 사이에서 전환합니다.", "agentRuntime": "에이전트 런타임", "agentRuntimeDescription": "기본적으로 Windows 또는 WSL에서 agents를 감지하고 시작할지 선택합니다.", "runtime": "런타임", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f009c611419..aa269aa3e3b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6925,8 +6925,6 @@ "0d9e293a02": "刷新", "c9b33eb5c0": "刷新中…", "13647f9f80": "重新读取 shell PATH 并重新检测已安装的智能体", - "dc4a2ffdc0": "扩展命令覆盖", - "cea7d97be1": "折叠命令覆盖", "db9e9e5887": "自定义命令", "959b67385b": "设置默认值", "24e032fa34": "默认", @@ -6936,9 +6934,6 @@ "8fbe1f37c1": "环境", "2d133152fa": "无默认环境", "agentPermissions": "智能体权限", - "agentPermissionsInfo": "智能体权限信息", - "agentPermissionsTooltip": "切换模式时,自定义智能体参数保持不变。", - "agentPermissionsDescription": "选择 Orca 是以更少的权限提示启动智能体,还是手动检查。", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手动", "3f1bdf3cb4": "环境文本过长,无法安全解析。", @@ -9294,7 +9289,6 @@ "a79d266f71": "会话", "afbf35be68": "稳定会话", "agentPermissions": "智能体权限", - "agentPermissionsDescription": "在 Yolo 和手动之间切换智能体权限默认值。", "agentRuntime": "智能体运行时", "agentRuntimeDescription": "选择默认在 Windows 还是 WSL 中检测并启动智能体。", "runtime": "运行时", diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index 3f3133c8228..6d34d1838a3 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -61,8 +61,13 @@ export const AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY = 'agent.launch.tab-close export const AGENT_LAUNCH_SETTINGS_CAPABILITY = 'settings.agent-launch.v1' as const +// Host accepts scoped terminal permission mutations, including Auto. +export const AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY = + 'settings.agent-launch.auto-permissions.v1' as const + export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [ AGENT_LAUNCH_SETTINGS_CAPABILITY, + AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY, AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, diff --git a/src/shared/agent-launch-settings-auto-projection.test.ts b/src/shared/agent-launch-settings-auto-projection.test.ts new file mode 100644 index 00000000000..27668dc784f --- /dev/null +++ b/src/shared/agent-launch-settings-auto-projection.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + agentLaunchSettingsMutationUpdates, + projectAgentLaunchSettings +} from './agent-launch-settings' +import { applyAgentPermissionMode, AUTO_TUI_AGENT_ARGS } from './tui-agent-permissions' + +describe('agent launch permission projection compatibility', () => { + it('publishes Auto through an optional marker alongside the existing Mixed value', () => { + const settings = projectAgentLaunchSettings(applyAgentPermissionMode({ mode: 'auto' })) + expect(settings.permissionMode).toBe('mixed') + expect(settings.autoPermissionMode).toBe(true) + expect(settings.agentDefaultArgs.codex).toBe('--approve-for-me') + expect(settings.agentDefaultArgs.claude).toBe('--permission-mode auto') + }) + + it.each(['yolo', 'manual'] as const)('preserves the existing %s projection', (mode) => { + const settings = projectAgentLaunchSettings(applyAgentPermissionMode({ mode })) + expect(settings.permissionMode).toBe(mode) + expect(settings).not.toHaveProperty('autoPermissionMode') + }) + + it('does not label a partly configured automatic profile as Auto', () => { + const settings = projectAgentLaunchSettings({ agentDefaultArgs: { codex: '--approve-for-me' } }) + expect(settings.permissionMode).toBe('mixed') + expect(settings).not.toHaveProperty('autoPermissionMode') + }) + + it('publishes the mode of agents Auto does not cover, never Auto itself', () => { + const yolo = applyAgentPermissionMode({ mode: 'yolo' }) + const settings = projectAgentLaunchSettings(applyAgentPermissionMode({ mode: 'auto', ...yolo })) + expect(settings.nonAutoPermissionMode).toBe('yolo') + const manual = projectAgentLaunchSettings(applyAgentPermissionMode({ mode: 'auto' })) + expect(manual.nonAutoPermissionMode).toBe('manual') + }) + + it('keeps one custom agent from making the profile mixed', () => { + const yolo = applyAgentPermissionMode({ mode: 'yolo' }) + const settings = projectAgentLaunchSettings({ + ...yolo, + agentDefaultArgs: { ...yolo.agentDefaultArgs, codex: '--model gpt-5' } + }) + expect(settings.permissionMode).toBe('yolo') + }) + + it('changes only agents Auto does not cover', () => { + const automatic = applyAgentPermissionMode({ + mode: 'auto', + ...applyAgentPermissionMode({ mode: 'yolo' }) + }) + const updates = agentLaunchSettingsMutationUpdates( + automatic, + { type: 'permissions', mode: 'manual', exceptAuto: true }, + 'darwin' + ) + expect(updates.agentDefaultArgs).toMatchObject(AUTO_TUI_AGENT_ARGS) + expect(updates.agentDefaultArgs?.gemini).toBe('') + expect(() => + agentLaunchSettingsMutationUpdates( + automatic, + { type: 'permissions', mode: 'auto', exceptAuto: true }, + 'darwin' + ) + ).toThrow() + }) +}) diff --git a/src/shared/agent-launch-settings-permissions.test.ts b/src/shared/agent-launch-settings-permissions.test.ts new file mode 100644 index 00000000000..e1797361167 --- /dev/null +++ b/src/shared/agent-launch-settings-permissions.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { agentLaunchSettingsMutationUpdates } from './agent-launch-settings' +import { YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' + +const settings = { agentDefaultArgs: { ...YOLO_TUI_AGENT_ARGS } } + +describe('terminal agent permission settings', () => { + it.each(['darwin', 'linux', 'win32'] as const)('changes only Codex on %s', (platform) => { + const updates = agentLaunchSettingsMutationUpdates( + settings, + { type: 'permissions', agent: 'codex', mode: 'auto' }, + platform + ) + expect(updates.agentDefaultArgs?.codex).toBe('--approve-for-me') + expect(updates.agentDefaultArgs?.claude).toBe(YOLO_TUI_AGENT_ARGS.claude) + expect(updates.agentDefaultArgs?.gemini).toBe(YOLO_TUI_AGENT_ARGS.gemini) + expect(updates).not.toHaveProperty('nativeChatPermissionMode') + expect(updates).not.toHaveProperty('agentNativeChatPermissionModes') + }) + + it.each(['auto', 'yolo', 'manual'] as const)( + 'keeps %s terminal changes independent of native chat', + (mode) => { + const before = { ...settings, nativeChatPermissionMode: 'accept-edits' as const } + const updates = agentLaunchSettingsMutationUpdates( + before, + { type: 'permissions', mode }, + 'linux' + ) + expect(updates).not.toHaveProperty('nativeChatPermissionMode') + expect({ ...before, ...updates }.nativeChatPermissionMode).toBe('accept-edits') + } + ) + + it('rejects custom arguments without losing them', () => { + const before = { agentDefaultArgs: { codex: '--model custom --approve-for-me' } } + expect(() => + agentLaunchSettingsMutationUpdates( + before, + { type: 'permissions', agent: 'codex', mode: 'manual' }, + 'linux' + ) + ).toThrow('custom launch arguments') + expect(before.agentDefaultArgs.codex).toBe('--model custom --approve-for-me') + }) + + it('rejects Auto for agents without a reviewer', () => { + expect(() => + agentLaunchSettingsMutationUpdates( + settings, + { type: 'permissions', agent: 'gemini', mode: 'auto' }, + 'linux' + ) + ).toThrow('does not support') + }) +}) diff --git a/src/shared/agent-launch-settings.ts b/src/shared/agent-launch-settings.ts index 9254576b3e8..7c3021713c1 100644 --- a/src/shared/agent-launch-settings.ts +++ b/src/shared/agent-launch-settings.ts @@ -5,10 +5,19 @@ import { buildAgentAvailabilitySettingsUpdate, normalizeDisabledTuiAgents } from './tui-agent-selection' -import { normalizeTuiAgentArgsRecord, resolveTuiAgentLaunchEnv } from './tui-agent-launch-defaults' +import { + normalizeTuiAgentArgsRecord, + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from './tui-agent-launch-defaults' import { applyAgentPermissionMode, + AUTO_TUI_AGENT_ARGS, + YOLO_TUI_AGENT_ARGS, + YOLO_TUI_AGENT_ENV, + resolveTuiAgentPermissionMode, resolveAgentPermissionModeSummary, + resolveNonAutoAgentPermissionMode, type AgentPermissionMode } from './tui-agent-permissions' @@ -31,13 +40,22 @@ export type AgentLaunchSettings = { agentCmdOverrides: NonNullable agentDefaultArgs: NonNullable environmentNames: Partial> - permissionMode: AgentPermissionMode + permissionMode: Exclude + autoPermissionMode?: true + /** With Auto on: the combined mode of agents Auto doesn't cover. Absent from older hosts. */ + nonAutoPermissionMode?: 'manual' | 'yolo' | 'mixed' } export type AgentLaunchSettingsMutation = | { type: 'default'; agent: GlobalSettings['defaultTuiAgent']; setupOnly?: boolean } | { type: 'availability'; agent: TuiAgent; enabled: boolean } - | { type: 'permissions'; mode: Exclude } + | { + type: 'permissions' + mode: Exclude + agent?: TuiAgent + /** Change only agents Auto doesn't cover. Sent only to hosts with the Auto capability. */ + exceptAuto?: true + } | { type: 'command'; agent: TuiAgent; value: string } | { type: 'arguments'; agent: TuiAgent; value: string } | { type: 'environment-set'; agent: TuiAgent; name: string; value: string } @@ -46,6 +64,7 @@ export type AgentLaunchSettingsMutation = export function projectAgentLaunchSettings( settings: AgentLaunchSettingsSource ): AgentLaunchSettings { + const permissionMode = resolveAgentPermissionModeSummary(settings) return { defaultTuiAgent: settings.defaultTuiAgent ?? null, newWorkspaceSetupOnly: false, @@ -58,7 +77,14 @@ export function projectAgentLaunchSettings( Object.keys(env ?? {}).sort() ]) ), - permissionMode: resolveAgentPermissionModeSummary(settings) + // Older clients understand Mixed and ignore the optional automatic-mode marker. + permissionMode: permissionMode === 'auto' ? 'mixed' : permissionMode, + ...(permissionMode === 'auto' + ? { + autoPermissionMode: true, + nonAutoPermissionMode: resolveNonAutoAgentPermissionMode(settings) + } + : {}) } } @@ -79,8 +105,35 @@ export function agentLaunchSettingsMutationUpdates( mutation.agent, mutation.enabled ) - case 'permissions': + case 'permissions': { + if (mutation.agent) { + const agent = mutation.agent + if (!(agent in YOLO_TUI_AGENT_ARGS) && !(agent in YOLO_TUI_AGENT_ENV)) { + throw new Error('This agent has no configurable terminal permission mode') + } + if (mutation.mode === 'auto' && !AUTO_TUI_AGENT_ARGS[agent]) { + throw new Error('This agent does not support automatic permission review') + } + if ( + resolveTuiAgentPermissionMode({ + agent, + agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) + }) === 'mixed' + ) { + throw new Error('Edit custom launch arguments before choosing a permission mode') + } + return applyAgentPermissionMode({ ...settings, mode: mutation.mode, agent }) + } + if (mutation.exceptAuto) { + if (mutation.mode === 'auto') { + throw new Error('Auto cannot apply to agents without automatic permission review') + } + return applyAgentPermissionMode({ ...settings, mode: mutation.mode, exceptAuto: true }) + } return applyAgentPermissionMode({ ...settings, mode: mutation.mode }) + } + case 'command': { const commands = { ...settings.agentCmdOverrides } if (mutation.value.trim()) { diff --git a/src/shared/rpc-contract/agent-launch-settings-params.ts b/src/shared/rpc-contract/agent-launch-settings-params.ts index cc7de5d33b9..24fec536c62 100644 --- a/src/shared/rpc-contract/agent-launch-settings-params.ts +++ b/src/shared/rpc-contract/agent-launch-settings-params.ts @@ -22,7 +22,14 @@ export const AgentLaunchSettingsMutation = z.discriminatedUnion('type', [ }) .strict(), z.object({ type: z.literal('availability'), agent: Agent, enabled: z.boolean() }).strict(), - z.object({ type: z.literal('permissions'), mode: z.enum(['manual', 'yolo']) }).strict(), + z + .object({ + type: z.literal('permissions'), + mode: z.enum(['manual', 'yolo', 'auto']), + agent: Agent.optional(), + exceptAuto: z.literal(true).optional() + }) + .strict(), z.object({ type: z.literal('command'), agent: Agent, value: Value }).strict(), z.object({ type: z.literal('arguments'), agent: Agent, value: Value }).strict(), z.object({ type: z.literal('environment-set'), agent: Agent, name: Name, value: Value }).strict(), diff --git a/src/shared/tui-agent-auto-permissions.test.ts b/src/shared/tui-agent-auto-permissions.test.ts new file mode 100644 index 00000000000..d26d5086db9 --- /dev/null +++ b/src/shared/tui-agent-auto-permissions.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { agentLaunchSettingsMutationUpdates } from './agent-launch-settings' +import { AgentLaunchSettingsMutation } from './rpc-contract/agent-launch-settings-params' +import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from './tui-agent-startup' +import { + resolveTuiAgentLaunchArgs, + tuiAgentArgsBypassPermissions +} from './tui-agent-launch-defaults' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' + +describe('automatic terminal permissions', () => { + it.each(['posix', 'powershell', 'cmd'] as const)( + 'sends the automatic mode through a fresh launch and resume on %s', + (shell) => { + const platform = shell === 'posix' ? 'linux' : 'win32' + const mutation = AgentLaunchSettingsMutation.parse({ type: 'permissions', mode: 'auto' }) + const settings = agentLaunchSettingsMutationUpdates({}, mutation, platform) + + for (const agent of ['claude', 'claude-agent-teams', 'codex'] as const) { + const agentArgs = resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs) + const startup = buildAgentStartupPlan({ + agent, + agentArgs, + prompt: '', + cmdOverrides: {}, + platform, + shell, + isRemote: true, + allowEmptyPromptLaunch: true + }) + expect(startup).not.toBeNull() + expect(startup?.launchConfig.agentArgs).toBe(agentArgs) + expect(tokenizeStartupCommand(startup?.launchCommand ?? '', shell)).toMatchObject({ + ok: true, + tokens: expect.arrayContaining( + agent === 'codex' ? ['--approve-for-me'] : ['--permission-mode', 'auto'] + ) + }) + expect(tuiAgentArgsBypassPermissions(agent, agentArgs, shell)).toBe(false) + + if (agent === 'claude-agent-teams') { + continue + } + const resumed = buildAgentResumeStartupPlan({ + agent, + ...startup?.launchConfig, + providerSession: { key: 'session_id', id: 'saved-session' }, + cmdOverrides: {}, + platform, + shell, + isRemote: true + }) + expect(resumed?.launchConfig.agentArgs).toBe(agentArgs) + expect(tokenizeStartupCommand(resumed?.launchCommand ?? '', shell)).toMatchObject({ + ok: true, + tokens: expect.arrayContaining( + agent === 'codex' ? ['--approve-for-me'] : ['--permission-mode', 'auto'] + ) + }) + } + } + ) +}) diff --git a/src/shared/tui-agent-permissions.test.ts b/src/shared/tui-agent-permissions.test.ts index 3a5570be5c3..f0815dc2b29 100644 --- a/src/shared/tui-agent-permissions.test.ts +++ b/src/shared/tui-agent-permissions.test.ts @@ -2,12 +2,114 @@ import { describe, expect, it } from 'vitest' import { applyAgentPermissionMode, resolveAgentPermissionModeSummary, + resolveNonAutoAgentPermissionMode, resolveTuiAgentPermissionMode, + AUTO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ENV } from './tui-agent-permissions' describe('tui agent permissions', () => { + it.each(['yolo', 'manual'] as const)( + 'applies auto to supporting agents while preserving the %s policy elsewhere', + (mode) => { + const previous = applyAgentPermissionMode({ mode }) + const automatic = applyAgentPermissionMode({ mode: 'auto', ...previous }) + + expect(automatic.agentDefaultArgs).toMatchObject(AUTO_TUI_AGENT_ARGS) + expect(automatic.agentDefaultArgs.gemini).toBe(previous.agentDefaultArgs.gemini) + expect(automatic.agentDefaultEnv).toEqual(previous.agentDefaultEnv) + expect(resolveAgentPermissionModeSummary(automatic)).toBe('auto') + expect(applyAgentPermissionMode({ mode, ...automatic })).toEqual(previous) + } + ) + + it('recognizes manually configured auto arguments for each supporting agent', () => { + for (const agent of ['claude', 'claude-agent-teams', 'codex'] as const) { + expect(resolveTuiAgentPermissionMode({ agent, agentArgs: AUTO_TUI_AGENT_ARGS[agent] })).toBe( + 'auto' + ) + } + }) + + it('preserves custom arguments and leaves them out of the Auto summary', () => { + const automatic = applyAgentPermissionMode({ + mode: 'auto', + agentDefaultArgs: { claude: '--model sonnet' } + }) + expect(automatic.agentDefaultArgs.claude).toBe('--model sonnet') + expect(automatic.agentDefaultArgs.codex).toBe('--approve-for-me') + expect(resolveAgentPermissionModeSummary(automatic)).toBe('auto') + }) + + it('keeps an Auto profile Auto when an unsupported agent has custom arguments', () => { + const automatic = applyAgentPermissionMode({ + mode: 'auto', + agentDefaultArgs: { ...YOLO_TUI_AGENT_ARGS, gemini: '--model custom' }, + agentDefaultEnv: YOLO_TUI_AGENT_ENV + }) + expect(resolveAgentPermissionModeSummary(automatic)).toBe('auto') + expect(resolveNonAutoAgentPermissionMode(automatic)).toBe('yolo') + }) + + it('does not call a profile Auto when every Auto-capable agent has custom arguments', () => { + expect( + resolveAgentPermissionModeSummary({ + agentDefaultArgs: { + claude: '--model a', + 'claude-agent-teams': '--model b', + codex: '--model c' + } + }) + ).toBe('manual') + }) + + it('reports the agents Auto does not cover as mixed only when their presets differ', () => { + const automatic = applyAgentPermissionMode({ + mode: 'auto', + ...applyAgentPermissionMode({ mode: 'yolo' }) + }) + expect(resolveNonAutoAgentPermissionMode(automatic)).toBe('yolo') + expect( + resolveNonAutoAgentPermissionMode({ + ...automatic, + agentDefaultArgs: { ...automatic.agentDefaultArgs, gemini: '' } + }) + ).toBe('mixed') + }) + + it('reports no single mode when every agent Auto does not cover has custom arguments', () => { + const automatic = applyAgentPermissionMode({ mode: 'auto' }) + const custom = Object.fromEntries( + Object.keys(YOLO_TUI_AGENT_ARGS) + .filter((agent) => !(agent in AUTO_TUI_AGENT_ARGS)) + .map((agent) => [agent, '--custom-flag']) + ) + expect( + resolveNonAutoAgentPermissionMode({ + agentDefaultArgs: { ...automatic.agentDefaultArgs, ...custom }, + agentDefaultEnv: { goose: { GOOSE_MODE: 'custom' } } + }) + ).toBe('mixed') + }) + + it('changes only agents Auto does not cover when asked to', () => { + const automatic = applyAgentPermissionMode({ + mode: 'auto', + ...applyAgentPermissionMode({ mode: 'yolo' }) + }) + const manualOthers = applyAgentPermissionMode({ + mode: 'manual', + exceptAuto: true, + ...automatic + }) + expect(manualOthers.agentDefaultArgs).toMatchObject(AUTO_TUI_AGENT_ARGS) + expect(manualOthers.agentDefaultArgs.gemini).toBe('') + expect(manualOthers.agentDefaultEnv.goose).toEqual({}) + expect(resolveAgentPermissionModeSummary(manualOthers)).toBe('auto') + expect(resolveNonAutoAgentPermissionMode(manualOthers)).toBe('manual') + }) + it('recognizes the current default profile as yolo', () => { expect( resolveAgentPermissionModeSummary({ @@ -38,7 +140,7 @@ describe('tui agent permissions', () => { expect(result.agentDefaultEnv.goose).toEqual({}) }) - it('reports mixed when custom arguments are present', () => { + it('ignores custom arguments when summarizing the shared mode', () => { expect( resolveAgentPermissionModeSummary({ agentDefaultArgs: { @@ -47,7 +149,7 @@ describe('tui agent permissions', () => { }, agentDefaultEnv: YOLO_TUI_AGENT_ENV }) - ).toBe('mixed') + ).toBe('yolo') }) it('resolves one Codex yolo launch as yolo', () => { diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index db014311459..ceb356c168c 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -1,7 +1,13 @@ import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { TuiAgent } from './tui-agent' -export type AgentPermissionMode = 'yolo' | 'manual' | 'mixed' +export type AgentPermissionMode = 'yolo' | 'manual' | 'auto' | 'mixed' + +export const AUTO_TUI_AGENT_ARGS: Partial> = { + claude: '--permission-mode auto', + 'claude-agent-teams': '--permission-mode auto', + codex: '--approve-for-me' +} export const YOLO_TUI_AGENT_ARGS: Partial> = { claude: '--dangerously-skip-permissions', @@ -46,6 +52,10 @@ const PERMISSION_AGENT_IDS = Object.keys(TUI_AGENT_CONFIG).filter( (agent): agent is TuiAgent => agent in YOLO_TUI_AGENT_ARGS || agent in YOLO_TUI_AGENT_ENV ) +export function hasConfigurableTuiAgentPermissions(agent: TuiAgent): boolean { + return agent in YOLO_TUI_AGENT_ARGS || agent in YOLO_TUI_AGENT_ENV +} + function normalizeArgs(value: string | null | undefined): string { return value?.trim() ?? '' } @@ -62,11 +72,15 @@ function sameEnv( return leftEntries.every(([name, value]) => right?.[name] === value) } -function resolveAgentPermissionMode(args: string, yoloArgs: string): AgentPermissionMode { +function resolveAgentPermissionMode( + args: string, + yoloArgs: string, + autoArgs: string | undefined +): AgentPermissionMode { if (!args) { return 'manual' } - return args === yoloArgs ? 'yolo' : 'mixed' + return args === yoloArgs ? 'yolo' : args === autoArgs ? 'auto' : 'mixed' } function resolveAgentEnvPermissionMode( @@ -80,24 +94,8 @@ function resolveAgentEnvPermissionMode( } function combinePermissionModes(modes: AgentPermissionMode[]): AgentPermissionMode { - let sawYolo = false - let sawManual = false - let sawMixed = false - - for (const mode of modes) { - if (mode === 'yolo') { - sawYolo = true - } else if (mode === 'manual') { - sawManual = true - } else { - sawMixed = true - } - } - - if (sawMixed || (sawYolo && sawManual)) { - return 'mixed' - } - return sawYolo ? 'yolo' : 'manual' + const distinct = new Set(modes) + return distinct.size > 1 ? 'mixed' : (modes[0] ?? 'manual') } export function resolveTuiAgentPermissionMode(args: { @@ -110,7 +108,8 @@ export function resolveTuiAgentPermissionMode(args: { modes.push( resolveAgentPermissionMode( normalizeArgs(args.agentArgs), - YOLO_TUI_AGENT_ARGS[args.agent] ?? '' + YOLO_TUI_AGENT_ARGS[args.agent] ?? '', + AUTO_TUI_AGENT_ARGS[args.agent] ) ) } @@ -121,27 +120,63 @@ export function resolveTuiAgentPermissionMode(args: { return combinePermissionModes(modes) } -export function resolveAgentPermissionModeSummary(args: { +type AgentPermissionSettings = { agentDefaultArgs?: Partial> | null agentDefaultEnv?: Partial>> | null -}): AgentPermissionMode { - const modes: AgentPermissionMode[] = [] +} +// Custom launch arguments are left alone by every bulk change, so they don't count toward the summary. +function presetPermissionModes( + args: AgentPermissionSettings +): { agent: TuiAgent; mode: Exclude }[] { + const result: { agent: TuiAgent; mode: Exclude }[] = [] for (const agent of PERMISSION_AGENT_IDS) { - modes.push( - resolveTuiAgentPermissionMode({ - agent, - agentArgs: args.agentDefaultArgs?.[agent], - agentEnv: args.agentDefaultEnv?.[agent] - }) - ) + const mode = resolveTuiAgentPermissionMode({ + agent, + agentArgs: args.agentDefaultArgs?.[agent], + agentEnv: args.agentDefaultEnv?.[agent] + }) + if (mode !== 'mixed') { + result.push({ agent, mode }) + } } + return result +} - return combinePermissionModes(modes) +export function resolveAgentPermissionModeSummary( + args: AgentPermissionSettings +): AgentPermissionMode { + const presets = presetPermissionModes(args) + const autoModes = presets.filter(({ agent }) => agent in AUTO_TUI_AGENT_ARGS) + // Auto changes only supporting agents, so the rest keep their existing permission policy. + if (autoModes.length > 0 && autoModes.every(({ mode }) => mode === 'auto')) { + return 'auto' + } + return combinePermissionModes(presets.map(({ mode }) => mode)) +} + +/** Combined mode of preset agents Auto doesn't cover, so the UI can say what they still use. */ +export function resolveNonAutoAgentPermissionMode( + args: AgentPermissionSettings +): 'manual' | 'yolo' | 'mixed' { + const modes = new Set() + for (const { agent, mode } of presetPermissionModes(args)) { + if (!(agent in AUTO_TUI_AGENT_ARGS)) { + modes.add(mode) + } + } + // No preset left to describe (all custom arguments) reads as no single mode. + if (modes.size !== 1) { + return 'mixed' + } + return modes.has('yolo') ? 'yolo' : 'manual' } export function applyAgentPermissionMode(args: { mode: Exclude + agent?: TuiAgent + /** Leave Auto-capable agents as they are, e.g. to change only the agents Auto doesn't cover. */ + exceptAuto?: boolean agentDefaultArgs?: Partial> | null agentDefaultEnv?: Partial>> | null }): { @@ -151,12 +186,17 @@ export function applyAgentPermissionMode(args: { const nextArgs = { ...args.agentDefaultArgs } const nextEnv = { ...args.agentDefaultEnv } - for (const agent of PERMISSION_AGENT_IDS) { + const agents = args.agent ? [args.agent] : PERMISSION_AGENT_IDS + for (const agent of agents) { + const autoArgs = AUTO_TUI_AGENT_ARGS[agent] + if ((args.mode === 'auto' && !autoArgs) || (args.exceptAuto && autoArgs)) { + continue + } if (agent in YOLO_TUI_AGENT_ARGS) { const yoloArgs = YOLO_TUI_AGENT_ARGS[agent] ?? '' const currentArgs = normalizeArgs(nextArgs[agent]) - if (!currentArgs || currentArgs === yoloArgs) { - nextArgs[agent] = args.mode === 'yolo' ? yoloArgs : '' + if (!currentArgs || currentArgs === yoloArgs || currentArgs === autoArgs) { + nextArgs[agent] = args.mode === 'yolo' ? yoloArgs : args.mode === 'auto' ? autoArgs : '' } } diff --git a/tests/e2e/agent-auto-permissions.spec.ts b/tests/e2e/agent-auto-permissions.spec.ts new file mode 100644 index 00000000000..e1c1d24ca16 --- /dev/null +++ b/tests/e2e/agent-auto-permissions.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +test.use({ seedTestRepo: false }) + +test('global Auto applies to supporting agents, other agents switch separately, and it persists', async ({ + orcaPage +}) => { + await waitForSessionReady(orcaPage) + // Terminal permission changes must leave the native chat default alone. + await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Orca store is unavailable') + } + return store.getState().updateSettingsOrThrow({ nativeChatPermissionMode: 'accept-edits' }) + }) + await orcaPage.getByRole('button', { name: 'Settings', exact: true }).click() + await orcaPage.getByRole('button', { name: 'Agents', exact: true }).click() + const group = orcaPage.getByRole('radiogroup', { name: 'Agent Permissions' }) + const others = orcaPage.getByRole('radiogroup', { name: 'Permissions for other agents' }) + const readArgs = () => + orcaPage.evaluate(async () => { + const settings = await window.api.settings.get() + return { + claude: settings.agentDefaultArgs?.claude, + codex: settings.agentDefaultArgs?.codex, + gemini: settings.agentDefaultArgs?.gemini, + native: settings.nativeChatPermissionMode + } + }) + + await group.getByRole('radio', { name: 'Yolo', exact: true }).click() + await expect(others).toHaveCount(0) + await group.getByRole('radio', { name: 'Auto', exact: true }).click() + await expect(group.getByRole('radio', { name: 'Auto', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await expect(others.getByRole('radio', { name: 'Yolo', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await expect.poll(readArgs).toEqual({ + claude: '--permission-mode auto', + codex: '--approve-for-me', + gemini: '--yolo', + native: 'accept-edits' + }) + + await others.getByRole('radio', { name: 'Manual', exact: true }).click() + await expect.poll(readArgs).toEqual({ + claude: '--permission-mode auto', + codex: '--approve-for-me', + gemini: '', + native: 'accept-edits' + }) + + await orcaPage.reload() + await orcaPage.waitForFunction(() => Boolean(window.__store)) + await waitForSessionReady(orcaPage) + await orcaPage.getByRole('button', { name: 'Agents', exact: true }).click() + await expect(group.getByRole('radio', { name: 'Auto', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await expect(others.getByRole('radio', { name: 'Manual', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + + await group.getByRole('radio', { name: 'Manual', exact: true }).click() + await expect.poll(readArgs).toEqual({ claude: '', codex: '', gemini: '', native: 'accept-edits' }) +}) diff --git a/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts b/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts index fc0a40407f1..0ad8be0104d 100644 --- a/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts +++ b/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts @@ -26,7 +26,8 @@ vi.mock('../../src/main/agent-hooks/managed-agent-hook-controls', () => ({ })) vi.mock('../../src/renderer/src/components/settings/agent-launch-settings-transport', () => ({ readHostAgentLaunchSettings: transport.read, - mutateHostAgentLaunchSettings: transport.mutate + mutateHostAgentLaunchSettings: transport.mutate, + hostSupportsAutoPermissions: async () => true })) vi.mock('@/hooks/useDetectedAgents', () => ({ useDetectedAgents: () => ({