feat(agents): add Auto to the global agent permission switch (#30677)

* feat(agents): configure Auto terminal permissions per supported agent

* feat(agents): restore global permission switch with Auto, per-agent exceptions

Replace the Set defaults menu and inline per-row dropdowns with a global
Manual | Auto | Yolo switch. Auto reveals an Other agents control beneath it,
mixed states badge exceptions, and each row's expanded panel owns its
per-agent permission choice.

* test(e2e): fail loudly when the store is unavailable in the Auto permissions spec
This commit is contained in:
Neil
2026-10-19 18:15:07 -07:00
committed by GitHub
parent 883583b4c8
commit 8d0501622a
31 changed files with 1413 additions and 318 deletions
@@ -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<string, string>) => 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<HTMLDivElement>(null)
const openPermission = (): void => {
setCmdOpen(true)
requestAnimationFrame(() => {
const control = permissionRef.current
;(
control?.querySelector<HTMLElement>('[aria-checked="true"]') ??
control?.querySelector<HTMLElement>('button')
)?.focus()
})
}
const expandable = isDetected || permission !== undefined
return (
<div className={cn('py-3', !isDetected && 'opacity-70')}>
@@ -112,8 +140,8 @@ export function AgentCatalogRow({
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border/50 bg-background/50">
<AgentIcon agent={agentId} size={16} />
</div>
<div className="min-w-0 flex-1 sm:min-w-[12rem]">
<div className="flex items-center gap-2">
<div className="flex-1 max-sm:min-w-0 sm:min-w-48">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium leading-none">{label}</span>
{!isEnabled && (
<SettingsBadge tone="muted">
@@ -135,118 +163,141 @@ export function AgentCatalogRow({
</div>
</div>
<div className="ml-auto grid shrink-0 grid-cols-[max-content_6.5rem_1.75rem_1.75rem] items-center gap-1.5">
<AgentAvailabilityControl
label={label}
isEnabled={isEnabled}
onSetEnabled={onSetEnabled}
/>
<div className="flex justify-start">
{isDetected && isEnabled && (
<Button
type="button"
variant={isDefault ? 'secondary' : 'ghost'}
size="xs"
onClick={onSetDefault}
title={
isDefault
? translate('settings.agents.clearDefault', 'Clear default agent')
: translate('auto.components.settings.AgentsPane.5f986a9b92', 'Set as default')
}
className="h-7 w-full justify-center gap-1 text-xs"
>
{isDefault && <Check className="size-3" />}
{isDefault
? translate('auto.components.settings.AgentsPane.24e032fa34', 'Default')
: translate('auto.components.settings.AgentsPane.959b67385b', 'Set default')}
</Button>
)}
</div>
<a
href={homepageUrl}
target="_blank"
rel="noopener noreferrer"
title={
isDetected
? translate('auto.components.settings.AgentsPane.fe4d630c94', 'Docs')
: translate('auto.components.settings.AgentsPane.f95b5c79b8', 'Install')
}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
<ExternalLink className="size-3.5" />
</a>
<div className="flex size-7 items-center justify-center">
{isDetected && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setCmdOpen((previous) => !previous)}
aria-label={
cmdOpen
? translate(
'auto.components.settings.AgentsPane.cea7d97be1',
'Collapse command override'
)
: translate(
'auto.components.settings.AgentsPane.dc4a2ffdc0',
'Expand command override'
)
}
className="size-7 text-muted-foreground hover:text-foreground"
>
<ChevronDown
className={cn('size-3.5 transition-transform', cmdOpen && 'rotate-180')}
/>
</Button>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{permission && permission.mode !== permission.expectedMode && (
// Right-aligned against the controls so badges line up without reserving width on every row.
<div className="mr-3 flex h-7 items-center">
<AgentPermissionExceptionBadge
label={label}
permission={permission}
onSelect={openPermission}
/>
</div>
)}
<div className="grid grid-cols-[max-content_6.5rem_1.75rem_1.75rem] items-center gap-1.5">
<AgentAvailabilityControl
label={label}
isEnabled={isEnabled}
onSetEnabled={onSetEnabled}
/>
<div className="flex justify-start">
{isDetected && isEnabled && (
<Button
type="button"
variant={isDefault ? 'secondary' : 'ghost'}
size="xs"
onClick={onSetDefault}
title={
isDefault
? translate('settings.agents.clearDefault', 'Clear default agent')
: translate(
'auto.components.settings.AgentsPane.5f986a9b92',
'Set as default'
)
}
className="h-7 w-full justify-center gap-1 text-xs"
>
{isDefault && <Check className="size-3" />}
{isDefault
? translate('auto.components.settings.AgentsPane.24e032fa34', 'Default')
: translate('auto.components.settings.AgentsPane.959b67385b', 'Set default')}
</Button>
)}
</div>
<a
href={homepageUrl}
target="_blank"
rel="noopener noreferrer"
title={
isDetected
? translate('auto.components.settings.AgentsPane.fe4d630c94', 'Docs')
: translate('auto.components.settings.AgentsPane.f95b5c79b8', 'Install')
}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
<ExternalLink className="size-3.5" />
</a>
<div className="flex size-7 items-center justify-center">
{expandable && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setCmdOpen((previous) => !previous)}
aria-label={
cmdOpen
? translate(
'settings.agents.collapseAgentSettings',
'Collapse agent settings'
)
: translate('settings.agents.expandAgentSettings', 'Expand agent settings')
}
className="size-7 text-muted-foreground hover:text-foreground"
>
<ChevronDown
className={cn('size-3.5 transition-transform', cmdOpen && 'rotate-180')}
/>
</Button>
)}
</div>
</div>
</div>
</div>
{isDetected && cmdOpen && (
<div className="mt-3 pl-10">
<AgentCommandOverrideInput
key={cmdOverride ?? defaultCmd}
defaultCmd={defaultCmd}
cmdOverride={cmdOverride}
onSaveOverride={onSaveOverride}
/>
<div className="mt-2">
<AgentDefaultArgsInput
key={`${agentId}:${argsOverride}`}
defaultArgs={defaultArgs}
argsOverride={argsOverride}
onSaveArgs={onSaveArgs}
{expandable && cmdOpen && (
<div className="mt-3 space-y-3 pl-10">
{permission && (
<AgentRowPermissionControl
label={label}
permission={permission}
controlRef={permissionRef}
/>
</div>
{environmentEditor ??
((defaultEnvSummary || envSummary) && (
)}
{isDetected && (
<div>
<AgentCommandOverrideInput
key={cmdOverride ?? defaultCmd}
defaultCmd={defaultCmd}
cmdOverride={cmdOverride}
onSaveOverride={onSaveOverride}
/>
<div className="mt-2">
<AgentDefaultEnvInput
key={`${agentId}:${envSummary}`}
defaultEnv={defaultEnv}
envOverride={envOverride}
onSaveEnv={onSaveEnv}
<AgentDefaultArgsInput
key={`${agentId}:${argsOverride}`}
defaultArgs={defaultArgs}
argsOverride={argsOverride}
onSaveArgs={onSaveArgs}
/>
</div>
))}
{sessionSourceHome && (
<div className="mt-2">
<AgentSessionSourceHomeInput
key={`${agentId}:${sessionSourceHome.runtimeLabel}:${sessionSourceHome.value}`}
runtimeLabel={sessionSourceHome.runtimeLabel}
value={sessionSourceHome.value}
onSave={sessionSourceHome.onSave}
/>
{environmentEditor ??
((defaultEnvSummary || envSummary) && (
<div className="mt-2">
<AgentDefaultEnvInput
key={`${agentId}:${envSummary}`}
defaultEnv={defaultEnv}
envOverride={envOverride}
onSaveEnv={onSaveEnv}
/>
</div>
))}
{sessionSourceHome && (
<div className="mt-2">
<AgentSessionSourceHomeInput
key={`${agentId}:${sessionSourceHome.runtimeLabel}:${sessionSourceHome.value}`}
runtimeLabel={sessionSourceHome.runtimeLabel}
value={sessionSourceHome.value}
onSave={sessionSourceHome.onSave}
/>
</div>
)}
<p className="mt-2 text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.AgentsPane.f9f127d664',
'Override the binary path or name, and edit the default launch arguments or environment for this agent.'
)}
</p>
</div>
)}
<p className="mt-2 text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.AgentsPane.f9f127d664',
'Override the binary path or name, and edit the default launch arguments or environment for this agent.'
)}
</p>
</div>
)}
</div>
@@ -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<AgentPermissionMode, 'mixed'>) => 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<GlobalSettings>) => void | Promise<void>
@@ -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
})}
<AgentDetectionCatalog
detectedAgents={detectedAgents}
undetectedAgents={undetectedAgents}
@@ -0,0 +1,164 @@
import { Trans } from 'react-i18next'
import type { AgentPermissionMode } from '../../../../shared/tui-agent-permissions'
import { getIntlLocale, i18n, translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { SettingsSegmentedControl } from './SettingsFormControls'
import { agentPermissionModeLabel } from './agent-permission-mode-label'
type PresetMode = Exclude<AgentPermissionMode, 'mixed'>
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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="cursor-help rounded-sm underline decoration-dotted underline-offset-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" sideOffset={6}>
{new Intl.ListFormat(getIntlLocale(), { type: 'conjunction' }).format(labels)}
</TooltipContent>
</Tooltip>
)
}
function AgentPermissionsDescription({
mode,
majorityMode,
autoAgentLabels
}: AgentPermissionsSettingProps): React.ReactNode {
if (mode === 'auto') {
return (
<Trans
i18n={i18n}
defaults={translate(
'settings.agents.permissionsAutoDescription',
'<agents>Supported agents</agents> approve safe actions on their own.'
)}
components={{ agents: <SupportedAgentsTrigger labels={autoAgentLabels ?? []} /> }}
/>
)
}
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 (
<section className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-xs text-muted-foreground">
<AgentPermissionsDescription {...props} />
</p>
{!autoSupported ? (
<p className="text-xs text-muted-foreground">
{translate('settings.agents.autoUnsupported', 'Auto needs a newer Orca on this host.')}
</p>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end">
<SettingsSegmentedControl<AgentPermissionMode>
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. */}
<div
inert={!showOthers}
aria-hidden={!showOthers}
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
showOthers ? 'grid-rows-[1fr]' : 'grid-rows-[0fr] delay-100'
)}
>
<div className="min-h-0 overflow-hidden">
{/* Fades in after the row has mostly opened and out before it closes, so it never shows half-clipped. */}
<div
className={cn(
'flex items-center gap-2 whitespace-nowrap pt-1.5 transition-[opacity,translate] ease-out motion-reduce:transition-none',
showOthers
? 'translate-y-0 opacity-100 delay-100 duration-150'
: '-translate-y-1 opacity-0 duration-100'
)}
>
<span className="text-xs text-muted-foreground">
{translate('settings.agents.otherAgentsPermissions', 'Other agents')}
</span>
<SettingsSegmentedControl<AgentPermissionMode>
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') }
]}
/>
</div>
</div>
</div>
</div>
</section>
)
}
@@ -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<AgentPermissionMode, 'mixed'>) => void
/** Replaces custom arguments with the expected preset; absent for environment-only agents. */
reset?: { mode: Exclude<AgentPermissionMode, 'mixed'>; 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 = (
<button
type="button"
onClick={onSelect}
aria-label={translate(
'settings.agents.editRowPermissions',
'{{agent}} permissions: {{mode}}',
{
agent: label,
mode: text
}
)}
className="rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<SettingsBadge tone="accent">{text}</SettingsBadge>
</button>
)
if (mode !== 'mixed') {
return button
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
{translate('settings.agents.customArgsTooltip', 'Uses its own launch arguments.')}
</TooltipContent>
</Tooltip>
)
}
function customLabel(): string {
return translate('settings.agents.customArgsBadge', 'Custom')
}
export function AgentRowPermissionControl({
label,
permission,
controlRef
}: {
label: string
permission: AgentRowPermission
controlRef: React.Ref<HTMLDivElement>
}): React.JSX.Element {
return (
<div ref={controlRef} className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-xs font-medium">
{translate('settings.agents.rowPermissions', 'Permissions')}
</span>
{permission.mode === 'mixed' ? (
<span className="flex items-center gap-2 text-xs text-muted-foreground">
{translate('settings.agents.customPermissions', 'Custom arguments')}
{permission.reset ? (
<Button type="button" variant="outline" size="xs" onClick={permission.reset.apply}>
{translate('settings.agents.replaceWithPermissionMode', 'Replace with {{mode}}', {
mode: agentPermissionModeLabel(permission.reset.mode)
})}
</Button>
) : null}
</span>
) : (
<SettingsSegmentedControl<AgentPermissionMode>
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') }
]}
/>
)}
</div>
)
}
@@ -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</button> approve safe actions on their own.', 2],
['auto', 'mixed', 'Supported agents</button> 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(
<TooltipProvider>
<AgentPermissionsSetting
mode={mode}
nonAutoMode={nonAutoMode}
majorityMode="yolo"
autoAgentLabels={['Claude', 'Claude Agent Teams', 'Codex']}
onChange={vi.fn()}
onChangeNonAuto={vi.fn()}
/>
</TooltipProvider>
)
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(
<TooltipProvider>
<AgentPermissionsSetting mode="yolo" autoSupported={false} onChange={vi.fn()} />
</TooltipProvider>
)
expect(markup).toContain('Auto needs a newer Orca on this host.')
expect(markup).toMatch(/aria-disabled="true"[^>]*>Auto</)
})
it('keeps catalog agent ids, labels, and commands discoverable in settings search', () => {
@@ -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<AgentPermissionMode, 'mixed'>) => void
}): React.JSX.Element {
const visibleMode: Exclude<AgentPermissionMode, 'mixed'> = mode === 'manual' ? 'manual' : 'yolo'
return (
<section className="space-y-3">
<SettingsSubsectionHeader
title={
<span className="flex items-center gap-2">
{translate('auto.components.settings.AgentsPane.agentPermissions', 'Agent Permissions')}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={translate(
'auto.components.settings.AgentsPane.agentPermissionsInfo',
'Agent permissions info'
)}
className="grid size-5 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate(
'auto.components.settings.AgentsPane.agentPermissionsTooltip',
"Doesn't apply to agents where you've overridden launch arguments."
)}
</TooltipContent>
</Tooltip>
</span>
}
description={translate(
'auto.components.settings.AgentsPane.agentPermissionsDescription',
'Choose whether Orca launches agents with fewer permission prompts or with manual checks.'
)}
action={
<SettingsSegmentedControl<AgentPermissionMode>
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'
)
}
]}
/>
}
/>
</section>
)
}
export function AgentsPane({
settings,
updateSettings,
@@ -176,11 +100,8 @@ export function AgentsPane({
)
}
function renderAgentPermissions(
mode: AgentPermissionMode,
onChange: (mode: Exclude<AgentPermissionMode, 'mixed'>) => void
): React.ReactNode {
return <AgentPermissionsSetting mode={mode} onChange={onChange} />
function renderAgentPermissions(props: AgentPermissionsSettingProps): React.ReactNode {
return <AgentPermissionsSetting {...props} />
}
export function AgentStatusHooksSetting({ settings, updateSettings }: AgentSettingProps) {
@@ -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) => (
<button onClick={() => change('manual')}>Manual</button>
const renderPermissions: AgentPermissionsRenderer = ({ onChange }) => (
<button onClick={() => onChange('manual')}>Manual</button>
)
const pane = (revision: number) => (
<TooltipProvider>
@@ -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(
<TooltipProvider>
<HostAgentLaunchSettings
environmentId="ssh-host"
pairingRevision={17}
hostName="SSH host"
renderPermissions={(props) => <AgentPermissionsSetting {...props} />}
/>
</TooltipProvider>
)
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 = () => (
<TooltipProvider>
<HostAgentLaunchSettings
environmentId="ssh-host"
pairingRevision={17}
hostName="SSH host"
renderPermissions={(props) => <AgentPermissionsSetting {...props} />}
/>
</TooltipProvider>
)
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', () => {
</TooltipProvider>
)
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 () => {
@@ -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()}
/>
</fieldset>
@@ -216,7 +216,7 @@ export function SettingsBadge({
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-3xs font-medium',
tone === 'accent'
? 'border-foreground/20 bg-foreground/10 text-foreground'
: tone === 'muted'
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { projectAgentLaunchSettings } from '../../../../shared/agent-launch-settings'
import { AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability'
const rpc = vi.hoisted(() => ({ 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'
})
})
})
@@ -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<boolean> {
return runtimeEnvironmentSupportsCapability(
owner.environmentId,
AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY
)
}
@@ -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<AgentPermissionMode, 'mixed'>
/** Most common preset mode; ties keep the first seen. Custom-argument agents don't vote. */
export function majorityPermissionMode(modes: Iterable<AgentPermissionMode>): PresetMode | null {
const counts = new Map<PresetMode, number>()
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<TuiAgent, AgentPermissionMode>,
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
}
@@ -0,0 +1,10 @@
import type { AgentPermissionMode } from '../../../../shared/tui-agent-permissions'
import { translate } from '@/i18n/i18n'
export function agentPermissionModeLabel(mode: Exclude<AgentPermissionMode, 'mixed'>): 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')
}
@@ -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<Record<TuiAgent, Record<string, string>>> | null
autoSupported: boolean
save: (mutation: AgentLaunchSettingsMutation) => void
}): {
globalMode: AgentPermissionMode
majorityMode: Exclude<AgentPermissionMode, 'mixed'> | null
rows: ReadonlyMap<TuiAgent, AgentRowPermission>
} {
const globalMode = settings.autoPermissionMode ? 'auto' : settings.permissionMode
const modes = new Map<TuiAgent, AgentPermissionMode>()
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<TuiAgent, AgentRowPermission>()
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<AgentPermissionMode, 'mixed'> {
return mode === 'yolo' || (mode === 'auto' && agent in AUTO_TUI_AGENT_ARGS) ? mode : 'manual'
}
function buildPresetReset(
agent: TuiAgent,
mode: Exclude<AgentPermissionMode, 'mixed'>,
save: (mutation: AgentLaunchSettingsMutation) => void
): NonNullable<AgentRowPermission['reset']> {
// 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 }) }
}
@@ -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')
]
+19 -7
View File
@@ -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": "<agents>Supported agents</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",
-6
View File
@@ -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",
-6
View File
@@ -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",
-6
View File
@@ -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": "ランタイム",
-6
View File
@@ -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": "런타임",
-6
View File
@@ -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": "运行时",
@@ -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,
@@ -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()
})
})
@@ -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')
})
})
+58 -5
View File
@@ -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<GlobalSettings['agentCmdOverrides']>
agentDefaultArgs: NonNullable<GlobalSettings['agentDefaultArgs']>
environmentNames: Partial<Record<TuiAgent, string[]>>
permissionMode: AgentPermissionMode
permissionMode: Exclude<AgentPermissionMode, 'auto'>
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<AgentPermissionMode, 'mixed'> }
| {
type: 'permissions'
mode: Exclude<AgentPermissionMode, 'mixed'>
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()) {
@@ -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(),
@@ -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']
)
})
}
}
)
})
+104 -2
View File
@@ -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', () => {
+76 -36
View File
@@ -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<Record<TuiAgent, string>> = {
claude: '--permission-mode auto',
'claude-agent-teams': '--permission-mode auto',
codex: '--approve-for-me'
}
export const YOLO_TUI_AGENT_ARGS: Partial<Record<TuiAgent, string>> = {
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<Record<TuiAgent, string>> | null
agentDefaultEnv?: Partial<Record<TuiAgent, Record<string, string>>> | 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<AgentPermissionMode, 'mixed'> }[] {
const result: { agent: TuiAgent; mode: Exclude<AgentPermissionMode, 'mixed'> }[] = []
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<AgentPermissionMode>()
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<AgentPermissionMode, 'mixed'>
agent?: TuiAgent
/** Leave Auto-capable agents as they are, e.g. to change only the agents Auto doesn't cover. */
exceptAuto?: boolean
agentDefaultArgs?: Partial<Record<TuiAgent, string>> | null
agentDefaultEnv?: Partial<Record<TuiAgent, Record<string, string>>> | 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 : ''
}
}
+74
View File
@@ -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' })
})
@@ -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: () => ({