diff --git a/config/vitest.performance.config.ts b/config/vitest.performance.config.ts index 8c0b623d4c7..6626416378c 100644 --- a/config/vitest.performance.config.ts +++ b/config/vitest.performance.config.ts @@ -5,6 +5,10 @@ import baseConfig from './vitest.config' import { nodeRuntimePool } from './scripts/vitest-node-runtime-pool' const contracts = [ + 'src/shared/tui-agent-permission-choice.test.ts', + 'src/renderer/src/components/settings/agent-permission-conversion-persistence.test.tsx', + 'src/main/runtime/rpc/methods/repo.test.ts', + 'src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx', 'src/main/sqlite/sync-database.test.ts', 'src/main/runtime/orchestration/db/row-column-lists.test.ts', 'src/shared/native-chat-tool-attribution-allocation.test.ts', diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index 29518120d12..1ef0df98fe9 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -104,8 +104,8 @@ export function buildMobileAiVaultResumeLaunch(args: { cmdOverrides, platform: args.hostPlatform, shell, - agentArgs: resolveTuiAgentLaunchArgs(args.session.agent, args.settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.settings?.agentDefaultEnv), + agentArgs: resolveTuiAgentLaunchArgs(args.session.agent, args.settings), + agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.settings, args.hostPlatform), ...(args.session.agent === 'omp' && resumeFilePath ? { ompResumeFilePath: resumeFilePath } : {}) diff --git a/src/main/codex/codex-structured-launch-args.ts b/src/main/codex/codex-structured-launch-args.ts index 5c5b2ca2d7a..bb9fba71b3a 100644 --- a/src/main/codex/codex-structured-launch-args.ts +++ b/src/main/codex/codex-structured-launch-args.ts @@ -1,3 +1,4 @@ +import { CODEX_PERMISSION_CONFIG_ROOTS } from '../../shared/agent-permission-args' import { parseTomlKeyPath } from './config-toml-key-path' import { StructuredAgentArgumentsError } from '../native-chat/structured-agent-arguments-error' @@ -60,12 +61,7 @@ function inlineValue(token: string, option: string): string | undefined { function isPermissionConfig(value: string): boolean { const key = parseTomlKeyPath(value)?.segments[0] - return ( - key === 'approval_policy' || - key === 'sandbox_mode' || - key === 'approvals_reviewer' || - key === 'sandbox_workspace_write' - ) + return CODEX_PERMISSION_CONFIG_ROOTS.some((root) => root === key) } /** Accept only root CLI options; Orca sets permissions on the thread RPC. */ diff --git a/src/main/native-chat/agent-session-wire/at-rest-command-context.ts b/src/main/native-chat/agent-session-wire/at-rest-command-context.ts index cd110251e1b..b71a99974cc 100644 --- a/src/main/native-chat/agent-session-wire/at-rest-command-context.ts +++ b/src/main/native-chat/agent-session-wire/at-rest-command-context.ts @@ -6,6 +6,8 @@ const launchSettings = new Set([ 'agentCmdOverrides', 'agentDefaultArgs', 'agentDefaultEnv', + 'agentPermissionDefault', + 'agentPermissionOverrides', 'nativeChatInheritShellEnvironment', 'nativeChatShellEnvironmentVariables', 'terminalWindowsShell', diff --git a/src/main/native-chat/structured-agent-configured-args.ts b/src/main/native-chat/structured-agent-configured-args.ts index aa9cec790b7..57890b4a8c5 100644 --- a/src/main/native-chat/structured-agent-configured-args.ts +++ b/src/main/native-chat/structured-agent-configured-args.ts @@ -1,12 +1,15 @@ import type { GlobalSettings } from '../../shared/global-settings-types' -import { resolveTuiAgentLaunchArgs } from '../../shared/tui-agent-launch-defaults' +import { + resolveTuiAgentLaunchArgs, + type AgentLaunchProfileSettings +} from '../../shared/tui-agent-launch-defaults' import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' /** Use the same grouping rules as terminal launches before the provider filters its owned flags. */ export function structuredAgentConfiguredArgs( agent: string, - settings: Partial>, + settings: AgentLaunchProfileSettings & Partial>, platform: NodeJS.Platform = process.platform ): string[] { const shell = @@ -15,10 +18,7 @@ export function structuredAgentConfiguredArgs( isRemote: false, terminalWindowsShell: settings.terminalWindowsShell }) ?? 'posix' - const parsed = tokenizeStartupCommand( - resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), - shell - ) + const parsed = tokenizeStartupCommand(resolveTuiAgentLaunchArgs(agent, settings), shell) if (!parsed.ok) { throw new Error(`${agent} Arguments are invalid: ${parsed.error}`) } diff --git a/src/main/orcad/orcad-account-services.ts b/src/main/orcad/orcad-account-services.ts index d30616f2533..428e0a02f67 100644 --- a/src/main/orcad/orcad-account-services.ts +++ b/src/main/orcad/orcad-account-services.ts @@ -107,7 +107,7 @@ export function createOrcadAccountServices(args: { prepareCodexCatalogProbeHome: (homePath) => codexRuntimeHome.prepareHostCodexHomeForReadOnlyAppServer( homePath, - resolveTuiAgentLaunchEnv('codex', store.getSettings().agentDefaultEnv) + resolveTuiAgentLaunchEnv('codex', store.getSettings()) ), prepareAiVaultSessionResume: (resumeArgs) => prepareCodexAiVaultSessionResume(resumeArgs, { diff --git a/src/main/persistence-settings-update.test.ts b/src/main/persistence-settings-update.test.ts index f610a765527..593e8d1716d 100644 --- a/src/main/persistence-settings-update.test.ts +++ b/src/main/persistence-settings-update.test.ts @@ -1,3 +1,4 @@ +import { composeAgentLaunchProfile } from '../shared/tui-agent-launch-defaults' import { closeTestStores, testState, @@ -459,12 +460,12 @@ describe('Store', () => { ) const store = await createStore() - expect(store.getSettings().agentDefaultArgs).toMatchObject({ + expect(composeAgentLaunchProfile(store.getSettings()).agentDefaultArgs).toMatchObject({ claude: '--dangerously-skip-permissions', codex: '--dangerously-bypass-approvals-and-sandbox', cursor: '--yolo' }) - expect(store.getSettings().agentDefaultEnv).toMatchObject({ + expect(composeAgentLaunchProfile(store.getSettings()).agentDefaultEnv).toMatchObject({ goose: { GOOSE_MODE: 'auto' } }) expect(store.getSettings().agentYoloDefaultsMigrated).toBe(true) @@ -484,9 +485,10 @@ describe('Store', () => { ) const store = await createStore() - expect(store.getSettings().agentDefaultArgs?.codex).toBe('') - expect(store.getSettings().agentDefaultEnv?.goose).toEqual({}) - expect(store.getSettings().agentDefaultArgs?.claude).toBe('--dangerously-skip-permissions') + const launched = composeAgentLaunchProfile(store.getSettings()) + expect(launched.agentDefaultArgs.codex).toBe('') + expect(launched.agentDefaultEnv.goose).toEqual({}) + expect(launched.agentDefaultArgs.claude).toBe('--dangerously-skip-permissions') }) it('removes unsupported TUI skip-permissions args from migrated profiles', async () => { @@ -508,7 +510,7 @@ describe('Store', () => { expect(store.getSettings().agentDefaultArgs?.opencode).toBe('--model opencode/gpt-5') expect(store.getSettings().agentDefaultArgs?.kilo).toBe('') - expect(store.getSettings().agentDefaultArgs?.codex).toBe( + expect(composeAgentLaunchProfile(store.getSettings()).agentDefaultArgs.codex).toBe( '--dangerously-bypass-approvals-and-sandbox' ) expect((readDataFile() as PersistedState).settings.agentDefaultArgs?.opencode).toBe( diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 0e474d0a3bf..b41d291f548 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -5,10 +5,7 @@ import { retireAtRestCommandSettings } from '../../native-chat/agent-session-wir import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { normalizeNativeChatProviderChoices } from '../../../shared/native-chat-providers' import { resolveNestedWorkerMaxDepth } from '../../../shared/nested-worker-depth' -import { - normalizeTuiAgentArgsRecord, - normalizeTuiAgentEnvRecord -} from '../../../shared/tui-agent-launch-defaults' +import { sanitizeAgentLaunchProfileUpdates } from '../../../shared/tui-agent-launch-defaults' import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' import { assertTerminalQuickCommandsStorable } from '../../../shared/terminal-quick-command-prompt-limit' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' @@ -111,14 +108,7 @@ export function updateSettings( }) } } - if ('agentDefaultArgs' in updates) { - sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs) - sanitizedUpdates.agentYoloDefaultsMigrated = true - } - if ('agentDefaultEnv' in updates) { - sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv) - sanitizedUpdates.agentYoloDefaultsMigrated = true - } + sanitizeAgentLaunchProfileUpdates(sanitizedUpdates, updates) if ('terminalQuickCommands' in updates) { const terminalQuickCommands = normalizeTerminalQuickCommands(updates.terminalQuickCommands) // Every local and paired save lands here, so this is where an unstorable list is refused. diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts index 2b7ae200a39..6db4d0fdb82 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts @@ -1,5 +1,5 @@ import { homedir } from 'node:os' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getDefaultPersistedState } from '../../../shared/constants' import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings' import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings' @@ -7,6 +7,8 @@ import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' import type { GlobalSettings } from '../../../shared/global-settings-types' import type { PersistedState } from '../../../shared/persisted-state-types' import { applyAgentPermissionMode } from '../../../shared/tui-agent-permissions' +import { resolveAgentPermission } from '../../../shared/tui-agent-permission-choice' +import { resolveTuiAgentLaunchArgs } from '../../../shared/tui-agent-launch-defaults' // Simulates a profile created before the dedicated Experimental switch was persisted. function normalizeLegacyProfile(overrides: Record): PersistedState['settings'] { @@ -142,6 +144,112 @@ describe('chat appearance settings', () => { }) describe('the new-chat permission setting', () => { + it.each<{ + name: string + saved: Partial + raw: string[] + choices: string[] + chat: string + terminal: string[] + }>([ + { + name: 'mixed terminal permissions before the Chat seed exists', + saved: { + agentYoloDefaultsMigrated: true, + agentDefaultArgs: { + claude: '--dangerously-skip-permissions --model sonnet', + codex: '--approve-for-me --config tui.animations=false' + } + }, + raw: ['--model sonnet', '--config tui.animations=false'], + choices: ['full-access', 'auto'], + chat: 'ask', + terminal: [ + '--dangerously-skip-permissions --model sonnet', + '--approve-for-me --config tui.animations=false' + ] + }, + { + name: 'explicit empty Arguments with saved Chat defaults', + saved: { + agentYoloDefaultsMigrated: true, + agentDefaultArgs: { claude: '', codex: '' }, + nativeChatPermissionMode: 'auto' + }, + raw: ['', ''], + choices: ['own', 'own'], + chat: 'auto', + terminal: ['', ''] + }, + { + name: 'Claude plan text owned by the user', + saved: { + agentYoloDefaultsMigrated: true, + agentDefaultArgs: { claude: '--permission-mode plan --model sonnet', codex: '' }, + nativeChatPermissionMode: 'accept-edits' + }, + raw: ['--permission-mode plan --model sonnet', ''], + choices: ['own', 'own'], + chat: 'accept-edits', + terminal: ['--permission-mode plan --model sonnet', ''] + }, + { + name: 'legacy yolo defaults before either migration', + saved: { agentYoloDefaultsMigrated: false, agentDefaultArgs: {} }, + raw: ['', ''], + choices: ['full-access', 'full-access'], + chat: 'full-access', + terminal: ['--dangerously-skip-permissions', '--dangerously-bypass-approvals-and-sandbox'] + } + ])( + 'loads and reloads $name through the real profile pipeline', + ({ saved, raw, choices, chat, terminal }) => { + const defaults = getDefaultPersistedState(homedir()) + const parsed: PersistedState = { + ...defaults, + settings: { + ...defaults.settings, + agentPermissionDefault: undefined, + agentPermissionOverrides: undefined, + nativeChatPermissionMode: undefined, + agentNativeChatPermissionModes: { claude: 'ask', codex: 'auto' }, + agentDefaultEnv: { claude: { QA_LOADER_NOTE: 'preserved' } }, + ...saved + } + } + const needsSave = vi.fn() + const profile = prepareLoadedProfileSettings(parsed, defaults, needsSave) + const loaded = normalizeLoadedGlobalSettings( + parsed, + prepareLoadedTerminalSettings(parsed, needsSave), + profile + ) + expect(needsSave).toHaveBeenCalled() + expect(loaded.agentYoloDefaultsMigrated).toBe(true) + expect(loaded.agentPermissionDefault).toBeDefined() + expect(loaded.nativeChatPermissionMode).toBe(chat) + expect(loaded.agentNativeChatPermissionModes).toEqual({ claude: 'ask', codex: 'auto' }) + expect(loaded.agentDefaultEnv?.claude).toEqual({ QA_LOADER_NOTE: 'preserved' }) + expect( + (['claude', 'codex'] as const).map((agent) => loaded.agentDefaultArgs?.[agent]) + ).toEqual(raw) + expect( + (['claude', 'codex'] as const).map((agent) => resolveAgentPermission(agent, loaded).choice) + ).toEqual(choices) + expect( + (['claude', 'codex'] as const).map((agent) => resolveTuiAgentLaunchArgs(agent, loaded)) + ).toEqual(terminal) + const reloaded: PersistedState = { ...parsed, settings: JSON.parse(JSON.stringify(loaded)) } + expect( + normalizeLoadedGlobalSettings( + reloaded, + prepareLoadedTerminalSettings(reloaded, needsSave), + prepareLoadedProfileSettings(reloaded, defaults, needsSave) + ) + ).toEqual(loaded) + } + ) + it.each([ ['manual', 'ask'], ['yolo', 'full-access'] diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts index 7f0a8d968b8..717c5babc4b 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -54,6 +54,7 @@ export function normalizeLoadedGlobalSettings( stampPrimarySelectionTerminalDefaults, migratedDisabledTuiAgents, migratedAgentYoloDefaults, + migratedAgentPermission, migratedWindowsRuntimeDefault, migratedLocalAccountRuntime, loadedCompactWorktreeCards, @@ -139,7 +140,8 @@ export function normalizeLoadedGlobalSettings( nativeChatShellEnvironmentVariables: normalizeNativeChatShellEnvironmentVariables( parsed.settings?.nativeChatShellEnvironmentVariables ), - // Why: the next settings save stores the seed; until then a reload derives the same one. + // Why: the next settings save stores the seed; until then a reload derives the same one. Read + // from the Arguments as saved, before their permission flags moved into the stored choice. agentNativeChatPermissionModes: normalizeAgentNativeChatPermissionModes( parsed.settings?.agentNativeChatPermissionModes ), @@ -159,6 +161,7 @@ export function normalizeLoadedGlobalSettings( ), disabledTuiAgents: migratedDisabledTuiAgents, ...migratedAgentYoloDefaults, + ...migratedAgentPermission, claudeAgentTeamsDefaultDisabledMigrated: true, sshRemoteCliControlExistingHostsMigrated: true, openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { diff --git a/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts index f9619ffc4a1..fcee84131e2 100644 --- a/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts +++ b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts @@ -17,6 +17,8 @@ import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cu import { normalizeTerminalLineHeight } from '../../../shared/terminal-line-height-settings' import { migratePortugueseUiLanguageOnLoad } from '../../../shared/portuguese-ui-language-migration' import { migrateAgentYoloDefaults } from '../applying-settings/terminal-settings-migrations' +import { hasTypedAgentPermission } from '../../../shared/tui-agent-permission-choice' +import { migrateAgentPermissionChoice } from '../../../shared/tui-agent-permission-lift' import { normalizeLoadedOnboardingState, normalizeNotificationSettings, @@ -49,6 +51,8 @@ export type PreparedLoadedProfileSettings = { GlobalSettings, 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentYoloDefaultsMigrated' > + /** The same launch profile with permissions moved into the stored choice; this is what's saved. */ + migratedAgentPermission: ReturnType migratedWindowsRuntimeDefault: GlobalSettings['localWindowsRuntimeDefault'] migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] loadedCompactWorktreeCards: boolean @@ -146,8 +150,17 @@ export function prepareLoadedProfileSettings( } const migratedDisabledTuiAgents = normalizeDisabledTuiAgents(parsed.settings?.disabledTuiAgents) const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) + const migratedAgentPermission = migrateAgentPermissionChoice( + { + ...migratedAgentYoloDefaults, + agentPermissionDefault: parsed.settings?.agentPermissionDefault, + agentPermissionOverrides: parsed.settings?.agentPermissionOverrides + }, + process.platform + ) if ( parsed.settings?.agentYoloDefaultsMigrated !== true || + !hasTypedAgentPermission(parsed.settings) || parsed.settings?.agentDefaultArgs?.devin !== migratedAgentYoloDefaults.agentDefaultArgs?.devin || hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || @@ -257,6 +270,7 @@ export function prepareLoadedProfileSettings( stampPrimarySelectionTerminalDefaults, migratedDisabledTuiAgents, migratedAgentYoloDefaults, + migratedAgentPermission, migratedWindowsRuntimeDefault, migratedLocalAccountRuntime, loadedCompactWorktreeCards, diff --git a/src/main/preflight/codex-workspace-relative-path.test.ts b/src/main/preflight/codex-workspace-relative-path.test.ts index 4b9602f7fc0..e0ef8ba0cbe 100644 --- a/src/main/preflight/codex-workspace-relative-path.test.ts +++ b/src/main/preflight/codex-workspace-relative-path.test.ts @@ -58,7 +58,7 @@ describe.skipIf(process.platform === 'win32')('workspace-relative Codex selectio const sources = { resolveCommand: (options?: { pathEnv?: string | null; homePath?: string }) => resolveStructuredAgentCommand('codex', settings, options), - resolveAgentLaunchEnv: () => resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv) + resolveAgentLaunchEnv: () => resolveTuiAgentLaunchEnv('codex', settings) } const environment = createStructuredAgentEnvironmentResolvers(sources) const record: AgentSessionRecord = { diff --git a/src/main/runtime/agent-launch-settings-rollback.ts b/src/main/runtime/agent-launch-settings-rollback.ts index 423541a5826..d1bc3926780 100644 --- a/src/main/runtime/agent-launch-settings-rollback.ts +++ b/src/main/runtime/agent-launch-settings-rollback.ts @@ -35,6 +35,14 @@ export function agentLaunchSettingsRollbackUpdates( Object.is(current.agentDefaultEnv, applied.agentDefaultEnv) ? { agentDefaultEnv: before.agentDefaultEnv ?? {} } : {}), + ...(Object.hasOwn(updates, 'agentPermissionDefault') && + Object.is(current.agentPermissionDefault, applied.agentPermissionDefault) + ? { agentPermissionDefault: before.agentPermissionDefault } + : {}), + ...(Object.hasOwn(updates, 'agentPermissionOverrides') && + Object.is(current.agentPermissionOverrides, applied.agentPermissionOverrides) + ? { agentPermissionOverrides: before.agentPermissionOverrides ?? {} } + : {}), ...(Object.hasOwn(updates, 'agentNativeChatPermissionModes') && Object.is(current.agentNativeChatPermissionModes, applied.agentNativeChatPermissionModes) ? { agentNativeChatPermissionModes: before.agentNativeChatPermissionModes ?? {} } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 246b013a573..9e7648b670b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -72,7 +72,7 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { (this.authoritativeWindowId !== null && this.authoritativeWindowId !== HEADLESS_RUNTIME_WINDOW_ID && this.store?.getUI?.().sidebarOpen !== false), - hostLabelOverrides: () => getHostDisplayLabelOverrides(this.getClientSettings()) + hostLabelOverrides: () => getHostDisplayLabelOverrides(this.requireStore().getSettings()) }) : null // Switch point: the phone's icon source. diff --git a/src/main/runtime/rpc/methods/agent-hooks.test.ts b/src/main/runtime/rpc/methods/agent-hooks.test.ts index f78e7709d6e..b9e11bedaa3 100644 --- a/src/main/runtime/rpc/methods/agent-hooks.test.ts +++ b/src/main/runtime/rpc/methods/agent-hooks.test.ts @@ -33,11 +33,14 @@ function prepareMethod() { } function runtimeWithSettings(enabled = true, disabledTuiAgents: string[] = []): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook handler reads only requireStore/getSettings from this runtime fixture. return { - getClientSettings: vi.fn(() => ({ - agentStatusHooksEnabled: enabled, - disabledTuiAgents - })) + requireStore: () => ({ + getSettings: () => ({ + agentStatusHooksEnabled: enabled, + disabledTuiAgents + }) + }) } as unknown as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts index 388ba3736b7..5ed586f569b 100644 --- a/src/main/runtime/rpc/methods/agent-hooks.ts +++ b/src/main/runtime/rpc/methods/agent-hooks.ts @@ -18,7 +18,10 @@ export const AGENT_HOOK_METHODS = [ ORCA_CODEX_HOME: params.orcaCodexHome, WSL_DISTRO_NAME: params.wslDistro }, - hooksEnabled: isAgentStatusHooksEnabledForAgent(runtime.getClientSettings(), 'codex') + hooksEnabled: isAgentStatusHooksEnabledForAgent( + runtime.requireStore().getSettings(), + 'codex' + ) }) } }) diff --git a/src/main/runtime/rpc/methods/agent-launch-settings-work.test.ts b/src/main/runtime/rpc/methods/agent-launch-settings-work.test.ts new file mode 100644 index 00000000000..d5f911c31e3 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-settings-work.test.ts @@ -0,0 +1,198 @@ +import '../unused-default-rpc-methods.test-fixture' +import { afterEach, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RuntimeStore } from '../../runtime-store-contract' +import { RuntimeRpcPairing } from '../../runtime-rpc/runtime-rpc-pairing' +import { REPO_METHODS } from './repo' +import { AGENT_HOOK_METHODS } from './agent-hooks' +import { CLIENT_UI_METHODS } from './client-ui' +import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import * as updatesModule from '../../../../shared/agent-launch-permission-updates' +import * as launchProfile from '../../../../shared/tui-agent-launch-defaults' +import * as shell from '../../../../shared/tui-agent-startup-shell' +import { + measureSettingsWork, + expectSettingsWorkScale, + createGlobalSettingsFixture +} from '../../../../shared/global-settings-test-fixture' + +vi.mock('../../../codex/managed-wsl-home-shell-preflight', () => ({ + prepareManagedWslCodexHomeBeforeShellLaunch: async ({ + hooksEnabled + }: { + hooksEnabled: boolean + }) => (hooksEnabled ? { agent: 'codex', state: 'installed' } : null) +})) +vi.mock('../../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true })) +afterEach(() => vi.restoreAllMocks()) + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +it.each([20, 200])('projects %s repos without launch work', async (n) => { + const settings = createGlobalSettingsFixture({ + hostSettingOverrides: { 'runtime:alpha': { displayLabel: ' Label ' } }, + worktreeVisibilityDefaults: { external: 'show' }, + agentPermissionDefault: 'ask', + agentDefaultArgs: { claude: '--model opus '.repeat(n) }, + agentDefaultEnv: { + claude: Object.fromEntries(Array.from({ length: n }, (_, i) => [`K${i}`, 'private'])) + } + }) + const ingressReceipts: ReturnType['work'][] = [] + const legacy = updatesModule.legacyLaunchTextUpdates + vi.spyOn(updatesModule, 'legacyLaunchTextUpdates').mockImplementation((...args) => { + const receipt = measureSettingsWork(() => legacy(...args)) + ingressReceipts.push(receipt.work) + return receipt.value + }) + const composed = vi.spyOn(launchProfile, 'composeAgentLaunchProfile') + const tokenized = vi.spyOn(shell, 'tokenizeStartupCommand') + const store: Pick = { + getSettings: () => settings, + updateSettings: (updates) => Object.assign(settings, updates), + runDurableMutation: async (mutate) => mutate().value + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these settings and offline-label paths use only the supplied store methods. + const host = new OrcaRuntimeService(store as unknown as RuntimeStore) + vi.spyOn(host, 'configureNotificationDismissalStore').mockImplementation(() => {}) + vi.spyOn(host.pairedServerRows!, 'attach').mockReturnValue(() => {}) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: invoking captured label callbacks; only relay retirement subscribes, no transport is used. + const relayHosts = { onEnvironmentRetired: () => () => {} } as unknown as NonNullable< + Parameters[0] + > + const labels = new RuntimeRpcPairing({ + runtime: host, + userDataPath: '/unused/pr31315', + methods: [] + }) + labels.setMobileDesktopRelayHosts(relayHosts) + expect(host.pairedServerRows?.['options'].hostLabelOverrides().get('runtime:alpha')).toBe('Label') + expect( + labels['mobileRelayHostCatalog']?.['options'].hostLabelOverrides().get('runtime:alpha') + ).toBe('Label') + labels.setMobileDesktopRelayHosts(null) + vi.spyOn(host, 'enrichMissingRepoGitRemoteIdentities').mockResolvedValue(undefined) + vi.spyOn(host, 'listVisibleRepos').mockReturnValue( + Array.from({ length: n }, (_, i) => ({ + id: `repo-${i}`, + path: '/repo', + displayName: `Repo ${i}`, + badgeColor: 'gray', + addedAt: 0, + externalWorktreeVisibilityLegacy: false + })) + ) + const dispatcher = new RpcDispatcher({ + runtime: host, + methods: [...REPO_METHODS, ...CLIENT_UI_METHODS, ...AGENT_HOOK_METHODS] + }) + const legacyReplies: string[] = [] + const currentReplies: string[] = [] + + await dispatcher.dispatchStreaming(makeRequest('repo.list'), (reply) => legacyReplies.push(reply)) + await dispatcher.dispatchStreaming( + makeRequest('repo.list'), + (reply) => currentReplies.push(reply), + { clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] } + ) + + expect(JSON.parse(legacyReplies[0]!).result.repos).toHaveLength(n) + expect(JSON.parse(legacyReplies[0]!).result.repos[0].externalWorktreeVisibility).toBe('show') + delete settings.worktreeVisibilityDefaults + legacyReplies.length = 0 + await dispatcher.dispatchStreaming(makeRequest('repo.list'), (reply) => legacyReplies.push(reply)) + expect( + JSON.parse(legacyReplies[0]!).result.repos.map( + (repo: { externalWorktreeVisibility: string }) => repo.externalWorktreeVisibility + ) + ).toEqual(Array(n).fill('hide')) + settings.agentStatusHooksEnabled = true + settings.disabledTuiAgents = [] + expect( + await dispatcher.dispatch( + makeRequest('agentHooks.prepareCodexForWslPane', { + codexHome: '/home/qa/codex', + orcaCodexHome: '/home/qa/codex', + wslDistro: 'Ubuntu' + }) + ) + ).toMatchObject({ ok: true, result: { state: 'installed' } }) + expect(composed).not.toHaveBeenCalled() + expect(tokenized).not.toHaveBeenCalled() + const originalArgs = settings.agentDefaultArgs?.claude + if (originalArgs === undefined) { + throw new Error('Missing Claude Arguments fixture') + } + const published = launchProfile.composeAgentLaunchProfile(settings) + for (const [row, params] of Object.entries({ + 'settings.get': undefined, + 'settings.update': { machineName: 'Host' }, + 'settings.updatePRBotAuthorOverride': { author: 'test', isBot: true }, + 'settings.getAgentLaunch': undefined, + 'settings.mutateAgentLaunch': { type: 'permission', agent: 'claude', choice: 'ask' }, + 'settings.update legacy': { + agentDefaultArgs: { + ...published.agentDefaultArgs, + claude: `--permission-mode auto ${originalArgs}` + }, + agentDefaultEnv: { ...published.agentDefaultEnv, goose: { KEEP: 'new' } } + } + })) { + const [method, ingress] = row.split(' ') + composed.mockClear() + tokenized.mockClear() + ingressReceipts.length = 0 + const { value, work } = measureSettingsWork(() => + dispatcher.dispatch(makeRequest(method, params)) + ) + const response = await value + const ingressWork = ingressReceipts.at(-1) + const currentArgs = settings.agentDefaultArgs?.claude + if (currentArgs === undefined) { + throw new Error('Missing Claude Arguments after dispatch') + } + expect(response.ok).toBe(true) + const text = JSON.stringify(response) + expect(text).toContain(ingress ? '--permission-mode auto' : '--permission-mode default') + expect(composed).toHaveBeenCalledTimes(ingress ? 2 : 1) + expect( + tokenized.mock.calls.reduce((bytes, [text]) => bytes + text.length, 0) + ).toBeLessThanOrEqual((ingress ? 60 : 6) * currentArgs.length + 12000) + // Each profile gets14n; three recipes with W<=6 get2(W+1)B construction bytes each. + const bytes = + text.length + + work.bytes + + (ingressWork ? 42 * Math.max(0, ...ingressWork.inputs.map((input) => input.length)) : 0), + visits = work.entries + work.visits + 14 * n * (ingress ? 2 : 1), + comparisons = work.comparisons + expect(work.entries - (ingressWork?.entries ?? 0) + 14 * n).toBeLessThanOrEqual(40 * n + 8000) + if (ingressWork) { + // One original profile plus the three declared Claude recipes' bounded proofs. + const proof = 3 * 3 * (2 + 10 * (6 + 1)) * (2 * n + 6) + 3 * 4096 + expect(ingressWork.entries + ingressWork.visits + 14 * n).toBeLessThanOrEqual( + 40 * n + 8000 + proof + ) + } + expect(comparisons).toBeLessThanOrEqual(4 * (n + 1) * Math.ceil(Math.log2(n + 1))) + expectSettingsWorkScale(`response-${method}-${!!ingress}`, n, [visits, bytes]) + const namesOnly = method.includes('AgentLaunch') + const env = settings.agentDefaultEnv + if (!env?.claude) { + throw new Error('Missing Claude Environment after dispatch') + } + const result = JSON.parse(text).result.settings + expect(result[namesOnly ? 'environmentNames' : 'agentDefaultEnv'].claude).toEqual( + namesOnly ? Object.keys(env.claude).sort() : env.claude + ) + expect(text.includes('private')).toBe(!namesOnly) + if (ingress) { + expect(settings.agentPermissionOverrides?.claude).toBe('auto') + expect(env.goose).toEqual({ KEEP: 'new' }) + } + } + expect(JSON.parse(currentReplies[0]!).result.repos[0].externalWorktreeVisibility).toBeUndefined() +}) diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index ea4edaa4a61..9719ba34cea 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -110,35 +110,6 @@ describe('repo RPC methods', () => { expect(inProcessResponse).toMatchObject({ ok: true, result }) }) - it('projects inherited visibility for old clients but preserves inheritance for capable clients', async () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: repo.list only calls the fixture methods defined below. - const runtime = { - getRuntimeId: () => 'test-runtime', - enrichMissingRepoGitRemoteIdentities: vi.fn(), - listVisibleRepos: () => [ - { id: 'repo-1', path: '/repo', externalWorktreeVisibilityLegacy: false } - ], - getClientSettings: () => ({ worktreeVisibilityDefaults: { external: 'show' } }) - } as unknown as OrcaRuntimeService - const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) - const legacyReplies: string[] = [] - const currentReplies: string[] = [] - - await dispatcher.dispatchStreaming(makeRequest('repo.list'), (reply) => - legacyReplies.push(reply) - ) - await dispatcher.dispatchStreaming( - makeRequest('repo.list'), - (reply) => currentReplies.push(reply), - { clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] } - ) - - expect(JSON.parse(legacyReplies[0]!).result.repos[0].externalWorktreeVisibility).toBe('show') - expect( - JSON.parse(currentReplies[0]!).result.repos[0].externalWorktreeVisibility - ).toBeUndefined() - }) - it('projects inherited visibility on repo mutation responses for old clients', async () => { const repo = { id: 'repo-1', @@ -146,10 +117,13 @@ describe('repo RPC methods', () => { kind: 'git' as const, externalWorktreeVisibilityLegacy: false } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: repo.add, visibility projection and response envelope use only these supplied runtime methods, including raw settings. const runtime = { getRuntimeId: () => 'test-runtime', addRepo: vi.fn().mockResolvedValue(repo), - getClientSettings: () => ({ worktreeVisibilityDefaults: { external: 'show' } }) + requireStore: () => ({ + getSettings: () => ({ worktreeVisibilityDefaults: { external: 'show' } }) + }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) diff --git a/src/main/runtime/rpc/repo-visibility-projection.ts b/src/main/runtime/rpc/repo-visibility-projection.ts index 05c0db0b715..4a03b50ac06 100644 --- a/src/main/runtime/rpc/repo-visibility-projection.ts +++ b/src/main/runtime/rpc/repo-visibility-projection.ts @@ -16,12 +16,13 @@ export function projectRepoVisibilityForClient(repo: Repo, context: RepoProjecti ) { return repo } + const settings = context.runtime.requireStore?.().getSettings() return { ...repo, externalWorktreeVisibility: effectiveExternalWorktreeVisibility( repo, isLegacyRepoForExternalWorktreeVisibility(repo), - context.runtime.getClientSettings?.().worktreeVisibilityDefaults + settings ? (settings.worktreeVisibilityDefaults ?? { external: 'hide' }) : undefined ) } } diff --git a/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts b/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts index cb486d77a3b..33611437c72 100644 --- a/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts +++ b/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts @@ -1,3 +1,10 @@ +import { Store } from '../persistence/loading-store/store' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../shared/tui-agent-launch-defaults' +import { newChatPermissionModeForSettings } from '../../shared/agent-chat-permission-mode' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { deferred, @@ -24,6 +31,98 @@ vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ })) describe('durable execution-host launch settings', () => { + it('persists host carrier edits and reloads the same terminal and new-chat intent', async () => { + const { store, readState } = await fixture() + const controller = new RuntimeClientSettingsController(store) + await controller.mutateAgentLaunch({ type: 'permission', choice: 'ask' }) + await controller.mutateAgentLaunch({ type: 'permission', choice: 'auto' }) + await controller.mutateAgentLaunch({ + type: 'user-arguments', + agent: 'claude', + value: '--append-system-prompt --dangerously-skip-permissions' + }) + await controller.mutateAgentLaunch({ + type: 'user-arguments', + agent: 'codex', + value: '--config=sandbox_mode=read-only' + }) + await controller.mutateAgentLaunch({ + type: 'permission', + agent: 'goose', + choice: 'full-access' + }) + await controller.update({ + agentDefaultArgs: { ...controller.get().agentDefaultArgs, goose: '--model foo' } + }) + await store.flushPendingOrThrowAsync() + const saved = readState() + const reloaded = new Store({ + serializedState: JSON.stringify(saved), + dataFile: join(saved.settings.workspaceDir, 'permission-reload.json') + }) + try { + const settings = reloaded.getSettings() + expect(resolveTuiAgentLaunchArgs('claude', settings)).toBe( + '--permission-mode auto --append-system-prompt --dangerously-skip-permissions' + ) + expect(resolveTuiAgentLaunchArgs('codex', settings)).toBe('--config=sandbox_mode=read-only') + expect(resolveTuiAgentLaunchEnv('goose', settings)).toMatchObject({ GOOSE_MODE: 'auto' }) + expect(newChatPermissionModeForSettings('claude', settings)).toBe('auto') + const loadedController = new RuntimeClientSettingsController(reloaded) + expect(loadedController.get().agentDefaultArgs?.codex).toBe('--config=sandbox_mode=read-only') + for (const agent of ['antigravity', 'opencode', 'opencode2', 'omp']) { + expect(newChatPermissionModeForSettings(agent, reloaded.getSettings())).toBe('ask') + expect(resolveTuiAgentLaunchArgs(agent, reloaded.getSettings())).toBe('') + } + } finally { + await reloaded.freezeWritesAsync() + } + }) + + it('replaces legacy maps, preserving absent carriers and clearing omitted user values', async () => { + const { store, readState } = await fixture() + store.updateSettings({ + agentPermissionDefault: 'own', + agentPermissionOverrides: { claude: 'full-access', goose: 'full-access', opencode: 'ask' }, + agentDefaultArgs: { claude: '--model opus', codex: '--model old' }, + agentDefaultEnv: { + claude: { ANTHROPIC_BASE_URL: 'http://old-proxy' }, + goose: { KEEP: 'custom' } + } + }) + const controller = new RuntimeClientSettingsController(store) + await controller.update({ agentDefaultArgs: { codex: '--model new' } }) + expect(store.getSettings()).toMatchObject({ + agentDefaultArgs: { codex: '--model new' }, + agentDefaultEnv: { + claude: { ANTHROPIC_BASE_URL: 'http://old-proxy' }, + goose: { KEEP: 'custom' } + }, + agentPermissionOverrides: { claude: 'own', goose: 'full-access', opencode: 'ask' } + }) + expect(store.getSettings().agentDefaultArgs).not.toHaveProperty('claude') + await controller.update({ agentDefaultEnv: { goose: { GOOSE_MODE: 'auto', KEEP: 'new' } } }) + expect(store.getSettings().agentDefaultEnv).toEqual({ goose: { KEEP: 'new' } }) + expect(store.getSettings().agentDefaultArgs).toEqual({ codex: '--model new' }) + expect(resolveTuiAgentLaunchEnv('goose', store.getSettings())).toEqual({ + GOOSE_MODE: 'auto', + KEEP: 'new' + }) + await controller.update({ agentDefaultArgs: {}, agentDefaultEnv: {} }) + await store.flushPendingOrThrowAsync() + const saved = readState().settings + expect(saved.agentDefaultArgs).toEqual({}) + expect(saved.agentDefaultEnv).toEqual({}) + expect(saved.agentPermissionOverrides).toMatchObject({ + claude: 'own', + goose: 'own', + opencode: 'ask' + }) + expect(controller.get().agentDefaultArgs?.claude).toBe('') + expect(controller.get().agentDefaultEnv?.goose).toEqual({}) + expect(newChatPermissionModeForSettings('opencode', store.getSettings())).toBe('ask') + }) + it('withdraws cached plugin skills when saved launch arguments disable plugins', async () => { const { store } = await fixture() const controller = new RuntimeClientSettingsController(store) @@ -280,7 +379,7 @@ describe('durable execution-host launch settings', () => { }) expect(readState().settings.agentNativeChatPermissionModes).toEqual({ codex: 'auto' }) expect(readState().settings.nativeChatPermissionMode).toBe(beforeNative) - expect(readState().settings.agentDefaultArgs.codex).toBe('--approve-for-me') + expect(readState().settings.agentPermissionOverrides).toMatchObject({ codex: 'auto' }) }) it('recomputes queued intents after a failed write instead of persisting its stale map', async () => { diff --git a/src/main/runtime/runtime-client-agent-launch-settings.test.ts b/src/main/runtime/runtime-client-agent-launch-settings.test.ts index bafbbc7f17f..a9d106518a8 100644 --- a/src/main/runtime/runtime-client-agent-launch-settings.test.ts +++ b/src/main/runtime/runtime-client-agent-launch-settings.test.ts @@ -3,7 +3,10 @@ import { RuntimeClientSettingsController } from './runtime-client-settings' import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' import type { GlobalSettings } from '../../shared/global-settings-types' import { structuredAgentConfiguredArgs } from '../native-chat/structured-agent-configured-args' -import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../shared/tui-agent-launch-defaults' import { agentLaunchSettingsMutationUpdates } from '../../shared/agent-launch-settings' import { AgentLaunchSettingsMutation } from '../../shared/rpc-contract/agent-launch-settings-params' import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc/runtime-rpc-mobile-method-allowlist' @@ -71,7 +74,7 @@ describe('execution-host agent launch settings', () => { expect(host.agentCmdOverrides?.claude).toBe('/host/claude-wrapper') expect(structuredAgentConfiguredArgs('claude', host)).toEqual(['--host-marker']) - expect(resolveTuiAgentLaunchEnv('claude', host.agentDefaultEnv)).toEqual({ + expect(resolveTuiAgentLaunchEnv('claude', host)).toEqual({ API_KEY: 'host-secret', KEEP: 'host-only', MARKER: 'host-marker' @@ -112,13 +115,13 @@ describe('execution-host agent launch settings', () => { it('changes terminal permissions on the host without roundtripping unrelated secrets', async () => { const { controller, host } = fixture() - host.agentDefaultArgs = { claude: '--dangerously-skip-permissions' } + host.agentDefaultArgs = { claude: '' } await controller.mutateAgentLaunch({ type: 'permissions', syncNativeChat: true, mode: 'manual' }) - expect(host.agentDefaultArgs?.claude).toBe('') + expect(resolveTuiAgentLaunchArgs('claude', host)).toBe('') expect(host.nativeChatPermissionMode).toBe('ask') expect(host.agentDefaultEnv?.claude).toEqual({ API_KEY: 'host-secret', KEEP: 'host-only' }) expect(JSON.stringify(controller.getAgentLaunch())).not.toContain('host-only') @@ -129,7 +132,7 @@ describe('execution-host agent launch settings', () => { const beforeNative = host.nativeChatPermissionMode host.agentDefaultArgs = { claude: '', codex: '' } await controller.mutateAgentLaunch({ type: 'permissions', syncNativeChat: true, mode: 'auto' }) - expect(host.agentDefaultArgs).toMatchObject({ + expect(controller.get().agentDefaultArgs).toMatchObject({ claude: '--permission-mode auto', codex: '--approve-for-me' }) diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 8c668eeb1cd..efc0b8eb302 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -26,6 +26,8 @@ import { type AgentLaunchSettings, type AgentLaunchSettingsMutation } from '../../shared/agent-launch-settings' +import { legacyLaunchTextUpdates } from '../../shared/agent-launch-permission-updates' +import { composeAgentLaunchProfile } from '../../shared/tui-agent-launch-defaults' import { agentLaunchSettingsRollbackUpdates } from './agent-launch-settings-rollback' import type { RuntimeClientSettings, @@ -51,13 +53,16 @@ export class RuntimeClientSettingsController { throw new Error('runtime_unavailable') } const settings = this.store.getSettings() + // Why composed: paired clients (the phone's resume, legacy-host chat checks) read the flag in + // this text as the permission, as every client before the stored choice did. + const launchProfile = composeAgentLaunchProfile(settings, process.platform) return { defaultTuiAgent: settings.defaultTuiAgent ?? null, newWorkspaceSetupOnly: false, disabledTuiAgents: settings.disabledTuiAgents ?? [], agentCmdOverrides: settings.agentCmdOverrides ?? {}, - agentDefaultArgs: settings.agentDefaultArgs ?? {}, - agentDefaultEnv: settings.agentDefaultEnv ?? {}, + agentDefaultArgs: launchProfile.agentDefaultArgs, + agentDefaultEnv: launchProfile.agentDefaultEnv, agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, // Why projected: mobile's terminal Copy honours this, and a host predating // the setting sends no key, which the client reads as on (#19770). @@ -114,6 +119,17 @@ export class RuntimeClientSettingsController { this.store.updateSettings( { ...updates, + // An older client writes Arguments as it reads them, flag included. + ...(updates.agentDefaultArgs !== undefined || updates.agentDefaultEnv !== undefined + ? legacyLaunchTextUpdates( + beforeSettings, + { + agentDefaultArgs: updates.agentDefaultArgs, + agentDefaultEnv: updates.agentDefaultEnv + }, + process.platform + ) + : {}), ...(updates.nativeChatPermissionMode !== undefined ? { agentNativeChatPermissionModes: {} } : {}), @@ -147,7 +163,7 @@ export class RuntimeClientSettingsController { if (!this.store?.getSettings) { throw new Error('runtime_unavailable') } - return projectAgentLaunchSettings(this.store.getSettings()) + return projectAgentLaunchSettings(this.store.getSettings(), process.platform) } async mutateAgentLaunch(mutation: AgentLaunchSettingsMutation): Promise { @@ -163,7 +179,7 @@ export class RuntimeClientSettingsController { const applied = store.getSettings() return { value: { - settings: projectAgentLaunchSettings(applied), + settings: projectAgentLaunchSettings(applied, process.platform), reconcileHooks: updates.disabledTuiAgents !== undefined && !haveSameDisabledTuiAgents(before.disabledTuiAgents, applied.disabledTuiAgents) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index ea0eeea9c98..d72c41ae5c7 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -109,7 +109,8 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { ? new MobileRelayHostCatalog({ hosts, serverRows: pairedServerRows ?? new OnDemandServerWorktreeRows(hosts), - hostLabelOverrides: () => getHostDisplayLabelOverrides(this.runtime.getClientSettings()), + hostLabelOverrides: () => + getHostDisplayLabelOverrides(this.runtime.requireStore().getSettings()), getWorkspaceSession: (hostId) => this.runtime.getWorkspaceSessionForHost(hostId) }) : null diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 1b41e1fc91e..3a66b5b2169 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -127,6 +127,8 @@ export type RuntimeStore = { agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] agentDefaultEnv?: GlobalSettings['agentDefaultEnv'] + agentPermissionDefault?: GlobalSettings['agentPermissionDefault'] + agentPermissionOverrides?: GlobalSettings['agentPermissionOverrides'] terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] // Read by the launch-line carry rule to name the shell a local line is typed into. terminalDefaultShell?: GlobalSettings['terminalDefaultShell'] diff --git a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts index cd2748e1cc3..5a253efde33 100644 --- a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts +++ b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts @@ -141,8 +141,8 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O agentArgs: request.agentArgs !== undefined ? request.agentArgs - : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + : resolveTuiAgentLaunchArgs(request.agent, settings), + agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings), ompResumeFilePath: request.ompResumeFilePath, sessionOptions: this.toAgentSessionOptions(request.launchPreferences), sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences), diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 43f6f2da998..8a90895dd54 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -97,7 +97,7 @@ export async function resolveHostStructuredAgentLaunchEnvironment( ): Promise> { return { ...(await resolveHostAgentBaseEnvironment(settings)), - ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) + ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings) : {}) } } diff --git a/src/main/runtime/workspaces/workspace-process-listing.ts b/src/main/runtime/workspaces/workspace-process-listing.ts index bb2b02f6930..01942b846b8 100644 --- a/src/main/runtime/workspaces/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/workspace-process-listing.ts @@ -174,7 +174,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr settings: Pick ) { return { - ...resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + ...resolveTuiAgentLaunchEnv(agent, settings), ...this.accounts.hostLaunchEnvFor(agent) } } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index e6d683fc29f..3680effce80 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -126,7 +126,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { prepareCodexCatalogProbeHome: (homePath) => state.codexRuntimeHome?.prepareHostCodexHomeForReadOnlyAppServer( homePath, - resolveTuiAgentLaunchEnv('codex', store.getSettings().agentDefaultEnv) + resolveTuiAgentLaunchEnv('codex', store.getSettings()) ), orchestrationEnvironmentTransport, workspaceSleepPlatform: { 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 3bd647df2c2..5d6da268361 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -9,7 +9,7 @@ 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 { applyAllAgentsPermission } from '../../../../shared/tui-agent-permission-choice' import type { AgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode' import type { StepId, StepNumber } from './use-onboarding-flow-types' @@ -184,11 +184,10 @@ export function usePersistCurrentStep({ const defaultTuiAgent = selectedAgentOrBlank(selectedAgent) await updateSettings({ defaultTuiAgent, - ...applyAgentPermissionMode({ - mode: yoloPermissions ? 'yolo' : 'manual', - agentDefaultArgs: settings.agentDefaultArgs, - agentDefaultEnv: settings.agentDefaultEnv - }), + // Off adds no flag, as it always has: each agent's own settings apply. + ...(yoloPermissionsChosen.current + ? applyAllAgentsPermission(settings, yoloPermissions ? 'full-access' : 'own') + : {}), ...onboardingChatPermissionSetting( settings.nativeChatPermissionMode, yoloPermissions, diff --git a/src/renderer/src/components/onboarding/use-onboarding-yolo-permissions.ts b/src/renderer/src/components/onboarding/use-onboarding-yolo-permissions.ts index 35849d5ab34..3491e60d5d2 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-yolo-permissions.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-yolo-permissions.ts @@ -1,14 +1,9 @@ import { useCallback, useRef, useState } from 'react' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { resolveAgentPermissionModeSummary } from '../../../../shared/tui-agent-permissions' function savedYoloPermissions(settings: GlobalSettings | null | undefined): boolean { - return ( - resolveAgentPermissionModeSummary({ - agentDefaultArgs: settings?.agentDefaultArgs, - agentDefaultEnv: settings?.agentDefaultEnv - }) !== 'manual' - ) + const choice = settings?.agentPermissionDefault ?? 'full-access' + return choice === 'full-access' || choice === 'auto' } /** Onboarding's Yolo switch: follows saved settings as they hydrate, until the person flips it. */ diff --git a/src/renderer/src/components/settings/AgentCatalogRow.tsx b/src/renderer/src/components/settings/AgentCatalogRow.tsx index e7bfb99c070..865a7e788aa 100644 --- a/src/renderer/src/components/settings/AgentCatalogRow.tsx +++ b/src/renderer/src/components/settings/AgentCatalogRow.tsx @@ -20,6 +20,12 @@ import { AgentDefaultArgsInput, AgentDefaultEnvInput } from './AgentLaunchDefaultsEditor' +import { + AgentPermissionArgsChip, + agentPermissionChoiceLabel, + AgentPermissionMenu, + type AgentPermissionChoiceRow +} from './AgentPermissionChoiceControls' type AgentAvailability = 'enabled' | 'disabled' @@ -68,6 +74,7 @@ export type AgentCatalogRowProps = { defaultCmd: string defaultArgs: string defaultEnv: Record + hasDefaultEnv?: boolean isDetected: boolean isEnabled: boolean isDefault: boolean @@ -78,11 +85,15 @@ export type AgentCatalogRowProps = { onSetEnabled: (enabled: boolean) => void onSaveOverride: (value: string) => void | Promise onSaveArgs: (value: string) => void | Promise - onSaveEnv: (value: Record) => void + onSaveEnv: (value: Record) => void | Promise sessionSourceHome?: AgentSessionSourceHomeControl environmentNames?: readonly string[] /** Absent for agents without a configurable permission mode on this host. */ permission?: AgentRowPermission + /** On hosts that store the permission apart from Arguments; replaces `permission`. */ + permissionChoice?: AgentPermissionChoiceRow + /** The text a terminal launch gets, when it differs from the editable Arguments. */ + launchArgs?: string environmentEditor?: React.ReactNode } @@ -93,6 +104,7 @@ export function AgentCatalogRow({ defaultCmd, defaultArgs, defaultEnv, + hasDefaultEnv, isDetected, isEnabled, isDefault, @@ -107,7 +119,9 @@ export function AgentCatalogRow({ sessionSourceHome, environmentNames, environmentEditor, - permission + permission, + permissionChoice, + launchArgs = argsOverride }: AgentCatalogRowProps): React.JSX.Element { const envSummary = environmentNames?.join(' ') ?? stringifyAgentDefaultEnvDraft(envOverride) const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv) @@ -132,7 +146,7 @@ export function AgentCatalogRow({ )?.focus() }) } - const expandable = isDetected || permission !== undefined + const expandable = isDetected || permission !== undefined || permissionChoice !== undefined return (
@@ -158,12 +172,25 @@ export function AgentCatalogRow({ ) : ( defaultCmd )} - {argsOverride && {argsOverride}} + {launchArgs && {launchArgs}} {envSummary && {envSummary}}
+ {permissionChoice?.source === 'agent' && ( +
+ +
+ )} {permission && permission.mode !== permission.expectedMode && ( // Right-aligned against the controls so badges line up without reserving width on every row.
@@ -246,6 +273,11 @@ export function AgentCatalogRow({ {expandable && cmdOpen && (
+ {permissionChoice && ( +
+ +
+ )} {permission && (
permissionChoice.onSelect('own')} + /> + ) : undefined + } + note={ + permissionChoice?.argsSetPermission + ? translate( + 'settings.agents.permissionArgsTerminalOnly', + 'These arguments may control {{agent}}’s terminal permissions, so Orca adds no flag. Chats use the Permissions choice above.', + { agent: label } + ) + : undefined + } />
- {environmentEditor ?? - ((defaultEnvSummary || envSummary) && ( -
- -
- ))} + {environmentEditor ?? ( +
+ +
+ )} {sessionSourceHome && (
{ }) const save = vi.fn().mockResolvedValue(undefined).mockReturnValueOnce(blurSave) const user = userEvent.setup() - render( + const defaults = { defaultArgs: '--default', argsOverride: '--default', onSaveArgs: save } + const view = render( field === 'arguments' ? ( - + ) : ( { ) const input = screen.getByRole('textbox') await user.click(input) + await user.type(input, ' dirty') await user.click(screen.getByRole('button', { name: 'Reset' })) - expect(save).toHaveBeenNthCalledWith(1, field === 'arguments' ? '--custom' : '/host/custom') + expect(save).toHaveBeenNthCalledWith( + 1, + field === 'arguments' ? '--custom dirty' : '/host/custom dirty' + ) finishBlur() await waitFor(() => expect(save).toHaveBeenLastCalledWith(field === 'arguments' ? '--default' : '') ) + if (field === 'arguments') { + view.rerender() + } await waitFor(() => expect(input).toHaveProperty('value', field === 'arguments' ? '--default' : 'claude') ) @@ -51,22 +55,26 @@ describe('launch field reset ordering', () => { async (result) => { let finishBlur!: () => void let rejectBlur!: (error: Error) => void + let rejectReset!: (error: Error) => void let finishReset!: () => void const blurSave = new Promise((resolve, reject) => { finishBlur = resolve rejectBlur = reject }) - const resetSave = new Promise((resolve) => { + const resetSave = new Promise((resolve, reject) => { + rejectReset = reject finishReset = resolve }) const save = vi.fn().mockReturnValueOnce(blurSave).mockReturnValueOnce(resetSave) const user = userEvent.setup() - render( - + const defaults = { defaultArgs: '--default', onSaveArgs: save } + const editor = (argsOverride: string) => ( + ) + const view = render(editor('--custom')) const input = screen.getByRole('textbox') await user.click(input) - await user.keyboard('{Enter}') + await user.type(input, ' dirty') await user.click(screen.getByRole('button', { name: 'Reset' })) await user.click(screen.getByRole('button', { name: 'Reset' })) expect(save).toHaveBeenCalledTimes(1) @@ -77,10 +85,22 @@ describe('launch field reset ordering', () => { } await waitFor(() => expect(save).toHaveBeenNthCalledWith(2, '--default')) expect(input).toHaveProperty('readOnly', true) - finishReset() + view.rerender(editor('--custom dirty')) + await user.click(input) + await user.tab() + view.rerender(editor('--default')) + expect(input).toHaveProperty('value', '--custom dirty') + if (result === 'rejected') { + rejectReset(new Error('unknown acknowledgement')) + await waitFor(() => expect(input).toHaveProperty('readOnly', false)) + expect(input).toHaveProperty('value', '--custom dirty') + await user.click(screen.getByRole('button', { name: 'Reset' })) + } else { + finishReset() + } await waitFor(() => expect(input).toHaveProperty('readOnly', false)) expect(input).toHaveProperty('value', '--default') - expect(save).toHaveBeenCalledTimes(2) + expect(save).toHaveBeenCalledTimes(result === 'rejected' ? 3 : 2) } ) }) diff --git a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx index 1691aff673c..933e0e7ef14 100644 --- a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx +++ b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx @@ -84,17 +84,34 @@ export function AgentCommandOverrideInput({ export function AgentDefaultArgsInput({ defaultArgs, argsOverride, - onSaveArgs + onSaveArgs, + permissionChip, + note }: { defaultArgs: string argsOverride: string onSaveArgs: (value: string) => void | Promise + /** The permission flag terminals add ahead of the text, drawn just before the box. */ + permissionChip?: React.ReactNode + note?: string }): React.JSX.Element { - const [argsDraft, setArgsDraft] = useState(argsOverride) + const [draft, setDraft] = useState(null) + const argsDraft = draft ?? argsOverride const { pending, write } = useAgentLaunchFieldWrite() const commitArgs = (): void => { - const value = argsDraft.trim() - write(value, () => onSaveArgs(value)) + if (pending || draft === null) { + return + } + const value = draft.trim() + if (value === argsOverride) { + setDraft(null) + return + } + write( + value, + () => onSaveArgs(value), + () => setDraft((current) => (current === draft ? null : current)) + ) } return ( @@ -103,10 +120,11 @@ export function AgentDefaultArgsInput({ {translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
+ {permissionChip} setArgsDraft(event.target.value)} + onChange={(event) => setDraft(event.target.value)} onBlur={commitArgs} onKeyDown={(event) => { if (event.key === 'Enter') { @@ -114,8 +132,7 @@ export function AgentDefaultArgsInput({ event.currentTarget.blur() } if (event.key === 'Escape') { - setArgsDraft(argsOverride) - event.currentTarget.blur() + setDraft(null) } }} placeholder={ @@ -125,16 +142,17 @@ export function AgentDefaultArgsInput({ spellCheck={false} className="h-7 flex-1 font-mono text-xs" /> - {argsOverride !== defaultArgs && ( + {(argsOverride !== defaultArgs || argsDraft !== defaultArgs) && ( )}
+ {note ?

{note}

: null}
) } export function AgentDefaultEnvInput({ + showEmpty = false, defaultEnv, envOverride, onSaveEnv }: { + showEmpty?: boolean defaultEnv: Record envOverride: Record - onSaveEnv: (value: Record) => void -}): React.JSX.Element { + onSaveEnv: (value: Record) => void | Promise +}): React.JSX.Element | null { const defaultEnvText = stringifyAgentDefaultEnvDraft(defaultEnv) const draftSeed = stringifyAgentDefaultEnvDraft(envOverride) - const [envDraft, setEnvDraft] = useState(draftSeed) + const [draft, setDraft] = useState(null) + const envDraft = draft ?? draftSeed + const { pending, write } = useAgentLaunchFieldWrite() const [envDraftError, setEnvDraftError] = useState(null) const envDraftErrorId = useId() const commitEnv = (): void => { + if (pending || draft === null) { + return + } if (envDraft === draftSeed) { + setDraft(null) return } const parsedDraft = parseAgentDefaultEnvDraft(envDraft) setEnvDraftError(parsedDraft.invalidReason) if (!parsedDraft.invalidReason) { - onSaveEnv(parsedDraft.env) + setDraft(envDraft) + write( + stringifyAgentDefaultEnvDraft(parsedDraft.env), + () => onSaveEnv(parsedDraft.env), + () => setDraft((current) => (current === envDraft ? null : current)) + ) } } + if (!showEmpty && !defaultEnvText && !draftSeed && draft === null && !pending) { + return null + } return (
@@ -180,8 +215,9 @@ export function AgentDefaultEnvInput({
{ - setEnvDraft(event.target.value) + setDraft(event.target.value) if (envDraftError) { setEnvDraftError(null) } @@ -193,9 +229,8 @@ export function AgentDefaultEnvInput({ event.currentTarget.blur() } if (event.key === 'Escape') { - setEnvDraft(draftSeed) + setDraft(null) setEnvDraftError(null) - event.currentTarget.blur() } }} placeholder={ @@ -207,15 +242,21 @@ export function AgentDefaultEnvInput({ aria-describedby={envDraftError ? envDraftErrorId : undefined} className="h-7 flex-1 font-mono text-xs" /> - {draftSeed !== defaultEnvText && ( + {(draftSeed !== defaultEnvText || envDraft !== defaultEnvText) && ( + + + + permission.onSelect('default')}> + + {followAll} · {agentPermissionChoiceLabel(permission.defaultChoice, label)} + + + + {permission.choices.map((choice) => ( + permission.onSelect(choice)} + > +
+
+ {agentPermissionChoiceLabel(choice, label)} +
+
+ {choice === 'own' + ? ownDescription() + : nativeChatPermissionModeDescription(choice, agentId)} +
+
+
+ ))} +
+
+ + {permission.terminalChoice === 'own' && permission.chatChoice ? ( + + {translate( + 'settings.agents.permissionSurfaceDifference', + 'Terminals use the agent’s own settings; new chats use {{mode}}.', + { mode: nativeChatPermissionModeLabel(permission.chatChoice) } + )} + + ) : null} +
+ ) +} + +/** The flag Orca adds, drawn inside the Arguments box; removing it means the agent's own settings. */ +export function AgentPermissionArgsChip({ + label, + flag, + onRemove +}: { + label: string + flag: string + onRemove: () => void +}): React.JSX.Element { + return ( + + + + {flag} + + + + + {translate( + 'settings.agents.permissionChipTooltip', + 'Added by Permissions. Remove it to use {{agent}}’s own settings.', + { agent: label } + )} + + + ) +} diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 694207f9fe3..b7f845bb730 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -1,6 +1,6 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -166,6 +166,11 @@ function findSegmentedControl(node: unknown, ariaLabel: string): ReactElementLik return found } +beforeEach(() => { + vi.stubGlobal('window', { api: { platform: { get: () => ({ platform: 'darwin' }) } } }) +}) +afterEach(() => vi.unstubAllGlobals()) + describe('AgentsPane', () => { beforeEach(() => { detectedAgentsMock.detectedIds = ['claude'] diff --git a/src/renderer/src/components/settings/ChatBehaviorSetting.test.tsx b/src/renderer/src/components/settings/ChatBehaviorSetting.test.tsx index eed7605218e..68cbddcf457 100644 --- a/src/renderer/src/components/settings/ChatBehaviorSetting.test.tsx +++ b/src/renderer/src/components/settings/ChatBehaviorSetting.test.tsx @@ -1,3 +1,5 @@ +import { AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import { getChatPermissionSearchEntry } from './chat-permission-search' // @vitest-environment happy-dom import { act, cleanup, fireEvent, render } from '@testing-library/react' @@ -238,3 +240,18 @@ describe('ChatBehaviorSetting permissions', () => { expect(container.querySelector('#chat-permissions')).toBeNull() }) }) + +it.each([false, true])( + 'matches Chat copy and search to its API owner typed capability %s', + (typed) => { + setLocalRuntimeCapabilitiesForTests(typed ? [AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY] : []) + const { container } = renderSetting({ + nativeChatPermissionMode: 'ask', + agentPermissionDefault: 'full-access', + activeRuntimeEnvironmentId: 'unrelated-agents-host' + }) + const description = getChatPermissionSearchEntry(typed).description + expect(container.querySelector('#chat-permissions')?.textContent).toContain(description) + expect(description?.includes('own settings')).toBe(typed) + } +) diff --git a/src/renderer/src/components/settings/ChatBehaviorSetting.tsx b/src/renderer/src/components/settings/ChatBehaviorSetting.tsx index a81971726f7..fe3c6ac539a 100644 --- a/src/renderer/src/components/settings/ChatBehaviorSetting.tsx +++ b/src/renderer/src/components/settings/ChatBehaviorSetting.tsx @@ -1,7 +1,10 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { useStructuredAgentSessionHostQueuesMessages } from '@/runtime/structured-agent-session-host-capability' +import { + useChatSettingsTypedPermissions, + useStructuredAgentSessionHostQueuesMessages +} from '@/runtime/structured-agent-session-host-capability' import { useAppStore } from '../../store' import { Card, CardContent } from '../ui/card' import { ChatPermissionSetting } from './ChatPermissionSetting' @@ -27,12 +30,15 @@ export function ChatBehaviorSetting({ updateSettings, forceVisible = false }: ChatBehaviorSettingProps): React.JSX.Element | null { + const typedPermissions = useChatSettingsTypedPermissions() const query = useAppStore((state) => state.settingsSearchQuery) // A host without the capability ignores the preference; the switch would do nothing. const hostQueuesMessages = useStructuredAgentSessionHostQueuesMessages(STRUCTURED_CHAT_HOST) const entries = getChatBehaviorEntriesByKey() const rows = [ - ...(settings.nativeChatPermissionMode !== undefined ? [getChatPermissionSearchEntry()] : []), + ...(settings.nativeChatPermissionMode !== undefined + ? [getChatPermissionSearchEntry(typedPermissions)] + : []), entries.resumeOnRestart, ...(hostQueuesMessages ? [entries.queueFollowUps] : []), entries.shellEnvironment diff --git a/src/renderer/src/components/settings/ChatPermissionSetting.tsx b/src/renderer/src/components/settings/ChatPermissionSetting.tsx index 4c21a723905..8c13406e7f5 100644 --- a/src/renderer/src/components/settings/ChatPermissionSetting.tsx +++ b/src/renderer/src/components/settings/ChatPermissionSetting.tsx @@ -1,3 +1,4 @@ +import { useChatSettingsTypedPermissions } from '@/runtime/structured-agent-session-host-capability' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { AGENT_CHAT_PERMISSION_MODES } from '../../../../shared/agent-chat-permission-mode' import { NativeChatPermissionModePicker } from '../native-chat/NativeChatPermissionModePicker' @@ -15,11 +16,12 @@ export function ChatPermissionSetting({ updateSettings: (updates: Partial) => void forceVisible: boolean }): React.JSX.Element | null { + const typedPermissions = useChatSettingsTypedPermissions() const current = settings.nativeChatPermissionMode if (current === undefined) { return null } - const entry = getChatPermissionSearchEntry() + const entry = getChatPermissionSearchEntry(typedPermissions) return ( { }) describe('host launch settings form', () => { + it('shows resolved launch defaults in a collapsed legacy host row', async () => { + transport.read.mockResolvedValue(projectAgentLaunchSettings({ agentDefaultArgs: {} })) + render(pane(17)) + const installed = (await screen.findByText('Installed')).closest('section') + if (!installed) { + throw new Error('missing installed agents section') + } + expect(within(installed).getByText('--dangerously-skip-permissions')).toBeTruthy() + expect(transport.mutate).not.toHaveBeenCalled() + }) + it.each([true, false])( 'offers automatic permissions only when supported: %s', async (supported) => { @@ -405,6 +416,38 @@ describe('host launch settings form', () => { }) }) + it.each([false, true])( + 'retains failed Arguments and follows acknowledged host text (typed=%s)', + async (typed) => { + const settings = (value: string) => + projectAgentLaunchSettings({ + ...(typed ? { agentPermissionDefault: 'ask' as const } : {}), + agentDefaultArgs: { claude: value } + }) + transport.read.mockResolvedValue(settings('--host-marker')) + transport.mutate + .mockRejectedValueOnce(new Error('unknown acknowledgement')) + .mockResolvedValue(settings('--host-normalized')) + const view = render(pane(17)) + const args = await screen.findByDisplayValue('--host-marker') + fireEvent.change(args, { target: { value: '--next-marker' } }) + fireEvent.blur(args) + await screen.findByRole('alert') + expect(args).toHaveProperty('value', '--next-marker') + fireEvent.blur(args) + await screen.findByDisplayValue('--host-normalized') + expect(transport.mutate.mock.calls[1]?.[1]).toEqual({ + type: typed ? 'user-arguments' : 'arguments', + agent: 'claude', + value: '--next-marker' + }) + fireEvent.change(args, { target: { value: '--unsubmitted' } }) + view.rerender(pane(18)) + await screen.findByDisplayValue('--host-marker') + expect(screen.queryByDisplayValue('--unsubmitted')).toBeNull() + } + ) + it('does not display launch controls or local defaults for an old server', async () => { transport.read.mockResolvedValue(null) render(pane(17)) diff --git a/src/renderer/src/components/settings/agent-launch-settings-transport.ts b/src/renderer/src/components/settings/agent-launch-settings-transport.ts index 4aa84c08187..8085267a867 100644 --- a/src/renderer/src/components/settings/agent-launch-settings-transport.ts +++ b/src/renderer/src/components/settings/agent-launch-settings-transport.ts @@ -5,6 +5,7 @@ import type { import { AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY, AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY, + AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY, AGENT_LAUNCH_SETTINGS_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' @@ -82,6 +83,15 @@ export async function mutateHostAgentLaunchSettings( ) { throw new Error('agent_launch_native_chat_permissions_unsupported') } + if ( + (mutation.type === 'permission' || mutation.type === 'user-arguments') && + !(await runtimeEnvironmentSupportsCapability( + owner.environmentId, + AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY + )) + ) { + throw new Error('agent_launch_permission_choice_unsupported') + } const result = await callRuntimeRpc<{ settings: AgentLaunchSettings }>( { kind: 'environment', environmentId: owner.environmentId }, 'settings.mutateAgentLaunch', diff --git a/src/renderer/src/components/settings/agent-permission-conversion-persistence.test.tsx b/src/renderer/src/components/settings/agent-permission-conversion-persistence.test.tsx new file mode 100644 index 00000000000..521c5d5b844 --- /dev/null +++ b/src/renderer/src/components/settings/agent-permission-conversion-persistence.test.tsx @@ -0,0 +1,472 @@ +import * as updatesModule from '../../../../shared/agent-launch-permission-updates' +import * as liftModule from '../../../../shared/tui-agent-permission-lift' +import * as envDraftModule from './agent-default-env-draft' +// @vitest-environment happy-dom +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import userEvent from '@testing-library/user-event' +import { toast } from 'sonner' +import { useAppStore } from '../../store' +import { AgentsPane } from './AgentsPane' +import { persist } from './settings-persistence-test-fixture' +import { TooltipProvider } from '../ui/tooltip' +import { + measureSettingsWork, + expectSettingsWorkScale +} from '../../../../shared/global-settings-test-fixture' +import { resolveTuiAgentLaunchArgs } from '../../../../shared/tui-agent-launch-defaults' + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })) +vi.mock('@/hooks/useDetectedAgents', () => ({ + useDetectedAgents: () => ({ + detectedIds: ['claude', 'gemini', 'goose'], + detectionFailed: false, + isRefreshing: false, + refresh: vi.fn() + }) +})) +vi.mock('./AgentAwakeSetting', () => ({ AgentAwakeSetting: () => null })) +vi.mock('./AgentCacheTimerSection', () => ({ AgentCacheTimerSection: () => null })) + +const feedback: (ReturnType['work'] & { text?: string | null })[] = [] +const stringifyFixture = envDraftModule.stringifyAgentDefaultEnvDraft +beforeEach(() => { + feedback.length = 0 + Object.assign(uiWork, { entries: 0, bytes: 0 }) + envWork.entries = envWork.bytes = 0 + const prepare = updatesModule.wholeAgentEnvironmentUpdates + vi.spyOn(updatesModule, 'wholeAgentEnvironmentUpdates').mockImplementation((...args) => { + const [settings, agent, requested] = args + const size = + Object.keys(settings.agentDefaultEnv?.[agent] ?? {}).length + Object.keys(requested).length + const result = measureSettingsWork(() => prepare(...args)) + const entries = result.work.entries + 12 * size + if (size >= 20) { + expect(entries).toBeLessThanOrEqual(36 * size + 64) + } + envWork.entries += entries + envWork.bytes += result.work.bytes + return result.value + }) + const importer = liftModule.liftAgentPermissionArgs + vi.spyOn(liftModule, 'liftAgentPermissionArgs').mockImplementation((...args) => { + const receipt = measureSettingsWork(() => importer(...args)) + feedback.push({ ...receipt.work, text: args[1] }) + return receipt.value + }) + const parse = envDraftModule.parseAgentDefaultEnvDraft + vi.spyOn(envDraftModule, 'parseAgentDefaultEnvDraft').mockImplementation((input) => { + const receipt = measureSettingsWork(() => parse(input)) + // Parse reads/writes each pair; stringify maps and joins each output entry. + uiWork.entries += receipt.work.entries + 3 * Object.keys(receipt.value.env).length + uiWork.bytes += receipt.work.bytes + 4 * input.length + return receipt.value + }) + const stringify = envDraftModule.stringifyAgentDefaultEnvDraft + vi.spyOn(envDraftModule, 'stringifyAgentDefaultEnvDraft').mockImplementation((input) => { + const receipt = measureSettingsWork(() => stringify(input)) + uiWork.entries += receipt.work.entries + 3 * Object.keys(input).length + uiWork.bytes += receipt.work.bytes + 4 * JSON.stringify(input).length + return receipt.value + }) + Object.assign(window.api, { platform: { get: () => ({ platform: 'darwin' }) } }) + const settings = useAppStore.getState().settings + if (!settings) { + throw new Error('Missing settings fixture') + } + useAppStore.setState({ settings: { ...settings, agentPermissionDefault: 'ask' } }) +}) + +function AgentsHarness() { + const settings = useAppStore((state) => state.settings) + const updateSettings = useAppStore((state) => state.updateSettings) + if (!settings) { + throw new Error('Missing settings fixture') + } + return ( + + + + ) +} + +function argumentsInput(label: string): HTMLInputElement { + const row = screen.getByText(label, { selector: 'span' }).closest('div.py-3') + if (!(row instanceof HTMLElement)) { + throw new Error('Missing agent row') + } + const expand = within(row).queryByRole('button', { name: 'Expand agent settings' }) + if (expand) { + fireEvent.click(expand) + } + const input = within(row).getByPlaceholderText('No default arguments') + if (!(input instanceof HTMLInputElement)) { + throw new Error('Missing Arguments input') + } + return input +} + +it.each( + [20, 200].flatMap((n) => [ + `${'--model opus '.repeat(n)}--dangerously-skip-permissions`, + `--dangerously-skip-permissions ${'--model opus '.repeat(n).trim()}` + ]) +)('retires the acknowledged recipe %s before chip removal and neutral events', async (recipe) => { + const models = recipe.replace('--dangerously-skip-permissions', '').trim() + savedArguments(models, 'full-access') + render() + const input = argumentsInput('Claude') + const remove = () => + screen.getByRole('button', { name: /Remove .* and use Claude's own settings/ }) + fireEvent.click(remove()) + await waitFor(() => expect(persist).toHaveBeenCalledTimes(1)) + const user = userEvent.setup() + await user.click(input) + await user.clear(input) + fireEvent.change(input, { target: { value: recipe } }) + const before = feedback.length + await user.keyboard('{Enter}') + await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1)) + expect(persist).toHaveBeenCalledTimes(2) + const imports = feedback.slice(before) + expect(imports.map((work) => work.text)).toEqual([recipe, recipe]) + for (const work of imports) { + expect(work.chars).toBeLessThanOrEqual(36 * recipe.length + 12000) + } + const key = recipe.startsWith('--dangerously') ? 'leading' : 'tail', + total = imports.reduce( + (sum, work) => + sum + work.chars + work.entries + work.visits + work.bytes + 42 * recipe.length, + 0 + ) + expectSettingsWorkScale(`feedback-${key}`, recipe.length > 1000 ? 200 : 20, [total]) + expect(useAppStore.getState().settings?.agentPermissionOverrides?.claude).toBe('full-access') + expect(resolveTuiAgentLaunchArgs('claude', useAppStore.getState().settings ?? {})).toBe( + `--dangerously-skip-permissions ${models}` + ) + expect(input.value).toBe(models) + fireEvent.click(remove()) + await waitFor(() => expect(persist).toHaveBeenCalledTimes(3)) + await user.click(input) + await user.tab() + await user.click(input) + await user.keyboard('{Enter}') + expect(persist).toHaveBeenCalledTimes(3) + expect(toast.success).toHaveBeenCalledTimes(1) + expect(useAppStore.getState().settings?.agentPermissionOverrides?.claude).toBe('own') + await user.click(input) + await user.type(input, ' --dangerously-skip-permissions') + await user.keyboard('{Escape}') + expect(input.value).toBe(models) + await user.tab() + fireEvent.change(input, { target: { value: ` ${models} ` } }) + fireEvent.blur(input) + expect(input.value).toBe(models) + expect(persist).toHaveBeenCalledTimes(3) +}) + +it('retains failed conversion through the ordinary callback and announces only the saved retry', async () => { + savedArguments('--model opus') + const save = deferredSave() + render() + const input = argumentsInput('Claude') + fireEvent.change(input, { target: { value: '--permission-mode auto --model opus' } }) + fireEvent.blur(input) + await waitFor(() => expect(persist).toHaveBeenCalledTimes(1)) + act(() => savedArguments('')) + expect(input.isConnected).toBe(true) + await act(async () => save.reject(new Error('Settings disk is unavailable'))) + await waitFor(() => expect(input.readOnly).toBe(false)) + expect(toast.error).toHaveBeenCalledExactlyOnceWith( + 'Claude settings could not be saved. Try again.' + ) + expect(toast.success).not.toHaveBeenCalled() + expect(useAppStore.getState().settings?.agentPermissionDefault).toBe('ask') + expect(useAppStore.getState().settings?.agentPermissionOverrides?.claude).toBeUndefined() + expect(input.value).toBe('--permission-mode auto --model opus') + fireEvent.blur(input) + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith( + 'Claude set to Approve for me in terminals and new chats.' + ) + ) + expect(useAppStore.getState().settings?.agentPermissionOverrides?.claude).toBe('auto') + expect(useAppStore.getState().settings?.agentDefaultArgs?.claude).toBe('--model opus') + await waitFor(() => expect(input.value).toBe('--model opus')) + act(() => savedArguments('--later')) + expect(input.value).toBe('--later') + fireEvent.change(input, { target: { value: '--dirty' } }) + act(() => savedArguments('')) + expect(input.value).toBe('--dirty') + fireEvent.keyDown(input, { key: 'Escape' }) + expect(input.value).toBe('') +}) + +it('describes terminal-only conversion without claiming new native chats', async () => { + render() + const input = argumentsInput('Gemini') + fireEvent.change(input, { target: { value: '--yolo' } }) + fireEvent.blur(input) + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith('Gemini set to Full access in terminals.') + ) + expect(useAppStore.getState().settings?.agentPermissionOverrides?.gemini).toBe('full-access') + expect(useAppStore.getState().settings?.agentDefaultArgs?.gemini).toBe('') +}) + +function savedArguments(value: string, choice: 'ask' | 'full-access' = 'ask') { + useAppStore.setState(({ settings }) => ({ + settings: settings && { + ...settings, + agentPermissionDefault: choice, + agentDefaultArgs: { claude: value } + } + })) +} + +function savedEnvironment(env: Record, agent = 'claude') { + const settings = useAppStore.getState().settings + if (!settings) { + throw new Error('Missing settings fixture') + } + useAppStore.setState({ + settings: { ...settings, agentDefaultEnv: { ...settings.agentDefaultEnv, [agent]: env } } + }) +} +function environmentInput(value: string): HTMLInputElement { + const input = screen.getByDisplayValue(value) + if (!(input instanceof HTMLInputElement)) { + throw new Error('Missing Environment input') + } + return input +} +function deferredSave() { + let resolve!: () => void + let reject!: (error: Error) => void + const done = new Promise((yes, no) => { + resolve = yes + reject = no + }) + persist.mockImplementationOnce(async (updates) => { + await done + const settings = useAppStore.getState().settings + if (!settings) { + throw new Error('Missing settings fixture') + } + return { ...settings, ...updates } + }) + return { resolve, reject } +} + +it('retains a whole failed Environment edit across empty publication and retries once', async () => { + savedEnvironment({ A: 'old', B: 'old' }) + const save = deferredSave() + render() + const input = environmentInput('A=old B=old') + fireEvent.change(input, { target: { value: 'A=new B=new' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.blur(input) + await waitFor(() => expect(persist).toHaveBeenCalledTimes(1)) + expect(persist.mock.calls[0][0].agentDefaultEnv?.claude).toEqual({ A: 'new', B: 'new' }) + act(() => savedEnvironment({})) + expect(input.isConnected).toBe(true) + await act(async () => save.reject(new Error('secret provider details'))) + await waitFor(() => expect(input.readOnly).toBe(false)) + expect(input.value).toBe('A=new B=new') + expect(toast.error).toHaveBeenCalledExactlyOnceWith( + 'Claude settings could not be saved. Try again.' + ) + expect(toast.success).not.toHaveBeenCalled() + fireEvent.blur(input) + await waitFor(() => + expect(useAppStore.getState().settings?.agentDefaultEnv?.claude).toEqual({ A: 'new', B: 'new' }) + ) + expect(persist).toHaveBeenCalledTimes(2) + act(() => savedEnvironment({ A: 'external' })) + expect(input.value).toBe('A=external') + const user = userEvent.setup() + await user.click(input) + await user.type(input, 'discard') + await user.keyboard('{Escape}') + expect(input.value).toBe('A=external') + fireEvent.blur(input) + await waitFor(() => expect(input.readOnly).toBe(false)) + fireEvent.change(input, { target: { value: 'A=two' } }) + fireEvent.change(input, { target: { value: 'A=external' } }) + fireEvent.blur(input) + act(() => savedEnvironment({ A: 'two' })) + await user.click(input) + await user.tab() + expect(input.value).toBe('A=two') + expect(persist).toHaveBeenCalledTimes(2) +}) + +it.each(['blur', 'reset'])('retains ordered Environment work when %s rejects', async (failure) => { + savedEnvironment({ A: 'old' }) + const blur = deferredSave() + const reset = deferredSave() + const user = userEvent.setup() + render() + const input = environmentInput('A=old') + await user.click(input) + await user.clear(input) + await user.type(input, 'A=draft') + const row = input.closest('div.py-3') + if (!(row instanceof HTMLElement)) { + throw new Error('Missing row') + } + const resetButton = within(row).getByRole('button', { name: 'Reset' }) + await user.click(resetButton) + await waitFor(() => expect(persist).toHaveBeenCalledTimes(1)) + await act(async () => (failure === 'blur' ? blur.reject(new Error('offline')) : blur.resolve())) + await waitFor(() => expect(persist).toHaveBeenCalledTimes(2)) + expect(input.readOnly).toBe(true) + expect(input.value).toBe('A=draft') + await user.click(input) + await user.tab() + await user.click(input) + await user.keyboard('{Enter}') + expect(persist).toHaveBeenCalledTimes(2) + if (failure === 'reset') { + act(() => savedEnvironment({})) + expect(input.isConnected).toBe(true) + await act(async () => reset.reject(new Error('offline'))) + await waitFor(() => expect(input.readOnly).toBe(false)) + expect(input.value).toBe('A=draft') + await user.click(resetButton) + } else { + await act(async () => reset.resolve()) + } + await waitFor(() => expect(input.isConnected).toBe(false)) + expect(useAppStore.getState().settings?.agentDefaultEnv?.claude).toEqual({}) + expect(persist).toHaveBeenCalledTimes(2) + expect(toast.error).toHaveBeenCalledTimes(1) +}) + +it.each(['darwin', 'win32'].flatMap((platform) => [20, 200].map((n) => [platform, n] as const)))( + 'bounds whole Environment on %s with %i entries', + async (platform, n) => { + Object.assign(window.api, { platform: { get: () => ({ platform }) } }) + savedEnvironment({ Path: 'old' }) + savedEnvironment({ TOKEN: 'keep' }, 'gemini') + const original = useAppStore.getState().settings?.agentDefaultEnv + render() + const input = environmentInput('Path=old') + const names = Array.from({ length: n }, (_, i) => `K${String(i).padStart(3, '0')}`) + for (const value of ['new', 'last']) { + const extra = Object.fromEntries(names.map((k) => [k, value])) + fireEvent.change(input, { + target: { + value: (value === 'new' ? 'Path=old PATH=new ' : 'PATH=new ') + stringifyFixture(extra) + } + }) + act(() => savedEnvironment({ TOKEN: 'latest' }, 'gemini')) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(input.readOnly).toBe(false)) + expect(useAppStore.getState().settings?.agentDefaultEnv).toEqual({ + ...original, + claude: { + ...extra, + ...(platform === 'win32' || value === 'last' + ? { PATH: 'new' } + : { Path: 'old', PATH: 'new' }) + }, + gemini: { TOKEN: 'latest' } + }) + } + expect(persist).toHaveBeenCalledTimes(2) + const calls = vi.mocked(updatesModule.wholeAgentEnvironmentUpdates).mock.calls.length + fireEvent.change(input, { target: { value: `${input.value} ` } }) + fireEvent.blur(input) + await waitFor(() => expect(input.readOnly).toBe(false)) + expect(persist).toHaveBeenCalledTimes(2) + expect(updatesModule.wholeAgentEnvironmentUpdates).toHaveBeenCalledTimes(calls + 1) + const counts = [envWork.entries, envWork.bytes, uiWork.entries, uiWork.bytes] + expectSettingsWorkScale(`renderer-env-${platform}`, n, counts) + } +) + +it('imports then removes Goose carrier through one whole edit per acknowledgement', async () => { + savedEnvironment({ OTHER: 'keep' }, 'goose') + render() + const input = environmentInput('OTHER=keep') + fireEvent.change(input, { target: { value: 'OTHER=next GOOSE_MODE=auto' } }) + fireEvent.blur(input) + await waitFor(() => + expect(useAppStore.getState().settings?.agentPermissionOverrides?.goose).toBe('full-access') + ) + expect(useAppStore.getState().settings?.agentDefaultEnv?.goose).toEqual({ OTHER: 'next' }) + fireEvent.change(input, { target: { value: 'OTHER=last' } }) + fireEvent.blur(input) + await waitFor(() => + expect(useAppStore.getState().settings?.agentPermissionOverrides?.goose).toBe('own') + ) + expect(useAppStore.getState().settings?.agentDefaultEnv?.goose).toEqual({ OTHER: 'last' }) + expect(persist).toHaveBeenCalledTimes(2) +}) + +it.each([20, 200])('keeps Goose Environment editable after Reset/reopen (%i)', async (n) => { + const extra = Object.fromEntries( + Array.from({ length: n }, (_, i) => [`K${String(i).padStart(3, '0')}`, 'keep']) + ) + savedEnvironment( + { goose_mode: 'lowercase-is-distinct', QA_GOOS_KEEP: 'keep', QA_EDITOR_ONE: 'one', ...extra }, + 'goose' + ) + useAppStore.setState(({ settings }) => ({ + settings: settings && { + ...settings, + agentPermissionOverrides: { goose: 'own' } + } + })) + render() + const input = environmentInput( + `goose_mode=lowercase-is-distinct QA_GOOS_KEEP=keep QA_EDITOR_ONE=one ${stringifyFixture(extra)}` + ) + const row = input.closest('div.py-3') + if (!(row instanceof HTMLElement)) { + throw new Error('Missing Goose row') + } + fireEvent.click(within(row).getByRole('button', { name: 'Reset' })) + await waitFor(() => expect(persist).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(useAppStore.getState().settings?.agentDefaultEnv?.goose).toEqual({})) + await waitFor(() => + expect(within(row).getByPlaceholderText('No default environment')).toHaveProperty('value', '') + ) + fireEvent.click(within(row).getByRole('button', { name: 'Collapse agent settings' })) + fireEvent.click(within(row).getByRole('button', { name: 'Expand agent settings' })) + const empty = within(row).getByPlaceholderText('No default environment') + fireEvent.change(empty, { + target: { value: `QA_AFTER_RESET=yes ${stringifyFixture(extra)}` } + }) + fireEvent.blur(empty) + await waitFor(() => + expect(useAppStore.getState().settings?.agentDefaultEnv?.goose).toEqual({ + QA_AFTER_RESET: 'yes', + ...extra + }) + ) + expect(useAppStore.getState().settings?.agentPermissionOverrides?.goose).toBe('own') + expect(persist).toHaveBeenCalledTimes(2) + const counts = [envWork.entries, envWork.bytes, uiWork.entries, uiWork.bytes] + expectSettingsWorkScale('renderer-Reset', n, counts) +}) + +it('reports one direct local permission action failure without changing saved intent', async () => { + persist.mockRejectedValueOnce(new Error('private error')) + render() + fireEvent.click(screen.getByRole('radio', { name: 'Full access' })) + await waitFor(() => + expect(toast.error).toHaveBeenCalledExactlyOnceWith( + 'Agent settings could not be saved. Try again.' + ) + ) + expect(useAppStore.getState().settings?.agentPermissionDefault).toBe('ask') +}) + +const uiWork = { entries: 0, bytes: 0 } +const envWork = { entries: 0, bytes: 0 } diff --git a/src/renderer/src/components/settings/agent-row-permission-model.ts b/src/renderer/src/components/settings/agent-row-permission-model.ts index 88ef77516a4..3821572710e 100644 --- a/src/renderer/src/components/settings/agent-row-permission-model.ts +++ b/src/renderer/src/components/settings/agent-row-permission-model.ts @@ -49,8 +49,10 @@ export function buildAgentRowPermissions({ agent, resolveTuiAgentPermissionMode({ agent, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), - agentEnv: localEnv ? resolveTuiAgentLaunchEnv(agent, localEnv) : undefined + agentArgs: resolveTuiAgentLaunchArgs(agent, settings), + agentEnv: localEnv + ? resolveTuiAgentLaunchEnv(agent, { agentDefaultEnv: localEnv }) + : undefined }) ) } diff --git a/src/renderer/src/components/settings/chat-permission-search.ts b/src/renderer/src/components/settings/chat-permission-search.ts index 8461c7933e4..d2b952c1eae 100644 --- a/src/renderer/src/components/settings/chat-permission-search.ts +++ b/src/renderer/src/components/settings/chat-permission-search.ts @@ -2,7 +2,17 @@ import { translate } from '@/i18n/i18n' import { createLocalizedCatalog } from '@/i18n/localized-catalog' import type { SettingsSearchEntry } from './settings-search' -export const getChatPermissionSearchEntry = createLocalizedCatalog((): SettingsSearchEntry => ({ +const getTypedEntry = createLocalizedCatalog((): SettingsSearchEntry => ({ + targetSectionId: 'chat-permissions', + title: translate('settings.chat.permissionsTitle', 'Permissions for new chats'), + // Temporary until chats can run an agent's own settings. + description: translate( + 'settings.chat.permissionsDescriptionOwnAgents', + 'New chats of agents set to their own settings in Settings → Agents. Other agents start chats in their permission there.' + ) +})) + +const getLegacyEntry = createLocalizedCatalog((): SettingsSearchEntry => ({ targetSectionId: 'chat-permissions', title: translate('settings.chat.permissionsTitle', 'Permissions for new chats'), description: translate( @@ -10,3 +20,7 @@ export const getChatPermissionSearchEntry = createLocalizedCatalog((): SettingsS 'New chats only. Agent-specific defaults are in Settings → Agents.' ) })) + +export function getChatPermissionSearchEntry(typedPermissions = false): SettingsSearchEntry { + return typedPermissions ? getTypedEntry() : getLegacyEntry() +} diff --git a/src/renderer/src/components/settings/use-agent-launch-field-write.ts b/src/renderer/src/components/settings/use-agent-launch-field-write.ts index 61894744ad0..2ce64d48472 100644 --- a/src/renderer/src/components/settings/use-agent-launch-field-write.ts +++ b/src/renderer/src/components/settings/use-agent-launch-field-write.ts @@ -19,7 +19,11 @@ export function useAgentLaunchFieldWrite(): { queuedValues.current.add(value) setPending(true) const completion: Promise = result - .then(onSaved) + .then(() => { + if (tail.current === completion) { + onSaved?.() + } + }) .catch(() => {}) .finally(() => { queuedValues.current.delete(value) diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx index 261b0e5160e..cfccc9aeb0b 100644 --- a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-host-preferences.test.tsx @@ -5,6 +5,11 @@ import { useAppStore } from '@/store' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' import { getDefaultSettings } from '../../../../shared/constants' +import { + measureSettingsWork, + expectSettingsWorkScale +} from '../../../../shared/global-settings-test-fixture' +import * as profile from '../../../../shared/tui-agent-launch-defaults' import { projectAgentLaunchSettings } from '../../../../shared/agent-launch-settings' import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status' import { @@ -36,6 +41,28 @@ const server = projectAgentLaunchSettings({ defaultTuiAgent: 'codex', disabledTuiAgents: ['opencode'] }) +const raw = { + ...server, + agentPermissionDefault: 'ask' as const, + agentDefaultEnv: { + claude: Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`K${i}`, 'private'])) + } +} +const payloadWork = { entries: 0, bytes: 0, count: 0 } +const compositions = vi.spyOn(profile, 'composeAgentLaunchProfile') +function payload(source: Parameters[0] = raw) { + const before = compositions.mock.calls.length, + { value: settings, work } = measureSettingsWork(() => projectAgentLaunchSettings(source)) + expect(compositions.mock.calls.length - before).toBe(1) + const result = { settings }, + text = JSON.stringify(result) + expect(text).not.toContain('private') + payloadWork.count++ + payloadWork.entries += work.entries + work.visits + 14 * 20 + payloadWork.bytes += text.length + work.bytes + return result +} + function status(runtimeId = 'runtime-1'): RuntimeEnvironmentStatus { return { status: { @@ -51,7 +78,8 @@ function status(runtimeId = 'runtime-1'): RuntimeEnvironmentStatus { } } beforeEach(() => { - rpc.call.mockReset().mockResolvedValue({ settings: server }) + Object.assign(payloadWork, { entries: 0, bytes: 0, count: 0 }) + rpc.call.mockReset().mockImplementation(() => payload()) rpc.supports.mockReset().mockResolvedValue(true) replaceRuntimeEnvironmentRevisions([{ id: 'alpha', createdAt: 7 }]) setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT]) @@ -171,38 +199,47 @@ it('ignores old reads across host switches, restarts, and re-pairing', async () ) }) -it('refreshes after a save, fences the earlier read, and refreshes on reopening', async () => { - const old = Promise.withResolvers<{ settings: typeof server }>() - rpc.call.mockReturnValueOnce(old.promise) - const hook = renderHook( - ({ open }) => useNewTabAgentMenuRows('wt', open, { kind: 'runtime', environmentId: 'alpha' }), - { initialProps: { open: true } } - ) - await act(async () => {}) - await act(async () => { - await mutateHostAgentLaunchSettings( +it.each([20, 200])('bounds %s menu owners across save/open', async (n) => { + const notify = () => + mutateHostAgentLaunchSettings( { environmentId: 'alpha', pairingRevision: 7 }, { type: 'availability', agent: 'opencode', enabled: false } ) + const old = Promise.withResolvers() + rpc.call.mockImplementationOnce(() => { + const earlier = payload({ ...raw, disabledTuiAgents: ['codex'] }) + return old.promise.then(() => earlier) }) - await act(async () => - old.resolve({ settings: projectAgentLaunchSettings({ disabledTuiAgents: ['codex'] }) }) + const hooks = Array.from({ length: n }, () => + renderHook( + ({ open }) => useNewTabAgentMenuRows('wt', open, { kind: 'runtime', environmentId: 'alpha' }), + { initialProps: { open: true } } + ) ) + const hook = hooks[0] + await act(async () => {}) + await act(notify) + await act(async () => old.resolve()) expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe( 'codex' ) - hook.rerender({ open: false }) - rpc.call.mockResolvedValue({ - settings: projectAgentLaunchSettings({ - defaultTuiAgent: 'claude', - disabledTuiAgents: ['codex'] - }) - }) - hook.rerender({ open: true }) + expect(rpc.call).toHaveBeenCalledTimes(2 * n + 1) + hooks.forEach((h) => h.rerender({ open: false })) + await act(notify) + expect(rpc.call).toHaveBeenCalledTimes(2 * n + 2) + rpc.call.mockImplementation(() => + payload({ ...raw, defaultTuiAgent: 'claude', disabledTuiAgents: ['codex'] }) + ) + hooks.forEach((h) => h.rerender({ open: true })) await act(async () => {}) expect(hook.result.current.state === 'settled' && hook.result.current.rows[0]?.agent).toBe( 'claude' ) + hooks.forEach((h) => h.unmount()) + await act(notify) + expect(rpc.call).toHaveBeenCalledTimes(3 * n + 3) + expect(payloadWork.count).toBe(3 * n + 3) + expectSettingsWorkScale('menu', n, [payloadWork.entries, payloadWork.bytes]) }) it('keeps this runtime’s last preferences through a failed reconnect refresh', async () => { diff --git a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts index 13831b1a874..de3aec9c73a 100644 --- a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts @@ -31,6 +31,8 @@ import { useCallback } from 'react' import type { TuiAgent } from '../../../../shared/tui-agent' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import { useAppStore } from '@/store' +import { getFolderWorkspaceAgentLaunchFact } from '@/components/sidebar/folder-workspace-agent-startup' import { submitFolderWorkspaceCreate } from '@/components/sidebar/folder-workspace-composer-submit' import { resolveTuiAgentLaunchArgs, @@ -101,6 +103,10 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp if (isSubmissionCancelled()) { return } + const launchHost = getFolderWorkspaceAgentLaunchFact( + useAppStore.getState(), + selectedProjectGroup + ) const folderWorkspaceCreated = await submitFolderWorkspaceCreate({ projectGroup: selectedProjectGroup, name: smartGitHubMetadata?.workspaceName ?? name, @@ -112,10 +118,14 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp launchChoice: options?.launchChoice, autoRenameBranchFromWork: settings?.autoRenameBranchFromWork, agentCmdOverrides: settings?.agentCmdOverrides, - agentArgs: agent - ? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs) + agentArgs: agent ? resolveTuiAgentLaunchArgs(agent, settings) : undefined, + agentEnv: agent + ? resolveTuiAgentLaunchEnv( + agent, + settings, + launchHost.kind === 'known' ? launchHost.platform : null + ) : undefined, - agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined, terminalWindowsShell: settings?.terminalWindowsShell, isRemote: folderTargetIsRemote, launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer', diff --git a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts index 3c67a5e73c9..16760b210c2 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -42,8 +42,12 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui agent, draft: draftPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), + agentArgs: resolveTuiAgentLaunchArgs(agent, settings), + agentEnv: resolveTuiAgentLaunchEnv( + agent, + settings, + requireExecutionHostPlatform(input.launchHost) + ), platform: requireExecutionHostPlatform(input.launchHost), shell: input.shell ?? undefined, isRemote: input.isRemote @@ -66,8 +70,12 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui agent, prompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), + agentArgs: resolveTuiAgentLaunchArgs(agent, settings), + agentEnv: resolveTuiAgentLaunchEnv( + agent, + settings, + requireExecutionHostPlatform(input.launchHost) + ), platform: requireExecutionHostPlatform(input.launchHost), shell: input.shell ?? undefined, isRemote: input.isRemote, diff --git a/src/renderer/src/hooks/settings-navigation-build-options.ts b/src/renderer/src/hooks/settings-navigation-build-options.ts index fbac15468a9..e5f3027bb38 100644 --- a/src/renderer/src/hooks/settings-navigation-build-options.ts +++ b/src/renderer/src/hooks/settings-navigation-build-options.ts @@ -9,6 +9,7 @@ export type SettingsNavigationBuildOptions = { isWebClient: boolean nativeChatEnabled: boolean hostQueuesMessages: boolean + typedChatPermissions?: boolean managedBrowserCreationEnabled: boolean mobileEmulatorCreationEnabled: boolean isDev: boolean diff --git a/src/renderer/src/hooks/settings-navigation-interface-sections.ts b/src/renderer/src/hooks/settings-navigation-interface-sections.ts index 2ecd3ce0faf..c75b9f8912a 100644 --- a/src/renderer/src/hooks/settings-navigation-interface-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-interface-sections.ts @@ -20,6 +20,7 @@ export function buildInterfaceSettingsSections({ isWebClient, nativeChatEnabled, hostQueuesMessages, + typedChatPermissions, managedBrowserCreationEnabled, mobileEmulatorCreationEnabled }: SettingsNavigationBuildOptions): SettingsNavSection[] { @@ -51,7 +52,7 @@ export function buildInterfaceSettingsSections({ ), icon: MessageSquare, searchEntries: [ - getChatPermissionSearchEntry(), + getChatPermissionSearchEntry(typedChatPermissions), ...getChatAppearanceSearchEntries(), ...getChatBehaviorSearchEntries().filter( (entry) => hostQueuesMessages || entry.targetSectionId !== 'chat-queue-follow-ups' diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 821f7eab3a3..3ea20bfa6ce 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -21,7 +21,10 @@ import { import { defaultScopeHost } from '@/lib/default-creation-host' import { useAppStore } from '@/store' import { useProjectHostSetupProjection } from '@/store/selectors' -import { useStructuredAgentSessionHostQueuesMessages } from '@/runtime/structured-agent-session-host-capability' +import { + useChatSettingsTypedPermissions, + useStructuredAgentSessionHostQueuesMessages +} from '@/runtime/structured-agent-session-host-capability' import type { ProjectGroupingModel } from '../../../shared/project-section-grouping' import type { Repo } from '../../../shared/repo-types' import { @@ -44,6 +47,7 @@ export function buildSettingsNavigationMetadata({ isWebClient, nativeChatEnabled = false, hostQueuesMessages = false, + typedChatPermissions = false, managedBrowserCreationEnabled = !isWebClient, mobileEmulatorCreationEnabled = !isWebClient, isDev = import.meta.env.DEV, @@ -58,6 +62,7 @@ export function buildSettingsNavigationMetadata({ isWebClient: boolean nativeChatEnabled?: boolean hostQueuesMessages?: boolean + typedChatPermissions?: boolean managedBrowserCreationEnabled?: boolean mobileEmulatorCreationEnabled?: boolean isDev?: boolean @@ -87,6 +92,7 @@ export function buildSettingsNavigationMetadata({ isWebClient, nativeChatEnabled, hostQueuesMessages, + typedChatPermissions, managedBrowserCreationEnabled, mobileEmulatorCreationEnabled, isDev, @@ -127,6 +133,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { }) ) const nativeChatEnabled = settings?.experimentalNativeChat === true + const typedChatPermissions = useChatSettingsTypedPermissions() const hostQueuesMessages = useStructuredAgentSessionHostQueuesMessages({ kind: 'local' }) const isMac = isMacUserAgent() const isWindows = isWindowsUserAgent() @@ -173,6 +180,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { isWebClient, nativeChatEnabled, hostQueuesMessages, + typedChatPermissions, managedBrowserCreationEnabled, mobileEmulatorCreationEnabled, isDev: import.meta.env.DEV, @@ -192,6 +200,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { isWebClient, nativeChatEnabled, hostQueuesMessages, + typedChatPermissions, managedBrowserCreationEnabled, mobileEmulatorCreationEnabled, isLinearConnected, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 18404b66ffb..5d5f065f11f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -256,7 +256,8 @@ "antigravityNotSignedIn": "Not signed in. Chats need an Antigravity sign-in, separate from your agy terminal account.", "antigravityAccount": "Account", "antigravitySignInNeeded": "Chats need an Antigravity sign-in, separate from your agy terminal account." - } + }, + "permissionsDescriptionOwnAgents": "New chats of agents set to their own settings in Settings → Agents. Other agents start chats in their permission there." }, "terminal": { "optionLayoutAlt": "ABC or U.S. — Option sends Alt/Esc sequences", @@ -479,7 +480,23 @@ "customPermissions": "Custom arguments", "replaceWithPermissionMode": "Replace with {{mode}}", "expandAgentSettings": "Expand agent settings", - "collapseAgentSettings": "Collapse agent settings" + "collapseAgentSettings": "Collapse agent settings", + "permissionArgsTerminalOnly": "These arguments may control {{agent}}’s terminal permissions, so Orca adds no flag. Chats use the Permissions choice above.", + "localSaveFailed": "Agent settings could not be saved. Try again.", + "localSaveFailedAgent": "{{agent}} settings could not be saved. Try again.", + "permissionFromArgs": "{{agent}} set to {{mode}} in terminals and new chats.", + "permissionFromArgsTerminals": "{{agent}} set to {{mode}} in terminals.", + "permissionOwnAgent": "Use {{agent}}'s own settings", + "permissionOwnAll": "Agents' own settings", + "permissionOwnDescription": "Orca adds no permission flag in terminals; chats use Settings → Chat.", + "permissionChoiceDescription": "Applies where each agent supports this mode. Change one agent below.", + "permissionOwnShort": "Own settings", + "permissionWithoutAuto": "Agents without Approve for me", + "permissionSurfaceDifference": "Terminals use the agent’s own settings; new chats use {{mode}}.", + "permissionSameAsAll": "Same as all agents", + "permissionChipRemove": "Remove {{flag}} and use {{agent}}'s own settings", + "permissionChipTooltip": "Added by Permissions. Remove it to use {{agent}}’s own settings.", + "permissionResetsOwnChoices": "Choosing a mode here resets the agents marked as set separately." }, "followSymlinkedDirectoriesKeywords": { "symlink": "symlink", diff --git a/src/renderer/src/lib/agent-account-restart-startup.test.ts b/src/renderer/src/lib/agent-account-restart-startup.test.ts index 393f647188f..7188790a8cd 100644 --- a/src/renderer/src/lib/agent-account-restart-startup.test.ts +++ b/src/renderer/src/lib/agent-account-restart-startup.test.ts @@ -3,6 +3,7 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' import { useAppStore } from '@/store' import { makePaneKey } from '../../../shared/stable-pane-id' import { getDefaultSettings } from '../../../shared/constants' +import { resolveTuiAgentLaunchArgs } from '../../../shared/tui-agent-launch-defaults' import { getWorkspaceRuntimePreference, resolveProjectExecutionRuntime, @@ -290,11 +291,20 @@ describe('buildPaneAccountRestartStartup for Claude', () => { availableWslDistros: ['Ubuntu-24.04'] }) expect(projectRuntimeContext).toMatchObject({ status: 'resolved', runtime: { kind: 'wsl' } }) + useAppStore.getState().registerAgentLaunchConfig(makePaneKey(TAB_ID, LEAF_ID), { + agentArgs: resolveTuiAgentLaunchArgs('claude', { + agentPermissionDefault: 'full-access', + agentDefaultArgs: { claude: '--tools Read' } + }), + agentEnv: {} + }) const startup = buildPaneAccountRestartStartup('claude', pane) - expect(startup?.command).toBe(`claude '--resume' '${SESSION_ID}'`) - expect(startup?.resumeProviderSession?.id).toBe(SESSION_ID) + expect(startup?.command).toBe( + `claude '--dangerously-skip-permissions' '--tools' 'Read' '--resume' '${SESSION_ID}'` + ) + expect(startup?.resumeProviderSession).toEqual({ key: 'session_id', id: SESSION_ID }) } ) }) diff --git a/src/renderer/src/lib/agent-account-restart-startup.ts b/src/renderer/src/lib/agent-account-restart-startup.ts index 0bee37335de..fcd2034ed01 100644 --- a/src/renderer/src/lib/agent-account-restart-startup.ts +++ b/src/renderer/src/lib/agent-account-restart-startup.ts @@ -96,13 +96,13 @@ export function resolveAgentAccountRestartResumeStartup( launchConfig !== undefined ? launchConfig.agentArgs : unrecordedLaunchUsesDefaults - ? resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs) + ? resolveTuiAgentLaunchArgs(agent, state.settings) : null, agentEnv: launchConfig !== undefined ? launchConfig.agentEnv : unrecordedLaunchUsesDefaults - ? resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv) + ? resolveTuiAgentLaunchEnv(agent, state.settings, resumeTarget.platform) : null, ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), ...(launchConfig?.ompResumeFilePath diff --git a/src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts b/src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts index ac1e33e7fea..f5b30be9a19 100644 --- a/src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts +++ b/src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts @@ -9,7 +9,8 @@ import { buildAiVaultResumeCommand } from '../../../shared/ai-vault-resume-comma import { buildAgentStartupPlan } from '../../../shared/tui-agent-startup' import { resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv + resolveTuiAgentLaunchEnv, + type AgentLaunchProfileSettings } from '../../../shared/tui-agent-launch-defaults' import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -25,9 +26,9 @@ export function buildAntigravityReferenceStartup(args: { platform: NodeJS.Platform shell?: AgentStartupShell commandOverride?: string | null - settings?: Partial< - Pick - > | null + settings?: + | (Partial> & AgentLaunchProfileSettings) + | null }): AiVaultResumeStartup | null { const plan = buildAgentStartupPlan({ agent: 'antigravity', @@ -39,8 +40,8 @@ export function buildAntigravityReferenceStartup(args: { ...args.settings?.agentCmdOverrides, ...(args.commandOverride?.trim() ? { antigravity: args.commandOverride } : {}) }, - agentArgs: resolveTuiAgentLaunchArgs('antigravity', args.settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv('antigravity', args.settings?.agentDefaultEnv) + agentArgs: resolveTuiAgentLaunchArgs('antigravity', args.settings), + agentEnv: resolveTuiAgentLaunchEnv('antigravity', args.settings, args.platform) }) if (!plan) { return null diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 63a755f643d..bc0d1a6663f 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -147,10 +147,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent } const { resolvedLaunchPlatform, isRemote, queuedShell } = executionContext const effectiveAgentArgs = - agentArgs !== undefined - ? agentArgs - : resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) - const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) + agentArgs !== undefined ? agentArgs : resolveTuiAgentLaunchArgs(agent, store.settings) + const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings, resolvedLaunchPlatform) const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' diff --git a/src/renderer/src/lib/launch-parity-workspace-start.test-fixture.ts b/src/renderer/src/lib/launch-parity-workspace-start.test-fixture.ts index 682c37a4004..e15180f3b67 100644 --- a/src/renderer/src/lib/launch-parity-workspace-start.test-fixture.ts +++ b/src/renderer/src/lib/launch-parity-workspace-start.test-fixture.ts @@ -94,8 +94,8 @@ export async function startWorkspaceAgent( autoRenameBranchFromWork: false, // As the composer's folder submit passes them. agentCmdOverrides: settings?.agentCmdOverrides, - agentArgs: resolveTuiAgentLaunchArgs(producer.agent, settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(producer.agent, settings?.agentDefaultEnv), + agentArgs: resolveTuiAgentLaunchArgs(producer.agent, settings), + agentEnv: resolveTuiAgentLaunchEnv(producer.agent, settings), terminalWindowsShell: settings?.terminalWindowsShell, launchSource: 'new_workspace_composer', createFolderWorkspace: async () => folderWorkspace ?? null, diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index f55bcf3cadf..ec105dddb16 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -13,7 +13,8 @@ import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' import { resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv + resolveTuiAgentLaunchEnv, + type AgentLaunchProfileSettings } from '../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' @@ -23,11 +24,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { draftContent: string promptDelivery: 'draft' | 'submit-after-ready' settings: - | { - agentCmdOverrides?: Partial> - agentDefaultArgs?: Partial> - agentDefaultEnv?: Partial>> - } + | ({ agentCmdOverrides?: Partial> } & AgentLaunchProfileSettings) | null | undefined launchPlatform: NodeJS.Platform @@ -47,9 +44,9 @@ export function buildDirectWorkItemAgentStartupPlan(args: { const effectiveAgentArgs = args.agentArgs === undefined - ? resolveTuiAgentLaunchArgs(args.agent, args.settings?.agentDefaultArgs) + ? resolveTuiAgentLaunchArgs(args.agent, args.settings) : args.agentArgs - const effectiveAgentEnv = resolveTuiAgentLaunchEnv(args.agent, args.settings?.agentDefaultEnv) + const effectiveAgentEnv = resolveTuiAgentLaunchEnv(args.agent, args.settings, args.launchPlatform) const draftLaunchPlan = args.promptDelivery === 'submit-after-ready' ? null diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index d35c1350ccd..c80702991d6 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -48,8 +48,8 @@ export function buildOnboardingFolderAgentStartup( agent, prompt: '', cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + agentArgs: resolveTuiAgentLaunchArgs(agent, settings), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings, host.platform), ...host, allowEmptyPromptLaunch: true }) diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.ts b/src/renderer/src/runtime/structured-agent-session-host-capability.ts index 16e565cdf81..d9deb58b996 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts @@ -1,3 +1,4 @@ +import { AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY } from '../../../shared/agent-launch-runtime-capability' import { useEffect, useState } from 'react' import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, @@ -156,3 +157,11 @@ export function useStructuredAgentSessionHostEditsQueuedImages( AGENT_SESSION_QUEUED_MESSAGE_IMAGE_EDIT_RUNTIME_CAPABILITY ) } + +/** The API runtime owns Chat settings; on web the local API forwards to the active paired host. */ +export function useChatSettingsTypedPermissions(): boolean { + return useStructuredAgentSessionHostCapability( + { kind: 'local' }, + AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY + ) +} diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index 9fce9b24728..a80f4b24caa 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -125,7 +125,8 @@ it.each(['paired-linux', 'local-bash', 'withheld', 'structured-withheld'] as con experimentalNativeChat: true, nativeChatProviders: { claude: target === 'structured-withheld' }, terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash.exe', - agentDefaultArgs: { claude: '--append-system-prompt "don\'t edit"' } + agentDefaultArgs: { claude: '--append-system-prompt "don\'t edit"' }, + agentPermissionOverrides: { claude: 'own' } }, runtimeStatusByEnvironmentId: new Map([ [ diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 11b127fdfee..f9760b2397f 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -18,10 +18,7 @@ import { normalizeOpenInApplications } from '../../../../shared/open-in-applicat import { createSettingsSearchState, type SettingsSearchState } from './settings-search-state' import { isRuntimeCatalogListingStale } from './runtime-status-hydration' import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' -import { - normalizeTuiAgentArgsRecord, - normalizeTuiAgentEnvRecord -} from '../../../../shared/tui-agent-launch-defaults' +import { sanitizeAgentLaunchProfileUpdates } from '../../../../shared/tui-agent-launch-defaults' import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-context' import { normalizeUiLanguage } from '../../../../shared/ui-language' import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy' @@ -106,14 +103,7 @@ function normalizeSettingsUpdates( if ('disabledTuiAgents' in updates) { sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) } - if ('agentDefaultArgs' in updates) { - sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs) - sanitizedUpdates.agentYoloDefaultsMigrated = true - } - if ('agentDefaultEnv' in updates) { - sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv) - sanitizedUpdates.agentYoloDefaultsMigrated = true - } + sanitizeAgentLaunchProfileUpdates(sanitizedUpdates, updates) if ('uiLanguage' in updates) { sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage) } diff --git a/src/renderer/src/web/preload-api/web-chat-permission-normalization.test.ts b/src/renderer/src/web/preload-api/web-chat-permission-normalization.test.ts index e747d884c1b..974b94ea266 100644 --- a/src/renderer/src/web/preload-api/web-chat-permission-normalization.test.ts +++ b/src/renderer/src/web/preload-api/web-chat-permission-normalization.test.ts @@ -1,5 +1,50 @@ import { expect, it } from 'vitest' -import { runtimeChatPermissionSetting } from './web-preference-normalization' +import { + migrateStoredWebSettings, + runtimeChatPermissionSetting +} from './web-preference-normalization' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { resolveAgentPermission } from '../../../../shared/tui-agent-permission-choice' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../../../shared/tui-agent-launch-defaults' + +it.each([ + ['mixed', '--dangerously-skip-permissions --model sonnet', '--model sonnet', 'full-access'], + ['explicit empty', '', '', 'own'], + [ + 'Claude plan', + '--permission-mode plan --model sonnet', + '--permission-mode plan --model sonnet', + 'own' + ] +] as const)( + 'migrates saved browser %s Arguments once while retaining Chat', + (_name, args, raw, choice) => { + // Recorded Goose process environment: ticket 1447, PID 20979, creation validated around capture. + const recordedGooseEnv = { GOOSE_MODE: 'auto', QA_GOOSE_NOTE: 'ordinary70a4' } + const stored: Partial = { + agentDefaultArgs: { claude: args, codex: '' }, + agentDefaultEnv: { goose: recordedGooseEnv }, + nativeChatPermissionMode: 'ask', + agentNativeChatPermissionModes: { claude: 'auto', codex: 'ask' } + } + const loaded = migrateStoredWebSettings(stored) + expect(loaded.agentPermissionDefault).toBeDefined() + expect(resolveAgentPermission('claude', loaded).choice).toBe(choice) + expect(resolveAgentPermission('codex', loaded).choice).toBe('own') + expect(loaded.agentDefaultArgs?.claude).toBe(raw) + expect(loaded.agentDefaultArgs?.codex).toBe('') + expect(resolveTuiAgentLaunchArgs('claude', loaded)).toBe(args) + expect(loaded.nativeChatPermissionMode).toBe('ask') + expect(loaded.agentNativeChatPermissionModes).toEqual(stored.agentNativeChatPermissionModes) + expect(resolveAgentPermission('goose', loaded).choice).toBe('full-access') + expect(loaded.agentDefaultEnv?.goose).toEqual({ QA_GOOSE_NOTE: 'ordinary70a4' }) + expect(resolveTuiAgentLaunchEnv('goose', loaded)).toEqual(recordedGooseEnv) + expect(migrateStoredWebSettings(JSON.parse(JSON.stringify(loaded)))).toEqual(loaded) + } +) it('clears a previous host scoped default when reading settings from a pre-native-sync host', () => { const cached = { diff --git a/src/renderer/src/web/preload-api/web-preference-normalization.ts b/src/renderer/src/web/preload-api/web-preference-normalization.ts index 36f21082f5a..a31978d3d3b 100644 --- a/src/renderer/src/web/preload-api/web-preference-normalization.ts +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -22,11 +22,18 @@ import type { PersistedUIState } from '../../../../shared/persisted-ui-state-typ import { normalizeStatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes' import { + DEFAULT_TUI_AGENT_ARGS, + DEFAULT_TUI_AGENT_ENV, normalizeTuiAgentArgsRecord, normalizeTuiAgentEnvRecord } from '../../../../shared/tui-agent-launch-defaults' +import { hasTypedAgentPermission } from '../../../../shared/tui-agent-permission-choice' +import { migrateAgentPermissionChoice } from '../../../../shared/tui-agent-permission-lift' +import { normalizeOsc52ClipboardDefaultOn } from '../../../../shared/osc52-clipboard-settings' +import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings' import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' import { normalizeUiLanguage } from '../../../../shared/ui-language' +import { migratePortugueseUiLanguageOnLoad } from '../../../../shared/portuguese-ui-language-migration' import { normalizeUsagePercentageDisplay } from '../../../../shared/usage-percentage-display' import { mergeWorkspaceCleanupUIState } from '../../../../shared/workspace-cleanup-ui-state' @@ -143,6 +150,34 @@ export function mergeOsc52ClipboardNoticePending( ) } +/** A browser-saved settings blob with each load-time migration applied. */ +export function migrateStoredWebSettings(stored: Partial): Partial { + return { + ...stored, + ...migrateBrowserSavedAgentPermission(stored), + ...normalizeAutoRenameBranchFromWorkDefaultOn(stored), + ...normalizeTerminalCursorStyleDefault(stored), + ...normalizeOsc52ClipboardDefaultOn(stored), + terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes), + ...migratePortugueseUiLanguageOnLoad(stored) + } +} + +/** Browser-saved Arguments from before the stored permission carry their flags; moved so the + * stored default can't add a second one. A missing key launched with the shipped default. */ +function migrateBrowserSavedAgentPermission(stored: Partial) { + if (hasTypedAgentPermission(stored) || (!stored.agentDefaultArgs && !stored.agentDefaultEnv)) { + return {} + } + return migrateAgentPermissionChoice( + { + agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS, ...stored.agentDefaultArgs }, + agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV, ...stored.agentDefaultEnv } + }, + null + ) +} + export function mergeSettings( base: GlobalSettings, updates: Partial, diff --git a/src/renderer/src/web/preload-api/web-preferences-store.ts b/src/renderer/src/web/preload-api/web-preferences-store.ts index 346c9ba193d..2d6e86255a8 100644 --- a/src/renderer/src/web/preload-api/web-preferences-store.ts +++ b/src/renderer/src/web/preload-api/web-preferences-store.ts @@ -1,5 +1,4 @@ import type { PreloadApi } from '../../../../preload/api-types' -import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings' import { getDefaultSettings, getDefaultUIState, @@ -7,22 +6,17 @@ import { } from '../../../../shared/constants' import { normalizeWorktreeVisibilityDefaults } from '../../../../shared/external-worktree-visibility' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { - normalizeOsc52ClipboardDefaultOn, - osc52ClipboardDefaultOnOverridesPersistedOff -} from '../../../../shared/osc52-clipboard-settings' +import { osc52ClipboardDefaultOnOverridesPersistedOff } from '../../../../shared/osc52-clipboard-settings' import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' import { applyPRBotAuthorOverride, normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides' -import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings' -import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes' -import { migratePortugueseUiLanguageOnLoad } from '../../../../shared/portuguese-ui-language-migration' import { readStoredWebRuntimeEnvironment } from '../web-runtime-environment' import { mergeSettings, mergeWebUIState, + migrateStoredWebSettings, runtimeChatPermissionSetting } from './web-preference-normalization' import { callRuntimeResult } from './web-runtime-calls' @@ -37,14 +31,7 @@ export function getStoredSettings(): GlobalSettings { const defaults = getDefaultSettings('~') const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY) const stored = readJson>(SETTINGS_STORAGE_KEY, {}) - const migratedStored = { - ...stored, - ...normalizeAutoRenameBranchFromWorkDefaultOn(stored), - ...normalizeTerminalCursorStyleDefault(stored), - ...normalizeOsc52ClipboardDefaultOn(stored), - terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes), - ...migratePortugueseUiLanguageOnLoad(stored) - } + const migratedStored = migrateStoredWebSettings(stored) if ( rawStoredSettings && (stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork || diff --git a/src/shared/agent-chat-permission-host-support.ts b/src/shared/agent-chat-permission-host-support.ts index 918273ef2eb..3c4ce2d57b6 100644 --- a/src/shared/agent-chat-permission-host-support.ts +++ b/src/shared/agent-chat-permission-host-support.ts @@ -43,7 +43,9 @@ export function legacyHostChatPermissionMode( typeof configured === 'object' && configured !== null && agent in configured ? Object.getOwnPropertyDescriptor(configured, agent)?.value : undefined - const args = resolveTuiAgentLaunchArgs(agent, typeof raw === 'string' ? { [agent]: raw } : {}) + const args = resolveTuiAgentLaunchArgs(agent, { + agentDefaultArgs: typeof raw === 'string' ? { [agent]: raw } : {} + }) return HOST_STARTUP_SHELLS.some((shell) => tuiAgentArgsBypassPermissions(agent, args, shell)) ? 'full-access' : 'ask' diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index e6447636683..03dc8244cf8 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -4,6 +4,12 @@ import { resolveTuiAgentLaunchArgs } from './tui-agent-launch-defaults' import { AUTO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' +import { + hasTypedAgentPermission, + resolveAgentPermission, + type AgentPermissionSettings +} from './tui-agent-permission-choice' +import { isTuiAgent } from './tui-agent-config' import type { TuiAgent } from './tui-agent' /** A chat's own permission choice. Saved in chat records and settings: never rename a value. */ @@ -35,46 +41,14 @@ export function isAgentChatPermissionMode(value: unknown): value is AgentChatPer return AGENT_CHAT_PERMISSION_MODES.some((mode) => mode === value) } -/** The modes one agent's chats offer. */ -export type AgentChatPermissionModeSupport = { - modes: readonly AgentChatPermissionMode[] - /** `auto` runs only on a model the agent's listing says supports it. */ - autoNeedsModelSupport?: true -} - -const CLAUDE_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { - modes: AGENT_CHAT_PERMISSION_MODES, - autoNeedsModelSupport: true -} - -const CODEX_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { - modes: ['ask', 'auto', 'full-access'] -} - -// ACP chats: Orca answers the agent's permission requests yes under Full access, else the person does. -const ACP_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { - modes: ['ask', 'full-access'] -} - -// No Auto: the server has no reviewer that answers approvals for the person. -const ANTIGRAVITY_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { - modes: ['ask', 'accept-edits', 'full-access'] -} - -const AGENT_CHAT_PERMISSION_MODES_BY_AGENT = new Map([ - ['claude', CLAUDE_CHAT_PERMISSION_MODES], - ['codex', CODEX_CHAT_PERMISSION_MODES], - ['grok', ACP_CHAT_PERMISSION_MODES], - ['opencode', ACP_CHAT_PERMISSION_MODES], - ['opencode2', ACP_CHAT_PERMISSION_MODES], - ['omp', ACP_CHAT_PERMISSION_MODES], - ['antigravity', ANTIGRAVITY_CHAT_PERMISSION_MODES] -]) - -/** The one table of which agents' chats offer which modes; null keeps following Agent Permissions. */ -export function agentChatPermissionModes(agent: string): AgentChatPermissionModeSupport | null { - return AGENT_CHAT_PERMISSION_MODES_BY_AGENT.get(agent) ?? null -} +import { + agentChatPermissionModes, + type AgentChatPermissionModeSupport +} from './agent-chat-permission-support' +export { + agentChatPermissionModes, + type AgentChatPermissionModeSupport +} from './agent-chat-permission-support' /** The saved choice if this agent offers it; anything else asks, never grants more access. */ export function savedChatPermissionMode( @@ -136,14 +110,25 @@ export function newChatPermissionMode( return support ? savedChatPermissionMode(support, setting) : null } -/** A scoped agent choice takes precedence over the shared new-chat default. */ +export type NewChatPermissionSettings = AgentPermissionSettings & + Partial> + +/** + * An agent's stored permission starts its new chats, as it starts its terminals. On its own + * settings, chats keep the Chat page default (or its per-agent copy) until chats can run the + * agent's own config. + */ export function newChatPermissionModeForSettings( agent: string, - settings: - | Partial> - | null - | undefined + settings: NewChatPermissionSettings | null | undefined ): AgentChatPermissionMode | null { + const support = agentChatPermissionModes(agent) + if (support && isTuiAgent(agent) && hasTypedAgentPermission(settings)) { + const { choice } = resolveAgentPermission(agent, settings) + if (choice !== 'own') { + return savedChatPermissionMode(support, choice) + } + } const overrides = settings?.agentNativeChatPermissionModes const override = overrides && Object.getOwnPropertyDescriptor(overrides, agent)?.value return newChatPermissionMode(agent, override ?? settings?.nativeChatPermissionMode) @@ -172,8 +157,7 @@ export function loadedChatPermissionSetting( return isAgentChatPermissionMode(saved) ? saved : 'ask' } const automatic = (agent: 'claude' | 'codex'): boolean => - resolveTuiAgentLaunchArgs(agent, terminal.agentDefaultArgs).trim() === - AUTO_TUI_AGENT_ARGS[agent] + resolveTuiAgentLaunchArgs(agent, terminal).trim() === AUTO_TUI_AGENT_ARGS[agent] if (automatic('claude') && automatic('codex')) { return 'auto' } @@ -188,9 +172,10 @@ export function loadedChatPermissionSetting( * the new-chat setting. */ export function preModeChatGetsFullAccess( agent: TuiAgent, - settings: Partial< - Pick - >, + settings: AgentPermissionSettings & + Partial< + Pick + >, platform: NodeJS.Platform ): boolean { return agent in YOLO_TUI_AGENT_ARGS diff --git a/src/shared/agent-chat-permission-support.ts b/src/shared/agent-chat-permission-support.ts new file mode 100644 index 00000000000..02b65dbf0cf --- /dev/null +++ b/src/shared/agent-chat-permission-support.ts @@ -0,0 +1,41 @@ +import type { AgentChatPermissionMode } from './agent-chat-permission-mode' +/** The modes one agent's chats offer. */ +export type AgentChatPermissionModeSupport = { + modes: readonly AgentChatPermissionMode[] + /** `auto` runs only on a model the agent's listing says supports it. */ + autoNeedsModelSupport?: true +} + +const CLAUDE_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { + modes: ['ask', 'accept-edits', 'auto', 'full-access'], + autoNeedsModelSupport: true +} + +const CODEX_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { + modes: ['ask', 'auto', 'full-access'] +} + +// ACP chats: Orca answers the agent's permission requests yes under Full access, else the person does. +const ACP_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { + modes: ['ask', 'full-access'] +} + +// No Auto: the server has no reviewer that answers approvals for the person. +const ANTIGRAVITY_CHAT_PERMISSION_MODES: AgentChatPermissionModeSupport = { + modes: ['ask', 'accept-edits', 'full-access'] +} + +const AGENT_CHAT_PERMISSION_MODES_BY_AGENT = new Map([ + ['claude', CLAUDE_CHAT_PERMISSION_MODES], + ['codex', CODEX_CHAT_PERMISSION_MODES], + ['grok', ACP_CHAT_PERMISSION_MODES], + ['opencode', ACP_CHAT_PERMISSION_MODES], + ['opencode2', ACP_CHAT_PERMISSION_MODES], + ['omp', ACP_CHAT_PERMISSION_MODES], + ['antigravity', ANTIGRAVITY_CHAT_PERMISSION_MODES] +]) + +/** The one table of which agents' chats offer which modes; null keeps following Agent Permissions. */ +export function agentChatPermissionModes(agent: string): AgentChatPermissionModeSupport | null { + return AGENT_CHAT_PERMISSION_MODES_BY_AGENT.get(agent) ?? null +} diff --git a/src/shared/agent-launch-environment-updates.ts b/src/shared/agent-launch-environment-updates.ts new file mode 100644 index 00000000000..ec667416b6d --- /dev/null +++ b/src/shared/agent-launch-environment-updates.ts @@ -0,0 +1,95 @@ +import { agentPermissionHostPlatform, permissionEnvNameMatches } from './agent-permission-env' +import type { + AgentLaunchSettingsMutation, + AgentLaunchSettingsSource +} from './agent-launch-settings' +import { resolveTuiAgentLaunchEnv } from './tui-agent-launch-defaults' +import { + agentPermissionEnv, + applyAgentPermissionOverrides, + hasTypedAgentPermission, + resolveAgentPermission, + type AgentPermissionChoice +} from './tui-agent-permission-choice' +import { liftAgentPermissionEnv } from './tui-agent-permission-lift' +import type { TuiAgent } from './tui-agent' + +/** An agent's choice changed only when it differs from what it resolves to now. */ +export function choiceUpdate( + settings: AgentLaunchSettingsSource, + agent: TuiAgent, + choice: AgentPermissionChoice +): AgentLaunchSettingsSource { + return resolveAgentPermission(agent, settings).choice === choice + ? {} + : applyAgentPermissionOverrides(settings, [agent], choice) +} + +function hasEnvPermission(agent: TuiAgent): boolean { + return Object.keys(agentPermissionEnv(agent, 'full-access')).length > 0 +} + +/** A whole env record as its terminal gets it; for Goose, the bypass entry is the choice. */ +export function legacyEnvUpdates( + settings: AgentLaunchSettingsSource, + agent: TuiAgent, + env: Record, + platform: NodeJS.Platform | undefined = agentPermissionHostPlatform() +): AgentLaunchSettingsSource { + if (!hasEnvPermission(agent)) { + return { agentDefaultEnv: { ...settings.agentDefaultEnv, [agent]: env } } + } + const previous = resolveTuiAgentLaunchEnv(agent, settings, platform) + const names = Object.keys(agentPermissionEnv(agent, 'full-access')) + const carrier = (value: Record) => + Object.entries(value) + .filter(([key]) => names.some((name) => permissionEnvNameMatches(key, name, platform))) + .sort(([a], [b]) => a.localeCompare(b)) + if (JSON.stringify(carrier(previous)) === JSON.stringify(carrier(env))) { + const user = { ...env } + for (const [key] of carrier(env)) { + delete user[key] + } + return { + agentDefaultEnv: { + ...settings.agentDefaultEnv, + [agent]: { + ...user, + ...Object.fromEntries(carrier(settings.agentDefaultEnv?.[agent] ?? {})) + } + } + } + } + const lifted = liftAgentPermissionEnv(agent, env, platform) + return { + agentDefaultEnv: { ...settings.agentDefaultEnv, [agent]: lifted.env }, + ...choiceUpdate(settings, agent, lifted.choice ?? 'own') + } +} + +export function agentEnvironmentMutationUpdates( + settings: AgentLaunchSettingsSource, + mutation: Extract< + AgentLaunchSettingsMutation, + { type: 'environment-set' | 'environment-remove' } + >, + platform: NodeJS.Platform +): AgentLaunchSettingsSource { + // Edits the env the terminal gets, so removing Goose's published bypass entry means own. + const env = { ...resolveTuiAgentLaunchEnv(mutation.agent, settings, platform) } + // Windows environment names are case insensitive on the execution host. + for (const name of Object.keys(env)) { + if ( + name === mutation.name || + (platform === 'win32' && name.toLowerCase() === mutation.name.toLowerCase()) + ) { + delete env[name] + } + } + const nextEnv = + mutation.type === 'environment-set' ? { ...env, [mutation.name]: mutation.value } : env + if (hasTypedAgentPermission(settings)) { + return legacyEnvUpdates(settings, mutation.agent, nextEnv, platform) + } + return { agentDefaultEnv: { ...settings.agentDefaultEnv, [mutation.agent]: nextEnv } } +} diff --git a/src/shared/agent-launch-permission-updates.ts b/src/shared/agent-launch-permission-updates.ts new file mode 100644 index 00000000000..04d7b97c569 --- /dev/null +++ b/src/shared/agent-launch-permission-updates.ts @@ -0,0 +1,243 @@ +import { agentPermissionHostPlatform, permissionEnvNameMatches } from './agent-permission-env' +import { + agentEnvironmentMutationUpdates, + choiceUpdate, + legacyEnvUpdates +} from './agent-launch-environment-updates' +import type { + AgentLaunchSettingsMutation, + AgentLaunchSettingsSource +} from './agent-launch-settings' +import { + composeAgentLaunchProfile, + normalizeTuiAgentArgsRecord, + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from './tui-agent-launch-defaults' +import { + agentPermissionArgs, + agentPermissionChoices, + agentPermissionEnv, + applyAgentPermissionOverrides, + applyAllAgentsPermission, + hasTypedAgentPermission, + PERMISSION_CHOICE_AGENTS, + resolveAgentPermission, + type AgentPermissionChoice +} from './tui-agent-permission-choice' +import { liftAgentPermissionArgs } from './tui-agent-permission-lift' +import { isTuiAgent } from './tui-agent-config' +import type { TuiAgent } from './tui-agent' + +// Agent launch settings writes against the stored permission, including the shapes older clients +// send, which treat the permission flag as part of the Arguments text. + +export function permissionUpdates( + settings: AgentLaunchSettingsSource, + mutation: Extract +): AgentLaunchSettingsSource { + const { agent, choice } = mutation + if (agent) { + if (choice !== 'default' && !agentPermissionChoices(agent).includes(choice)) { + throw new Error('This agent does not offer that permission') + } + return applyAgentPermissionOverrides(settings, [agent], choice) + } + if (choice === 'default') { + throw new Error('Choose a permission for all agents') + } + if (mutation.withoutAuto) { + const agents = PERMISSION_CHOICE_AGENTS.filter( + (candidate) => !agentPermissionChoices(candidate).includes('auto') + ) + return applyAgentPermissionOverrides(settings, agents, choice) + } + return applyAllAgentsPermission(settings, choice) +} + +/** An older client's Auto/Yolo/Manual switch, as the choice it means. Manual passed no flag. */ +export function legacyPermissionsUpdates( + settings: AgentLaunchSettingsSource, + mutation: Extract +): AgentLaunchSettingsSource { + const choice: AgentPermissionChoice = + mutation.mode === 'yolo' ? 'full-access' : mutation.mode === 'auto' ? 'auto' : 'own' + if (mutation.agent) { + return applyAgentPermissionOverrides(settings, [mutation.agent], choice) + } + if (mutation.exceptAuto) { + return permissionUpdates(settings, { type: 'permission', choice, withoutAuto: true }) + } + return applyAllAgentsPermission(settings, choice) +} + +/** + * Arguments text from a client that sees the flag as part of it. Unchanged text changes nothing; + * otherwise Orca's flag in it becomes the choice, and text without one means the agent's own. + */ +export function legacyArgumentsUpdates( + settings: AgentLaunchSettingsSource, + agent: TuiAgent, + value: string +): AgentLaunchSettingsSource { + if (value.trim() === resolveTuiAgentLaunchArgs(agent, settings).trim()) { + return {} + } + const lifted = liftAgentPermissionArgs(agent, value) + return { + agentDefaultArgs: normalizeTuiAgentArgsRecord({ + ...settings.agentDefaultArgs, + [agent]: lifted.args + }), + ...(lifted.choice + ? choiceUpdate(settings, agent, lifted.choice) + : agentPermissionArgs(agent, resolveAgentPermission(agent, settings).choice) && + resolveTuiAgentLaunchArgs(agent, settings).trim() !== + (settings.agentDefaultArgs?.[agent]?.trim() ?? '') + ? choiceUpdate(settings, agent, 'own') + : {}) + } +} + +/** + * Typed-store updates for a client that writes whole Arguments or env records (an older paired + * client's settings update): each changed agent is read as `legacyArgumentsUpdates` does. + */ +export function legacyLaunchTextUpdates( + settings: AgentLaunchSettingsSource, + updates: Pick, + platform: NodeJS.Platform | undefined = agentPermissionHostPlatform() +): AgentLaunchSettingsSource { + if (!hasTypedAgentPermission(settings)) { + return updates + } + const published = composeAgentLaunchProfile(settings, platform) + const args: Partial> = {} + const envs: Partial>> = {} + let overrides = settings.agentPermissionOverrides + if (updates.agentDefaultArgs !== undefined) { + for (const agent of new Set([ + ...Object.keys(published.agentDefaultArgs), + ...Object.keys(updates.agentDefaultArgs) + ])) { + if (!isTuiAgent(agent)) { + continue + } + const change = legacyArgumentsUpdates(settings, agent, updates.agentDefaultArgs[agent] ?? '') + if (Object.hasOwn(updates.agentDefaultArgs, agent)) { + args[agent] = change.agentDefaultArgs?.[agent] ?? settings.agentDefaultArgs?.[agent] ?? '' + } + if (change.agentPermissionOverrides?.[agent]) { + overrides = { ...overrides, [agent]: change.agentPermissionOverrides[agent] } + } + } + } + if (updates.agentDefaultEnv !== undefined) { + for (const agent of new Set([ + ...Object.keys(published.agentDefaultEnv), + ...Object.keys(updates.agentDefaultEnv) + ])) { + if (!isTuiAgent(agent)) { + continue + } + const change = legacyEnvUpdates( + settings, + agent, + updates.agentDefaultEnv[agent] ?? {}, + platform + ) + if (Object.hasOwn(updates.agentDefaultEnv, agent)) { + envs[agent] = change.agentDefaultEnv?.[agent] ?? settings.agentDefaultEnv?.[agent] ?? {} + } + if (change.agentPermissionOverrides?.[agent]) { + overrides = { ...overrides, [agent]: change.agentPermissionOverrides[agent] } + } + } + } + return { + ...(updates.agentDefaultArgs !== undefined ? { agentDefaultArgs: args } : {}), + ...(updates.agentDefaultEnv !== undefined ? { agentDefaultEnv: envs } : {}), + agentPermissionOverrides: overrides + } +} + +export function wholeAgentEnvironmentUpdates( + settings: AgentLaunchSettingsSource, + agent: TuiAgent, + requested: Record, + platform: NodeJS.Platform +): AgentLaunchSettingsSource { + const carrierNames = hasTypedAgentPermission(settings) + ? Object.keys(agentPermissionEnv(agent, 'full-access')) + : [] + const isCarrier = (name: string) => + carrierNames.some((carrier) => permissionEnvNameMatches(name, carrier, platform)) + const ordinary = new Map() + const groups = new Map>() + const identity = (name: string) => (platform === 'win32' ? name.toLowerCase() : name) + const carrierEntries: [string, string][] = [] + const previous = resolveTuiAgentLaunchEnv(agent, settings, platform, (name, value) => { + if (isCarrier(name)) { + carrierEntries.push([name, value]) + return true + } + ordinary.set(name, value) + const key = identity(name) + groups.set(key, (groups.get(key) ?? new Set()).add(name)) + return true + }) + const carrier: AgentLaunchSettingsSource = { + agentPermissionDefault: settings.agentPermissionDefault, + agentPermissionOverrides: settings.agentPermissionOverrides?.[agent] + ? { [agent]: settings.agentPermissionOverrides[agent] } + : {}, + agentDefaultEnv: { [agent]: Object.fromEntries(carrierEntries) } + } + let changed = false + const change = (name: string, present: boolean): void => { + changed = true + if (isCarrier(name)) { + const mutation: AgentLaunchSettingsMutation = present + ? { type: 'environment-set', agent, name, value: requested[name] } + : { type: 'environment-remove', agent, name } + Object.assign(carrier, agentEnvironmentMutationUpdates(carrier, mutation, platform)) + return + } + const key = identity(name) + const group = groups.get(key) ?? new Set() + for (const spelling of group) { + ordinary.delete(spelling) + } + if (present) { + ordinary.set(name, requested[name]) + if (group.size !== 1 || !group.has(name)) { + groups.set(key, new Set().add(name)) + } + } else { + groups.delete(key) + } + } + for (const name in previous) { + if (Object.hasOwn(previous, name) && previous[name] !== requested[name]) { + change(name, Object.hasOwn(requested, name)) + } + } + for (const name in requested) { + if (Object.hasOwn(requested, name) && !Object.hasOwn(previous, name)) { + change(name, true) + } + } + if (!changed) { + return {} + } + Object.entries(carrier.agentDefaultEnv?.[agent] ?? {}) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([name, value]) => ordinary.set(name, value)) + const override = carrier.agentPermissionOverrides?.[agent] + return { + agentDefaultEnv: { ...settings.agentDefaultEnv, [agent]: Object.fromEntries(ordinary) }, + ...(override !== settings.agentPermissionOverrides?.[agent] + ? applyAgentPermissionOverrides(settings, [agent], override ?? 'default') + : {}) + } +} diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index 5f24c2fb518..5239cc9414c 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -69,6 +69,10 @@ export const AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY = export const AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY = 'settings.agent-launch.native-chat-permissions.v1' as const +// Host stores each agent's permission apart from its Arguments: accepts `permission` and +// `user-arguments` and publishes `agentPermission`. Older hosts keep the permission in the text. +export const AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY = 'settings.agent-permission.v1' as const + // Host-advertised: reads `surface`, the user's chat or terminal pick for one launch, and refuses an // explicit chat it cannot open before creating anything. Older hosts strip the field. export const AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY = 'agent.launch.surface.v1' as const @@ -77,6 +81,7 @@ export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [ AGENT_LAUNCH_SETTINGS_CAPABILITY, AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY, AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY, + AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY, AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, diff --git a/src/shared/agent-launch-settings.ts b/src/shared/agent-launch-settings.ts index 6c73d09f1e2..191c4942eb2 100644 --- a/src/shared/agent-launch-settings.ts +++ b/src/shared/agent-launch-settings.ts @@ -1,3 +1,4 @@ +import { agentEnvironmentMutationUpdates, choiceUpdate } from './agent-launch-environment-updates' import type { GlobalSettings } from './global-settings-types' import { agentChatPermissionModes, @@ -11,10 +12,18 @@ import { normalizeDisabledTuiAgents } from './tui-agent-selection' import { + composeAgentLaunchProfile, normalizeTuiAgentArgsRecord, resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from './tui-agent-launch-defaults' +import { hasTypedAgentPermission, type AgentPermissionChoice } from './tui-agent-permission-choice' +import { liftAgentPermissionArgs } from './tui-agent-permission-lift' +import { + legacyArgumentsUpdates, + legacyPermissionsUpdates, + permissionUpdates +} from './agent-launch-permission-updates' import { applyAgentPermissionMode, AUTO_TUI_AGENT_ARGS, @@ -35,6 +44,8 @@ export type AgentLaunchSettingsSource = Partial< | 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv' + | 'agentPermissionDefault' + | 'agentPermissionOverrides' | 'nativeChatPermissionMode' | 'agentNativeChatPermissionModes' > @@ -51,6 +62,15 @@ export type AgentLaunchSettings = { autoPermissionMode?: true /** With Auto on: the combined mode of agents Auto doesn't cover. Absent from older hosts. */ nonAutoPermissionMode?: 'manual' | 'yolo' | 'mixed' + /** + * The stored permission and each agent's own Arguments without Orca's flag. `agentDefaultArgs` + * keeps the flag for older clients. Absent from hosts that store the permission as text. + */ + agentPermission?: { + default: AgentPermissionChoice + overrides: Partial> + userArgs: Partial> + } } export type AgentLaunchSettingsMutation = @@ -64,23 +84,35 @@ export type AgentLaunchSettingsMutation = exceptAuto?: true syncNativeChat?: true } + /** Hosts with the stored permission. No agent: all agents; `withoutAuto`: agents without Auto. */ + | { + type: 'permission' + choice: AgentPermissionChoice | 'default' + agent?: TuiAgent + withoutAuto?: true + } | { type: 'command'; agent: TuiAgent; value: string } + /** The whole terminal text, flag included, as older clients see it. */ | { type: 'arguments'; agent: TuiAgent; value: string } + /** Only the user's own text; a permission flag Orca recognizes in it becomes the choice. */ + | { type: 'user-arguments'; agent: TuiAgent; value: string } | { type: 'environment-set'; agent: TuiAgent; name: string; value: string } | { type: 'environment-remove'; agent: TuiAgent; name: string } export function projectAgentLaunchSettings( - settings: AgentLaunchSettingsSource + settings: AgentLaunchSettingsSource, + platform?: NodeJS.Platform ): AgentLaunchSettings { - const permissionMode = resolveAgentPermissionModeSummary(settings) + const composed = composeAgentLaunchProfile(settings, platform) + const permissionMode = resolveAgentPermissionModeSummary(composed) return { defaultTuiAgent: settings.defaultTuiAgent ?? null, newWorkspaceSetupOnly: false, disabledTuiAgents: normalizeDisabledTuiAgents(settings.disabledTuiAgents), agentCmdOverrides: { ...settings.agentCmdOverrides }, - agentDefaultArgs: normalizeTuiAgentArgsRecord(settings.agentDefaultArgs), + agentDefaultArgs: normalizeTuiAgentArgsRecord(composed.agentDefaultArgs), environmentNames: Object.fromEntries( - Object.entries(settings.agentDefaultEnv ?? {}).map(([agent, env]) => [ + Object.entries(composed.agentDefaultEnv).map(([agent, env]) => [ agent, Object.keys(env ?? {}).sort() ]) @@ -90,7 +122,16 @@ export function projectAgentLaunchSettings( ...(permissionMode === 'auto' ? { autoPermissionMode: true, - nonAutoPermissionMode: resolveNonAutoAgentPermissionMode(settings) + nonAutoPermissionMode: resolveNonAutoAgentPermissionMode(composed) + } + : {}), + ...(hasTypedAgentPermission(settings) + ? { + agentPermission: { + default: settings.agentPermissionDefault ?? 'own', + overrides: { ...settings.agentPermissionOverrides }, + userArgs: normalizeTuiAgentArgsRecord(settings.agentDefaultArgs) + } } : {}) } @@ -110,8 +151,8 @@ function nativeChatModesForPreset( agentChatPermissionModes(agent) && resolveTuiAgentPermissionMode({ agent, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) + agentArgs: resolveTuiAgentLaunchArgs(agent, settings), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings) }) !== 'mixed' ) .map((agent) => [agent, mode]) @@ -135,9 +176,17 @@ export function agentLaunchSettingsMutationUpdates( mutation.agent, mutation.enabled ) + case 'permission': + if (!hasTypedAgentPermission(settings)) { + throw new Error('This host stores permissions in launch arguments') + } + return permissionUpdates(settings, mutation) case 'permissions': { const nativeMode = mutation.mode === 'yolo' ? 'full-access' : mutation.mode === 'auto' ? 'auto' : 'ask' + const typed = hasTypedAgentPermission(settings) + const terminal = (args: Parameters[0]) => + typed ? legacyPermissionsUpdates(settings, mutation) : applyAgentPermissionMode(args) if (mutation.agent) { const agent = mutation.agent if (!(agent in YOLO_TUI_AGENT_ARGS) && !(agent in YOLO_TUI_AGENT_ENV)) { @@ -147,16 +196,17 @@ export function agentLaunchSettingsMutationUpdates( throw new Error('This agent does not support automatic permission review') } if ( + !typed && resolveTuiAgentPermissionMode({ agent, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) + agentArgs: resolveTuiAgentLaunchArgs(agent, settings), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings) }) === 'mixed' ) { throw new Error('Edit custom launch arguments before choosing a permission mode') } return { - ...applyAgentPermissionMode({ ...settings, mode: mutation.mode, agent }), + ...terminal({ ...settings, mode: mutation.mode, agent }), ...(mutation.syncNativeChat && agentChatPermissionModes(agent) ? { agentNativeChatPermissionModes: { @@ -172,7 +222,7 @@ export function agentLaunchSettingsMutationUpdates( throw new Error('Auto cannot apply to agents without automatic permission review') } return { - ...applyAgentPermissionMode({ ...settings, mode: mutation.mode, exceptAuto: true }), + ...terminal({ ...settings, mode: mutation.mode, exceptAuto: true }), ...(mutation.syncNativeChat ? { agentNativeChatPermissionModes: { @@ -185,7 +235,7 @@ export function agentLaunchSettingsMutationUpdates( } if (mutation.mode === 'auto') { return { - ...applyAgentPermissionMode({ ...settings, mode: mutation.mode }), + ...terminal({ ...settings, mode: mutation.mode }), ...(mutation.syncNativeChat ? { agentNativeChatPermissionModes: { @@ -197,7 +247,7 @@ export function agentLaunchSettingsMutationUpdates( } } return { - ...applyAgentPermissionMode({ ...settings, mode: mutation.mode }), + ...terminal({ ...settings, mode: mutation.mode }), ...(mutation.syncNativeChat ? { nativeChatPermissionMode: nativeMode, agentNativeChatPermissionModes: {} } : {}) @@ -213,27 +263,30 @@ export function agentLaunchSettingsMutationUpdates( return { agentCmdOverrides: commands } } case 'arguments': + if (hasTypedAgentPermission(settings)) { + return legacyArgumentsUpdates(settings, mutation.agent, mutation.value) + } return { agentDefaultArgs: normalizeTuiAgentArgsRecord({ ...settings.agentDefaultArgs, [mutation.agent]: mutation.value }) } - case 'environment-set': - case 'environment-remove': { - const env = { ...resolveTuiAgentLaunchEnv(mutation.agent, settings.agentDefaultEnv) } - // Windows environment names are case insensitive on the execution host. - for (const name of Object.keys(env)) { - if ( - name === mutation.name || - (platform === 'win32' && name.toLowerCase() === mutation.name.toLowerCase()) - ) { - delete env[name] - } + case 'user-arguments': { + if (!hasTypedAgentPermission(settings)) { + throw new Error('This host stores permissions in launch arguments') + } + const lifted = liftAgentPermissionArgs(mutation.agent, mutation.value) + return { + agentDefaultArgs: normalizeTuiAgentArgsRecord({ + ...settings.agentDefaultArgs, + [mutation.agent]: lifted.args + }), + ...(lifted.choice ? choiceUpdate(settings, mutation.agent, lifted.choice) : {}) } - const nextEnv = - mutation.type === 'environment-set' ? { ...env, [mutation.name]: mutation.value } : env - return { agentDefaultEnv: { ...settings.agentDefaultEnv, [mutation.agent]: nextEnv } } } + case 'environment-set': + case 'environment-remove': + return agentEnvironmentMutationUpdates(settings, mutation, platform) } } diff --git a/src/shared/agent-permission-args.ts b/src/shared/agent-permission-args.ts new file mode 100644 index 00000000000..92e75a23547 --- /dev/null +++ b/src/shared/agent-permission-args.ts @@ -0,0 +1,296 @@ +import { agentRequiredValueOptions } from './agent-session-option-agent-args' +import { agentPermissionArgs, agentPermissionChoices } from './tui-agent-permission-choice' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import type { TuiAgent } from './tui-agent' + +export const CODEX_PERMISSION_CONFIG_ROOTS = [ + 'approval_policy', + 'sandbox_mode', + 'approvals_reviewer', + 'sandbox_workspace_write' +] as const + +const CLAUDE_PERMISSION_OPTIONS = [ + '--permission-mode', + '--dangerously-skip-permissions', + '--allow-dangerously-skip-permissions', + '--permission-prompt-tool', + '--permission-prompts', + '--allowedTools', + '--allowed-tools', + '--disallowedTools', + '--disallowed-tools' +] +const EXTRA_CARRIERS: Partial> = { + claude: CLAUDE_PERMISSION_OPTIONS, + 'claude-agent-teams': CLAUDE_PERMISSION_OPTIONS, + codex: ['-a', '--ask-for-approval', '-s', '--sandbox', '--yolo', '--not-so-yolo', '--full-auto'], + grok: ['--permission-mode', '--always-approve', '--yolo', '--dangerously-skip-permissions'], + gemini: ['--approval-mode', '-y'] +} +const CODEX_VALUES = [ + '-a', + '--ask-for-approval', + '-s', + '--sandbox', + '-c', + '--config', + '-m', + '--model', + '--local-provider', + '--enable', + '--disable' +] +const MODEL_VALUES = ['--model', '--effort'] +const CLAUDE_VARIADIC = [ + '--tools', + '--allowedTools', + '--allowed-tools', + '--disallowedTools', + '--disallowed-tools', + '--add-dir', + '--mcp-config', + '--betas', + '--plugin-dir' +] +const PERMISSION_LAUNCH_GRAMMARS: readonly AgentStartupShell[] = ['posix', 'powershell', 'cmd'] + +function words(text: string): string[] { + const parsed = tokenizeStartupCommand(text, 'posix') + return parsed.ok ? parsed.tokens : [] +} + +function optionName(agent: TuiAgent, token: string): string { + if (agent === 'codex' && /^-[acms].+/.test(token)) { + return token.slice(0, 2) + } + return token.split('=', 1)[0] +} + +/** Literal CLI roots: shell quoting is already removed; TOML quoted keys are not decoded. */ +function isPermissionConfig(value: string | undefined): boolean { + if (!value?.includes('=')) { + return false + } + const root = value.slice(0, value.indexOf('=')).trim().split('.')[0] + return CODEX_PERMISSION_CONFIG_ROOTS.some((key) => key === root) +} + +type PermissionOptionInventory = { + values: Set + carriers: Set + switches: Set +} +function permissionOptionInventory(agent: TuiAgent): PermissionOptionInventory { + const recipes = agentPermissionChoices(agent).flatMap((choice) => { + const tokens = words(agentPermissionArgs(agent, choice)) + return tokens.map((token, index) => ({ token, next: tokens[index + 1] })) + }) + const carriers = new Set(EXTRA_CARRIERS[agent]) + const values = new Set(agent === 'codex' ? CODEX_VALUES : []) + const switches = new Set() + for (const { token, next } of recipes) { + if (!token.startsWith('-')) { + continue + } + const name = optionName(agent, token) + if (name !== '-c' && name !== '--config') { + carriers.add(name) + } + if (token === name && next && !next.startsWith('-')) { + values.add(name) + } else { + switches.add(name) + } + } + return { carriers, values, switches } +} + +/** Bounded root-option ownership. Unknown regions stay uncertain, never rejected. */ +function analyzeAgentPermissionWords( + agent: TuiAgent, + tokens: readonly string[], + inventory = permissionOptionInventory(agent) +) { + const claude = agent === 'claude' || agent === 'claude-agent-teams' + const required = agentRequiredValueOptions(agent) + const roles: string[] = [] + const optionIndices = new Set() + let uncertain = false + let terminated = false + const carrierIndices: number[] = [] + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] + if (terminated) { + roles[index] = 'positional' + continue + } + if (token === '--' && !uncertain) { + roles[index] = 'terminator' + terminated = true + continue + } + const name = optionName(agent, token) + const inline = token !== name + const value = inline + ? token.slice(name.length + (token[name.length] === '=' ? 1 : 0)) + : tokens[index + 1] + const policy = + inventory.carriers.has(name) || + (agent === 'codex' && ['-c', '--config'].includes(name) && isPermissionConfig(value)) + if (policy) { + carrierIndices.push(index) + } + const knownValue = + inventory.values.has(name) || + required.includes(name) || + (['claude', 'claude-agent-teams', 'grok', 'codex'].includes(agent) && + MODEL_VALUES.includes(name)) + const known = + knownValue || + inventory.carriers.has(name) || + inventory.switches.has(name) || + (agent === 'codex' && ['--search', '--oss', '--no-alt-screen'].includes(name)) + roles[index] = uncertain || !known ? 'uncertain' : `option:${name}` + if (!uncertain && known) { + optionIndices.add(index) + } + if (!known) { + uncertain = true + } + if (!knownValue || inline || uncertain) { + continue + } + const consumesDash = required.includes(name) + if (value === undefined || (!consumesDash && value.startsWith('-'))) { + continue + } + roles[++index] = uncertain ? 'uncertain' : `value:${name}` + if (claude && CLAUDE_VARIADIC.includes(name)) { + while (tokens[index + 1] !== undefined && !tokens[index + 1].startsWith('-')) { + roles[++index] = uncertain ? 'uncertain' : `value:${name}` + } + } + } + return { roles, optionIndices, carrier: carrierIndices.length > 0, carrierIndices } +} + +export function agentArgsSetPermission(agent: TuiAgent, text: string | null | undefined): boolean { + return PERMISSION_LAUNCH_GRAMMARS.some((grammar) => { + const parsed = tokenizeStartupCommand(text ?? '', grammar) + return parsed.ok && analyzeAgentPermissionWords(agent, parsed.tokens).carrier + }) +} + +/** Extraction must preserve both shell words and their CLI owners after prefix composition. */ +export function permissionRecipeCut( + agent: TuiAgent, + text: string, + recipe: string +): { args: string } | null { + const recipeTokens = tokenizeStartupCommand(recipe, 'posix') + const wanted = recipeTokens.ok ? recipeTokens.tokens : [] + const rawRecipe = recipeTokens.ok + ? recipeTokens.spans.map((span) => recipe.slice(span.start, span.end)) + : [] + if (!wanted.length) { + return null + } + const inventory = permissionOptionInventory(agent) + const originals = PERMISSION_LAUNCH_GRAMMARS.map((grammar) => { + const parsed = tokenizeStartupCommand(text, grammar) + return parsed.ok + ? { + parsed, + analysis: analyzeAgentPermissionWords(agent, parsed.tokens, inventory), + offsets: new Map(parsed.spans.map((span, index) => [span.start, index])) + } + : null + }) + const firstOriginal = originals[0] + if (!firstOriginal || originals.some((original) => !original)) { + return null + } + const { parsed: first, analysis } = firstOriginal + const candidates = new Set([0]) + const firstCarrier = analysis.carrierIndices[0] + const lastCarrier = analysis.carrierIndices.at(-1) + if ( + !analysis.roles.includes('uncertain') && + firstCarrier !== undefined && + lastCarrier !== undefined + ) { + for ( + let index = Math.max(1, firstCarrier - wanted.length + 1); + index <= firstCarrier; + index += 1 + ) { + if (index + wanted.length > lastCarrier && analysis.optionIndices.has(index)) { + candidates.add(index) + } + } + } + for (const index of candidates) { + const run = first.spans.slice(index, index + wanted.length) + const last = run.at(-1) + if ( + !last || + !run.every((span, offset) => text.slice(span.start, span.end) === rawRecipe[offset]) + ) { + continue + } + const start = run[0].start + const end = last.end + const before = text.slice(0, start).trimEnd() + const after = text.slice(end).trimStart() + const args = before && after ? `${before} ${after}` : before || after + const proven = originals.every((original, grammarIndex) => { + if (!original) { + return false + } + const { parsed, analysis: originalAnalysis, offsets } = original + const cut = tokenizeStartupCommand(args, PERMISSION_LAUNCH_GRAMMARS[grammarIndex]) + if (!cut.ok) { + return false + } + const offset = offsets.get(start) ?? -1 + if ( + offset === -1 || + !wanted.every( + (word, n) => + parsed.tokens[offset + n] === word && + text.slice(parsed.spans[offset + n].start, parsed.spans[offset + n].end) === + rawRecipe[n] + ) + ) { + return false + } + const kept = parsed.tokens.filter((_, n) => n < offset || n >= offset + wanted.length) + if (kept.length !== cut.tokens.length || !kept.every((word, n) => word === cut.tokens[n])) { + return false + } + if (analyzeAgentPermissionWords(agent, cut.tokens, inventory).carrier) { + return false + } + // A leading exact recipe reconstructs the original argv, even with an unknown remainder. + if (offset === 0) { + return true + } + if ( + !originalAnalysis.optionIndices.has(offset) || + originalAnalysis.roles.includes('uncertain') + ) { + return false + } + const composed = analyzeAgentPermissionWords(agent, [...wanted, ...cut.tokens], inventory) + const keptRoles = originalAnalysis.roles.filter( + (_, n) => n < offset || n >= offset + wanted.length + ) + return keptRoles.every((role, n) => role === composed.roles[wanted.length + n]) + }) + if (proven) { + return { args } + } + } + return null +} diff --git a/src/shared/agent-permission-env.ts b/src/shared/agent-permission-env.ts new file mode 100644 index 00000000000..b1a7518ba56 --- /dev/null +++ b/src/shared/agent-permission-env.ts @@ -0,0 +1,12 @@ +/** The execution host supplies this; browser caches without a host use exact identity. */ +export function agentPermissionHostPlatform(): NodeJS.Platform | undefined { + return typeof process !== 'undefined' ? process.platform : undefined +} + +export function permissionEnvNameMatches( + left: string, + right: string, + platform: NodeJS.Platform | null | undefined +): boolean { + return platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right +} diff --git a/src/shared/agent-session-option-agent-args.ts b/src/shared/agent-session-option-agent-args.ts index e75577f473f..d1885531d5f 100644 --- a/src/shared/agent-session-option-agent-args.ts +++ b/src/shared/agent-session-option-agent-args.ts @@ -3,7 +3,7 @@ import type { AgentType } from './agent-status-types' // Required values consume dash-leading text; boolean and optional-valued options are excluded. // Codex (clap) and OpenCode (yargs) never take a dash-leading value, so they have no entry. -const VALUE_OPTIONS: Partial> = { +const AGENT_REQUIRED_VALUE_OPTIONS: Partial> = { claude: [ '--add-dir', '--agent', @@ -31,6 +31,7 @@ const VALUE_OPTIONS: Partial> = { '--output-format', '--permission-mode', '--permission-prompts', + '--permission-prompt-tool', '--plugin-dir', '--plugin-url', '--remote-control-session-name-prefix', @@ -60,7 +61,10 @@ const VALUE_OPTIONS: Partial> = { } export function agentArgTerminatorIndex(agent: AgentType, tokens: readonly string[]): number { - return findOptionOccurrence(tokens, ['--'], false, VALUE_OPTIONS[agent])?.index ?? tokens.length + return ( + findOptionOccurrence(tokens, ['--'], false, agentRequiredValueOptions(agent))?.index ?? + tokens.length + ) } export function agentArgOptionTokens(tokens: readonly string[]): readonly string[] { @@ -76,13 +80,31 @@ export function removeAgentArgOption( matchesValue: (value: string | undefined) => boolean = () => true ): string[] { const kept: string[] = [] - let rest = tokens - let found = findOptionOccurrence(rest, aliases, true, VALUE_OPTIONS[agent]) + const required = agentRequiredValueOptions(agent) + let cursor = 0 + let found = findOptionOccurrence(tokens, aliases, true, required, cursor) while (found) { const end = found.index + found.consumed - kept.push(...rest.slice(0, matchesValue(found.value) ? found.index : end)) - rest = rest.slice(end) - found = findOptionOccurrence(rest, aliases, true, VALUE_OPTIONS[agent]) + const keptEnd = matchesValue(found.value) ? found.index : end + for (let index = cursor; index < keptEnd; index += 1) { + kept.push(tokens[index]) + } + cursor = end + found = findOptionOccurrence(tokens, aliases, true, required, cursor) } - return [...kept, ...rest] + for (let index = cursor; index < tokens.length; index += 1) { + kept.push(tokens[index]) + } + return kept +} + +/** Shared required-operand facts for terminal parsing, including CLI-sharing teams. */ +export function agentRequiredValueOptions(agent: string): readonly string[] { + const value: unknown = Object.getOwnPropertyDescriptor( + AGENT_REQUIRED_VALUE_OPTIONS, + agent === 'claude-agent-teams' ? 'claude' : agent + )?.value + return Array.isArray(value) && value.every((item): item is string => typeof item === 'string') + ? value + : [] } diff --git a/src/shared/agent-startup-plan-inputs-extra-agent-args.spec.ts b/src/shared/agent-startup-plan-inputs-extra-agent-args.spec.ts index 75375e3500f..d42b608260a 100644 --- a/src/shared/agent-startup-plan-inputs-extra-agent-args.spec.ts +++ b/src/shared/agent-startup-plan-inputs-extra-agent-args.spec.ts @@ -40,7 +40,7 @@ describe('resolveAgentStartupPlanInputs with extra agent args', () => { }) const desktopArgs = mergeExtraAgentArgs({ agent: 'claude', - defaultArgs: resolveTuiAgentLaunchArgs('claude', SETTINGS.agentDefaultArgs), + defaultArgs: resolveTuiAgentLaunchArgs('claude', SETTINGS), extraAgentArgs: '--effort high', shell: 'posix' }) diff --git a/src/shared/agent-startup-plan-inputs.ts b/src/shared/agent-startup-plan-inputs.ts index 08837d699ea..77cda50c95a 100644 --- a/src/shared/agent-startup-plan-inputs.ts +++ b/src/shared/agent-startup-plan-inputs.ts @@ -10,7 +10,12 @@ import { resolveLocalWindowsAgentStartupShell } from './windows-terminal-shell' export type AgentStartupSettings = Partial< Pick< GlobalSettings, - 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv' | 'terminalWindowsShell' + | 'agentCmdOverrides' + | 'agentDefaultArgs' + | 'agentDefaultEnv' + | 'agentPermissionDefault' + | 'agentPermissionOverrides' + | 'terminalWindowsShell' > > @@ -55,9 +60,7 @@ export function resolveAgentStartupPlanInputs(args: { // A per-launch override wins over the Settings default; `null` is "no arguments", so this // tests for absence rather than falsiness. let agentArgs = - args.agentArgs !== undefined - ? args.agentArgs - : resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs) + args.agentArgs !== undefined ? args.agentArgs : resolveTuiAgentLaunchArgs(agent, settings) const shell = args.shell ?? resolveLocalWindowsAgentStartupShell({ @@ -81,7 +84,7 @@ export function resolveAgentStartupPlanInputs(args: { agent, cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs, - agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings, platform), platform, shell, isRemote, diff --git a/src/shared/ai-vault-resume-startup.ts b/src/shared/ai-vault-resume-startup.ts index e27744c2cc3..64a9849db26 100644 --- a/src/shared/ai-vault-resume-startup.ts +++ b/src/shared/ai-vault-resume-startup.ts @@ -14,7 +14,11 @@ import { type SleepingAgentLaunchConfig } from './agent-session-resume' import { normalizeAiVaultResumeFilePath } from './ai-vault-resume-path' -import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from './tui-agent-launch-defaults' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv, + type AgentLaunchProfileSettings +} from './tui-agent-launch-defaults' import { buildAgentResumeStartupPlan } from './tui-agent-startup' import { quoteStartupArg, @@ -47,9 +51,8 @@ export type AiVaultResumeStartup = { providerSession?: AgentProviderSessionMetadata } -export type AiVaultResumeLaunchSettings = Partial< - Pick -> +export type AiVaultResumeLaunchSettings = Partial> & + AgentLaunchProfileSettings /** The platform and live shell a resume line is written for, and the Codex home it pins. */ export type AiVaultResumeHostShell = { @@ -133,9 +136,8 @@ export function buildAiVaultLocalResumeStartup( cmdOverrides: resumeCommandOverrides(args), platform, shell: liveShell, - agentArgs: - args.agentArgs ?? resolveTuiAgentLaunchArgs(session.agent, settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(session.agent, settings?.agentDefaultEnv), + agentArgs: args.agentArgs ?? resolveTuiAgentLaunchArgs(session.agent, settings), + agentEnv: resolveTuiAgentLaunchEnv(session.agent, settings, platform), resumeInLaunchCwd: true, ...(session.agent === 'omp' && resumeFilePath ? { ompResumeFilePath: resumeFilePath } : {}) }) @@ -218,8 +220,8 @@ export function buildAiVaultForkStartup( cmdOverrides: resumeCommandOverrides(args), platform, shell: liveShell, - agentArgs: resolveTuiAgentLaunchArgs(session.agent, settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(session.agent, settings?.agentDefaultEnv), + agentArgs: resolveTuiAgentLaunchArgs(session.agent, settings), + agentEnv: resolveTuiAgentLaunchEnv(session.agent, settings, platform), resumeInLaunchCwd: true, fork: true }) @@ -250,7 +252,7 @@ export function resolveAiVaultCopiedSessionAgentArgs( const { platform, liveShell } = args.host const shell = resolveStartupShell(platform, liveShell) const configuredArgs = isTuiAgent(session.agent) - ? resolveTuiAgentLaunchArgs(session.agent, settings?.agentDefaultArgs) + ? resolveTuiAgentLaunchArgs(session.agent, settings) : '' const configured = tokenizeStartupCommand(configuredArgs, shell) if (!configured.ok) { @@ -298,7 +300,7 @@ export function assertAiVaultCopiedSessionStoreAllowed(args: AiVaultResumeStartu args.settings?.agentCmdOverrides?.[agent] || getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[agent], platform) const parsed = tokenizeStartupCommand( - `${command} ${resolveTuiAgentLaunchArgs(agent, args.settings?.agentDefaultArgs)}`, + `${command} ${resolveTuiAgentLaunchArgs(agent, args.settings)}`, resolveStartupShell(platform, liveShell) ) if (!parsed.ok) { diff --git a/src/shared/command-option-occurrence.ts b/src/shared/command-option-occurrence.ts index d7e14933672..471fc69b2c9 100644 --- a/src/shared/command-option-occurrence.ts +++ b/src/shared/command-option-occurrence.ts @@ -14,9 +14,10 @@ export function findOptionOccurrence( tokens: readonly string[], aliases: readonly string[], stopAtTerminator: boolean, - valueOptions: readonly string[] = [] + valueOptions: readonly string[] = [], + startIndex = 0 ): { index: number; consumed: number; value?: string } | null { - for (let index = 0; index < tokens.length; index += 1) { + for (let index = startIndex; index < tokens.length; index += 1) { const token = tokens[index] if (stopAtTerminator && token === '--') { break diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 67f48ef54f3..ef4f65efc06 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -8,7 +8,6 @@ import { getDefaultSourceControlAiSettings } from './source-control-ai-settings' import { DEFAULT_APP_ICON_ID } from './app-icon' import { DEFAULT_OPEN_IN_APPLICATIONS } from './open-in-applications' import { DEFAULT_DISABLED_TUI_AGENTS } from './tui-agent-selection' -import { DEFAULT_TUI_AGENT_ARGS, DEFAULT_TUI_AGENT_ENV } from './tui-agent-launch-defaults' import { UI_LANGUAGE_SYSTEM } from './ui-language' import { DEFAULT_LEFT_SIDEBAR_TINT_COLOR, @@ -142,7 +141,7 @@ export function buildDefaultSettings(args: { nativeChatInheritShellEnvironment: true, nativeChatShellEnvironmentVariables: [], nativeChatSessionOptions: {}, - // The shipped Claude and Codex arguments carry their bypass flags. + // New chats of agents on their own settings; others start in their agent permission. nativeChatPermissionMode: 'full-access', openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS], rightSidebarOpenByDefault: true, @@ -225,9 +224,12 @@ export function buildDefaultSettings(args: { zcodePlanSite: 'zai', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, - agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, - agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV }, + // Arguments hold only the user's own text; the permission flag comes from the choice below. + agentDefaultArgs: {}, + agentDefaultEnv: {}, agentYoloDefaultsMigrated: true, + agentPermissionDefault: 'full-access', + agentPermissionOverrides: {}, agentStatusHooksEnabled: true, agentWorkspaceTrustEnabled: true, codexTerminalServerIsolation: true, diff --git a/src/shared/global-settings-base.ts b/src/shared/global-settings-base.ts index af5c2ca0777..54818bc12f1 100644 --- a/src/shared/global-settings-base.ts +++ b/src/shared/global-settings-base.ts @@ -3,10 +3,12 @@ import type { ClaudeAccountSwitchSettings } from './claude-account-switch-settin import type { ExternalConversationSettings } from './external-conversation-settings' import type { NativeChatGlobalSettings } from './native-chat-appearance-settings' import type { WorktreeDeletionPreferences } from './worktree-deletion-preferences' +import type { AgentPermissionGlobalSettings } from './tui-agent-permission-choice' /** Settings groups owned by their feature modules and composed into GlobalSettings. */ export type GlobalSettingsBase = NativeChatGlobalSettings & WorktreeDeletionPreferences & ClaudeAccountSwitchSettings & + AgentPermissionGlobalSettings & ExternalConversationSettings & BrowserLinkOpeningSettings diff --git a/src/shared/global-settings-test-fixture.ts b/src/shared/global-settings-test-fixture.ts index a106bbef52e..6b7a799969c 100644 --- a/src/shared/global-settings-test-fixture.ts +++ b/src/shared/global-settings-test-fixture.ts @@ -1,3 +1,5 @@ +import { expect, vi } from 'vitest' +import * as shell from './tui-agent-startup-shell' import type { GlobalSettings } from './global-settings-types' import { getDefaultVoiceSettings } from './constants' import { getDefaultNotificationSettings } from './notification-settings-defaults' @@ -25,3 +27,195 @@ export function createGlobalSettingsFixture( ...overrides } } + +const originalTokenizer = shell.tokenizeStartupCommand + +// Native materializations and string outputs; callers add the finite semantic/property passes. +export function measureSettingsWork(call: () => T) { + const work = { + entries: 0, + assignments: 0, + comparisons: 0, + bytes: 0, + chars: 0, + visits: 0, + inputs: Array() + } + const currentTokenizer = shell.tokenizeStartupCommand + const existingTokenizerSpy = vi.isMockFunction(currentTokenizer) + const tokenize = vi.isMockFunction(currentTokenizer) + ? (currentTokenizer.getMockImplementation() ?? originalTokenizer) + : currentTokenizer + const tokenizerSpy = vi.spyOn(shell, 'tokenizeStartupCommand') + tokenizerSpy.mockImplementation((...args) => { + work.inputs.push(args[0]) + work.chars += args[0].length + const parsed = tokenize(...args) + // Fixed token/span/owner passes; this is not a dynamic index-read counter. + if (parsed.ok) { + work.visits += 10 * parsed.tokens.length + } + return parsed + }) + const restores: (() => void)[] = [] + function replace(target: T, name: K, wrap: (original: T[K]) => T[K]) { + const original = target[name] + target[name] = wrap(original) + restores.push(() => { + target[name] = original + }) + } + replace(Object, 'entries', (original) => (value) => { + const result = original(value) + work.entries += 2 * result.length + return result + }) + replace(Object, 'keys', (original) => (value) => { + const result = original(value) + work.entries += 2 * result.length + return result + }) + replace(Object, 'fromEntries', (original) => (value) => { + const result = original(value) + work.entries += Reflect.ownKeys(result).length + return result + }) + replace(Object, 'hasOwn', (original) => (value, key) => { + const result = original(value, key) + work.entries++ + return result + }) + replace(Object, 'assign', (original) => (target, ...sources) => { + const result = original(target, ...sources) + work.assignments++ + for (const source of sources) { + work.entries += 2 * Reflect.ownKeys(Object(source)).length + } + return result + }) + replace( + Map.prototype, + 'get', + (original) => + function (this: Map, key: unknown) { + const result: unknown = original.call(this, key) + work.entries++ + return result + } + ) + replace( + Map.prototype, + 'set', + (original) => + function (this: Map, key: unknown, value: unknown) { + const result = original.call(this, key, value) + work.entries++ + return result + } + ) + replace( + Map.prototype, + 'delete', + (original) => + function (this: Map, key: unknown) { + const result = original.call(this, key) + work.entries++ + return result + } + ) + replace( + Set.prototype, + 'add', + (original) => + function (this: Set, value: unknown) { + const result = original.call(this, value) + work.entries++ + return result + } + ) + replace( + Set.prototype, + 'has', + (original) => + function (this: Set, value: unknown) { + const result = original.call(this, value) + work.entries++ + return result + } + ) + replace( + Array.prototype, + 'sort', + (original) => + function (this: unknown[], compare?: (a: unknown, b: unknown) => number) { + return original.call(this, (a, b) => { + work.comparisons++ + return typeof compare === 'function' + ? compare(a, b) + : +(String(a) > String(b)) - +(String(a) < String(b)) + }) + } + ) + replace( + Array.prototype, + 'slice', + (original) => + function (this: unknown[], start?: number, end?: number) { + const result = original.call(this, start, end) + work.visits += 2 * result.length + return result + } + ) + replace( + String.prototype, + 'slice', + (original) => + function (this: string, start?: number, end?: number) { + const result = original.call(this, start, end) + work.bytes += result.length + return result + } + ) + replace( + String.prototype, + 'toLowerCase', + (original) => + function (this: string) { + const result = original.call(this) + work.bytes += result.length + return result + } + ) + work.entries = 0 + const restore = () => { + restores.toReversed().forEach((reset) => reset()) + if (existingTokenizerSpy) { + tokenizerSpy.mockImplementation(tokenize) + } else { + tokenizerSpy.mockRestore() + } + } + let pending = false + try { + const value = call() + pending = value instanceof Promise + if (value instanceof Promise) { + void value.then(restore, restore) + } + return { value, work } + } finally { + if (!pending) { + restore() + } + } +} + +const workScales = new Map() +export function expectSettingsWorkScale(key: string, size: number, counts: number[]) { + if (size === 200) { + const baseline = workScales.get(key) + expect(baseline, key).toBeDefined() + counts.forEach((value, i) => expect(value, key).toBeLessThanOrEqual(12 * baseline![i])) + } + workScales.set(key, counts) +} diff --git a/src/shared/launch-parity-plus-cases.test-fixture.ts b/src/shared/launch-parity-plus-cases.test-fixture.ts index 240e59b2183..f35e0a00750 100644 --- a/src/shared/launch-parity-plus-cases.test-fixture.ts +++ b/src/shared/launch-parity-plus-cases.test-fixture.ts @@ -37,7 +37,7 @@ function plus( producer: { kind: 'plus', agent: 'claude', via }, request, provider, - settings: { agentDefaultArgs: { claude: ARGS } } + settings: { agentDefaultArgs: { claude: ARGS }, agentPermissionOverrides: { claude: 'own' } } } } diff --git a/src/shared/native-chat-session-option-defaults.ts b/src/shared/native-chat-session-option-defaults.ts index b24be0d610f..981a951e2e2 100644 --- a/src/shared/native-chat-session-option-defaults.ts +++ b/src/shared/native-chat-session-option-defaults.ts @@ -2,7 +2,8 @@ import type { AgentType } from './agent-status-types' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, isAgentChatPermissionMode, - newChatPermissionModeForSettings + newChatPermissionModeForSettings, + type NewChatPermissionSettings } from './agent-chat-permission-mode' import type { GlobalSettings } from './global-settings-types' import { sessionOptionValueIsValid } from './agent-session-option-catalog' @@ -118,10 +119,11 @@ export function resolveStructuredLaunchSeedOptions( /** A new chat's seed: the saved selection plus the permission default narrowed to the agent. */ export function structuredLaunchSeedOptions( settings: - | Pick< + | (Pick< GlobalSettings, 'nativeChatSessionOptions' | 'nativeChatPermissionMode' | 'agentNativeChatPermissionModes' - > + > & + NewChatPermissionSettings) | null | undefined, agent: AgentType diff --git a/src/shared/rpc-contract/agent-launch-settings-params.ts b/src/shared/rpc-contract/agent-launch-settings-params.ts index 6b8cb9f14d8..846dc3a67e6 100644 --- a/src/shared/rpc-contract/agent-launch-settings-params.ts +++ b/src/shared/rpc-contract/agent-launch-settings-params.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { isTuiAgent } from '../tui-agent-config' +import { AGENT_PERMISSION_CHOICES } from '../tui-agent-permission-choice' import type { TuiAgent } from '../tui-agent' const Agent = z.custom((value) => typeof value === 'string' && isTuiAgent(value)) @@ -31,8 +32,17 @@ export const AgentLaunchSettingsMutation = z.discriminatedUnion('type', [ syncNativeChat: z.literal(true).optional() }) .strict(), + z + .object({ + type: z.literal('permission'), + choice: z.enum([...AGENT_PERMISSION_CHOICES, 'default']), + agent: Agent.optional(), + withoutAuto: 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('user-arguments'), agent: Agent, value: Value }).strict(), z.object({ type: z.literal('environment-set'), agent: Agent, name: Name, value: Value }).strict(), z.object({ type: z.literal('environment-remove'), agent: Agent, name: Name }).strict() ]) diff --git a/src/shared/tui-agent-auto-permissions.test.ts b/src/shared/tui-agent-auto-permissions.test.ts index d26d5086db9..92764ce796e 100644 --- a/src/shared/tui-agent-auto-permissions.test.ts +++ b/src/shared/tui-agent-auto-permissions.test.ts @@ -17,7 +17,7 @@ describe('automatic terminal permissions', () => { const settings = agentLaunchSettingsMutationUpdates({}, mutation, platform) for (const agent of ['claude', 'claude-agent-teams', 'codex'] as const) { - const agentArgs = resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs) + const agentArgs = resolveTuiAgentLaunchArgs(agent, settings) const startup = buildAgentStartupPlan({ agent, agentArgs, diff --git a/src/shared/tui-agent-launch-defaults.spec.ts b/src/shared/tui-agent-launch-defaults.spec.ts index ef3e58d5a2a..55a361c86f2 100644 --- a/src/shared/tui-agent-launch-defaults.spec.ts +++ b/src/shared/tui-agent-launch-defaults.spec.ts @@ -121,14 +121,14 @@ describe('resolveTuiAgentLaunchArgs', () => { it('hands the configured arguments to a terminal launch unchanged', () => { expect( resolveTuiAgentLaunchArgs('claude', { - claude: '--dangerously-skip-permissions --model Opus' + agentDefaultArgs: { claude: '--dangerously-skip-permissions --model Opus' } }) ).toBe('--dangerously-skip-permissions --model Opus') }) it('falls back to the agent default when nothing is configured', () => { expect(resolveTuiAgentLaunchArgs('claude', {})).toBe('--dangerously-skip-permissions') - expect(resolveTuiAgentLaunchArgs('claude', { claude: '' })).toBe('') + expect(resolveTuiAgentLaunchArgs('claude', { agentDefaultArgs: { claude: '' } })).toBe('') }) // Their chats run their own permission mode, so terminals get no permission argument for them. diff --git a/src/shared/tui-agent-launch-defaults.ts b/src/shared/tui-agent-launch-defaults.ts index df9c43adca1..66043852048 100644 --- a/src/shared/tui-agent-launch-defaults.ts +++ b/src/shared/tui-agent-launch-defaults.ts @@ -1,7 +1,17 @@ +import { agentPermissionHostPlatform, permissionEnvNameMatches } from './agent-permission-env' import type { SleepingAgentLaunchConfig } from './agent-session-resume' import type { GlobalSettings } from './global-settings-types' import { isTuiAgent } from './tui-agent-config' import { YOLO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ENV } from './tui-agent-permissions' +import { + agentPermissionArgs, + agentPermissionEnv, + hasTypedAgentPermission, + PERMISSION_CHOICE_AGENTS, + resolveAgentPermission, + sanitizeAgentPermissionUpdates +} from './tui-agent-permission-choice' +import { agentArgsSetPermission } from './tui-agent-permission-lift' import { resolveStartupShell, tokenizeStartupCommand, @@ -15,6 +25,15 @@ const UNSUPPORTED_TUI_AGENT_ARGS: Partial> = kilo: ['--dangerously-skip-permissions'] } +/** What a launch reads: the user's Arguments and env plus the stored permission choice. */ +export type AgentLaunchProfileSettings = Partial< + Pick< + GlobalSettings, + 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentPermissionDefault' | 'agentPermissionOverrides' + > +> + +// What an untouched agent ran with before permissions were stored as a choice; legacy text only. export const DEFAULT_TUI_AGENT_ARGS: Partial> & Readonly> = YOLO_TUI_AGENT_ARGS @@ -35,8 +54,7 @@ export function hasUnsupportedTuiAgentArgs(agent: TuiAgent, value: unknown): boo /** * Whether the configured arguments carry this agent's permission-bypass flag. * - * The Agent Permissions toggle has no storage of its own — it writes and reads this flag inside - * the arguments string. Read the same argv the startup path builds so quoted prompt text and + * Legacy profiles carry the permission in Arguments. Read the startup argv so quoted text and * operands after `--` cannot authorize a structured session. */ export function tuiAgentArgsBypassPermissions( @@ -109,6 +127,22 @@ export function normalizeTuiAgentEnvRecord( return normalized } +/** Normalizes a settings write's launch profile in place, wherever the write lands. */ +export function sanitizeAgentLaunchProfileUpdates( + target: Partial, + updates: Partial +): void { + if ('agentDefaultArgs' in updates) { + target.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs) + target.agentYoloDefaultsMigrated = true + } + if ('agentDefaultEnv' in updates) { + target.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv) + target.agentYoloDefaultsMigrated = true + } + sanitizeAgentPermissionUpdates(target, updates) +} + export function getTuiAgentDefaultArgs(agent: string): string { return DEFAULT_TUI_AGENT_ARGS[agent] ?? '' } @@ -117,18 +151,31 @@ export function getTuiAgentDefaultEnv(agent: string): Record { return { ...DEFAULT_TUI_AGENT_ENV[agent] } } +function ownArgs(agent: string, settings: AgentLaunchProfileSettings | null | undefined) { + const configured = settings?.agentDefaultArgs + const value: unknown = configured && Object.getOwnPropertyDescriptor(configured, agent)?.value + return typeof value === 'string' ? value : undefined +} + +/** + * The Arguments a terminal launch of this agent gets: Orca's flag for the agent's stored + * permission, then the user's own text. Text that sets permissions itself wins, so Orca adds none. + * Settings without a stored choice (an older host's published text) already carry the flag. + */ export function resolveTuiAgentLaunchArgs( agent: string, - configuredArgs: Readonly> | null | undefined + settings: AgentLaunchProfileSettings | null | undefined ): string { - if ( - configuredArgs && - Object.hasOwn(configuredArgs, agent) && - typeof configuredArgs[agent] === 'string' - ) { - return configuredArgs[agent] ?? '' + const own = ownArgs(agent, settings) + if (!hasTypedAgentPermission(settings) || !isTuiAgent(agent)) { + return own ?? getTuiAgentDefaultArgs(agent) } - return getTuiAgentDefaultArgs(agent) + const user = own?.trim() ?? '' + const flag = agentPermissionArgs(agent, resolveAgentPermission(agent, settings).choice) + if (!flag || agentArgsSetPermission(agent, user)) { + return user + } + return user ? `${flag} ${user}` : flag } /** @@ -141,7 +188,7 @@ export function resolveTuiAgentLaunchArgs( export function resolvedTuiAgentArgsBypassPermissions( agent: TuiAgent, settings: - | Partial> + | (AgentLaunchProfileSettings & Partial>) | null | undefined, platform: NodeJS.Platform @@ -154,21 +201,71 @@ export function resolvedTuiAgentArgsBypassPermissions( terminalWindowsShell: settings?.terminalWindowsShell }) ) - return tuiAgentArgsBypassPermissions( - agent, - resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), - shell - ) + return tuiAgentArgsBypassPermissions(agent, resolveTuiAgentLaunchArgs(agent, settings), shell) } +/** The env a terminal launch of this agent gets; the user's own entries win over Orca's. */ export function resolveTuiAgentLaunchEnv( agent: string, - configuredEnv: Partial>> | null | undefined + settings: AgentLaunchProfileSettings | null | undefined, + platform: NodeJS.Platform | null | undefined = agentPermissionHostPlatform(), + visitBaseEntry?: (name: string, value: string) => true ): Record { - if (configuredEnv && Object.hasOwn(configuredEnv, agent)) { - return { ...configuredEnv[agent] } + const configured = settings?.agentDefaultEnv + const value: unknown = configured && Object.getOwnPropertyDescriptor(configured, agent)?.value + const own = + value && typeof value === 'object' + ? Object.entries(value).filter( + (entry): entry is [string, string] => + typeof entry[1] === 'string' && (visitBaseEntry?.(entry[0], entry[1]) ?? true) + ) + : undefined + if (!hasTypedAgentPermission(settings) || !isTuiAgent(agent)) { + const entries = own ?? Object.entries(getTuiAgentDefaultEnv(agent)) + if (!own) { + entries.forEach(([name, value]) => visitBaseEntry?.(name, value)) + } + return Object.fromEntries(entries) + } + const added = Object.entries( + agentPermissionEnv(agent, resolveAgentPermission(agent, settings).choice) + ).filter(([name]) => !own?.some(([key]) => permissionEnvNameMatches(key, name, platform))) + return Object.fromEntries(added.length ? [...added, ...(own ?? [])] : (own ?? [])) +} + +/** + * Every agent's Arguments and env as its terminal launch gets them, flags included: what older + * clients read, since they treat that text as the permission. Each permission agent gets a key so + * an older client never falls back to its own built-in default. + */ +export function composeAgentLaunchProfile( + settings: AgentLaunchProfileSettings, + platform: NodeJS.Platform | null | undefined = agentPermissionHostPlatform() +): Required> { + if (!hasTypedAgentPermission(settings)) { + return { + agentDefaultArgs: { ...settings.agentDefaultArgs }, + agentDefaultEnv: { ...settings.agentDefaultEnv } + } + } + const argAgents = new Set([ + ...Object.keys(settings.agentDefaultArgs ?? {}), + ...PERMISSION_CHOICE_AGENTS + ]) + const envAgents = new Set([ + ...Object.keys(settings.agentDefaultEnv ?? {}), + ...PERMISSION_CHOICE_AGENTS.filter( + (agent) => Object.keys(agentPermissionEnv(agent, 'full-access')).length > 0 + ) + ]) + return { + agentDefaultArgs: Object.fromEntries( + [...argAgents].map((agent) => [agent, resolveTuiAgentLaunchArgs(agent, settings)]) + ), + agentDefaultEnv: Object.fromEntries( + [...envAgents].map((agent) => [agent, resolveTuiAgentLaunchEnv(agent, settings, platform)]) + ) } - return getTuiAgentDefaultEnv(agent) } /** diff --git a/src/shared/tui-agent-permission-choice.test.ts b/src/shared/tui-agent-permission-choice.test.ts new file mode 100644 index 00000000000..37b6b778ffe --- /dev/null +++ b/src/shared/tui-agent-permission-choice.test.ts @@ -0,0 +1,681 @@ +import * as permissionArgs from './agent-permission-args' +import { measureSettingsWork, expectSettingsWorkScale } from './global-settings-test-fixture' +import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from './tui-agent-startup' +import { + wholeAgentEnvironmentUpdates, + legacyLaunchTextUpdates +} from './agent-launch-permission-updates' +import { describe, expect, it, vi } from 'vitest' +import { + agentLaunchSettingsMutationUpdates, + projectAgentLaunchSettings, + type AgentLaunchSettingsSource +} from './agent-launch-settings' +import { newChatPermissionModeForSettings } from './agent-chat-permission-mode' +import { + DEFAULT_TUI_AGENT_ARGS, + DEFAULT_TUI_AGENT_ENV, + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from './tui-agent-launch-defaults' +import { applyAllAgentsPermission, resolveAgentPermission } from './tui-agent-permission-choice' +import { agentArgsSetPermission, migrateAgentPermissionChoice } from './tui-agent-permission-lift' + +const legacy = ( + args: Record, + env: Record> = {} +): AgentLaunchSettingsSource => ({ + agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS, ...args }, + agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV, ...env } +}) + +describe('one-time move of permission flags out of Arguments', () => { + it('imports Grok modes from recorded 1.0.62 help without taking ownership of other modes', () => { + // Installed 1.0.62 (1474f9cbdd91) --help, permission-mode excerpt; not captured argv. + const recordedHelp = ` --permission-mode + Permission mode + + [possible values: default, acceptEdits, auto, dontAsk, bypassPermissions, plan]` + const option = recordedHelp.trim().split(' ')[0] + const modes = recordedHelp.match(/possible values: ([^\]]+)/)?.[1].split(', ') + expect(modes).toBeDefined() + for (const mode of modes ?? []) { + const text = `${option} ${mode} --model grok-code-fast-1` + const migrated = migrateAgentPermissionChoice({ agentDefaultArgs: { grok: text } }) + const choice = + mode === 'default' ? 'ask' : mode === 'bypassPermissions' ? 'full-access' : 'own' + expect(resolveAgentPermission('grok', migrated).choice).toBe(choice) + expect(migrated.agentDefaultArgs.grok).toBe( + choice === 'own' ? text : '--model grok-code-fast-1' + ) + expect(resolveTuiAgentLaunchArgs('grok', migrated)).toBe(text) + } + }) + + it('turns saved shapes into a stored choice and leaves the rest of the text', () => { + const migrated = migrateAgentPermissionChoice( + legacy( + { + claude: '--model opus --dangerously-skip-permissions', + codex: '--approve-for-me -c model_reasoning_effort="high"', + gemini: '', + // Not one of Orca's flags: left whole, and Orca adds nothing. + grok: '--permission-mode plan' + }, + { goose: { goose_mode: 'auto', OTHER: '1' } } + ), + 'win32' + ) + expect(migrated.agentPermissionDefault).toBe('full-access') + expect(migrated.agentDefaultArgs).toMatchObject({ + claude: '--model opus', + codex: '-c model_reasoning_effort="high"', + gemini: '', + grok: '--permission-mode plan', + cursor: '' + }) + // Windows reads env names case-insensitively; unrelated entries stay. + expect(migrated.agentDefaultEnv.goose).toEqual({ OTHER: '1' }) + expect(migrated.agentPermissionOverrides).toEqual({ + codex: 'auto', + gemini: 'own', + grok: 'own', + opencode: 'own', + opencode2: 'own', + omp: 'own' + }) + // Terminals launch what they launched before the move. + expect(resolveTuiAgentLaunchArgs('claude', migrated)).toBe( + '--dangerously-skip-permissions --model opus' + ) + expect(resolveTuiAgentLaunchArgs('grok', migrated)).toBe('--permission-mode plan') + expect(resolveTuiAgentLaunchEnv('goose', migrated)).toEqual({ GOOSE_MODE: 'auto', OTHER: '1' }) + }) + + it('cuts a flag beside quoted text, and leaves a quoted flag as the user’s own text', () => { + const migrated = migrateAgentPermissionChoice( + legacy({ claude: '--name "a b" --dangerously-skip-permissions' }) + ) + expect(migrated.agentDefaultArgs.claude).toBe('--name "a b"') + const quoted = migrateAgentPermissionChoice( + legacy({ claude: '"--dangerously-skip-permissions"' }) + ) + expect(quoted.agentPermissionOverrides.claude).toBe('own') + expect(quoted.agentDefaultArgs.claude).toBe('"--dangerously-skip-permissions"') + }) +}) + +describe('terminal launch composes the stored choice', () => { + const typed = { + agentPermissionDefault: 'full-access', + agentPermissionOverrides: { claude: 'ask', codex: 'own' }, + agentDefaultArgs: { claude: '--model opus', codex: '--search', grok: '--permission-mode plan' } + } as const + + it('adds Orca’s flag ahead of the user’s text, nothing for own or text that sets permissions', () => { + expect(resolveTuiAgentLaunchArgs('claude', typed)).toBe( + '--permission-mode default --model opus' + ) + expect(resolveTuiAgentLaunchArgs('codex', typed)).toBe('--search') + expect(resolveTuiAgentLaunchArgs('grok', typed)).toBe('--permission-mode plan') + expect(resolveTuiAgentLaunchArgs('gemini', typed)).toBe('--yolo') + // Stored intent survives; the terminal composes no unsupported recipe. + expect(resolveTuiAgentLaunchArgs('gemini', { agentPermissionDefault: 'ask' })).toBe('') + }) + + it('passes an older host’s published text through unchanged', () => { + expect( + resolveTuiAgentLaunchArgs('claude', { + agentDefaultArgs: { claude: '--permission-mode auto' } + }) + ).toBe('--permission-mode auto') + }) + + it('Auto for all agents keeps the others on what they had', () => { + const next = applyAllAgentsPermission({ agentPermissionDefault: 'full-access' }, 'auto') + expect(resolveAgentPermission('claude', next).choice).toBe('auto') + expect(resolveAgentPermission('gemini', next).choice).toBe('full-access') + }) +}) + +describe('older clients against a host with the stored choice', () => { + const host = { + ...migrateAgentPermissionChoice(legacy({ codex: '--approve-for-me --search' })), + nativeChatPermissionMode: 'ask' + } satisfies AgentLaunchSettingsSource + + it('see Arguments with the flag included', () => { + const projected = projectAgentLaunchSettings(host) + expect(projected.agentDefaultArgs.codex).toBe('--approve-for-me --search') + expect(projected.agentPermission?.userArgs.codex).toBe('--search') + }) + + it('deleting the published flag means the agent’s own settings; another flag becomes the choice', () => { + const deleted = agentLaunchSettingsMutationUpdates( + host, + { type: 'arguments', agent: 'codex', value: '--search' }, + 'linux' + ) + expect(resolveAgentPermission('codex', { ...host, ...deleted }).choice).toBe('own') + const swapped = agentLaunchSettingsMutationUpdates( + host, + { + type: 'arguments', + agent: 'codex', + value: '--dangerously-bypass-approvals-and-sandbox --search' + }, + 'linux' + ) + expect({ ...host, ...swapped }.agentDefaultArgs?.codex).toBe('--search') + expect(resolveAgentPermission('codex', { ...host, ...swapped }).choice).toBe('full-access') + const manual = agentLaunchSettingsMutationUpdates( + host, + { type: 'permissions', mode: 'manual', agent: 'claude' }, + 'linux' + ) + expect(resolveTuiAgentLaunchArgs('claude', { ...host, ...manual })).toBe('') + }) + + it('new chats start in the stored choice; agents on their own settings keep the Chat default', () => { + expect(newChatPermissionModeForSettings('codex', host)).toBe('auto') + const own = { ...host, agentPermissionOverrides: { codex: 'own' as const } } + expect(newChatPermissionModeForSettings('codex', own)).toBe('ask') + }) +}) + +describe('permission carrier ownership across save, reload and consumers', () => { + const disagreement = '--model op\\ us --permission-mode auto' + const repeated = '--dangerously-skip-permissions '.repeat(20).trim() + const ownershipCases = [ + [ + 'claude', + '--model opus --permission-mode auto', + '--model opus', + '--permission-mode auto --model opus', + 'auto', + true + ], + [ + 'claude', + '--permission-mode auto --model opus', + '--model opus', + '--permission-mode auto --model opus', + 'auto', + true + ], + [ + 'claude', + '--append-system-prompt --dangerously-skip-permissions', + '--append-system-prompt --dangerously-skip-permissions', + '--permission-mode auto --append-system-prompt --dangerously-skip-permissions', + 'auto', + false + ], + [ + 'claude', + '--tools Bash --dangerously-skip-permissions Read', + '--tools Bash --dangerously-skip-permissions Read', + '--tools Bash --dangerously-skip-permissions Read', + 'auto', + true + ], + [ + 'claude', + '--unknown value --permission-mode auto', + '--unknown value --permission-mode auto', + '--unknown value --permission-mode auto', + 'auto', + true + ], + [ + 'claude', + '--unknown value', + '--unknown value', + '--permission-mode auto --unknown value', + 'auto', + false + ], + [ + 'claude', + '-- --dangerously-skip-permissions', + '-- --dangerously-skip-permissions', + '--permission-mode auto -- --dangerously-skip-permissions', + 'auto', + false + ], + [ + 'claude', + '--append-system-prompt -- --permission-mode auto', + '--append-system-prompt --', + '--permission-mode auto --append-system-prompt --', + 'auto', + true + ], + [ + 'claude', + 'resume --permission-mode auto', + 'resume --permission-mode auto', + 'resume --permission-mode auto', + 'auto', + true + ], + [ + 'claude', + '--unknown --append-system-prompt --dangerously-skip-permissions', + '--unknown --append-system-prompt --dangerously-skip-permissions', + '--unknown --append-system-prompt --dangerously-skip-permissions', + 'auto', + true + ], + [ + 'claude', + '--unknown -- --dangerously-skip-permissions', + '--unknown -- --dangerously-skip-permissions', + '--unknown -- --dangerously-skip-permissions', + 'auto', + true + ], + ['codex', '--yolo', '--yolo', '--yolo', 'auto', true], + ['codex', '--not-so-yolo', '--not-so-yolo', '--not-so-yolo', 'auto', true], + ['codex', '--approve-for-me', '', '--approve-for-me', 'auto', true], + ['codex', '-sread-only', '-sread-only', '-sread-only', 'auto', true], + ['codex', '-aon-request', '-aon-request', '-aon-request', 'auto', true], + [ + 'codex', + '-c sandbox_mode="read-only"', + '-c sandbox_mode="read-only"', + '-c sandbox_mode="read-only"', + 'auto', + true + ], + [ + 'codex', + '--config approval_policy=on-request', + '--config approval_policy=on-request', + '--config approval_policy=on-request', + 'auto', + true + ], + [ + 'codex', + '-capprovals_reviewer=auto_review', + '-capprovals_reviewer=auto_review', + '-capprovals_reviewer=auto_review', + 'auto', + true + ], + [ + 'codex', + '--config=sandbox_workspace_write.network_access=true', + '--config=sandbox_workspace_write.network_access=true', + '--config=sandbox_workspace_write.network_access=true', + 'auto', + true + ], + [ + 'codex', + '-c model_reasoning_effort=high', + '-c model_reasoning_effort=high', + '--approve-for-me -c model_reasoning_effort=high', + 'auto', + false + ], + [ + 'codex', + `-c '"sandbox_mode"=read-only'`, + `-c '"sandbox_mode"=read-only'`, + `--approve-for-me -c '"sandbox_mode"=read-only'`, + 'auto', + false + ], + ['claude', repeated, repeated, repeated, 'auto', true], + ['claude', disagreement, disagreement, disagreement, 'auto', true], + ['grok', '--yolo', '--yolo', '--yolo', 'ask', true] + ] as const + it.each( + ownershipCases.flatMap(([agent, value, raw, launch, chat, carrier]) => + (value.includes('--model opus') || value === repeated ? [20, 200] : [1]).map((n) => { + const scale = (text: string) => + text === repeated + ? '--dangerously-skip-permissions '.repeat(n).trim() + : text.replace('--model opus', '--model opus '.repeat(n).trim()) + return [agent, scale(value), scale(raw), scale(launch), chat, carrier, n] as const + }) + ) + )('%s preserves ownership for %s', (agent, value, raw, launch, chat, carrier, n) => { + const before: AgentLaunchSettingsSource = { + agentPermissionDefault: agent === 'grok' ? 'ask' : 'auto' + } + const start = [importWork.chars, importWork.visits] + expect( + migrateAgentPermissionChoice({ agentDefaultArgs: { [agent]: value } }, 'linux') + .agentDefaultArgs[agent] + ).toBe(raw) + const updates = agentLaunchSettingsMutationUpdates( + before, + { type: 'user-arguments', agent, value }, + 'linux' + ) + const beforeReload = importWork.chars + const loaded = migrateAgentPermissionChoice( + JSON.parse(JSON.stringify({ ...before, ...updates })), + 'linux' + ) + expect(importWork.chars).toBe(beforeReload) + if (value.length > 100) { + const key = value.startsWith('--permission-mode') + ? 'leading' + : raw === value + ? 'rejected' + : 'tail' + const size = [importWork.chars - start[0], importWork.visits - start[1]] + expectSettingsWorkScale(`import-${key}`, n, size) + } + expect(loaded.agentDefaultArgs[agent]).toBe(raw) + expect(resolveTuiAgentLaunchArgs(agent, loaded)).toBe(launch) + expect(projectAgentLaunchSettings(loaded).agentDefaultArgs[agent]).toBe(launch) + expect(newChatPermissionModeForSettings(agent, loaded)).toBe(chat) + expect(agentArgsSetPermission(agent, value)).toBe(carrier) + }) + + it('keeps a Gemini mode from recorded 0.63.0 help user-owned through import and composition', () => { + // Installed 0.63.0 --help, ticket 1916: syntax evidence, without a provider turn. + const recordedHelp = ` --approval-mode Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools), plan (read-only mode) [string] [choices: "default", "auto_edit", "yolo", "plan"]` + const option = recordedHelp.trim().split(/\s+/)[0] + const mode = recordedHelp.match(/choices: "([^"]+)"/)?.[1] + const text = `${option} ${mode}` + expect(text).toBe('--approval-mode default') + const imported = migrateAgentPermissionChoice({ agentDefaultArgs: { gemini: text } }) + expect(imported.agentDefaultArgs.gemini).toBe(text) + expect(resolveAgentPermission('gemini', imported).choice).toBe('own') + const full = { ...imported, agentPermissionDefault: 'full-access' as const } + expect(resolveAgentPermission('gemini', full).choice).toBe('full-access') + expect(resolveTuiAgentLaunchArgs('gemini', full)).toBe(text) + }) + + it.each(['--approval-mode auto_edit', '--approval-mode=plan', '-y'])( + 'preserves Gemini literal %s under Full through save and reload', + (value) => { + const before: AgentLaunchSettingsSource = { agentPermissionDefault: 'full-access' } + const updates = agentLaunchSettingsMutationUpdates( + before, + { type: 'user-arguments', agent: 'gemini', value }, + 'linux' + ) + const loaded = migrateAgentPermissionChoice( + JSON.parse(JSON.stringify({ ...before, ...updates })), + 'linux' + ) + expect(loaded.agentDefaultArgs.gemini).toBe(value) + expect(loaded.agentPermissionOverrides).toEqual({}) + expect(resolveAgentPermission('gemini', loaded).choice).toBe('full-access') + expect(resolveTuiAgentLaunchArgs('gemini', loaded)).toBe(value) + expect(projectAgentLaunchSettings(loaded).agentDefaultArgs.gemini).toBe(value) + expect(agentArgsSetPermission('gemini', value)).toBe(true) + } + ) + + it.each( + (['linux', 'win32'] as const).flatMap((platform) => + [20, 200].map((n) => [platform, n] as const) + ) + )('prepares Goose with ordered semantics on %s (%i)', (platform, n) => { + const carrierName = (key: string) => + (platform === 'win32' ? key.toUpperCase() : key) === 'GOOSE_MODE' + const aliases = Object.fromEntries( + Array.from({ length: n }, (_, i) => { + let bit = 0 + return [ + 'GOOSE_MODE'.replace(/[A-Z]/g, (c) => (i & (1 << bit++) ? c.toLowerCase() : c)), + 'custom' + ] + }) + ) + const ordinary = Object.fromEntries( + Array.from({ length: n }, (_, i) => [ + [...'ORDINARYKEY'].map((c, bit) => (i & (1 << bit) ? c.toLowerCase() : c)).join(''), + 'old' + ]) + ) + const changed = Object.fromEntries(Object.keys(ordinary).map((k) => [k, 'new'])) + const proto = (value: string) => Object.fromEntries([['__proto__', value]]) + for (const [row, [raw, requested, mode = 'full-access']] of ( + [ + [{}, { goose_mode: 'auto' }], + [ + { GOOSE_MODE: 'a', goose_mode: 'b' }, + { GOOSE_MODE: 'a', goose_mode: 'b', X: 'c' } + ], + [{ GOOSE_MODE: 'custom', goose_mode: 'auto', OTHER: 'keep' }, {}], + [proto('own'), proto('next')], + [aliases, { ...aliases, X: 'new' }], + [aliases, {}], + [aliases, aliases], + [ordinary, changed, 'ask'], + [ordinary, { ...changed, GOOSE_MODE: 'auto' }] + ] as const + ).entries()) { + const extras = Object.fromEntries(Array.from({ length: n }, (_, i) => [`Other${i}`, {}])) + const initial: AgentLaunchSettingsSource = { + agentPermissionDefault: mode === 'ask' ? 'ask' : 'full-access', + agentDefaultEnv: { ...extras, goose: raw, gemini: { LATEST: 'keep' } }, + agentPermissionOverrides: Object.fromEntries( + Object.keys(extras).map((k) => [k, 'own' as const]) + ) + } + const previous = resolveTuiAgentLaunchEnv('goose', initial, platform) + const changedNames = [ + ...new Set([...Object.keys(previous), ...Object.keys(requested)]) + ].filter((name) => previous[name] !== requested[name]) + const expectedActions = changedNames.filter(carrierName).length + const sequential = changedNames.reduce( + (state, name) => ({ + ...state, + ...agentLaunchSettingsMutationUpdates( + state, + Object.hasOwn(requested, name) + ? { type: 'environment-set', agent: 'goose', name, value: requested[name] } + : { type: 'environment-remove', agent: 'goose', name }, + platform + ) + }), + initial + ) + let outer = 0 + type AgentSettingsMap = Record< + string, + | Record + | NonNullable + | undefined + > + const observe = (target: T) => + new Proxy(target, { + ownKeys(value) { + const keys = Reflect.ownKeys(value) + outer += 2 * keys.length + return keys + }, + get(value, key) { + outer++ + return typeof key === 'string' ? value[key] : undefined + } + }) + initial.agentDefaultEnv = observe(initial.agentDefaultEnv!) + initial.agentPermissionOverrides = observe(initial.agentPermissionOverrides!) + const receipt = measureSettingsWork(() => + wholeAgentEnvironmentUpdates(initial, 'goose', requested, platform) + ) + const d = Object.keys(raw).filter(carrierName).length + expect(outer).toBeLessThanOrEqual(4 * (2 * n + 2)) + const ordinary = + Object.keys(raw).filter((k) => !carrierName(k)).length + + Object.keys(requested).filter((k) => !carrierName(k)).length + expect(receipt.work.assignments).toBe(expectedActions) + // Removal/rename rows empty the raw carrier group on the first action; later raw groups stay empty. + const complete = + row >= 7 + ? 13 * n + 1 + (row === 8 ? 3 * n + 24 : 0) + : (expectedActions ? 20 * (d + 2 * expectedActions) : 0) + 12 * (d + ordinary + 1) + const entries = receipt.work.entries + complete + expect(entries).toBeLessThanOrEqual( + row >= 7 + ? 36 * (n + (row === 8 ? 1 : 0)) + 64 + : 64 * (d + expectedActions + 1) + 36 * ordinary + ) + if (row >= 7 && platform === 'win32') { + expect(receipt.value.agentDefaultEnv?.goose).toEqual({ + [Object.keys(changed).at(-1)!]: 'new' + }) + } + const key = `env-${platform}-${row}`, + counts = [entries + outer, receipt.work.bytes] + expectSettingsWorkScale(key, n, counts) + if (row === 6) { + expect(receipt.value).toEqual({}) + } + expect(receipt.work.comparisons).toBeLessThanOrEqual( + 16 * (d + 1) * Math.ceil(Math.log2(d + 1)) + ) + const prepared = { ...initial, ...receipt.value } + expect(prepared).toEqual(sequential) + expect(Object.keys(prepared.agentDefaultEnv?.goose ?? {})).toEqual( + Object.keys(sequential.agentDefaultEnv?.goose ?? {}) + ) + } + }) + + it('applies combined old-client carrier edits against the original projection', () => { + const host: AgentLaunchSettingsSource = { + agentPermissionDefault: 'full-access', + agentDefaultEnv: { goose: { KEEP: '1' } } + } + const updated = { + ...host, + ...legacyLaunchTextUpdates( + host, + { + agentDefaultArgs: { goose: '--model foo', claude: '--permission-mode auto --model opus' }, + agentDefaultEnv: { goose: { GOOSE_MODE: 'auto', KEEP: '2' } } + }, + 'linux' + ) + } + expect(resolveTuiAgentLaunchEnv('goose', updated, 'linux')).toEqual({ + GOOSE_MODE: 'auto', + KEEP: '2' + }) + expect(resolveTuiAgentLaunchArgs('goose', updated)).toBe('--model foo') + expect(newChatPermissionModeForSettings('claude', updated)).toBe('auto') + expect(updated.agentDefaultArgs?.claude).toBe('--model opus') + }) + + it('uses host environment identity without guessing duplicate Windows values', () => { + const raw = legacy({}, { goose: { goose_mode: 'auto', KEEP: '1' } }) + const posix = migrateAgentPermissionChoice(raw, 'linux') + expect(resolveTuiAgentLaunchEnv('goose', posix, 'linux')).toEqual({ + goose_mode: 'auto', + KEEP: '1' + }) + const explicit = { ...posix, agentPermissionOverrides: { goose: 'full-access' as const } } + expect(resolveTuiAgentLaunchEnv('goose', explicit, 'linux')).toEqual({ + GOOSE_MODE: 'auto', + goose_mode: 'auto', + KEEP: '1' + }) + const unknown = migrateAgentPermissionChoice(raw, null) + expect(unknown.agentDefaultEnv.goose).toEqual({ goose_mode: 'auto', KEEP: '1' }) + const windows = migrateAgentPermissionChoice(raw, 'win32') + expect(resolveTuiAgentLaunchEnv('goose', windows, 'win32')).toEqual({ + GOOSE_MODE: 'auto', + KEEP: '1' + }) + const duplicate = migrateAgentPermissionChoice( + legacy({}, { goose: { GOOSE_MODE: 'auto', goose_mode: 'approve' } }), + 'win32' + ) + expect(resolveAgentPermission('goose', duplicate).choice).toBe('own') + expect(resolveTuiAgentLaunchEnv('goose', duplicate, 'win32')).toEqual({ + GOOSE_MODE: 'auto', + goose_mode: 'approve' + }) + }) + + it('preserves chat-only migration defaults, then seeds Ask and retains raw choices through Auto', () => { + const agents = ['antigravity', 'opencode', 'opencode2', 'omp'] as const + const upgraded = { + ...migrateAgentPermissionChoice(legacy({})), + nativeChatPermissionMode: 'ask' as const + } + for (const agent of ['opencode', 'opencode2', 'omp'] as const) { + expect(newChatPermissionModeForSettings(agent, upgraded)).toBe('ask') + } + const ask = { ...upgraded, ...applyAllAgentsPermission(upgraded, 'ask') } + for (const agent of agents) { + expect(resolveTuiAgentLaunchArgs(agent, ask)).toBe('') + expect(newChatPermissionModeForSettings(agent, ask)).toBe('ask') + const edited = { + ...ask, + ...agentLaunchSettingsMutationUpdates( + ask, + { type: 'arguments', agent, value: '--model foo' }, + 'linux' + ) + } + expect(newChatPermissionModeForSettings(agent, edited)).toBe('ask') + } + const own = { ...ask, agentPermissionOverrides: { omp: 'own' as const } } + const auto = { ...own, ...applyAllAgentsPermission(own, 'auto') } + expect(resolveAgentPermission('omp', auto).choice).toBe('own') + for (const agent of ['antigravity', 'opencode', 'opencode2'] as const) { + expect(newChatPermissionModeForSettings(agent, auto)).toBe('ask') + } + }) +}) + +const importWork = { chars: 0, visits: 0 } +const cut = permissionArgs.permissionRecipeCut +vi.spyOn(permissionArgs, 'permissionRecipeCut').mockImplementation((agent, text, recipe) => { + const { value, work } = measureSettingsWork(() => cut(agent, text, recipe)) + importWork.chars += work.chars + importWork.visits += work.visits + if (text.length < 100) { + return value + } + const calls = work.inputs, + w = recipe.split(' ').length, + t = text.split(' ').length + expect(calls.filter((v) => v === text)).toHaveLength(3) + // Fixed token/span/owner allowance plus native slices and two B construction passes per candidate. + for (const [actual, bound] of [ + [calls.filter((v) => v.length > 100 && v !== text).length, 3 * (w + 1)], + [work.chars, 3 * (w + 2) * text.length + 4096], + [work.entries + work.visits, 3 * (2 + 10 * (w + 1)) * (t + w) + 4096], + [work.bytes + 2 * (w + 1) * text.length, 12 * (w + 1) * text.length + 4096] + ]) { + expect(actual).toBeLessThanOrEqual(bound) + } + return value +}) +it.each([false, true])('bounds actual startup/raw resume (%s)', (resume) => { + for (const n of [20, 200]) { + const models = '--model opus '.repeat(n).trim(), + raw = Array(n).fill("'--model' 'opus'").join(' ') + const { value: plan, work } = measureSettingsWork(() => + (resume ? buildAgentResumeStartupPlan : buildAgentStartupPlan)({ + agent: 'claude', + cmdOverrides: {}, + platform: 'linux', + agentArgs: models, + sessionOptions: { model: 'opus' }, + sessionOptionsOverrideAgentArgs: resume, + prompt: '', + allowEmptyPromptLaunch: true, + providerSession: { key: 'session_id', id: 's' } + }) + ) + expect(plan?.launchConfig.agentCommand).toBe(`claude ${raw}`) + expect(plan?.launchCommand).toBe(`claude ${resume ? "'--model' 'opus' '--resume' 's'" : raw}`) + expect(work.visits).toBeLessThanOrEqual(40 * (2 * n + 1)) + expectSettingsWorkScale(`startup-${resume}`, n, [work.visits]) + } +}) diff --git a/src/shared/tui-agent-permission-choice.ts b/src/shared/tui-agent-permission-choice.ts new file mode 100644 index 00000000000..ac8b1c0edc9 --- /dev/null +++ b/src/shared/tui-agent-permission-choice.ts @@ -0,0 +1,180 @@ +import { agentChatPermissionModes } from './agent-chat-permission-support' +import type { AgentChatPermissionMode } from './agent-chat-permission-mode' +import { isTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config' +import { + AUTO_TUI_AGENT_ARGS, + YOLO_TUI_AGENT_ARGS, + YOLO_TUI_AGENT_ENV +} from './tui-agent-permissions' +import type { TuiAgent } from './tui-agent' + +/** + * An agent's stored permission. `own`: Orca adds no permission flag and the agent's own config + * applies. The rest use the chat's four modes. Saved in settings: never rename a value. + */ +export type AgentPermissionChoice = 'own' | AgentChatPermissionMode + +export const AGENT_PERMISSION_CHOICES = [ + 'own', + 'ask', + 'accept-edits', + 'auto', + 'full-access' +] as const satisfies readonly AgentPermissionChoice[] + +export type AgentPermissionGlobalSettings = { + /** Every agent's permission in terminals and new chats, unless it has its own below. Absent on + * profiles saved before permissions were stored apart from Arguments. */ + agentPermissionDefault?: AgentPermissionChoice + /** Agents given their own permission instead of the all-agents one. */ + agentPermissionOverrides?: Partial> +} + +export function isAgentPermissionChoice(value: unknown): value is AgentPermissionChoice { + return AGENT_PERMISSION_CHOICES.some((choice) => choice === value) +} + +// Why only these: each recipe is verified against its CLI. Ask states every field Orca's own Ask +// chat states (Codex: approvals, sandbox and reviewer), so an agent's config can't widen it. +const ASK_TUI_AGENT_ARGS: Partial> = { + claude: '--permission-mode default', + 'claude-agent-teams': '--permission-mode default', + grok: '--permission-mode default', + codex: '-a on-request -s workspace-write -c approvals_reviewer=user' +} + +const ACCEPT_EDITS_TUI_AGENT_ARGS: Partial> = { + claude: '--permission-mode acceptEdits', + 'claude-agent-teams': '--permission-mode acceptEdits' +} + +const ARGS_BY_CHOICE: Record>> = { + ask: ASK_TUI_AGENT_ARGS, + 'accept-edits': ACCEPT_EDITS_TUI_AGENT_ARGS, + auto: AUTO_TUI_AGENT_ARGS, + 'full-access': YOLO_TUI_AGENT_ARGS +} + +/** The flag text Orca adds for this choice; empty for `own` or a choice the agent lacks. */ +export function agentPermissionArgs(agent: TuiAgent, choice: AgentPermissionChoice): string { + return choice === 'own' ? '' : (ARGS_BY_CHOICE[choice][agent] ?? '') +} + +/** The environment Orca adds for this choice (Goose's bypass is an env var). */ +export function agentPermissionEnv( + agent: TuiAgent, + choice: AgentPermissionChoice +): Record { + return choice === 'full-access' ? { ...YOLO_TUI_AGENT_ENV[agent] } : {} +} + +function supportsChoice(agent: TuiAgent, choice: AgentPermissionChoice): boolean { + return ( + choice === 'own' || + agentPermissionArgs(agent, choice) !== '' || + Object.keys(agentPermissionEnv(agent, choice)).length > 0 || + agentChatPermissionModes(agent)?.modes.includes(choice) === true + ) +} + +/** Modes supported by at least one of this agent's terminal or chat surfaces. */ +export function agentPermissionChoices(agent: TuiAgent): readonly AgentPermissionChoice[] { + return AGENT_PERMISSION_CHOICES.filter((choice) => supportsChoice(agent, choice)) +} + +/** Agents with at least one terminal or chat mode beyond `own`, in catalog order. */ +export const PERMISSION_CHOICE_AGENTS: readonly TuiAgent[] = Object.keys(TUI_AGENT_CONFIG).filter( + (agent): agent is TuiAgent => isTuiAgent(agent) && agentPermissionChoices(agent).length > 1 +) + +export type AgentPermissionSettings = AgentPermissionGlobalSettings + +/** Settings that store the permission as a choice rather than as flag text in Arguments. */ +export function hasTypedAgentPermission(settings: AgentPermissionSettings | null | undefined) { + return isAgentPermissionChoice(settings?.agentPermissionDefault) +} + +/** + * Reads stored intent. Each consuming surface applies its own supported-mode rules. + */ +export function resolveAgentPermission( + agent: TuiAgent, + settings: AgentPermissionSettings | null | undefined +): { choice: AgentPermissionChoice; source: 'agent' | 'all' } { + const override = settings?.agentPermissionOverrides?.[agent] + if (isAgentPermissionChoice(override)) { + return { choice: override, source: 'agent' } + } + const fallback = settings?.agentPermissionDefault + const choice = isAgentPermissionChoice(fallback) ? fallback : 'own' + return { choice, source: 'all' } +} + +export function normalizeAgentPermissionOverrides( + value: unknown +): NonNullable { + if (!value || typeof value !== 'object') { + return {} + } + return Object.fromEntries( + Object.entries(value).filter( + ([agent, choice]) => isTuiAgent(agent) && isAgentPermissionChoice(choice) + ) + ) +} + +/** + * The all-agents choice applied: every agent follows it, except that Auto leaves agents without + * it on what they had, as the Auto switch always has (falling to `own` could widen or narrow them). + */ +export function applyAllAgentsPermission( + settings: AgentPermissionSettings, + choice: AgentPermissionChoice +): Required { + const overrides: Partial> = {} + if (choice === 'auto') { + for (const agent of PERMISSION_CHOICE_AGENTS) { + const previous = resolveAgentPermission(agent, settings).choice + if (!supportsChoice(agent, 'auto')) { + overrides[agent] = previous + } + } + } + return { agentPermissionDefault: choice, agentPermissionOverrides: overrides } +} + +/** Sets agents' own choices; `'default'` makes them follow the all-agents choice again. */ +export function applyAgentPermissionOverrides( + settings: AgentPermissionSettings, + agents: readonly TuiAgent[], + choice: AgentPermissionChoice | 'default' +): Pick { + const overrides = { ...settings.agentPermissionOverrides } + for (const agent of agents) { + if (choice === 'default') { + delete overrides[agent] + } else { + overrides[agent] = choice + } + } + return { agentPermissionOverrides: overrides } +} + +/** Normalizes the stored permission in a settings write. An unknown all-agents choice is dropped: + * saved, it would read as legacy text and lose every agent's stored permission. */ +export function sanitizeAgentPermissionUpdates( + target: AgentPermissionGlobalSettings, + updates: AgentPermissionGlobalSettings +): void { + if ( + 'agentPermissionDefault' in updates && + !isAgentPermissionChoice(updates.agentPermissionDefault) + ) { + delete target.agentPermissionDefault + } + if ('agentPermissionOverrides' in updates) { + target.agentPermissionOverrides = normalizeAgentPermissionOverrides( + updates.agentPermissionOverrides + ) + } +} diff --git a/src/shared/tui-agent-permission-lift.ts b/src/shared/tui-agent-permission-lift.ts new file mode 100644 index 00000000000..f25840dfe93 --- /dev/null +++ b/src/shared/tui-agent-permission-lift.ts @@ -0,0 +1,130 @@ +import { agentPermissionHostPlatform, permissionEnvNameMatches } from './agent-permission-env' +import type { AgentChatPermissionMode } from './agent-chat-permission-mode' +import type { GlobalSettings } from './global-settings-types' +import { + agentPermissionArgs, + agentPermissionChoices, + agentPermissionEnv, + hasTypedAgentPermission, + normalizeAgentPermissionOverrides, + PERMISSION_CHOICE_AGENTS, + resolveAgentPermission, + type AgentPermissionChoice +} from './tui-agent-permission-choice' +import { permissionRecipeCut } from './agent-permission-args' +export { agentArgsSetPermission } from './agent-permission-args' +import type { TuiAgent } from './tui-agent' + +/** Only exact recipes with preserved shell words and CLI ownership become stored intent. */ +export function liftAgentPermissionArgs( + agent: TuiAgent, + text: string | null | undefined +): { choice: AgentChatPermissionMode | null; args: string } { + const original = text?.trim() ?? '' + for (const choice of agentPermissionChoices(agent)) { + if (choice === 'own') { + continue + } + const cut = permissionRecipeCut(agent, original, agentPermissionArgs(agent, choice)) + if (cut) { + return { choice, args: cut.args } + } + } + return { choice: null, args: original } +} + +/** + * Splits an env-driven bypass (Goose's `GOOSE_MODE=auto`) out of an environment record. Names + * follow the execution host's identity; unknown browser caches preserve ambiguous spellings. + */ +export function liftAgentPermissionEnv( + agent: TuiAgent, + env: Record | null | undefined, + platform: NodeJS.Platform | null | undefined = agentPermissionHostPlatform() +): { choice: 'full-access' | null; env: Record } { + const rest = { ...env } + const bypass = Object.entries(agentPermissionEnv(agent, 'full-access')) + const matches = (name: string, wanted: string): boolean => + permissionEnvNameMatches(name, wanted, platform) + const setsBypass = ([wanted, value]: [string, string]): boolean => { + const spellings = Object.entries(rest).filter(([name]) => matches(name, wanted)) + return ( + spellings.length > 0 && + spellings.every(([, current]) => current === value) && + (platform != null || + !Object.keys(rest).some( + (name) => name !== wanted && name.toLowerCase() === wanted.toLowerCase() + )) + ) + } + if (bypass.length === 0 || !bypass.every(setsBypass)) { + return { choice: null, env: rest } + } + for (const name of Object.keys(rest)) { + if (bypass.some(([wanted]) => matches(name, wanted))) { + delete rest[name] + } + } + return { choice: 'full-access', env: rest } +} + +type AgentPermissionProfile = Required< + Pick< + GlobalSettings, + 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentPermissionDefault' | 'agentPermissionOverrides' + > +> + +/** + * Moves each agent's permission out of its Arguments into a stored choice, once per profile. + * Orca's own flags become the choice and leave the text; no flag means the agent's own settings + * (those terminals passed none). Text that sets permissions some other way is left whole, on own. + */ +export function migrateAgentPermissionChoice( + settings: Partial, + platform: NodeJS.Platform | null | undefined = agentPermissionHostPlatform() +): AgentPermissionProfile { + const args = { ...settings.agentDefaultArgs } + const env = { ...settings.agentDefaultEnv } + if (hasTypedAgentPermission(settings)) { + return { + agentDefaultArgs: args, + agentDefaultEnv: env, + agentPermissionDefault: settings.agentPermissionDefault ?? 'own', + agentPermissionOverrides: normalizeAgentPermissionOverrides(settings.agentPermissionOverrides) + } + } + const choices = new Map() + const counts = new Map() + for (const agent of PERMISSION_CHOICE_AGENTS) { + const fromArgs = liftAgentPermissionArgs(agent, args[agent]) + const fromEnv = fromArgs.choice ? null : liftAgentPermissionEnv(agent, env[agent], platform) + if (fromArgs.choice) { + args[agent] = fromArgs.args + } else if (fromEnv?.choice && env[agent]) { + env[agent] = fromEnv.env + } + const choice = fromArgs.choice ?? fromEnv?.choice ?? 'own' + choices.set(agent, choice) + counts.set(choice, (counts.get(choice) ?? 0) + 1) + } + // The most common choice becomes the all-agents one; ties keep the first counted. + let agentPermissionDefault: AgentPermissionChoice = 'own' + for (const [choice, count] of counts) { + if (count > (counts.get(agentPermissionDefault) ?? 0)) { + agentPermissionDefault = choice + } + } + const agentPermissionOverrides: Partial> = {} + for (const [agent, choice] of choices) { + if (resolveAgentPermission(agent, { agentPermissionDefault }).choice !== choice) { + agentPermissionOverrides[agent] = choice + } + } + return { + agentDefaultArgs: args, + agentDefaultEnv: env, + agentPermissionDefault, + agentPermissionOverrides + } +} diff --git a/src/shared/tui-agent-startup.spec.ts b/src/shared/tui-agent-startup.spec.ts index 6da47b35f1a..b1b7de942d5 100644 --- a/src/shared/tui-agent-startup.spec.ts +++ b/src/shared/tui-agent-startup.spec.ts @@ -52,7 +52,7 @@ describe('tui agent startup plans', () => { agent: 'claude', prompt: '', agentArgs: resolveTuiAgentLaunchArgs('claude', { - claude: '--dangerously-skip-permissions --model Opus' + agentDefaultArgs: { claude: '--dangerously-skip-permissions --model Opus' } }), cmdOverrides: {}, platform: 'linux', @@ -400,7 +400,7 @@ describe('tui agent startup plans', () => { cmdOverrides: {}, platform, shell, - agentArgs: resolveTuiAgentLaunchArgs('muse', defaults) + agentArgs: resolveTuiAgentLaunchArgs('muse', { agentDefaultArgs: defaults }) }) expect(plan).toMatchObject({ @@ -607,7 +607,7 @@ describe('tui agent startup plans', () => { agent: 'opencode', prompt: 'fix it', cmdOverrides: {}, - agentArgs: resolveTuiAgentLaunchArgs('opencode', agentDefaultArgs), + agentArgs: resolveTuiAgentLaunchArgs('opencode', { agentDefaultArgs }), platform: 'linux' })