From fb1f4b3e6fa045da8e57f03b94d564a34cb16450 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:20:55 -0700 Subject: [PATCH] Agents: store each agent's permission itself; terminals and new chats read it (#31315) * feat(agents): store each agent's permission itself instead of typing flags into Arguments Terminals and new chats read one stored choice per agent (Use the agent's own settings, Ask for approval, Accept edits, Approve for me, Full access). Orca adds the flag at launch; Arguments hold only the user's own text. A one-time move lifts Orca's flags out of saved Arguments. Older clients still see and write the flag as part of Arguments; the host translates. * fix(agents): say the all-agents switch resets agents set separately; keep primitives unstyled * test(agents): the one-time move returns every launch-profile field * Fix permission carrier ownership and surface-specific defaults * Preserve uncertain permission operands and name the option inventory * Check env entries without indexing typed agent maps * Combine Chat permission capability imports * Use folder execution-host platform for permission environment * Preserve launch map replacement and acknowledge permission saves * Keep permission feedback fallbacks statically discoverable * Provide preload platform to Agents settings render tests * Preserve Gemini approval arguments under Full access * fix(settings): retain complete environment edits through save outcomes * fix(settings): narrow local failure scope and preserve host messages * fix(settings): retire argument drafts after acknowledged saves * fix(settings): keep Goose environment editable after reset * fix(settings): show resolved arguments for legacy hosts * Preserve pending environment resets and bound launch settings work * Keep optional settings fallback in repository metadata reads * Read repository visibility defaults through the raw settings fixtures * Prepare whole Environment edits and retire unchanged drafts * Keep Environment settings transitions in an acyclic module * Update raw settings reader test fixtures * Place test fixture safety notes on assertion starts * test: pin permission settings work and recovery contracts * test: type permission settings work fixtures * test: cover migrated permission inputs and captured CLI grammar * test: preserve typed permissions in WSL Claude restarts --- config/vitest.performance.config.ts | 4 + mobile/src/session/ai-vault-resume-launch.ts | 4 +- .../codex/codex-structured-launch-args.ts | 8 +- .../at-rest-command-context.ts | 2 + .../structured-agent-configured-args.ts | 12 +- src/main/orcad/orcad-account-services.ts | 2 +- src/main/persistence-settings-update.test.ts | 14 +- .../applying-settings/settings-update.ts | 14 +- .../normalize-loaded-global-settings.test.ts | 110 ++- .../normalize-loaded-global-settings.ts | 5 +- .../prepare-loaded-profile-settings.ts | 14 + .../codex-workspace-relative-path.test.ts | 2 +- .../runtime/agent-launch-settings-rollback.ts | 8 + src/main/runtime/orca-runtime.ts | 2 +- .../runtime/rpc/methods/agent-hooks.test.ts | 11 +- src/main/runtime/rpc/methods/agent-hooks.ts | 5 +- .../agent-launch-settings-work.test.ts | 198 +++++ src/main/runtime/rpc/methods/repo.test.ts | 34 +- .../runtime/rpc/repo-visibility-projection.ts | 3 +- ...t-agent-launch-settings-durability.test.ts | 101 ++- ...ntime-client-agent-launch-settings.test.ts | 13 +- src/main/runtime/runtime-client-settings.ts | 24 +- .../runtime-rpc/runtime-rpc-pairing.ts | 3 +- src/main/runtime/runtime-store-contract.ts | 2 + .../get-agent-session-execution-namespace.ts | 4 +- .../structured-agent-launch-support.ts | 2 +- .../workspaces/workspace-process-listing.ts | 2 +- .../startup/main-process-runtime-service.ts | 2 +- .../use-onboarding-flow-persistence.ts | 11 +- .../use-onboarding-yolo-permissions.ts | 9 +- .../components/settings/AgentCatalogRow.tsx | 82 ++- .../AgentLaunchDefaultsEditor.test.tsx | 46 +- .../settings/AgentLaunchDefaultsEditor.tsx | 81 ++- .../settings/AgentLaunchSettingsCatalog.tsx | 150 +++- .../AgentLaunchSettingsSection.test.tsx | 6 +- .../settings/AgentLaunchSettingsSection.tsx | 44 +- .../AgentPermissionChoiceControls.tsx | 261 +++++++ .../components/settings/AgentsPane.test.tsx | 7 +- .../settings/ChatBehaviorSetting.test.tsx | 17 + .../settings/ChatBehaviorSetting.tsx | 10 +- .../settings/ChatPermissionSetting.tsx | 4 +- .../settings/HostAgentLaunchSettings.test.tsx | 43 ++ .../agent-launch-settings-transport.ts | 10 + ...permission-conversion-persistence.test.tsx | 472 ++++++++++++ .../settings/agent-row-permission-model.ts | 6 +- .../settings/chat-permission-search.ts | 16 +- .../settings/use-agent-launch-field-write.ts | 6 +- ...w-tab-agent-menu-host-preferences.test.tsx | 79 +- .../folder-submit-orchestration.ts | 16 +- .../composer-state/quick-startup-plan.ts | 16 +- .../settings-navigation-build-options.ts | 1 + .../settings-navigation-interface-sections.ts | 3 +- .../hooks/useSettingsNavigationMetadata.ts | 11 +- src/renderer/src/i18n/locales/en.json | 21 +- .../lib/agent-account-restart-startup.test.ts | 14 +- .../src/lib/agent-account-restart-startup.ts | 4 +- .../ai-vault-antigravity-reference-startup.ts | 13 +- .../src/lib/launch-agent-in-new-tab.ts | 6 +- ...nch-parity-workspace-start.test-fixture.ts | 4 +- .../src/lib/launch-work-item-direct-agent.ts | 13 +- .../lib/onboarding-folder-agent-startup.ts | 4 +- ...tructured-agent-session-host-capability.ts | 9 + .../repos-onboarding-folder-startup.test.ts | 3 +- src/renderer/src/store/slices/settings.ts | 14 +- .../web-chat-permission-normalization.test.ts | 47 +- .../web-preference-normalization.ts | 35 + .../web/preload-api/web-preferences-store.ts | 19 +- .../agent-chat-permission-host-support.ts | 4 +- src/shared/agent-chat-permission-mode.ts | 85 +-- src/shared/agent-chat-permission-support.ts | 41 ++ .../agent-launch-environment-updates.ts | 95 +++ src/shared/agent-launch-permission-updates.ts | 243 +++++++ src/shared/agent-launch-runtime-capability.ts | 5 + src/shared/agent-launch-settings.ts | 107 ++- src/shared/agent-permission-args.ts | 296 ++++++++ src/shared/agent-permission-env.ts | 12 + src/shared/agent-session-option-agent-args.ts | 38 +- ...artup-plan-inputs-extra-agent-args.spec.ts | 2 +- src/shared/agent-startup-plan-inputs.ts | 13 +- src/shared/ai-vault-resume-startup.ts | 24 +- src/shared/command-option-occurrence.ts | 5 +- src/shared/default-global-settings.ts | 10 +- src/shared/global-settings-base.ts | 2 + src/shared/global-settings-test-fixture.ts | 194 +++++ .../launch-parity-plus-cases.test-fixture.ts | 2 +- .../native-chat-session-option-defaults.ts | 8 +- .../agent-launch-settings-params.ts | 10 + src/shared/tui-agent-auto-permissions.test.ts | 2 +- src/shared/tui-agent-launch-defaults.spec.ts | 4 +- src/shared/tui-agent-launch-defaults.ts | 137 +++- .../tui-agent-permission-choice.test.ts | 681 ++++++++++++++++++ src/shared/tui-agent-permission-choice.ts | 180 +++++ src/shared/tui-agent-permission-lift.ts | 130 ++++ src/shared/tui-agent-startup.spec.ts | 6 +- 94 files changed, 4138 insertions(+), 416 deletions(-) create mode 100644 src/main/runtime/rpc/methods/agent-launch-settings-work.test.ts create mode 100644 src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx create mode 100644 src/renderer/src/components/settings/agent-permission-conversion-persistence.test.tsx create mode 100644 src/shared/agent-chat-permission-support.ts create mode 100644 src/shared/agent-launch-environment-updates.ts create mode 100644 src/shared/agent-launch-permission-updates.ts create mode 100644 src/shared/agent-permission-args.ts create mode 100644 src/shared/agent-permission-env.ts create mode 100644 src/shared/tui-agent-permission-choice.test.ts create mode 100644 src/shared/tui-agent-permission-choice.ts create mode 100644 src/shared/tui-agent-permission-lift.ts 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' })