mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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')
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ランタイム",
|
||||
|
||||
@@ -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": "런타임",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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']
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 : ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
Reference in New Issue
Block a user