diff --git a/docs/reference/agent-permission-presets.md b/docs/reference/agent-permission-presets.md new file mode 100644 index 00000000000..a041b509766 --- /dev/null +++ b/docs/reference/agent-permission-presets.md @@ -0,0 +1,49 @@ +# Agent permission presets + +Orca's permission presets map one product choice onto agent-specific CLI arguments or +environment variables. The word **Auto** does not describe one uniform security policy: +depending on the agent, it can mean classifier-reviewed actions, edits-only approval, +risk-threshold approval, or a sandboxed approval reviewer. Keep the exact behavior visible in +the UI and do not imply that every agent provides the same safety boundary. + +This matrix records vendor-documented presets checked on September 6, 2026. A local help +check identifies the syntax accepted by the installed CLI; the linked vendor source defines +its semantics. + +| Agent | Auto preset | Verified behavior | Evidence | +| --- | --- | --- | --- | +| Claude Code | `--permission-mode auto` | Uses Claude's auto-mode permission classifier. The installed Claude Code 2.1.263 help lists `auto` separately from `acceptEdits`, `manual`, and `bypassPermissions`. | [Claude Code permissions](https://code.claude.com/docs/en/permissions) and `claude --help` | +| Codex | `--approve-for-me` | Routes approval requests through automatic review while retaining the workspace-write sandbox. The installed Codex CLI 0.153.4 help documents this behavior. | [Codex security](https://developers.openai.com/codex/security/) and [Codex CLI argument tests](https://github.com/openai/codex/blob/main/codex-rs/cli/src/main.rs) | +| Gemini CLI | `--approval-mode auto_edit` | Auto-approves edit tools while continuing to prompt for other tools. The installed Gemini CLI 0.58.0 help lists `default`, `auto_edit`, `yolo`, and `plan`. | [Gemini CLI repository](https://github.com/google-gemini/gemini-cli) and `gemini --help` | +| goose | `GOOSE_MODE=smart_approve` | Automatically approves low-risk actions and asks for approval on higher-risk actions. | [goose permission modes](https://github.com/block/goose/blob/main/documentation/docs/guides/managing-tools/goose-permissions.md) and [mode values](https://github.com/block/goose/blob/main/crates/goose-provider-types/src/goose_mode.rs) | +| Qwen Code | `--approval-mode auto` | Uses an LLM classifier for shell commands, network calls, and out-of-workspace edits; risky or uncertain actions still prompt. | [Qwen Code approval modes](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/approval-mode.md) | +| Devin CLI | `--permission-mode smart` | Auto-approves workspace edits and uses a fast model to judge other actions, falling back to a prompt when unsafe, uncertain, or unavailable. Smart is rolling out gradually and may be unavailable for some accounts. | [Devin permissions](https://docs.devin.ai/cli/reference/permissions.md) and [commands and flags](https://docs.devin.ai/cli/reference/commands.md) | +| Droid | `--auto medium` | Auto-approves edits, low-risk tools, and reversible workspace changes such as installs, builds, local commits, moves, and copies. Higher-risk actions still prompt. | [Droid autonomy levels](https://docs.factory.ai/autonomy-and-safety/auto-run.md) and `droid --help` from Droid 0.205.0 | + +Gemini's `auto_edit` is deliberately narrower than classifier-based Auto modes. Droid also +offers `--auto low` and `--auto high`; Medium is the balanced candidate because Low covers +edits and low-risk tools, while High permits high-risk actions unless a separate safety check +intervenes. Devin's `accept-edits` is a narrower fallback if Smart is unavailable, but it is +not behaviorally equivalent to Smart. + +Claude Agent Teams forwards its arguments to the Claude binary through `orca claude-teams` +and uses the same `--permission-mode auto` preset (see `src/cli/handlers/core.ts`). + +The global Auto preset uses Manual for harnesses without a verified mapping. Per-agent +controls omit Auto for those harnesses. Custom argument and environment profiles are preserved. +Settings apply to future launches, including folder workspaces and SSH/WSL execution hosts. +The installed CLI and account must support the selected mode; Orca never retries with bypass +when a guarded mode is rejected. Existing default permission preferences are unchanged. + +## No verified Auto preset yet + +The remaining agents in `YOLO_TUI_AGENT_ARGS` have no intermediate preset verified for this +work: OpenClaude, Antigravity, Aider, Amp, Kiro, Crush, Autohand, Cline, +Command Code, Continue, Cursor, Kimi, Mistral Vibe, Rovo, Hermes, Copilot, Grok, Ante, and Trae. +This means only that this review did not find sufficiently clear vendor evidence for a preset; +it is not a claim that the agent lacks intermediate permission controls. Amp's `smart` agent +mode, for example, controls its model, system prompt, and tool selection rather than command +approval policy, so it must not be treated as an Auto permission preset. + +Re-check vendor documentation and current `--help` output before adding any of these agents. +CLI flags and rollout availability change independently of Orca releases. diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 13741c03789..4d174fe666c 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -247,3 +247,17 @@ predicate. It is unobservable today — the host publishes neither field for a c all, so a mirror has nothing to take either way. If the capability-gated publish this section anticipates ever lands, narrow them the same way rather than by placement kind: a mirror should take a failure it cannot otherwise see, and only the hosting client should refuse it. + +## Auto permission presets on paired settings + +`settings.agent-permission-auto.v1` identifies clients that understand Auto launch +argument and environment presets. New hosts project those presets as manual in every +settings response to older clients: their onboarding otherwise treats Auto as a custom +mixed profile and can apply bypass to the remaining harnesses. When the host contains +an Auto preset, unchanged legacy permission-map roundtrips are ignored, and changed +permission maps are rejected with a client-upgrade message. Unrelated settings remain +editable. Capable clients receive and edit the actual presets. + +New clients can store Auto presets on older hosts because their existing argument and +environment normalizers preserve these strings. Such hosts cannot protect the profile +from a second, older client changing settings; that protection requires the host upgrade. diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 162b431f1bf..675fd6776c1 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -18,6 +18,7 @@ import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { + AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, @@ -72,6 +73,7 @@ describe('CLI remote WebSocket transport', () => { clientCapabilities: [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, + AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/src/main/runtime/rpc/methods/client-permission-projection.test.ts b/src/main/runtime/rpc/methods/client-permission-projection.test.ts new file mode 100644 index 00000000000..6235200bca2 --- /dev/null +++ b/src/main/runtime/rpc/methods/client-permission-projection.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import { AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + applyAgentPermissionMode, + AUTO_TUI_AGENT_ARGS, + AUTO_TUI_AGENT_ENV +} from '../../../../shared/tui-agent-permissions' +import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { CLIENT_UI_METHODS } from './client-ui' + +function setup(mode: 'auto' | 'manual' = 'auto') { + const settings = { ...applyAgentPermissionMode({ mode }), compactWorktreeCards: false } + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => settings), + updateClientSettings: vi.fn(async (updates) => Object.assign(settings, updates)), + updateClientPRBotAuthorOverride: vi.fn(() => settings) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + const request = (method: string, params?: unknown, capabilities: string[] = []) => + dispatcher.dispatch( + { id: '1', authToken: 'token', method, params }, + { clientKind: 'runtime', clientCapabilities: capabilities } + ) + return { settings, runtime, request } +} + +function resultSettings(response: Awaited['request']>>) { + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + return (response.result as { settings: ReturnType['settings'] }).settings +} + +describe('Auto permissions across remote versions', () => { + it('advertises Auto support on remote request transports', () => { + expect(remoteRuntimeClientCapabilities()).toContain(AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY) + }) + + it('projects Auto as manual to old clients without changing host settings', async () => { + const { settings, request } = setup() + const before = structuredClone(settings) + const projected = resultSettings(await request('settings.get')) + for (const agent of Object.keys(AUTO_TUI_AGENT_ARGS)) { + expect(projected.agentDefaultArgs[agent as TuiAgent]).toBe('') + } + for (const agent of Object.keys(AUTO_TUI_AGENT_ENV)) { + expect(projected.agentDefaultEnv[agent as TuiAgent]).toEqual({}) + } + expect(settings).toEqual(before) + }) + + it('preserves Auto when an old client echoes its projection with an unrelated edit', async () => { + const { settings, runtime, request } = setup() + const before = structuredClone(settings) + const projected = resultSettings(await request('settings.get')) + const updated = resultSettings( + await request('settings.update', { ...projected, compactWorktreeCards: true }) + ) + expect(runtime.updateClientSettings).toHaveBeenCalledWith({ compactWorktreeCards: true }) + expect(settings.agentDefaultArgs).toEqual(before.agentDefaultArgs) + expect(settings.agentDefaultEnv).toEqual(before.agentDefaultEnv) + expect(updated).toEqual({ ...projected, compactWorktreeCards: true }) + }) + + it('rejects old-client permission widening, including unsupported Auto fallback agents', async () => { + const { settings, runtime, request } = setup() + const before = structuredClone(settings) + const projected = resultSettings(await request('settings.get')) + const response = await request('settings.update', { + ...applyAgentPermissionMode({ ...projected, mode: 'yolo' }) + }) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('Update this Orca client') } + }) + expect(runtime.updateClientSettings).not.toHaveBeenCalled() + expect(settings).toEqual(before) + }) + + it('allows a capable client to view and change Auto', async () => { + const { settings, request } = setup() + const capabilities = [AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY] + expect(resultSettings(await request('settings.get', undefined, capabilities))).toEqual(settings) + const manual = applyAgentPermissionMode({ mode: 'manual' }) + expect(resultSettings(await request('settings.update', manual, capabilities))).toMatchObject( + manual + ) + }) + + it('keeps permission edits available to legacy clients on hosts without Auto', async () => { + const { request } = setup('manual') + const bypass = applyAgentPermissionMode({ mode: 'yolo' }) + expect(resultSettings(await request('settings.update', bypass))).toMatchObject(bypass) + }) + + it('projects settings published by unrelated bot-author updates too', async () => { + const { request } = setup() + const projected = resultSettings(await request('settings.get')) + expect( + resultSettings( + await request('settings.updatePRBotAuthorOverride', { author: 'bot', isBot: true }) + ) + ).toEqual(projected) + }) +}) diff --git a/src/main/runtime/rpc/methods/client-permission-projection.ts b/src/main/runtime/rpc/methods/client-permission-projection.ts new file mode 100644 index 00000000000..7709e352167 --- /dev/null +++ b/src/main/runtime/rpc/methods/client-permission-projection.ts @@ -0,0 +1,68 @@ +import { isDeepStrictEqual } from 'node:util' +import { AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AUTO_TUI_AGENT_ARGS, AUTO_TUI_AGENT_ENV } from '../../../../shared/tui-agent-permissions' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { + RuntimeClientSettings, + RuntimeClientSettingsUpdate +} from '../../runtime-client-settings' +import type { RpcContext } from '../core' + +type ClientContext = Pick + +export function projectClientPermissionSettings( + settings: RuntimeClientSettings, + context: ClientContext +): RuntimeClientSettings { + if ( + context.clientKind === undefined || + context.clientCapabilities?.includes(AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY) + ) { + return settings + } + let projected = settings + for (const [agent, preset] of Object.entries(AUTO_TUI_AGENT_ARGS)) { + if (settings.agentDefaultArgs?.[agent as TuiAgent]?.trim() === preset) { + projected = { + ...projected, + agentDefaultArgs: { ...projected.agentDefaultArgs, [agent]: '' } + } + } + } + for (const [agent, preset] of Object.entries(AUTO_TUI_AGENT_ENV)) { + if (isDeepStrictEqual(settings.agentDefaultEnv?.[agent as TuiAgent], preset)) { + projected = { + ...projected, + agentDefaultEnv: { ...projected.agentDefaultEnv, [agent]: {} } + } + } + } + return projected +} + +export function protectClientPermissionUpdate( + updates: RuntimeClientSettingsUpdate, + context: RpcContext +): RuntimeClientSettingsUpdate { + if ( + context.clientKind === undefined || + context.clientCapabilities?.includes(AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY) || + (updates.agentDefaultArgs === undefined && updates.agentDefaultEnv === undefined) + ) { + return updates + } + const settings = context.runtime.getClientSettings() + const projected = projectClientPermissionSettings(settings, context) + if (projected === settings) { + return updates + } + + // Legacy onboarding turns an unrecognized permission profile into bypass. + for (const field of ['agentDefaultArgs', 'agentDefaultEnv'] as const) { + if (updates[field] !== undefined && !isDeepStrictEqual(updates[field], projected[field])) { + throw new Error('Update this Orca client to change permissions while Auto mode is enabled.') + } + } + const { agentDefaultArgs: _args, agentDefaultEnv: _env, ...remaining } = updates + return remaining +} diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index ffd964b6be6..91cbac67f3a 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -6,6 +6,11 @@ import { PRBotAuthorOverrideUpdate, SettingsUpdate } from './client-settings-schemas' +import { + projectClientPermissionSettings, + protectClientPermissionUpdate +} from './client-permission-projection' + import { FeatureInteractionIdParam, UiUpdate } from './client-ui-schemas' // Type-only side effect: keeps the schema/PersistedUIState parity assertions in // the typecheck graph so drift fails the build instead of a paired client. @@ -16,13 +21,18 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [ defineMethod({ name: 'settings.get', params: null, - handler: (_params, { runtime }) => ({ settings: runtime.getClientSettings() }) + handler: (_params, context) => ({ + settings: projectClientPermissionSettings(context.runtime.getClientSettings(), context) + }) }), defineMethod({ name: 'settings.update', params: SettingsUpdate, - handler: async (params, { runtime }) => ({ - settings: await runtime.updateClientSettings(params) + handler: async (params, context) => ({ + settings: projectClientPermissionSettings( + await context.runtime.updateClientSettings(protectClientPermissionUpdate(params, context)), + context + ) }) }), defineMethod({ @@ -44,8 +54,11 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [ defineMethod({ name: 'settings.updatePRBotAuthorOverride', params: PRBotAuthorOverrideUpdate, - handler: (params, { runtime }) => ({ - settings: runtime.updateClientPRBotAuthorOverride(params) + handler: (params, context) => ({ + settings: projectClientPermissionSettings( + context.runtime.updateClientPRBotAuthorOverride(params), + context + ) }) }), defineMethod({ diff --git a/src/renderer/src/components/onboarding/AgentStep.test.tsx b/src/renderer/src/components/onboarding/AgentStep.test.tsx index f250444952f..99db347ce13 100644 --- a/src/renderer/src/components/onboarding/AgentStep.test.tsx +++ b/src/renderer/src/components/onboarding/AgentStep.test.tsx @@ -13,17 +13,17 @@ describe('AgentStep', () => { onSelect={vi.fn()} detectedSet={new Set([AGENT_CATALOG[0].id])} isDetecting={false} - yoloPermissions - onYoloPermissionsChange={vi.fn()} + permissionMode="auto" + onPermissionModeChange={vi.fn()} /> ) expect(html).toContain(`Show ${AGENT_CATALOG.length - 1} more agents→`) expect(html).toContain('data-agent-grid-scroll') - expect(html).toContain('data-slot="checkbox"') - expect(html).toContain('Yolo / Dangerously skip permissions') - expect(html).not.toContain('role="radiogroup"') + expect(html).toContain('>Auto') + expect(html).toContain('agents without Auto use Manual') + expect(html).toContain('role="radiogroup"') }) it('labels the fallback agents summary as hide when expanded', () => { @@ -34,8 +34,8 @@ describe('AgentStep', () => { onSelect={vi.fn()} detectedSet={new Set([AGENT_CATALOG[0].id])} isDetecting={false} - yoloPermissions - onYoloPermissionsChange={vi.fn()} + permissionMode="auto" + onPermissionModeChange={vi.fn()} /> ) diff --git a/src/renderer/src/components/onboarding/AgentStep.tsx b/src/renderer/src/components/onboarding/AgentStep.tsx index 234d854d27e..26b07a265b1 100644 --- a/src/renderer/src/components/onboarding/AgentStep.tsx +++ b/src/renderer/src/components/onboarding/AgentStep.tsx @@ -1,10 +1,10 @@ import { useLayoutEffect, useRef, useState } from 'react' -import { Check, ExternalLink, Info } from 'lucide-react' +import { Check, ExternalLink } from 'lucide-react' import { getAgentCatalog, AgentIcon, type AgentCatalogEntry } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' -import { Checkbox } from '@/components/ui/checkbox' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { AgentPermissionsSetting } from '../settings/AgentPermissionsSetting' +import type { AgentPermissionMode } from '../../../../shared/tui-agent-permissions' import type { TuiAgent } from '../../../../shared/tui-agent' import { translate } from '@/i18n/i18n' @@ -18,8 +18,8 @@ type AgentStepProps = { onSelect: (agent: TuiAgent, fromCollapsedSection: boolean) => void detectedSet: Set isDetecting: boolean - yoloPermissions?: boolean - onYoloPermissionsChange?: (enabled: boolean) => void + permissionMode?: AgentPermissionMode + onPermissionModeChange?: (mode: Exclude) => void } function useAgentGridScrollMaxHeight( @@ -64,8 +64,8 @@ export function AgentStep({ onSelect, detectedSet, isDetecting, - yoloPermissions = true, - onYoloPermissionsChange + permissionMode, + onPermissionModeChange }: AgentStepProps) { const agentCatalog = getAgentCatalog() const detected = agentCatalog.filter((agent) => detectedSet.has(agent.id)) @@ -190,65 +190,15 @@ export function AgentStep({ - + {permissionMode && onPermissionModeChange && ( +
+ +
+ )} ) } -function YoloPermissionsControl({ - yoloPermissions, - onYoloPermissionsChange -}: { - yoloPermissions: boolean - onYoloPermissionsChange?: (enabled: boolean) => void -}): React.JSX.Element { - return ( - - ) -} - function SectionHeader({ label, count, diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index c57e2556014..14804150813 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -314,8 +314,8 @@ export default function OnboardingFlow({ onSelect={flow.setSelectedAgent} detectedSet={flow.detectedSet} isDetecting={flow.isDetectingAgents} - yoloPermissions={flow.yoloPermissions} - onYoloPermissionsChange={flow.setYoloPermissions} + permissionMode={flow.permissionMode} + onPermissionModeChange={flow.setPermissionMode} /> )} {currentStep.id === 'theme' && ( diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts index e3ab2b88857..ad80ca43d23 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts @@ -3,7 +3,7 @@ import { createElement, useEffect, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getDefaultOnboardingState } from '../../../../shared/constants' +import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants' import type { OnboardingState } from '../../../../shared/onboarding-state-types' const trackMock = vi.hoisted(() => vi.fn()) @@ -16,6 +16,7 @@ import { buildCompletedOnboardingNotificationSettings, buildOnboardingDismissedPayload, useCloseWith, + usePersistCurrentStep, type DismissedExtras, trackOnboardingDismissed } from './use-onboarding-flow-persistence' @@ -90,6 +91,48 @@ describe('onboarding flow persistence', () => { vi.useRealTimers() }) + it.each([null, 'auto'] as const)( + 'preserves permissions unless a preset is explicitly selected (%s)', + async (selection) => { + const settings = getDefaultSettings('/tmp') + settings.agentDefaultArgs = { + ...settings.agentDefaultArgs, + claude: '', + codex: '--model custom' + } + const updateSettings = vi.fn() + let persist: (() => Promise<{ ok: boolean }>) | undefined + function Probe(): null { + persist = usePersistCurrentStep({ + currentStepId: 'agent', + selectedAgent: 'claude', + permissionModeSelection: selection, + theme: settings.theme, + settings, + updateSettings, + onboardingChecklist: getDefaultOnboardingState().checklist, + onOnboardingChange: vi.fn(), + setError: vi.fn() + }) + return null + } + container = document.createElement('div') + root = createRoot(container) + act(() => root?.render(createElement(Probe))) + await act(async () => { + await persist?.() + }) + const update = updateSettings.mock.calls[0][0] + if (selection === null) { + expect(update).toEqual({ defaultTuiAgent: 'claude' }) + } else { + expect(update.agentDefaultArgs.claude).toBe('--permission-mode auto') + expect(update.agentDefaultArgs.codex).toBe('--model custom') + expect(update.agentDefaultArgs.aider).toBe('') + } + } + ) + it('builds dismissed telemetry with the triggering advance path', () => { expect( buildOnboardingDismissedPayload(3, { diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts index 93b4b62c153..2ea0c55ed11 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -6,7 +6,10 @@ import type { EventProps } from '../../../../shared/telemetry-events' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { OnboardingState } from '../../../../shared/onboarding-state-types' import type { TuiAgent } from '../../../../shared/tui-agent' -import { applyAgentPermissionMode } from '../../../../shared/tui-agent-permissions' +import { + applyAgentPermissionMode, + type AgentPermissionMode +} from '../../../../shared/tui-agent-permissions' import type { StepId, StepNumber } from './use-onboarding-flow-types' export async function persistStep( @@ -132,7 +135,7 @@ export function useCloseWith({ onOnboardingChange, startTimeRef, setError }: Clo type PersistCurrentStepDeps = { currentStepId: StepId selectedAgent: TuiAgent | null - yoloPermissions: boolean + permissionModeSelection: Exclude | null theme: GlobalSettings['theme'] settings: GlobalSettings | null updateSettings: (updates: Partial) => Promise | void @@ -148,7 +151,7 @@ export type PersistCurrentStepResult = { export function usePersistCurrentStep({ currentStepId, selectedAgent, - yoloPermissions, + permissionModeSelection, theme, settings, updateSettings, @@ -165,11 +168,13 @@ export function usePersistCurrentStep({ const defaultTuiAgent = selectedAgentOrBlank(selectedAgent) await updateSettings({ defaultTuiAgent, - ...applyAgentPermissionMode({ - mode: yoloPermissions ? 'yolo' : 'manual', - agentDefaultArgs: settings.agentDefaultArgs, - agentDefaultEnv: settings.agentDefaultEnv - }) + ...(permissionModeSelection === null + ? {} + : applyAgentPermissionMode({ + mode: permissionModeSelection, + agentDefaultArgs: settings.agentDefaultArgs, + agentDefaultEnv: settings.agentDefaultEnv + })) }) const choseAgent = defaultTuiAgent !== 'blank' const wasAlreadyChosen = onboardingChecklist.choseAgent @@ -226,7 +231,7 @@ export function usePersistCurrentStep({ settings, theme, updateSettings, - yoloPermissions, + permissionModeSelection, setError ]) } diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index 93ab76414b4..b94fb4fc40f 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -12,7 +12,10 @@ import { STEPS } from './use-onboarding-flow-types' import { persistStep, useCloseWith, usePersistCurrentStep } from './use-onboarding-flow-persistence' import { resolveOnboardingSettingsHydration } from './onboarding-settings-hydration' import { translate } from '@/i18n/i18n' -import { resolveAgentPermissionModeSummary } from '../../../../shared/tui-agent-permissions' +import { + resolveAgentPermissionModeSummary, + type AgentPermissionMode +} from '../../../../shared/tui-agent-permissions' import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import { isSkippedStepIndex, @@ -66,12 +69,16 @@ export function useOnboardingFlow( ? settings.defaultTuiAgent : null ) - const [yoloPermissions, setYoloPermissions] = useState( + const [permissionModeSelection, setPermissionModeSelection] = useState | null>(null) + const permissionMode = + permissionModeSelection ?? resolveAgentPermissionModeSummary({ agentDefaultArgs: settings?.agentDefaultArgs, agentDefaultEnv: settings?.agentDefaultEnv - }) !== 'manual' - ) + }) // Why: hydrate theme from saved settings so users who already chose one see it preselected. const [theme, setTheme] = useState(settings?.theme ?? 'dark') const [busyLabel, setBusyLabel] = useState(null) @@ -80,7 +87,6 @@ export function useOnboardingFlow( // Why: settings hydrate async after the lazy initializers run; re-sync once before commit unless the user edited the field. const themeInteractedRef = useRef(false) const agentInteractedRef = useRef(false) - const yoloPermissionsInteractedRef = useRef(false) const [settingsHydrated, setSettingsHydrated] = useState(settings != null) const settingsHydration = resolveOnboardingSettingsHydration({ settings, @@ -99,17 +105,6 @@ export function useOnboardingFlow( setSelectedAgent(settingsHydration.selectedAgent) } } - if (settings && !yoloPermissionsInteractedRef.current) { - const nextYoloPermissions = - resolveAgentPermissionModeSummary({ - agentDefaultArgs: settings.agentDefaultArgs, - agentDefaultEnv: settings.agentDefaultEnv - }) !== 'manual' - if (nextYoloPermissions !== yoloPermissions) { - setYoloPermissions(nextYoloPermissions) - } - } - // Why: track interaction so async settings hydration doesn't overwrite a value the user chose. const setThemeInteractive = useCallback((value: GlobalSettings['theme']) => { themeInteractedRef.current = true @@ -152,11 +147,6 @@ export function useOnboardingFlow( }, [] ) - const setYoloPermissionsInteractive = useCallback((enabled: boolean) => { - yoloPermissionsInteractedRef.current = true - setYoloPermissions(enabled) - }, []) - const detectedSet = useMemo(() => new Set(detectedAgentIds ?? []), [detectedAgentIds]) const currentStep = STEPS[stepIndex] // Why: the stepper shows only steps the user will land on; skipped optional steps are dropped, not rendered as dead dots. @@ -278,7 +268,7 @@ export function useOnboardingFlow( const persistCurrentStep = usePersistCurrentStep({ currentStepId: currentStep.id, selectedAgent, - yoloPermissions, + permissionModeSelection, theme, settings, updateSettings, @@ -318,8 +308,8 @@ export function useOnboardingFlow( currentStep, selectedAgent, setSelectedAgent: setSelectedAgentInteractive, - yoloPermissions, - setYoloPermissions: setYoloPermissionsInteractive, + permissionMode, + setPermissionMode: setPermissionModeSelection, theme, setTheme: setThemeInteractive, busyLabel, diff --git a/src/renderer/src/components/settings/AgentCatalogRow.tsx b/src/renderer/src/components/settings/AgentCatalogRow.tsx index d9f6c926c39..cd6e7e263b1 100644 --- a/src/renderer/src/components/settings/AgentCatalogRow.tsx +++ b/src/renderer/src/components/settings/AgentCatalogRow.tsx @@ -1,5 +1,7 @@ import { useState } from 'react' import { Check, ChevronDown, ExternalLink } from 'lucide-react' +import { AgentPermissionModeControl } from './AgentPermissionsSetting' +import type { AgentPermissionMode } from '../../../../shared/tui-agent-permissions' import type { TuiAgent } from '../../../../shared/tui-agent' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' @@ -68,6 +70,8 @@ export type AgentCatalogRowProps = { cmdOverride: string | undefined argsOverride: string envOverride: Record + permissionMode?: AgentPermissionMode + onSetPermissionMode?: (mode: Exclude) => void onSetDefault: () => void onSetEnabled: (enabled: boolean) => void onSaveOverride: (value: string) => void @@ -89,6 +93,8 @@ export function AgentCatalogRow({ cmdOverride, argsOverride, envOverride, + permissionMode, + onSetPermissionMode, onSetDefault, onSetEnabled, onSaveOverride, @@ -98,9 +104,7 @@ export function AgentCatalogRow({ }: AgentCatalogRowProps): React.JSX.Element { const envSummary = stringifyAgentDefaultEnvDraft(envOverride) const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv) - const [cmdOpen, setCmdOpen] = useState( - Boolean(cmdOverride) || argsOverride !== defaultArgs || envSummary !== defaultEnvSummary - ) + const [cmdOpen, setCmdOpen] = useState(Boolean(cmdOverride) || permissionMode === 'mixed') return (
@@ -181,12 +185,12 @@ export function AgentCatalogRow({ aria-label={ cmdOpen ? translate( - 'auto.components.settings.AgentsPane.cea7d97be1', - 'Collapse command override' + 'auto.components.settings.AgentsPane.collapseLaunchSettings', + 'Collapse launch settings' ) : translate( - 'auto.components.settings.AgentsPane.dc4a2ffdc0', - 'Expand command override' + 'auto.components.settings.AgentsPane.expandLaunchSettings', + 'Expand launch settings' ) } className="size-7 text-muted-foreground hover:text-foreground" @@ -202,6 +206,31 @@ export function AgentCatalogRow({ {isDetected && cmdOpen && (
+ {permissionMode && onSetPermissionMode && ( +
+
+ + {translate( + 'auto.components.settings.AgentsPane.agentPermissions', + 'Agent Permissions' + )} + + +
+ {permissionMode === 'mixed' && ( +

+ {translate( + 'auto.components.settings.AgentsPane.agentPermissionsEditCustom', + 'Custom configuration. Edit or reset the launch arguments and environment below to use a permission preset.' + )} +

+ )} +
+ )} @@ -175,10 +175,10 @@ export function AgentDefaultEnvInput({ event.currentTarget.blur() } }} - placeholder={ - defaultEnvText || - translate('auto.components.settings.AgentsPane.2d133152fa', 'No default environment') - } + placeholder={translate( + 'auto.components.settings.AgentsPane.2d133152fa', + 'No default environment' + )} spellCheck={false} aria-invalid={envDraftTooLarge || undefined} aria-describedby={envDraftTooLarge ? envDraftErrorId : undefined} diff --git a/src/renderer/src/components/settings/AgentPermissionsSetting.test.tsx b/src/renderer/src/components/settings/AgentPermissionsSetting.test.tsx new file mode 100644 index 00000000000..38bd8ca580f --- /dev/null +++ b/src/renderer/src/components/settings/AgentPermissionsSetting.test.tsx @@ -0,0 +1,36 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { AgentPermissionModeControl } from './AgentPermissionsSetting' + +describe('agent permission choices', () => { + it('offers Auto only to supported agents', () => { + const supported = renderToStaticMarkup( + + ) + const unsupported = renderToStaticMarkup( + + ) + expect(supported).toContain('>Auto') + expect(unsupported).not.toContain('>Auto') + expect(unsupported).toContain('>Manual') + expect(unsupported).toContain('>Yolo') + }) + + it('disables presets while custom launch settings need editing', () => { + const html = renderToStaticMarkup( + + ) + expect(html.match(/aria-disabled="true"/g)).toHaveLength(3) + expect(html).not.toContain('aria-checked="true"') + }) + + it('passes an explicitly selected global preset through', () => { + const onChange = vi.fn() + const element = AgentPermissionModeControl({ mode: 'mixed', onChange }) + element.props.onChange('auto') + expect(onChange).toHaveBeenCalledWith('auto') + expect(element.props.options.every((option: { disabled: boolean }) => !option.disabled)).toBe( + true + ) + }) +}) diff --git a/src/renderer/src/components/settings/AgentPermissionsSetting.tsx b/src/renderer/src/components/settings/AgentPermissionsSetting.tsx new file mode 100644 index 00000000000..7374995d05c --- /dev/null +++ b/src/renderer/src/components/settings/AgentPermissionsSetting.tsx @@ -0,0 +1,96 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { + supportsTuiAgentAutoPermissionMode, + type AgentPermissionMode +} from '../../../../shared/tui-agent-permissions' +import { translate } from '@/i18n/i18n' +import { SettingsSegmentedControl, SettingsSubsectionHeader } from './SettingsFormControls' + +export function AgentPermissionModeControl({ + mode, + onChange, + agent +}: { + mode: AgentPermissionMode + onChange: (mode: Exclude) => void + agent?: TuiAgent +}): React.JSX.Element { + const disabled = agent !== undefined && mode === 'mixed' + return ( + + value={mode} + onChange={(next) => { + if (next !== 'mixed') { + onChange(next) + } + }} + ariaLabel={translate( + 'auto.components.settings.AgentsPane.agentPermissions', + 'Agent Permissions' + )} + size="sm" + options={[ + { + value: 'manual', + label: translate('auto.components.settings.AgentsPane.agentPermissionsManual', 'Manual'), + disabled + }, + ...(!agent || supportsTuiAgentAutoPermissionMode(agent) + ? [ + { + value: 'auto' as const, + label: translate( + 'auto.components.settings.AgentsPane.agentPermissionsAuto', + 'Auto' + ), + disabled + } + ] + : []), + { + value: 'yolo', + label: translate('auto.components.settings.AgentsPane.agentPermissionsYolo', 'Yolo'), + disabled + } + ]} + /> + ) +} + +export function AgentPermissionsSetting({ + mode, + onChange +}: { + mode: AgentPermissionMode + onChange: (mode: Exclude) => void +}): React.JSX.Element { + return ( +
+ } + /> +

+ {translate( + 'auto.components.settings.AgentsPane.agentPermissionsModesDescription', + 'Manual asks for approval. Auto uses the agent’s guarded approval mode; agents without Auto use Manual. Yolo skips permission checks.' + )} +

+

+ {mode === 'mixed' + ? translate( + 'auto.components.settings.AgentsPane.agentPermissionsCustom', + 'Custom configuration. Choose a preset to update standard modes; custom launch arguments and environment values are preserved.' + ) + : translate( + 'auto.components.settings.AgentsPane.agentPermissionsPreserveCustom', + 'Applies to all agents. Custom launch arguments and environment values are preserved.' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index ada6d8f1938..b5940d95c38 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -414,22 +414,11 @@ describe('AgentsPane', () => { expect(matchesSettingsSearch('manual', 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 }[] - } - - 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('shows mixed permission settings without selecting a preset', () => { + const html = renderToStaticMarkup() + expect(html).toContain('Custom configuration') + expect(html).toContain('>Auto') + expect(html).not.toContain('aria-checked="true"') }) it('keeps catalog agent ids, labels, and commands discoverable in settings search', () => { diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 59e139973ad..af3cb8abd36 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -1,5 +1,4 @@ import { useMemo } from 'react' -import { Info } from 'lucide-react' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TuiAgent } from '../../../../shared/tui-agent' import { getAgentCatalog } from '@/lib/agent-catalog' @@ -14,11 +13,7 @@ import { getAgentGeneratedTabTitlesTitle } from './agent-generated-tab-title-copy' import { getAgentStatusHooksDescription, getAgentStatusHooksTitle } from './agent-status-hooks-copy' -import { - SettingsSegmentedControl, - SettingsSubsectionHeader, - SettingsSwitchRow -} from './SettingsFormControls' +import { SettingsSwitchRow } from './SettingsFormControls' import { isTuiAgentEnabled, normalizeDisabledTuiAgents @@ -31,12 +26,11 @@ import { } from '../../../../shared/tui-agent-launch-defaults' import { applyAgentPermissionMode, + applyTuiAgentPermissionMode, resolveAgentPermissionModeSummary, - type AgentPermissionMode + resolveTuiAgentPermissionMode } from '../../../../shared/tui-agent-permissions' import { getSettingOwnershipSummary } from './setting-ownership' -import { translate } from '@/i18n/i18n' -import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { getAgentsPaneSearchEntries } from './agents-search' import { @@ -44,6 +38,8 @@ import { createAgentAvailabilityUpdateQueue } from './agent-availability-settings' import { AgentAvailabilityControl, type AgentCatalogRowProps } from './AgentCatalogRow' +import { AgentPermissionsSetting } from './AgentPermissionsSetting' +export { AgentPermissionsSetting } from './AgentPermissionsSetting' import { AgentDefaultSetting } from './AgentDefaultSetting' import { AgentDetectionCatalog } from './AgentDetectionCatalog' @@ -65,79 +61,6 @@ type AgentsPaneProps = { const enqueueAgentAvailabilityUpdate = createAgentAvailabilityUpdateQueue() -export function AgentPermissionsSetting({ - mode, - onChange -}: { - mode: AgentPermissionMode - onChange: (mode: Exclude) => void -}): React.JSX.Element { - const visibleMode: Exclude = mode === 'manual' ? 'manual' : 'yolo' - return ( -
- - {translate('auto.components.settings.AgentsPane.agentPermissions', 'Agent Permissions')} - - - - - - {translate( - 'auto.components.settings.AgentsPane.agentPermissionsTooltip', - "Doesn't apply to agents where you've overridden launch arguments." - )} - - - - } - description={translate( - 'auto.components.settings.AgentsPane.agentPermissionsDescription', - 'Choose whether Orca launches agents with fewer permission prompts or with manual checks.' - )} - action={ - - value={visibleMode} - onChange={(nextMode) => { - if (nextMode !== 'mixed') { - onChange(nextMode) - } - }} - ariaLabel={translate( - 'auto.components.settings.AgentsPane.agentPermissions', - 'Agent Permissions' - )} - size="sm" - options={[ - { - value: 'yolo', - label: translate('auto.components.settings.AgentsPane.agentPermissionsYolo', 'Yolo') - }, - { - value: 'manual', - label: translate( - 'auto.components.settings.AgentsPane.agentPermissionsManual', - 'Manual' - ) - } - ]} - /> - } - /> -
- ) -} - export function AgentsPane({ settings, updateSettings, @@ -212,6 +135,23 @@ export function AgentsPane({ cmdOverride: isDetected ? cmdOverrides[agent.id] : undefined, argsOverride: resolveTuiAgentLaunchArgs(agent.id, agentDefaultArgs), envOverride: resolveTuiAgentLaunchEnv(agent.id, agentDefaultEnv), + permissionMode: resolveTuiAgentPermissionMode({ + agent: agent.id, + agentArgs: agentDefaultArgs[agent.id], + agentEnv: agentDefaultEnv[agent.id] + }), + onSetPermissionMode: (mode) => { + const next = applyTuiAgentPermissionMode({ + agent: agent.id, + mode, + agentArgs: resolveTuiAgentLaunchArgs(agent.id, agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent.id, agentDefaultEnv) + }) + updateSettings({ + agentDefaultArgs: { ...agentDefaultArgs, [agent.id]: next.agentArgs }, + agentDefaultEnv: { ...agentDefaultEnv, [agent.id]: next.agentEnv } + }) + }, onSetDefault: isDetected ? () => updateSettings({ defaultTuiAgent: agent.id }) : () => {}, onSetEnabled: (enabled) => setAgentEnabled(agent.id, enabled), onSaveOverride: isDetected diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index ed353d5acbf..590602675c5 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -34,6 +34,7 @@ function buildAgentSettingsKeywords(): string[] { { key: 'auto.components.settings.agents.search.permission', fallback: 'permission' }, { 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.auto', fallback: 'auto' }, { key: 'auto.components.settings.agents.search.manual', fallback: 'manual' }, { key: 'auto.components.settings.agents.search.e2b7c0dcd7', @@ -126,7 +127,7 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ ), description: translate( 'auto.components.settings.agents.search.agentPermissionsDescription', - 'Switch agent permission defaults between Yolo and Manual.' + 'Switch agent permission defaults between Manual, Auto, and Yolo.' ), keywords: [ ...translateSearchKeyword('auto.components.settings.agents.search.permission', 'permission'), @@ -135,6 +136,7 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ 'permissions' ), ...translateSearchKeyword('auto.components.settings.agents.search.yolo', 'yolo'), + ...translateSearchKeyword('auto.components.settings.agents.search.auto', 'auto'), ...translateSearchKeyword('auto.components.settings.agents.search.manual', 'manual'), ...translateSearchKeyword('auto.components.settings.agents.search.skip', 'skip'), ...translateSearchKeyword('auto.components.settings.agents.search.checks', 'checks') diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts index 28a9cca5bf7..3886ee2ae63 100644 --- a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' +import { AUTO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' import { createTestStore, makeTab } from '../../store/slices/store-test-helpers' import type { AppState } from '../../store/types' import { @@ -102,6 +102,28 @@ describe('Codex auto-approval status suppression', () => { ).toBe(false) }) + it.each(['waiting', 'blocked'] as const)( + 'preserves Auto %s attention, including user questions', + (state) => { + registerCodexLaunchConfig({ agentArgs: AUTO_TUI_AGENT_ARGS.codex ?? '', launchToken }) + for (const toolName of [undefined, 'request_user_input']) { + expect( + shouldSuppressCodexAutoApprovalStatus( + { state, prompt: 'needs attention', agentType: 'codex', toolName }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(false) + } + expect( + shouldSuppressCodexAutoApprovalSyntheticTitle('Codex - action required', { + paneKey, + tabId: 'tab-1', + launchToken + }) + ).toBe(false) + } + ) + it('preserves manual Codex permission attention', () => { registerCodexLaunchConfig({ agentArgs: '', launchToken }) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 64902d783ac..7541778375a 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -675,6 +675,11 @@ } } } + }, + "AgentStep": { + "yoloPermissionsInfo": "Agent permission info", + "yoloPermissionsLabel": "Yolo / Dangerously skip permissions", + "yoloPermissionsTooltip": "Skip permission checks for agents for less interruptions" } }, "right": { @@ -1087,7 +1092,12 @@ "9b175d0f5e": "Pre-selected agent when opening a new workspace.", "c8794e622e": "Detected", "db9e9e5887": "Customize command", - "df123171d1": "Not installed" + "df123171d1": "Not installed", + "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", + "agentPermissionsInfo": "Agent permissions info", + "agentPermissionsTooltip": "Doesn't apply to agents where you've overridden launch arguments.", + "cea7d97be1": "Collapse command override", + "dc4a2ffdc0": "Expand command override" }, "AppearancePane": { "0f28e7b30c": "Choose how Orca looks in the app window.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7a205ba816b..e0ee7a0f97c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6583,7 +6583,14 @@ "codexSessionSourceInfo": "About importing Codex history", "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex.", "storedDefaultUndetected": "Saved as your default, but not detected right now", - "noAgentsDetected": "No agents detected. If one is installed, the probe may have timed out." + "noAgentsDetected": "No agents detected. If one is installed, the probe may have timed out.", + "agentPermissionsAuto": "Auto", + "agentPermissionsModesDescription": "Manual asks for approval. Auto uses the agent’s guarded approval mode; agents without Auto use Manual. Yolo skips permission checks.", + "agentPermissionsCustom": "Custom configuration. Choose a preset to update standard modes; custom launch arguments and environment values are preserved.", + "agentPermissionsPreserveCustom": "Applies to all agents. Custom launch arguments and environment values are preserved.", + "agentPermissionsEditCustom": "Custom configuration. Edit or reset the launch arguments and environment below to use a permission preset.", + "collapseLaunchSettings": "Collapse launch settings", + "expandLaunchSettings": "Expand launch settings" }, "AppIconSelector": { "d5a112dc9b": "Next icon", @@ -8944,7 +8951,7 @@ "a79d266f71": "session", "afbf35be68": "stable session", "agentPermissions": "Agent Permissions", - "agentPermissionsDescription": "Switch agent permission defaults between Yolo and Manual.", + "agentPermissionsDescription": "Switch agent permission defaults between Manual, Auto, and Yolo.", "agentRuntime": "Agent Runtime", "agentRuntimeDescription": "Choose whether agents are detected and launched on Windows or in WSL by default.", "runtime": "runtime", @@ -8955,7 +8962,8 @@ "yolo": "yolo", "manual": "manual", "skip": "skip", - "checks": "checks" + "checks": "checks", + "auto": "auto" } }, "appearance": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 48fdd0b0462..93179ccf356 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5556,7 +5556,14 @@ "03e1a5081a": "on {{value0}}", "25a41a9aad": "Re-detect agents installed on the active server", "remoteDetectionFailed": "Couldn’t detect installed agents. Check the host connection and try again.", - "retryDetection": "Retry" + "retryDetection": "Retry", + "agentPermissionsAuto": "Auto", + "agentPermissionsModesDescription": "Manual pide aprobación. Auto usa el modo de aprobación protegido del agente; los agentes sin Auto usan Manual. Yolo omite las comprobaciones de permisos.", + "agentPermissionsCustom": "Configuración personalizada. Elige un ajuste para actualizar los modos estándar; se conservan los argumentos de inicio y las variables de entorno personalizados.", + "agentPermissionsPreserveCustom": "Se aplica a todos los agentes. Se conservan los argumentos de inicio y las variables de entorno personalizados.", + "agentPermissionsEditCustom": "Configuración personalizada. Edita o restablece los argumentos de inicio y el entorno de abajo para usar un ajuste de permisos.", + "collapseLaunchSettings": "Contraer ajustes de inicio", + "expandLaunchSettings": "Expandir ajustes de inicio" }, "AppIconSelector": { "d5a112dc9b": "Icono siguiente", @@ -7788,9 +7795,10 @@ "a79d266f71": "sesión", "afbf35be68": "sesión estable", "agentPermissions": "Permisos de agentes", - "agentPermissionsDescription": "Cambia los permisos predeterminados de los agentes entre Yolo y Manual.", + "agentPermissionsDescription": "Cambia los permisos predeterminados de los agentes entre Manual, Auto y Yolo.", "agentRuntime": "Runtime del agente", - "agentRuntimeDescription": "Elige si los agentes se detectan y ejecutan en Windows o en WSL de forma predeterminada." + "agentRuntimeDescription": "Elige si los agentes se detectan y ejecutan en Windows o en WSL de forma predeterminada.", + "auto": "automático" } }, "appearance": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index b7b4b7d23d1..6f6db2c5ad3 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -6298,7 +6298,14 @@ "codexSessionSourceInfo": "À propos de l'import de l'historique Codex", "codexSessionSourceTooltip": "Orca exécute Codex dans un home isolé. Pointez ceci vers votre home Codex existant pour en importer l'historique de sessions. Si vide, ~/.codex est utilisé.", "storedDefaultUndetected": "Enregistré par défaut, mais non détecté actuellement", - "noAgentsDetected": "Aucun agent détecté. Si un agent est installé, la détection a peut-être expiré." + "noAgentsDetected": "Aucun agent détecté. Si un agent est installé, la détection a peut-être expiré.", + "agentPermissionsAuto": "Auto", + "agentPermissionsModesDescription": "Manual demande une approbation. Auto utilise le mode d’approbation protégé de l’agent ; les agents sans Auto utilisent Manual. Yolo ignore les vérifications de permissions.", + "agentPermissionsCustom": "Configuration personnalisée. Choisissez un préréglage pour modifier les modes standard ; les arguments de lancement et les valeurs d’environnement personnalisés sont conservés.", + "agentPermissionsPreserveCustom": "S’applique à tous les agents. Les arguments de lancement et les valeurs d’environnement personnalisés sont conservés.", + "agentPermissionsEditCustom": "Configuration personnalisée. Modifiez ou réinitialisez les arguments de lancement et l’environnement ci-dessous pour utiliser un préréglage de permissions.", + "collapseLaunchSettings": "Réduire les paramètres de lancement", + "expandLaunchSettings": "Développer les paramètres de lancement" }, "AppIconSelector": { "d5a112dc9b": "Icône suivante", @@ -8629,9 +8636,10 @@ "a79d266f71": "session", "afbf35be68": "session stable", "agentPermissions": "Permissions des agents", - "agentPermissionsDescription": "Basculer les autorisations d'agent par défaut entre Yolo et Manuelle.", + "agentPermissionsDescription": "Choisissez les permissions par défaut des agents : Manual, Auto ou Yolo.", "agentRuntime": "Runtime des agents", - "agentRuntimeDescription": "Choisissez si les agents sont détectés et lancés par défaut sous Windows ou dans WSL." + "agentRuntimeDescription": "Choisissez si les agents sont détectés et lancés par défaut sous Windows ou dans WSL.", + "auto": "automatique" } }, "appearance": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d2cc4c5e91a..96bb2e8542c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5541,7 +5541,14 @@ "03e1a5081a": "{{value0}} 上", "25a41a9aad": "アクティブなサーバーにインストールされている Agent を再検出", "remoteDetectionFailed": "インストールされている Agent を検出できませんでした。ホストへの接続を確認して、もう一度お試しください。", - "retryDetection": "再試行" + "retryDetection": "再試行", + "agentPermissionsAuto": "Auto", + "agentPermissionsModesDescription": "Manual は承認を求めます。Auto はAgentの保護付き承認モードを使用します。Auto 非対応のAgentは Manual を使用します。Yolo は権限チェックをスキップします。", + "agentPermissionsCustom": "カスタム設定です。プリセットを選択すると標準モードを更新します。カスタムの起動引数と環境変数の値は保持されます。", + "agentPermissionsPreserveCustom": "すべてのAgentに適用します。カスタムの起動引数と環境変数の値は保持されます。", + "agentPermissionsEditCustom": "カスタム設定です。権限プリセットを使用するには、以下の起動引数と環境変数を編集またはリセットしてください。", + "collapseLaunchSettings": "起動設定を折りたたむ", + "expandLaunchSettings": "起動設定を展開" }, "AppIconSelector": { "d5a112dc9b": "次へのアイコン", @@ -7810,9 +7817,10 @@ "a79d266f71": "セッション", "afbf35be68": "安定したセッション", "agentPermissions": "Agent の権限", - "agentPermissionsDescription": "Agent の権限の既定値を Yolo と Manual で切り替えます。", + "agentPermissionsDescription": "Agentの既定の権限を Manual、Auto、Yolo から選択します。", "agentRuntime": "Agent のランタイム", - "agentRuntimeDescription": "デフォルトで Agent を Windows または WSL のどちらで検出および起動するかを選択します。" + "agentRuntimeDescription": "デフォルトで Agent を Windows または WSL のどちらで検出および起動するかを選択します。", + "auto": "自動" } }, "appearance": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4945e423a98..732c065928d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5546,7 +5546,14 @@ "03e1a5081a": "on {{value0}}", "25a41a9aad": "Re-detect agents installed on the active server", "remoteDetectionFailed": "Couldn’t detect installed agents. Check the host connection and try again.", - "retryDetection": "Retry" + "retryDetection": "Retry", + "agentPermissionsAuto": "Auto", + "agentPermissionsModesDescription": "Manual은 승인을 요청합니다. Auto는 에이전트의 보호된 승인 모드를 사용하며, Auto를 지원하지 않는 에이전트는 Manual을 사용합니다. Yolo는 권한 검사를 건너뜁니다.", + "agentPermissionsCustom": "사용자 지정 설정입니다. 프리셋을 선택하면 표준 모드가 변경되며, 사용자 지정 실행 인수와 환경 변수 값은 유지됩니다.", + "agentPermissionsPreserveCustom": "모든 에이전트에 적용됩니다. 사용자 지정 실행 인수와 환경 변수 값은 유지됩니다.", + "agentPermissionsEditCustom": "사용자 지정 설정입니다. 권한 프리셋을 사용하려면 아래 실행 인수와 환경 변수를 편집하거나 초기화하세요.", + "collapseLaunchSettings": "실행 설정 접기", + "expandLaunchSettings": "실행 설정 펼치기" }, "AppIconSelector": { "d5a112dc9b": "다음 아이콘", @@ -7778,9 +7785,10 @@ "a79d266f71": "세션", "afbf35be68": "안정적인 세션", "agentPermissions": "Agent 권한", - "agentPermissionsDescription": "agent 권한 기본값을 Yolo와 수동 사이에서 전환합니다.", + "agentPermissionsDescription": "agent의 기본 권한을 Manual, Auto, Yolo 중에서 선택합니다.", "agentRuntime": "Agent 런타임", - "agentRuntimeDescription": "기본적으로 Windows 또는 WSL에서 agents를 감지하고 시작할지 선택합니다." + "agentRuntimeDescription": "기본적으로 Windows 또는 WSL에서 agents를 감지하고 시작할지 선택합니다.", + "auto": "자동" } }, "appearance": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 30bb5388d15..bba409d6135 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5607,7 +5607,14 @@ "03e1a5081a": "on {{value0}}", "25a41a9aad": "重新检测活动服务器上安装的智能体", "remoteDetectionFailed": "无法检测已安装的智能体。请检查主机连接后重试。", - "retryDetection": "Retry" + "retryDetection": "Retry", + "agentPermissionsAuto": "Auto", + "agentPermissionsModesDescription": "Manual 会请求批准。Auto 使用智能体带保护机制的审批模式;不支持 Auto 的智能体使用 Manual。Yolo 会跳过权限检查。", + "agentPermissionsCustom": "自定义配置。选择预设以更新标准模式;自定义启动参数和环境变量值会保留。", + "agentPermissionsPreserveCustom": "应用于所有智能体。自定义启动参数和环境变量值会保留。", + "agentPermissionsEditCustom": "自定义配置。编辑或重置下方的启动参数和环境变量以使用权限预设。", + "collapseLaunchSettings": "收起启动设置", + "expandLaunchSettings": "展开启动设置" }, "AppIconSelector": { "d5a112dc9b": "下一个图标", @@ -7843,9 +7850,10 @@ "a79d266f71": "会话", "afbf35be68": "稳定会话", "agentPermissions": "智能体权限", - "agentPermissionsDescription": "在 Yolo 和手动之间切换智能体权限默认值。", + "agentPermissionsDescription": "在 Manual、Auto 和 Yolo 之间切换智能体的默认权限。", "agentRuntime": "智能体运行时", - "agentRuntimeDescription": "选择默认在 Windows 还是 WSL 中检测并启动智能体。" + "agentRuntimeDescription": "选择默认在 Windows 还是 WSL 中检测并启动智能体。", + "auto": "自动" } }, "appearance": { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index e1cf7594034..a49b0051988 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -173,6 +173,8 @@ export const GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY = 'gitlab.updateMR.readyForReview.v1' as const export const GITLAB_READY_FOR_REVIEW_UPDATE_REQUIRED_MESSAGE = 'Marking a merge request ready requires a newer Orca server. Update the server and try again.' +// Older clients cannot round-trip Auto permission presets safely. +export const AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY = 'settings.agent-permission-auto.v1' as const export const WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY = 'worktree.visibility-defaults.v1' as const export const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY = @@ -194,6 +196,7 @@ export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -266,6 +269,7 @@ export const RUNTIME_CAPABILITIES = [ FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, + AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, ACCOUNT_IMPORT_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 3797f08ffb9..f96e0f16cb7 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -1,4 +1,5 @@ import { + AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, @@ -18,6 +19,7 @@ export function remoteRuntimeClientCapabilities( new Set([ SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, + AGENT_PERMISSION_AUTO_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/src/shared/tui-agent-auto-permission-launch.test.ts b/src/shared/tui-agent-auto-permission-launch.test.ts new file mode 100644 index 00000000000..0130b9c4777 --- /dev/null +++ b/src/shared/tui-agent-auto-permission-launch.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { applyAgentPermissionMode, AUTO_TUI_AGENT_ARGS } from './tui-agent-permissions' +import { + normalizeTuiAgentArgsRecord, + normalizeTuiAgentEnvRecord, + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from './tui-agent-launch-defaults' +import { buildAgentStartupPlan } from './tui-agent-startup' +import type { TuiAgent } from './tui-agent' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' + +describe('Auto permission launch settings', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'retains guarded presets through normalization and local/remote %s launch planning', + (platform) => { + const profile = applyAgentPermissionMode({ mode: 'auto' }) + const args = normalizeTuiAgentArgsRecord(profile.agentDefaultArgs) + const env = normalizeTuiAgentEnvRecord(profile.agentDefaultEnv) + for (const isRemote of [false, true]) { + for (const agent of [...Object.keys(AUTO_TUI_AGENT_ARGS), 'goose', 'amp'] as TuiAgent[]) { + const agentArgs = resolveTuiAgentLaunchArgs(agent, args) + const agentEnv = resolveTuiAgentLaunchEnv(agent, env) + const plan = buildAgentStartupPlan({ + agent, + agentArgs, + agentEnv, + platform, + isRemote, + cmdOverrides: {}, + prompt: '', + allowEmptyPromptLaunch: true + }) + expect(plan).not.toBeNull() + if (agent in AUTO_TUI_AGENT_ARGS) { + const parsed = tokenizeStartupCommand( + plan?.launchCommand ?? '', + platform === 'win32' ? 'powershell' : 'posix' + ) + expect(parsed.ok).toBe(true) + if (parsed.ok) { + const flags = AUTO_TUI_AGENT_ARGS[agent]?.split(' ') ?? [] + expect(parsed.tokens.slice(-flags.length)).toEqual(flags) + } + } + if (agent === 'goose') { + expect(plan?.env).toEqual({ GOOSE_MODE: 'smart_approve' }) + } + if (agent === 'amp') { + expect(agentArgs).toBe('') + expect(plan?.launchCommand).not.toContain('--dangerously-allow-all') + } + } + } + } + ) +}) diff --git a/src/shared/tui-agent-permissions.test.ts b/src/shared/tui-agent-permissions.test.ts index e1fa10e1993..70d700bda79 100644 --- a/src/shared/tui-agent-permissions.test.ts +++ b/src/shared/tui-agent-permissions.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { applyAgentPermissionMode, + applyTuiAgentPermissionMode, + AUTO_TUI_AGENT_ARGS, + AUTO_TUI_AGENT_ENV, + supportsTuiAgentAutoPermissionMode, resolveAgentPermissionModeSummary, resolveTuiAgentPermissionMode, YOLO_TUI_AGENT_ARGS, @@ -86,3 +90,85 @@ describe('tui agent permissions', () => { ).toBe('yolo') }) }) + +describe('intermediate permission presets', () => { + it('round trips all presets including manual fallbacks from an Auto profile', () => { + let profile = applyAgentPermissionMode({ mode: 'yolo' }) + for (const mode of ['auto', 'manual', 'yolo', 'auto'] as const) { + profile = applyAgentPermissionMode({ ...profile, mode }) + expect(resolveAgentPermissionModeSummary(profile)).toBe(mode) + for (const agent of Object.keys( + YOLO_TUI_AGENT_ARGS + ) as (keyof typeof YOLO_TUI_AGENT_ARGS)[]) { + expect( + resolveTuiAgentPermissionMode({ agent, agentArgs: profile.agentDefaultArgs[agent] }) + ).toBe(mode === 'auto' && !supportsTuiAgentAutoPermissionMode(agent) ? 'manual' : mode) + } + expect( + resolveTuiAgentPermissionMode({ agent: 'goose', agentEnv: profile.agentDefaultEnv.goose }) + ).toBe(mode) + } + }) + + it('keeps unknown and custom settings when applying a global preset', () => { + const profile = applyAgentPermissionMode({ + mode: 'auto', + agentDefaultArgs: { + claude: '--model custom', + opencode: '--port 1234', + codex: ' --approve-for-me ' + }, + agentDefaultEnv: { + goose: { GOOSE_MODE: 'approve', CUSTOM: 'value' }, + claude: { CUSTOM: 'value' } + } + }) + expect(profile.agentDefaultArgs.claude).toBe('--model custom') + expect(profile.agentDefaultArgs.codex).toBe(' --approve-for-me ') + expect(profile.agentDefaultArgs.opencode).toBe('--port 1234') + expect(profile.agentDefaultEnv.goose).toEqual({ GOOSE_MODE: 'approve', CUSTOM: 'value' }) + expect(profile.agentDefaultEnv.claude).toEqual({ CUSTOM: 'value' }) + expect(resolveAgentPermissionModeSummary(profile)).toBe('mixed') + }) + + it('reports custom per-agent choices as mixed rather than misleadingly showing Auto or Yolo', () => { + const profile = applyAgentPermissionMode({ mode: 'auto' }) + expect( + resolveAgentPermissionModeSummary({ + ...profile, + agentDefaultArgs: { ...profile.agentDefaultArgs, codex: '' } + }) + ).toBe('mixed') + expect( + resolveAgentPermissionModeSummary({ + ...profile, + agentDefaultArgs: { ...profile.agentDefaultArgs, amp: YOLO_TUI_AGENT_ARGS.amp } + }) + ).toBe('mixed') + }) + + it('uses the existing args and env launch fields for per-agent choices', () => { + expect( + applyTuiAgentPermissionMode({ + agent: 'claude', + mode: 'auto', + agentArgs: YOLO_TUI_AGENT_ARGS.claude, + agentEnv: { CUSTOM: 'value' } + }) + ).toEqual({ agentArgs: AUTO_TUI_AGENT_ARGS.claude, agentEnv: { CUSTOM: 'value' } }) + expect( + applyTuiAgentPermissionMode({ + agent: 'goose', + mode: 'auto', + agentEnv: YOLO_TUI_AGENT_ENV.goose + }) + ).toEqual({ agentArgs: '', agentEnv: AUTO_TUI_AGENT_ENV.goose }) + expect( + applyTuiAgentPermissionMode({ + agent: 'amp', + mode: 'auto', + agentArgs: YOLO_TUI_AGENT_ARGS.amp + }) + ).toEqual({ agentArgs: '', agentEnv: {} }) + }) +}) diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index c0808b41a3d..c0bfe6677e1 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -1,7 +1,7 @@ import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { TuiAgent } from './tui-agent' -export type AgentPermissionMode = 'yolo' | 'manual' | 'mixed' +export type AgentPermissionMode = 'yolo' | 'auto' | 'manual' | 'mixed' export const YOLO_TUI_AGENT_ARGS: Partial> = { claude: '--dangerously-skip-permissions', @@ -36,12 +36,27 @@ export const YOLO_TUI_AGENT_ENV: Partial goose: { GOOSE_MODE: 'auto' } } +// Only documented intermediate CLI presets belong here; unsupported agents use Manual. +export const AUTO_TUI_AGENT_ARGS: Partial> = { + claude: '--permission-mode auto', + 'claude-agent-teams': '--permission-mode auto', + codex: '--approve-for-me', + gemini: '--approval-mode auto_edit', + 'qwen-code': '--approval-mode auto', + devin: '--permission-mode smart', + droid: '--auto medium' +} + +export const AUTO_TUI_AGENT_ENV: Partial>> = { + goose: { GOOSE_MODE: 'smart_approve' } +} + 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 ) -function normalizeArgs(value: string | null | undefined): string { - return value?.trim() ?? '' +export function supportsTuiAgentAutoPermissionMode(agent: TuiAgent): boolean { + return agent in AUTO_TUI_AGENT_ARGS || agent in AUTO_TUI_AGENT_ENV } function sameEnv( @@ -49,49 +64,15 @@ function sameEnv( right: Record | null | undefined ): boolean { const leftEntries = Object.entries(left ?? {}) - const rightEntries = Object.entries(right ?? {}) - if (leftEntries.length !== rightEntries.length) { - return false - } - return leftEntries.every(([name, value]) => right?.[name] === value) -} - -function resolveAgentPermissionMode(args: string, yoloArgs: string): AgentPermissionMode { - if (!args) { - return 'manual' - } - return args === yoloArgs ? 'yolo' : 'mixed' -} - -function resolveAgentEnvPermissionMode( - env: Record | null | undefined, - yoloEnv: Record | undefined -): AgentPermissionMode { - if (sameEnv(env, {})) { - return 'manual' - } - return sameEnv(env, yoloEnv) ? 'yolo' : 'mixed' + return ( + leftEntries.length === Object.keys(right ?? {}).length && + leftEntries.every(([name, value]) => right?.[name] === value) + ) } 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: { @@ -101,17 +82,28 @@ export function resolveTuiAgentPermissionMode(args: { }): AgentPermissionMode { const modes: AgentPermissionMode[] = [] if (args.agent in YOLO_TUI_AGENT_ARGS) { + const value = args.agentArgs ?? '' modes.push( - resolveAgentPermissionMode( - normalizeArgs(args.agentArgs), - YOLO_TUI_AGENT_ARGS[args.agent] ?? '' - ) + !value + ? 'manual' + : value === YOLO_TUI_AGENT_ARGS[args.agent] + ? 'yolo' + : value === AUTO_TUI_AGENT_ARGS[args.agent] + ? 'auto' + : 'mixed' ) } if (args.agent in YOLO_TUI_AGENT_ENV) { - modes.push(resolveAgentEnvPermissionMode(args.agentEnv, YOLO_TUI_AGENT_ENV[args.agent])) + modes.push( + sameEnv(args.agentEnv, {}) + ? 'manual' + : sameEnv(args.agentEnv, YOLO_TUI_AGENT_ENV[args.agent]) + ? 'yolo' + : AUTO_TUI_AGENT_ENV[args.agent] && sameEnv(args.agentEnv, AUTO_TUI_AGENT_ENV[args.agent]) + ? 'auto' + : 'mixed' + ) } - return combinePermissionModes(modes) } @@ -119,19 +111,62 @@ export function resolveAgentPermissionModeSummary(args: { agentDefaultArgs?: Partial> | null agentDefaultEnv?: Partial>> | null }): AgentPermissionMode { - const modes: AgentPermissionMode[] = [] - - for (const agent of PERMISSION_AGENT_IDS) { - modes.push( - resolveTuiAgentPermissionMode({ - agent, - agentArgs: args.agentDefaultArgs?.[agent], - agentEnv: args.agentDefaultEnv?.[agent] - }) + const modes = PERMISSION_AGENT_IDS.map((agent) => ({ + agent, + mode: resolveTuiAgentPermissionMode({ + agent, + agentArgs: args.agentDefaultArgs?.[agent], + agentEnv: args.agentDefaultEnv?.[agent] + }) + })) + if ( + modes.some(({ mode }) => mode === 'auto') && + modes.every( + ({ agent, mode }) => + mode === 'auto' || (mode === 'manual' && !supportsTuiAgentAutoPermissionMode(agent)) ) + ) { + return 'auto' } + return combinePermissionModes(modes.map(({ mode }) => mode)) +} - return combinePermissionModes(modes) +export function applyTuiAgentPermissionMode(args: { + agent: TuiAgent + mode: Exclude + agentArgs?: string | null + agentEnv?: Record | null +}): { agentArgs: string; agentEnv: Record } { + let agentArgs = args.agentArgs ?? '' + let agentEnv = { ...args.agentEnv } + if ( + args.agent in YOLO_TUI_AGENT_ARGS && + (!agentArgs || + agentArgs === YOLO_TUI_AGENT_ARGS[args.agent] || + agentArgs === AUTO_TUI_AGENT_ARGS[args.agent]) + ) { + agentArgs = + (args.mode === 'yolo' + ? YOLO_TUI_AGENT_ARGS[args.agent] + : args.mode === 'auto' + ? AUTO_TUI_AGENT_ARGS[args.agent] + : '') ?? '' + } + if ( + args.agent in YOLO_TUI_AGENT_ENV && + (sameEnv(agentEnv, {}) || + sameEnv(agentEnv, YOLO_TUI_AGENT_ENV[args.agent]) || + (AUTO_TUI_AGENT_ENV[args.agent] && sameEnv(agentEnv, AUTO_TUI_AGENT_ENV[args.agent]))) + ) { + agentEnv = { + ...(args.mode === 'yolo' + ? YOLO_TUI_AGENT_ENV[args.agent] + : args.mode === 'auto' + ? AUTO_TUI_AGENT_ENV[args.agent] + : {}) + } + } + return { agentArgs, agentEnv } } export function applyAgentPermissionMode(args: { @@ -144,24 +179,19 @@ export function applyAgentPermissionMode(args: { } { const nextArgs = { ...args.agentDefaultArgs } const nextEnv = { ...args.agentDefaultEnv } - for (const agent of PERMISSION_AGENT_IDS) { + const result = applyTuiAgentPermissionMode({ + agent, + mode: args.mode, + agentArgs: nextArgs[agent], + agentEnv: nextEnv[agent] + }) 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 : '' - } + nextArgs[agent] = result.agentArgs } - if (agent in YOLO_TUI_AGENT_ENV) { - const yoloEnv = YOLO_TUI_AGENT_ENV[agent] - const currentEnv = nextEnv[agent] - if (sameEnv(currentEnv, {}) || sameEnv(currentEnv, yoloEnv)) { - nextEnv[agent] = args.mode === 'yolo' ? { ...yoloEnv } : {} - } + nextEnv[agent] = result.agentEnv } } - return { agentDefaultArgs: nextArgs, agentDefaultEnv: nextEnv } }