diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 66b99681901..b1635a391cd 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, join, relative } from 'node:path' import { describe, expect, it, vi } from 'vitest' @@ -344,7 +344,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { // Both halves: the field exists, and it names the model the picker asked // for in the catalog's resolved shape rather than the id Orca sent. expect(frames[0]?.model).toEqual(expect.any(String)) - expect(frames[0]?.model).toBe('claude-haiku-4-5-20251001') + expect(frames[0]?.model).toMatch(/^claude-haiku-/) await expect( adapter.readOptions({ sessionId: 'real-cli-handshake', fence: 1 }) ).resolves.toMatchObject({ current: { model: 'haiku' } }) @@ -419,18 +419,20 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { // ride the launch, and the message is written after open answers initialize. it.skipIf(!realClaudeAuthenticated).each([ ['accept-edits', 'acceptEdits'], + ['auto', 'auto'], ['full-access', 'bypassPermissions'] ] as const)( 'runs a message after open with saved options and %s', async (permissionMode, cliPermissionMode) => { const providerSessionId = randomUUID() const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const cwd = await mkdtemp(join(tmpdir(), 'orca-claude-permissions-')) const events: ClaudeStructuredSessionEvent[] = [] const adapter = realAdapter( providerSessionId, claudeConfigDir, events, - process.cwd(), + cwd, undefined, realClaudeLaunchHome().env, false @@ -453,7 +455,15 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { body: { kind: 'message', role: 'user', - blocks: [{ type: 'text', text: 'Reply with exactly: OK. Do not use any tools.' }] + blocks: [ + { + type: 'text', + text: + permissionMode === 'auto' + ? 'Use tools to write auto-mode-proof.txt containing exactly AUTO_MODE_OK, then read it back. Reply with exactly: OK.' + : 'Reply with exactly: OK. Do not use any tools.' + } + ] }, fence: 1 }) @@ -469,11 +479,17 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { expect(messages().find((m) => m.type === 'result')).toMatchObject({ is_error: false }) // The turn's own init names what the child was launched with. expect(messages().find((m) => m.type === 'system' && m.subtype === 'init')).toMatchObject({ - model: 'claude-sonnet-5', + model: expect.stringMatching(/^claude-sonnet-/), permissionMode: cliPermissionMode }) + if (permissionMode === 'auto') { + expect((await readFile(join(cwd, 'auto-mode-proof.txt'), 'utf8')).trim()).toBe( + 'AUTO_MODE_OK' + ) + } } finally { await adapter.closeAll() + await rm(cwd, { recursive: true, force: true }) } }, 120_000 diff --git a/src/main/codex/codex-structured-real-cli-permissions.test.ts b/src/main/codex/codex-structured-real-cli-permissions.test.ts new file mode 100644 index 00000000000..fbb0c8f808a --- /dev/null +++ b/src/main/codex/codex-structured-real-cli-permissions.test.ts @@ -0,0 +1,92 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' +import { resolveCodexCommand } from '../codex-cli/command' +import { openCodexAppServerConnection } from './codex-app-server-connection' +import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy' +import { openCodexThread } from './codex-structured-thread-open' + +const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1' + +describe.skipIf(!enabled)('Codex real app-server permissions', () => { + it('starts in Auto and resets inherited policy on cold resume', async () => { + const base = mkdtempSync(join(tmpdir(), 'orca-real-codex-permissions-')) + const cwd = join(base, 'workspace') + const codexHome = join(base, 'codex-home') + try { + mkdirSync(cwd, { recursive: true }) + mkdirSync(codexHome, { recursive: true }) + writeFileSync( + join(codexHome, 'config.toml'), + 'approval_policy = "never"\nsandbox_mode = "danger-full-access"\n' + ) + let threadId: string | null = null + for (const mode of ['auto', 'full-access', 'auto', 'ask'] as const) { + let turnCompleted = false + const connection = await openCodexAppServerConnection( + { + command: resolveCodexCommand(), + args: ['app-server'], + cwd, + env: { HOME: base, CODEX_HOME: codexHome } + }, + { + onNotification: (method) => { + turnCompleted ||= method === 'turn/completed' + } + } + ) + try { + const replies: unknown[] = [] + const opened = await openCodexThread( + { + request: async (...args) => { + const reply = await connection.request(...args) + replies.push(reply) + return reply + } + }, + { + cwd, + resumeThreadId: threadId, + permissionPolicy: codexStructuredPermissionPolicy(mode) + }, + 20_000 + ) + if (threadId !== null) { + expect(opened.threadId).toBe(threadId) + } + threadId = opened.threadId + expect(replies).toMatchObject([effectivePolicy(mode)]) + // A shell-only turn saves a rollout for the next process without contacting a model. + await connection.request( + 'thread/shellCommand', + { threadId, command: 'echo auto-mode-proof' }, + { timeoutMs: 10_000 } + ) + await vi.waitFor(() => expect(turnCompleted).toBe(true), { timeout: 15_000 }) + } finally { + await connection.close() + } + } + } finally { + rmSync(base, { recursive: true, force: true }) + } + }, 120_000) +}) + +function effectivePolicy(mode: AgentChatPermissionMode) { + return mode === 'full-access' + ? { + approvalPolicy: 'never', + sandbox: { type: 'dangerFullAccess' }, + approvalsReviewer: 'user' + } + : { + approvalPolicy: 'on-request', + sandbox: { type: 'workspaceWrite' }, + approvalsReviewer: mode === 'auto' ? 'auto_review' : 'user' + } +} diff --git a/src/main/persistence/applying-settings/settings-update-normalization.test.ts b/src/main/persistence/applying-settings/settings-update-normalization.test.ts index bf4e2dc88b1..c440416ad70 100644 --- a/src/main/persistence/applying-settings/settings-update-normalization.test.ts +++ b/src/main/persistence/applying-settings/settings-update-normalization.test.ts @@ -122,3 +122,25 @@ describe('updateSettings terminalMinimumContrastRatio', () => { ) }) }) + +describe('updateSettings scoped chat defaults', () => { + it('clears agent overrides for a shared chat permission choice from any writer', () => { + const operations = settingsOperations({ + nativeChatPermissionMode: 'full-access', + agentNativeChatPermissionModes: { codex: 'auto' } + }) + expect( + updateSettings(operations, { nativeChatPermissionMode: 'ask' }).agentNativeChatPermissionModes + ).toEqual({}) + }) + + it('retains explicit overrides from a bulk mutation or durable rollback', () => { + const operations = settingsOperations({ nativeChatPermissionMode: 'ask' }) + expect( + updateSettings(operations, { + nativeChatPermissionMode: 'full-access', + agentNativeChatPermissionModes: { codex: 'auto' } + }).agentNativeChatPermissionModes + ).toEqual({ codex: 'auto' }) + }) +}) diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 9beb663d1ff..c4c3c0f8128 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -1,3 +1,4 @@ +import { normalizeAgentNativeChatPermissionModes } from '../../../shared/agent-chat-permission-mode' import { normalizeNativeChatAppearanceSettings } from '../../../shared/native-chat-appearance-settings' import type { GlobalSettings } from '../../../shared/global-settings-types' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' @@ -85,6 +86,11 @@ export function updateSettings( nestedWorkerMaxDepth: updates.nestedWorkerMaxDepth }) } + if ('nativeChatPermissionMode' in updates || 'agentNativeChatPermissionModes' in updates) { + sanitizedUpdates.agentNativeChatPermissionModes = normalizeAgentNativeChatPermissionModes( + updates.agentNativeChatPermissionModes + ) + } if ('nativeChatAppearance' in updates) { sanitizedUpdates.nativeChatAppearance = normalizeNativeChatAppearanceSettings( updates.nativeChatAppearance 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 393afcd0b19..2b7ae200a39 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 @@ -163,6 +163,17 @@ describe('the new-chat permission setting', () => { }) }) +describe('scoped new-chat permission defaults', () => { + it('preserves an agent override across load while rejecting unsupported stored modes', () => { + const normalized = normalizeLegacyProfile({ + nativeChatPermissionMode: 'full-access', + agentNativeChatPermissionModes: { codex: 'auto', claude: 'ask', omp: 'auto', unknown: 'auto' } + }) + expect(normalized.agentNativeChatPermissionModes).toEqual({ codex: 'auto', claude: 'ask' }) + expect(normalized.nativeChatPermissionMode).toBe('full-access') + }) +}) + describe('terminal default shell', () => { it('trims a hand-edited value and treats whitespace-only as unset', () => { expect( 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 5ac97bb0102..927a772a1cf 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -10,7 +10,10 @@ import { normalizeTerminalDefaultShell } from '../../../shared/terminal-default- import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat-shell-environment' -import { loadedChatPermissionSetting } from '../../../shared/agent-chat-permission-mode' +import { + loadedChatPermissionSetting, + normalizeAgentNativeChatPermissionModes +} from '../../../shared/agent-chat-permission-mode' import { savedChatUiWithTerminalDefaultView, stripRetiredGlobalSettings @@ -137,6 +140,9 @@ export function normalizeLoadedGlobalSettings( parsed.settings?.nativeChatShellEnvironmentVariables ), // Why: the next settings save stores the seed; until then a reload derives the same one. + agentNativeChatPermissionModes: normalizeAgentNativeChatPermissionModes( + parsed.settings?.agentNativeChatPermissionModes + ), nativeChatPermissionMode: loadedChatPermissionSetting( parsed.settings?.nativeChatPermissionMode, { diff --git a/src/main/runtime/agent-launch-settings-rollback.ts b/src/main/runtime/agent-launch-settings-rollback.ts index d6d4bccafdb..423541a5826 100644 --- a/src/main/runtime/agent-launch-settings-rollback.ts +++ b/src/main/runtime/agent-launch-settings-rollback.ts @@ -34,6 +34,14 @@ export function agentLaunchSettingsRollbackUpdates( ...(Object.hasOwn(updates, 'agentDefaultEnv') && Object.is(current.agentDefaultEnv, applied.agentDefaultEnv) ? { agentDefaultEnv: before.agentDefaultEnv ?? {} } + : {}), + ...(Object.hasOwn(updates, 'agentNativeChatPermissionModes') && + Object.is(current.agentNativeChatPermissionModes, applied.agentNativeChatPermissionModes) + ? { agentNativeChatPermissionModes: before.agentNativeChatPermissionModes ?? {} } + : {}), + ...(Object.hasOwn(updates, 'nativeChatPermissionMode') && + Object.is(current.nativeChatPermissionMode, applied.nativeChatPermissionMode) + ? { nativeChatPermissionMode: before.nativeChatPermissionMode ?? 'ask' } : {}) } } 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 46c1c4e45aa..53c2d4d9be2 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 @@ -157,6 +157,59 @@ describe('durable execution-host launch settings', () => { expect(readState().settings.agentDefaultArgs.claude).toBe('--retry-marker') }) + it('rolls back the new-chat default with a failed permission preset', async () => { + const { store, authority, readState } = await fixture() + const controller = new RuntimeClientSettingsController(store) + const before = store.getSettings().nativeChatPermissionMode + authority.failNextWrite() + try { + await expect( + controller.mutateAgentLaunch({ type: 'permissions', syncNativeChat: true, mode: 'auto' }) + ).rejects.toThrow('profile_state_write_failed') + } finally { + await store.flushPendingOrThrowAsync().catch(() => {}) + } + expect(store.getSettings().nativeChatPermissionMode).toBe(before) + expect(readState().settings.nativeChatPermissionMode).toBe(before) + await controller.mutateAgentLaunch({ type: 'permissions', syncNativeChat: true, mode: 'auto' }) + expect(readState().settings.nativeChatPermissionMode).toBe(before) + expect(readState().settings.agentNativeChatPermissionModes).toEqual({ + claude: 'auto', + codex: 'auto' + }) + }) + + it('rolls back a scoped chat default after a failed write and persists a retry', async () => { + const { store, authority, readState } = await fixture() + const controller = new RuntimeClientSettingsController(store) + const beforeArgs = store.getSettings().agentDefaultArgs?.codex + const beforeNative = store.getSettings().nativeChatPermissionMode + authority.failNextWrite() + try { + await expect( + controller.mutateAgentLaunch({ + type: 'permissions', + syncNativeChat: true, + agent: 'codex', + mode: 'auto' + }) + ).rejects.toThrow('profile_state_write_failed') + } finally { + await store.flushPendingOrThrowAsync().catch(() => {}) + } + expect(store.getSettings().agentDefaultArgs?.codex).toBe(beforeArgs) + expect(store.getSettings().agentNativeChatPermissionModes).toEqual({}) + await controller.mutateAgentLaunch({ + type: 'permissions', + syncNativeChat: true, + agent: 'codex', + mode: 'auto' + }) + expect(readState().settings.agentNativeChatPermissionModes).toEqual({ codex: 'auto' }) + expect(readState().settings.nativeChatPermissionMode).toBe(beforeNative) + expect(readState().settings.agentDefaultArgs.codex).toBe('--approve-for-me') + }) + it('recomputes queued intents after a failed write instead of persisting its stale map', async () => { const { store, authority, readState } = await fixture() const controller = new RuntimeClientSettingsController(store) 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 01215bfcb77..bafbbc7f17f 100644 --- a/src/main/runtime/runtime-client-agent-launch-settings.test.ts +++ b/src/main/runtime/runtime-client-agent-launch-settings.test.ts @@ -113,12 +113,46 @@ 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' } - await controller.mutateAgentLaunch({ type: 'permissions', mode: 'manual' }) + await controller.mutateAgentLaunch({ + type: 'permissions', + syncNativeChat: true, + mode: 'manual' + }) expect(host.agentDefaultArgs?.claude).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') }) + it('applies Auto to new chats on the execution host', async () => { + const { controller, host } = fixture() + const beforeNative = host.nativeChatPermissionMode + host.agentDefaultArgs = { claude: '', codex: '' } + await controller.mutateAgentLaunch({ type: 'permissions', syncNativeChat: true, mode: 'auto' }) + expect(host.agentDefaultArgs).toMatchObject({ + claude: '--permission-mode auto', + codex: '--approve-for-me' + }) + expect(host.nativeChatPermissionMode).toBe(beforeNative) + expect(host.agentNativeChatPermissionModes).toEqual({ claude: 'auto', codex: 'auto' }) + }) + + it('publishes scoped host defaults and resets them when a paired client chooses a shared chat default', async () => { + const { controller, host } = fixture() + host.agentDefaultArgs = { claude: '', codex: '' } + await controller.mutateAgentLaunch({ + type: 'permissions', + syncNativeChat: true, + agent: 'codex', + mode: 'auto' + }) + expect(controller.get().agentNativeChatPermissionModes).toEqual({ codex: 'auto' }) + expect(host.agentDefaultArgs.claude).toBe('') + await controller.update({ nativeChatPermissionMode: 'ask' }) + expect(controller.get().agentNativeChatPermissionModes).toEqual({}) + expect(host.nativeChatPermissionMode).toBe('ask') + }) + it('normalizes older-client None default requests to Blank Terminal', async () => { const { controller, host } = fixture() const mutation = AgentLaunchSettingsMutation.parse({ diff --git a/src/main/runtime/runtime-client-settings-shape.ts b/src/main/runtime/runtime-client-settings-shape.ts index 3dff43bfdc4..7868c9f3a8e 100644 --- a/src/main/runtime/runtime-client-settings-shape.ts +++ b/src/main/runtime/runtime-client-settings-shape.ts @@ -33,6 +33,7 @@ export type RuntimeClientSettings = Pick< | 'agentSkillSharingEnabled' | 'machineName' | 'nativeChatPermissionMode' + | 'agentNativeChatPermissionModes' > & { /** Older clients still read these; neither is persisted or accepted as an update. */ openAgentTabsInChatByDefault?: boolean diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 873ff358f8e..8c668eeb1cd 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -90,6 +90,9 @@ export class RuntimeClientSettingsController { ...(settings.nativeChatPermissionMode ? { nativeChatPermissionMode: settings.nativeChatPermissionMode } : {}), + ...(settings.agentNativeChatPermissionModes + ? { agentNativeChatPermissionModes: settings.agentNativeChatPermissionModes } + : {}), // Why projected: a paired client's AI buttons start these actions' agents, and must honour // the agent saved for each one as the desktop does. Absent on older hosts. sourceControlAi: projectSourceControlLaunchRecipes(settings), @@ -111,6 +114,9 @@ export class RuntimeClientSettingsController { this.store.updateSettings( { ...updates, + ...(updates.nativeChatPermissionMode !== undefined + ? { agentNativeChatPermissionModes: {} } + : {}), ...(updates.defaultTuiAgent !== undefined ? { newWorkspaceSetupOnly: false } : {}) }, { notifyListeners: true } diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index c6562c35804..4334ceb7517 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -166,6 +166,7 @@ export type RuntimeStore = { agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled'] nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions'] nativeChatPermissionMode?: GlobalSettings['nativeChatPermissionMode'] + agentNativeChatPermissionModes?: GlobalSettings['agentNativeChatPermissionModes'] nativeChatInlineVisuals?: GlobalSettings['nativeChatInlineVisuals'] nativeChatInheritShellEnvironment?: GlobalSettings['nativeChatInheritShellEnvironment'] nativeChatShellEnvironmentVariables?: GlobalSettings['nativeChatShellEnvironmentVariables'] diff --git a/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts b/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts index 08307c47565..e6f60dfc787 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts @@ -1,3 +1,4 @@ +import { newChatPermissionModeForSettings } from '../../../../shared/agent-chat-permission-mode' import { translate } from '@/i18n/i18n' import { hostPermissionRefusedText } from '@/lib/structured-agent-session-launch-permission-notice' import { useStructuredChatHostPermissionVerdict } from '@/runtime/structured-chat-host-permission' @@ -21,7 +22,7 @@ export function useNativeChatHostPermissionGate( agentLabel: string ): NativeChatHostPermissionGate { const verdict = useStructuredChatHostPermissionVerdict(target, agent) - const requested = useAppStore((state) => state.settings?.nativeChatPermissionMode) + const requested = useAppStore((state) => newChatPermissionModeForSettings(agent, state.settings)) if (verdict.kind === 'allowed') { return OPEN } diff --git a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx index d5f2f4aa39c..1f674228a7f 100644 --- a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx +++ b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx @@ -33,6 +33,7 @@ export function AgentLaunchSettingsCatalog({ renderPermissions, refreshSettings, autoSupported = true, + nativeChatPermissionsSupported = false, local }: { settings: AgentLaunchSettings @@ -42,6 +43,7 @@ export function AgentLaunchSettingsCatalog({ renderPermissions: AgentPermissionsRenderer refreshSettings?: () => void autoSupported?: boolean + nativeChatPermissionsSupported?: boolean local?: { settings: GlobalSettings updateSettings: (updates: Partial) => void | Promise @@ -61,7 +63,11 @@ export function AgentLaunchSettingsCatalog({ (agent) => detectedIds !== null && !detectedIds.has(agent.id) ) const save = (mutation: AgentLaunchSettingsMutation): void => { - void mutate(mutation).catch(() => {}) + const scopedMutation = + mutation.type === 'permissions' && nativeChatPermissionsSupported + ? { ...mutation, syncNativeChat: true as const } + : mutation + void mutate(scopedMutation).catch(() => {}) } const permissions = buildAgentRowPermissions({ agents: catalog.map((agent) => agent.id), diff --git a/src/renderer/src/components/settings/AgentLaunchSettingsSection.tsx b/src/renderer/src/components/settings/AgentLaunchSettingsSection.tsx index 9f4a863e994..305ebf83f37 100644 --- a/src/renderer/src/components/settings/AgentLaunchSettingsSection.tsx +++ b/src/renderer/src/components/settings/AgentLaunchSettingsSection.tsx @@ -87,6 +87,7 @@ function LocalAgentLaunchSettings({ detectionTarget={LOCAL_DETECTION_TARGET} hostName={null} renderPermissions={renderPermissions} + nativeChatPermissionsSupported local={{ settings, updateSettings }} /> ) diff --git a/src/renderer/src/components/settings/ChatPermissionSetting.tsx b/src/renderer/src/components/settings/ChatPermissionSetting.tsx index a06e395404f..4c21a723905 100644 --- a/src/renderer/src/components/settings/ChatPermissionSetting.tsx +++ b/src/renderer/src/components/settings/ChatPermissionSetting.tsx @@ -34,7 +34,9 @@ export function ChatPermissionSetting({ updateSettings({ nativeChatPermissionMode })} + onSelect={(nativeChatPermissionMode) => + updateSettings({ nativeChatPermissionMode, agentNativeChatPermissionModes: {} }) + } /> } /> diff --git a/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx b/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx index 4bca0b107c3..53bbcbb3106 100644 --- a/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx +++ b/src/renderer/src/components/settings/HostAgentLaunchSettings.test.tsx @@ -18,12 +18,14 @@ const transport = vi.hoisted(() => ({ read: vi.fn(), mutate: vi.fn(), detection: vi.fn(), - auto: vi.fn() + auto: vi.fn(), + native: vi.fn() })) vi.mock('./agent-launch-settings-transport', () => ({ readHostAgentLaunchSettings: transport.read, mutateHostAgentLaunchSettings: transport.mutate, - hostSupportsAutoPermissions: transport.auto + hostSupportsAutoPermissions: transport.auto, + hostSupportsNativeChatPermissions: transport.native })) vi.mock('@/hooks/useDetectedAgents', () => ({ useDetectedAgents: (target: unknown) => { @@ -62,6 +64,7 @@ beforeEach(() => { transport.mutate.mockReset().mockResolvedValue(host()) transport.detection.mockClear() transport.auto.mockReset().mockResolvedValue(true) + transport.native.mockReset().mockResolvedValue(true) }) describe('host launch settings form', () => { @@ -87,7 +90,7 @@ describe('host launch settings form', () => { await waitFor(() => expect(transport.mutate).toHaveBeenCalledWith( { environmentId: 'ssh-host', pairingRevision: 17 }, - { type: 'permissions', mode: 'auto' }, + { type: 'permissions', mode: 'auto', syncNativeChat: true }, expect.any(AbortSignal) ) ) @@ -97,6 +100,36 @@ describe('host launch settings form', () => { } ) + it.each([false, true])( + 'sends native synchronization only when the host supports it: %s', + async (nativeSupported) => { + transport.native.mockResolvedValue(nativeSupported) + render( + + } + /> + + ) + await screen.findByDisplayValue('--host-marker') + fireEvent.click(screen.getByRole('radio', { name: 'Manual' })) + await waitFor(() => + expect(transport.mutate).toHaveBeenCalledWith( + { environmentId: 'ssh-host', pairingRevision: 17 }, + { + type: 'permissions', + mode: 'manual', + ...(nativeSupported ? { syncNativeChat: true } : {}) + }, + expect.any(AbortSignal) + ) + ) + } + ) + const fullPane = () => ( { await waitFor(() => expect(transport.mutate).toHaveBeenCalledWith( { environmentId: 'ssh-host', pairingRevision: 17 }, - { type: 'permissions', mode: 'manual', exceptAuto: true }, + { type: 'permissions', mode: 'manual', exceptAuto: true, syncNativeChat: true }, expect.any(AbortSignal) ) ) @@ -151,7 +184,7 @@ describe('host launch settings form', () => { await waitFor(() => expect(transport.mutate).toHaveBeenCalledWith( { environmentId: 'ssh-host', pairingRevision: 17 }, - { type: 'permissions', agent: 'claude', mode: 'auto' }, + { type: 'permissions', agent: 'claude', mode: 'auto', syncNativeChat: true }, expect.any(AbortSignal) ) ) @@ -301,7 +334,7 @@ describe('host launch settings form', () => { await waitFor(() => expect(transport.mutate).toHaveBeenCalledWith( { environmentId: 'ssh-host', pairingRevision: 17 }, - { type: 'permissions', mode: 'manual' }, + { type: 'permissions', mode: 'manual', syncNativeChat: true }, expect.any(AbortSignal) ) ) @@ -321,7 +354,7 @@ describe('host launch settings form', () => { await waitFor(() => expect(transport.mutate).toHaveBeenCalledWith( { environmentId: 'ssh-host', pairingRevision: 17 }, - { type: 'permissions', mode: 'manual' }, + { type: 'permissions', mode: 'manual', syncNativeChat: true }, expect.any(AbortSignal) ) ) @@ -367,7 +400,8 @@ describe('host launch settings form', () => { await waitFor(() => expect(transport.mutate).toHaveBeenCalledTimes(2)) expect(transport.mutate.mock.calls[1]?.[1]).toEqual({ type: 'permissions', - mode: 'manual' + mode: 'manual', + syncNativeChat: true }) }) diff --git a/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx b/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx index 513520e497e..010d8b0c5b6 100644 --- a/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx +++ b/src/renderer/src/components/settings/HostAgentLaunchSettings.tsx @@ -9,6 +9,7 @@ import { mutateHostAgentLaunchSettings, readHostAgentLaunchSettings, hostSupportsAutoPermissions, + hostSupportsNativeChatPermissions, type AgentLaunchSettingsOwner } from './agent-launch-settings-transport' import { Button } from '../ui/button' @@ -16,7 +17,12 @@ import { translate } from '@/i18n/i18n' type ReadState = | { kind: 'loading' | 'unsupported' | 'failed' } - | { kind: 'ready'; settings: AgentLaunchSettings; autoSupported: boolean } + | { + kind: 'ready' + settings: AgentLaunchSettings + autoSupported: boolean + nativeChatPermissionsSupported: boolean + } export function HostAgentLaunchSettings({ environmentId, @@ -45,12 +51,17 @@ export function HostAgentLaunchSettings({ controllerRef.current = controller setRead({ kind: 'loading' }) try { - const [settings, autoSupported] = await Promise.all([ + const [settings, autoSupported, nativeChatPermissionsSupported] = await Promise.all([ readHostAgentLaunchSettings(owner, controller.signal), - hostSupportsAutoPermissions(owner) + hostSupportsAutoPermissions(owner), + hostSupportsNativeChatPermissions(owner) ]) if (!controller.signal.aborted) { - setRead(settings ? { kind: 'ready', settings, autoSupported } : { kind: 'unsupported' }) + setRead( + settings + ? { kind: 'ready', settings, autoSupported, nativeChatPermissionsSupported } + : { kind: 'unsupported' } + ) } } catch { if (!controller.signal.aborted) { @@ -77,7 +88,12 @@ export function HostAgentLaunchSettings({ try { const settings = await mutateHostAgentLaunchSettings(owner, mutation, controller.signal) if (!controller.signal.aborted) { - setRead({ kind: 'ready', settings, autoSupported: read.autoSupported }) + setRead({ + kind: 'ready', + settings, + autoSupported: read.autoSupported, + nativeChatPermissionsSupported: read.nativeChatPermissionsSupported + }) } } catch { if (!controller.signal.aborted) { @@ -123,6 +139,7 @@ export function HostAgentLaunchSettings({ hostName={hostName} renderPermissions={renderPermissions} autoSupported={read.autoSupported} + nativeChatPermissionsSupported={read.nativeChatPermissionsSupported} refreshSettings={() => void refresh()} /> diff --git a/src/renderer/src/components/settings/agent-launch-settings-transport.test.ts b/src/renderer/src/components/settings/agent-launch-settings-transport.test.ts index 52f5e076d82..d71fcf21759 100644 --- a/src/renderer/src/components/settings/agent-launch-settings-transport.test.ts +++ b/src/renderer/src/components/settings/agent-launch-settings-transport.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { projectAgentLaunchSettings } from '../../../../shared/agent-launch-settings' -import { AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import { + AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY, + AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY +} from '../../../../shared/agent-launch-runtime-capability' const rpc = vi.hoisted(() => ({ call: vi.fn(), supports: vi.fn() })) vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: rpc.call, @@ -111,3 +114,37 @@ describe('host agent launch settings transport', () => { }) }) }) + +it('refuses native synchronization against a settings-only host strict schema', async () => { + rpc.supports.mockImplementation( + async (_id, capability) => capability !== AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY + ) + await expect( + mutateHostAgentLaunchSettings( + { environmentId: 'settings-only' }, + { type: 'permissions', agent: 'codex', mode: 'auto', syncNativeChat: true } + ) + ).rejects.toThrow('agent_launch_native_chat_permissions_unsupported') + expect(rpc.call).not.toHaveBeenCalled() + await mutateHostAgentLaunchSettings( + { environmentId: 'settings-only' }, + { type: 'permissions', agent: 'codex', mode: 'auto' } + ) + expect(rpc.call.mock.calls[0]?.[2]).toEqual({ type: 'permissions', agent: 'codex', mode: 'auto' }) +}) + +it('sends the explicit native synchronization marker only after capability verification', async () => { + await mutateHostAgentLaunchSettings( + { environmentId: 'native' }, + { type: 'permissions', mode: 'manual', syncNativeChat: true } + ) + expect(rpc.supports).toHaveBeenCalledWith( + 'native', + AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY + ) + expect(rpc.call.mock.calls[0]?.[2]).toEqual({ + type: 'permissions', + mode: 'manual', + syncNativeChat: true + }) +}) 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 7ebac63ced1..f6da14982d3 100644 --- a/src/renderer/src/components/settings/agent-launch-settings-transport.ts +++ b/src/renderer/src/components/settings/agent-launch-settings-transport.ts @@ -4,6 +4,7 @@ import type { } from '../../../../shared/agent-launch-settings' import { AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY, + AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY, AGENT_LAUNCH_SETTINGS_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' @@ -57,6 +58,13 @@ export async function mutateHostAgentLaunchSettings( ) { throw new Error('agent_launch_auto_permissions_unsupported') } + if ( + mutation.type === 'permissions' && + mutation.syncNativeChat && + !(await hostSupportsNativeChatPermissions(owner)) + ) { + throw new Error('agent_launch_native_chat_permissions_unsupported') + } const result = await callRuntimeRpc<{ settings: AgentLaunchSettings }>( { kind: 'environment', environmentId: owner.environmentId }, 'settings.mutateAgentLaunch', @@ -72,3 +80,12 @@ export function hostSupportsAutoPermissions(owner: AgentLaunchSettingsOwner): Pr AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY ) } + +export function hostSupportsNativeChatPermissions( + owner: AgentLaunchSettingsOwner +): Promise { + return runtimeEnvironmentSupportsCapability( + owner.environmentId, + AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY + ) +} diff --git a/src/renderer/src/components/settings/chat-permission-search.ts b/src/renderer/src/components/settings/chat-permission-search.ts index 8c0cd837ce7..8461c7933e4 100644 --- a/src/renderer/src/components/settings/chat-permission-search.ts +++ b/src/renderer/src/components/settings/chat-permission-search.ts @@ -7,6 +7,6 @@ export const getChatPermissionSearchEntry = createLocalizedCatalog((): SettingsS title: translate('settings.chat.permissionsTitle', 'Permissions for new chats'), description: translate( 'settings.chat.permissionsDescription', - 'Applies to new chats only. Agents you open in a terminal use Settings → Agents → Agent Permissions.' + 'New chats only. Agent-specific defaults are in Settings → Agents.' ) })) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 9eee42064ab..9fbfcbe487c 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2979,7 +2979,6 @@ "stopRefusedQuoted": "{{agent}} didn't stop: {{detail}}.", "theAgent": "The agent", "thenSendAgain": "Then send your message again.", - "writeFailed": "Orca couldn't hand this message to the agent, so it was not sent.", "usageLimitReached": "{{agent}} hit its usage limit.", "usageLimitReachedNamed": "{{agent}} hit its {{window}} limit.", "usageLimitResetsAt": "It resets at {{when}}.", @@ -2987,7 +2986,8 @@ "usageLimitWindowCredits": "extra usage", "usageLimitWindowSession": "session", "usageLimitWindowWeekly": "weekly", - "usageLimitWindowWeeklyModel": "weekly model" + "usageLimitWindowWeeklyModel": "weekly model", + "writeFailed": "Orca couldn't hand this message to the agent, so it was not sent." }, "notices": { "compaction": "Context compacted", @@ -3082,9 +3082,6 @@ "usedManySummary": "Used {{toolCount}} tools", "usedOneSummary": "Used 1 tool" }, - "interruptedContinue": { - "unconfirmed": "Couldn't confirm whether {{agent}} received Continue. Check the chat before trying again." - }, "writeNotice": { "agentRefused": "The agent turned this down.", "agentStarting": "The agent is still starting.", @@ -3105,6 +3102,7 @@ "compactAfterAnswer": "Answer the agent's question or approval, then run /compact.", "compactAfterRetry": "Retry your earlier message, then run /compact.", "compactAfterSending": "Your earlier message is still being sent. Run /compact once it has gone.", + "continuationSuperseded": "This chat has moved on. Check the latest messages before continuing.", "conversationCleared": "This conversation has been cleared.", "goalsUnsupported": "This agent doesn't support goals.", "historyUnavailable": "Orca couldn't open this chat's history right now.", @@ -3128,7 +3126,6 @@ "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", "ownerUnproven": "The previous agent in this chat may still be running.", "promptPending": "The agent is waiting for an answer to a question or approval.", - "continuationSuperseded": "This chat has moved on. Check the latest messages before continuing.", "questionChanged": "This question was already answered or has changed.", "queueTooLarge": "Too much text is waiting in the queue.", "quitTerminalAgent": "Quit that agent to continue the chat here.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 851e166a396..d735840f6df 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -192,7 +192,7 @@ "settings": { "chat": { "permissionsTitle": "Permissions for new chats", - "permissionsDescription": "Applies to new chats only. Agents you open in a terminal use Settings → Agents → Agent Permissions.", + "permissionsDescription": "New chats only. Agent-specific defaults are in Settings → Agents.", "description": "Choose how chats look and behave.", "names": { "title": "Chat names", diff --git a/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts index 86ff808f0a5..69613040978 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts @@ -1,7 +1,10 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import { isAgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' +import { + isAgentChatPermissionMode, + newChatPermissionModeForSettings +} from '../../../shared/agent-chat-permission-mode' import type { TuiAgent } from '../../../shared/tui-agent' import { nativeChatPermissionModeLabel } from '@/components/native-chat/native-chat-permission-mode-labels' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' @@ -37,6 +40,12 @@ export function notifyHostPermissionRefused(agent: TuiAgent, kind: 'refused' | ' 'Orca did not start a {{value0}} chat', { value0: agentLabel } ), - { description: hostPermissionRefusedText(agentLabel, kind) } + { + description: hostPermissionRefusedText( + agentLabel, + kind, + newChatPermissionModeForSettings(agent, useAppStore.getState().settings) + ) + } ) } diff --git a/src/renderer/src/runtime/structured-chat-host-permission.test.ts b/src/renderer/src/runtime/structured-chat-host-permission.test.ts index b7c3b589d86..8993ecfcca2 100644 --- a/src/renderer/src/runtime/structured-chat-host-permission.test.ts +++ b/src/renderer/src/runtime/structured-chat-host-permission.test.ts @@ -1,10 +1,12 @@ // @vitest-environment happy-dom +import type { GlobalSettings } from '../../../shared/global-settings-types' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { const contactListeners: (() => void)[] = [] - return { supportsCapability: vi.fn(), callRuntimeRpc: vi.fn(), contactListeners } + const settings: Partial = { nativeChatPermissionMode: 'ask' } + return { supportsCapability: vi.fn(), callRuntimeRpc: vi.fn(), contactListeners, settings } }) vi.mock('./runtime-rpc-client', () => ({ @@ -12,7 +14,7 @@ vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) vi.mock('@/store', () => { - const state = { settings: { nativeChatPermissionMode: 'ask' } } + const state = { settings: mocks.settings } const useAppStore = (select: (s: typeof state) => unknown) => select(state) useAppStore.getState = () => state return { useAppStore } @@ -35,6 +37,8 @@ const PAIRED = { kind: 'environment', environmentId: 'server-1' } as const beforeEach(() => { vi.clearAllMocks() mocks.supportsCapability.mockResolvedValue(false) + mocks.settings.nativeChatPermissionMode = 'ask' + mocks.settings.agentNativeChatPermissionModes = {} }) describe('the permission verdict on a paired host', () => { @@ -75,6 +79,19 @@ describe('the permission verdict on a paired host', () => { ).resolves.toEqual({ kind: 'refused', hostMode: 'full-access' }) }) + it('checks the scoped agent choice instead of a permissive global fallback', async () => { + mocks.settings.nativeChatPermissionMode = 'full-access' + mocks.settings.agentNativeChatPermissionModes = { codex: 'auto' } + mocks.callRuntimeRpc.mockResolvedValue({ settings: { agentDefaultArgs: {} } }) + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'codex')).resolves.toEqual({ + kind: 'refused', + hostMode: 'full-access' + }) + await expect(readStructuredChatHostPermissionVerdict(PAIRED, 'claude')).resolves.toEqual({ + kind: 'allowed' + }) + }) + it('lets Full access run on that older host: it runs no more than asked for', async () => { mocks.callRuntimeRpc.mockResolvedValue({ settings: { agentDefaultArgs: {} } }) diff --git a/src/renderer/src/runtime/structured-chat-host-permission.ts b/src/renderer/src/runtime/structured-chat-host-permission.ts index e6338a3738b..70f93c1694b 100644 --- a/src/renderer/src/runtime/structured-chat-host-permission.ts +++ b/src/renderer/src/runtime/structured-chat-host-permission.ts @@ -1,5 +1,8 @@ import { useEffect, useState } from 'react' -import { agentChatPermissionModes } from '../../../shared/agent-chat-permission-mode' +import { + agentChatPermissionModes, + newChatPermissionModeForSettings +} from '../../../shared/agent-chat-permission-mode' import { chatPermissionHostVerdict, hostSettingsApplyChatPermissionModes, @@ -21,8 +24,8 @@ const HELD_RECHECK_MS = 30_000 /** The mode this client asks new chats to start in; on a paired host that predates per-chat modes * it is the only stated intent, since that host keeps none. */ -function requestedChatPermissionMode(): unknown { - return useAppStore.getState().settings?.nativeChatPermissionMode +function requestedChatPermissionMode(agent: string): unknown { + return newChatPermissionModeForSettings(agent, useAppStore.getState().settings) } /** @@ -32,7 +35,7 @@ function requestedChatPermissionMode(): unknown { export async function readStructuredChatHostPermissionVerdict( target: RuntimeClientTarget, agent: string, - requested: unknown = requestedChatPermissionMode() + requested: unknown = requestedChatPermissionMode(agent) ): Promise { if (target.kind === 'local' || !agentChatPermissionModes(agent)) { return { kind: 'allowed' } @@ -70,7 +73,7 @@ export function useStructuredChatHostPermissionVerdict( ): StructuredChatHostPermissionVerdict | { kind: 'checking' } { const gated = target.kind === 'environment' && agentChatPermissionModes(agent) !== null const environmentId = target.kind === 'environment' ? target.environmentId : null - const requested = useAppStore((state) => state.settings?.nativeChatPermissionMode) + const requested = useAppStore((state) => newChatPermissionModeForSettings(agent, state.settings)) const [contact, setContact] = useState(0) // Contact is part of the key: a host back from a restart may be an older build, so a verdict from // before it never stands in for the fresh one. 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 new file mode 100644 index 00000000000..e747d884c1b --- /dev/null +++ b/src/renderer/src/web/preload-api/web-chat-permission-normalization.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from 'vitest' +import { runtimeChatPermissionSetting } from './web-preference-normalization' + +it('clears a previous host scoped default when reading settings from a pre-native-sync host', () => { + const cached = { + nativeChatPermissionMode: 'full-access', + agentNativeChatPermissionModes: { codex: 'auto' } + } + const current = { + ...cached, + ...runtimeChatPermissionSetting({ nativeChatPermissionMode: 'ask' }) + } + expect(current).toEqual({ nativeChatPermissionMode: 'ask', agentNativeChatPermissionModes: {} }) +}) + +it('never adds scoped overrides to the legacy settings.update strict schema', () => { + expect( + runtimeChatPermissionSetting( + { nativeChatPermissionMode: 'ask', agentNativeChatPermissionModes: { codex: 'auto' } }, + false + ) + ).toEqual({ nativeChatPermissionMode: 'ask' }) +}) 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 4f1fa26175a..36f21082f5a 100644 --- a/src/renderer/src/web/preload-api/web-preference-normalization.ts +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -1,4 +1,7 @@ -import { isAgentChatPermissionMode } from '../../../../shared/agent-chat-permission-mode' +import { + isAgentChatPermissionMode, + normalizeAgentNativeChatPermissionModes +} from '../../../../shared/agent-chat-permission-mode' import { normalizeNativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings' import { @@ -193,9 +196,19 @@ export function mergeSettings( /** The host's new-chat permission default (the host seeds from it), kept only when a known mode. */ export function runtimeChatPermissionSetting( - settings: Partial -): Pick { - return isAgentChatPermissionMode(settings.nativeChatPermissionMode) - ? { nativeChatPermissionMode: settings.nativeChatPermissionMode } - : {} + settings: Partial, + includeAgentOverrides = true +): Pick { + return { + ...(isAgentChatPermissionMode(settings.nativeChatPermissionMode) + ? { nativeChatPermissionMode: settings.nativeChatPermissionMode } + : {}), + ...(includeAgentOverrides + ? { + agentNativeChatPermissionModes: normalizeAgentNativeChatPermissionModes( + settings.agentNativeChatPermissionModes + ) + } + : {}) + } } 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 3c46c8175e1..c2d23ca8a95 100644 --- a/src/renderer/src/web/preload-api/web-preferences-store.ts +++ b/src/renderer/src/web/preload-api/web-preferences-store.ts @@ -234,7 +234,7 @@ export async function syncRuntimeBackedSettings( updates.prBotAuthorOverrides ) } - Object.assign(runtimeUpdates, runtimeChatPermissionSetting(updates)) + Object.assign(runtimeUpdates, runtimeChatPermissionSetting(updates, false)) if (Object.keys(runtimeUpdates).length === 0) { return localNext } diff --git a/src/shared/agent-chat-permission-mode.test.ts b/src/shared/agent-chat-permission-mode.test.ts index f4d097a4368..14a3f2fbe85 100644 --- a/src/shared/agent-chat-permission-mode.test.ts +++ b/src/shared/agent-chat-permission-mode.test.ts @@ -123,7 +123,7 @@ describe('the new-chat setting as loaded', () => { expect(seed(yoloWithArgs({ codex: '--model gpt-5' }))).toBe('ask') }) - // Manual leaves custom arguments alone, and the all-agent summary still shows Yolo for them. + // Manual leaves custom arguments alone, so the all-agent summary can still be Mixed. it('seeds Ask after Manual even when another agent keeps custom arguments', () => { const custom = yoloWithArgs({ codex: '--model gpt-5', grok: '--model grok-4' }) expect(seed(applyAgentPermissionMode({ mode: 'manual', ...custom }))).toBe('ask') @@ -134,6 +134,18 @@ describe('the new-chat setting as loaded', () => { expect(getDefaultSettings('/tmp').nativeChatPermissionMode).toBe('full-access') }) + it('seeds Auto when both Claude and Codex already use the automatic preset', () => { + expect(seed(applyAgentPermissionMode({ mode: 'auto' }))).toBe('auto') + expect(seed({ agentDefaultArgs: { claude: '--permission-mode auto', codex: '' } })).toBe('ask') + expect( + loadedChatPermissionSetting( + 'accept-edits', + applyAgentPermissionMode({ mode: 'auto' }), + 'darwin' + ) + ).toBe('accept-edits') + }) + it.each(AGENT_CHAT_PERMISSION_MODES)( 'keeps a saved %s whatever Agent Permissions say', (mode) => { diff --git a/src/shared/agent-chat-permission-mode.ts b/src/shared/agent-chat-permission-mode.ts index 2a40715f2d0..b15dfde747f 100644 --- a/src/shared/agent-chat-permission-mode.ts +++ b/src/shared/agent-chat-permission-mode.ts @@ -1,6 +1,9 @@ import type { GlobalSettings } from './global-settings-types' -import { resolvedTuiAgentArgsBypassPermissions } from './tui-agent-launch-defaults' -import { YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' +import { + resolvedTuiAgentArgsBypassPermissions, + resolveTuiAgentLaunchArgs +} from './tui-agent-launch-defaults' +import { AUTO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' import type { TuiAgent } from './tui-agent' /** A chat's own permission choice. Saved in chat records and settings: never rename a value. */ @@ -122,6 +125,32 @@ export function newChatPermissionMode( return support ? savedChatPermissionMode(support, setting) : null } +/** A scoped agent choice takes precedence over the shared new-chat default. */ +export function newChatPermissionModeForSettings( + agent: string, + settings: + | Partial> + | null + | undefined +): AgentChatPermissionMode | null { + const overrides = settings?.agentNativeChatPermissionModes + const override = overrides && Object.getOwnPropertyDescriptor(overrides, agent)?.value + return newChatPermissionMode(agent, override ?? settings?.nativeChatPermissionMode) +} + +export function normalizeAgentNativeChatPermissionModes( + value: unknown +): NonNullable { + if (!value || typeof value !== 'object') { + return {} + } + return Object.fromEntries( + Object.entries(value).filter(([agent, mode]) => + agentChatPermissionModes(agent)?.modes.some((offered) => offered === mode) + ) + ) +} + /** Seeded once when absent, then stored; an unknown value asks. */ export function loadedChatPermissionSetting( saved: unknown, @@ -131,8 +160,13 @@ export function loadedChatPermissionSetting( if (saved !== undefined) { return isAgentChatPermissionMode(saved) ? saved : 'ask' } - // What chats ran before they had modes: Claude's and Codex's own arguments, not the all-agent - // Yolo/Manual summary, which shows custom arguments on any agent as Yolo even after Manual. + const automatic = (agent: 'claude' | 'codex'): boolean => + resolveTuiAgentLaunchArgs(agent, terminal.agentDefaultArgs).trim() === + AUTO_TUI_AGENT_ARGS[agent] + if (automatic('claude') && automatic('codex')) { + return 'auto' + } + // Seed from each agent's arguments; the all-agent summary can reflect unrelated overrides. const bypasses = (agent: 'claude' | 'codex'): boolean => resolvedTuiAgentArgsBypassPermissions(agent, terminal, platform) return bypasses('claude') && bypasses('codex') ? 'full-access' : 'ask' diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index 6d34d1838a3..419032a12f7 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -65,9 +65,14 @@ export const AGENT_LAUNCH_SETTINGS_CAPABILITY = 'settings.agent-launch.v1' as co export const AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY = 'settings.agent-launch.auto-permissions.v1' as const +// Host accepts explicit native-chat synchronization without changing older terminal-only mutations. +export const AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY = + 'settings.agent-launch.native-chat-permissions.v1' as const + export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [ AGENT_LAUNCH_SETTINGS_CAPABILITY, AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY, + AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY, AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, diff --git a/src/shared/agent-launch-settings-permissions.test.ts b/src/shared/agent-launch-settings-permissions.test.ts index e1797361167..35a9616620b 100644 --- a/src/shared/agent-launch-settings-permissions.test.ts +++ b/src/shared/agent-launch-settings-permissions.test.ts @@ -1,56 +1,76 @@ import { describe, expect, it } from 'vitest' import { agentLaunchSettingsMutationUpdates } from './agent-launch-settings' -import { YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' +import { structuredLaunchSeedOptions } from './native-chat-session-option-defaults' -const settings = { agentDefaultArgs: { ...YOLO_TUI_AGENT_ARGS } } - -describe('terminal agent permission settings', () => { - it.each(['darwin', 'linux', 'win32'] as const)('changes only Codex on %s', (platform) => { +describe('agent permission presets and new chats', () => { + it.each([ + ['yolo', 'full-access'], + ['manual', 'ask'] + ] as const)('uses %s as the terminal preset and %s as the new chat default', (mode, expected) => { const updates = agentLaunchSettingsMutationUpdates( - settings, - { type: 'permissions', agent: 'codex', mode: 'auto' }, - platform + { nativeChatPermissionMode: 'accept-edits' }, + { type: 'permissions', syncNativeChat: true, mode }, + 'linux' ) - expect(updates.agentDefaultArgs?.codex).toBe('--approve-for-me') - expect(updates.agentDefaultArgs?.claude).toBe(YOLO_TUI_AGENT_ARGS.claude) - expect(updates.agentDefaultArgs?.gemini).toBe(YOLO_TUI_AGENT_ARGS.gemini) - expect(updates).not.toHaveProperty('nativeChatPermissionMode') - expect(updates).not.toHaveProperty('agentNativeChatPermissionModes') + expect(updates.nativeChatPermissionMode).toBe(expected) + for (const agent of ['claude', 'codex'] as const) { + expect(structuredLaunchSeedOptions(updates, agent)).toEqual({ permissionMode: expected }) + } }) - it.each(['auto', 'yolo', 'manual'] as const)( - 'keeps %s terminal changes independent of native chat', - (mode) => { - const before = { ...settings, nativeChatPermissionMode: 'accept-edits' as const } + it.each(['ask', 'full-access', 'accept-edits'] as const)( + 'preserves the %s shared default when enabling Auto', + (nativeChatPermissionMode) => { + const before = { + nativeChatPermissionMode, + agentNativeChatPermissionModes: { grok: 'ask' as const } + } const updates = agentLaunchSettingsMutationUpdates( before, - { type: 'permissions', mode }, + { type: 'permissions', syncNativeChat: true, mode: 'auto' }, 'linux' ) + const after = { ...before, ...updates } expect(updates).not.toHaveProperty('nativeChatPermissionMode') - expect({ ...before, ...updates }.nativeChatPermissionMode).toBe('accept-edits') + expect(after.agentNativeChatPermissionModes).toEqual({ + grok: 'ask', + claude: 'auto', + codex: 'auto' + }) + expect(structuredLaunchSeedOptions(after, 'claude')).toEqual({ permissionMode: 'auto' }) + expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'auto' }) + expect(structuredLaunchSeedOptions(after, 'grok')).toEqual({ permissionMode: 'ask' }) + expect(structuredLaunchSeedOptions(after, 'omp')).toEqual({ + permissionMode: nativeChatPermissionMode === 'full-access' ? 'full-access' : 'ask' + }) + expect(structuredLaunchSeedOptions(after, 'pi')).toBeUndefined() } ) - it('rejects custom arguments without losing them', () => { - const before = { agentDefaultArgs: { codex: '--model custom --approve-for-me' } } - expect(() => - agentLaunchSettingsMutationUpdates( - before, - { type: 'permissions', agent: 'codex', mode: 'manual' }, - 'linux' - ) - ).toThrow('custom launch arguments') - expect(before.agentDefaultArgs.codex).toBe('--model custom --approve-for-me') - }) - - it('rejects Auto for agents without a reviewer', () => { - expect(() => - agentLaunchSettingsMutationUpdates( - settings, - { type: 'permissions', agent: 'gemini', mode: 'auto' }, - 'linux' - ) - ).toThrow('does not support') + it('keeps the separate chat default when editing terminal arguments', () => { + const updates = agentLaunchSettingsMutationUpdates( + { nativeChatPermissionMode: 'accept-edits' }, + { type: 'arguments', agent: 'claude', value: '--permission-mode auto' }, + 'linux' + ) + expect(updates).not.toHaveProperty('nativeChatPermissionMode') }) }) + +it.each(['manual', 'yolo', 'auto'] as const)( + 'keeps legacy %s mutations terminal-only on native-capable hosts', + (mode) => { + const before = { + nativeChatPermissionMode: 'accept-edits' as const, + agentNativeChatPermissionModes: { codex: 'auto' as const } + } + const updates = agentLaunchSettingsMutationUpdates( + before, + { type: 'permissions', mode }, + 'linux' + ) + expect(updates).not.toHaveProperty('nativeChatPermissionMode') + expect(updates).not.toHaveProperty('agentNativeChatPermissionModes') + expect({ ...before, ...updates }.nativeChatPermissionMode).toBe('accept-edits') + } +) diff --git a/src/shared/agent-launch-settings.ts b/src/shared/agent-launch-settings.ts index 7c3021713c1..6c73d09f1e2 100644 --- a/src/shared/agent-launch-settings.ts +++ b/src/shared/agent-launch-settings.ts @@ -1,5 +1,10 @@ import type { GlobalSettings } from './global-settings-types' +import { + agentChatPermissionModes, + type AgentChatPermissionMode +} from './agent-chat-permission-mode' import type { TuiAgent } from './tui-agent' +import { isTuiAgent } from './tui-agent-config' import { buildDefaultAgentSettingsUpdate } from './default-agent-settings' import { buildAgentAvailabilitySettingsUpdate, @@ -30,6 +35,8 @@ export type AgentLaunchSettingsSource = Partial< | 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv' + | 'nativeChatPermissionMode' + | 'agentNativeChatPermissionModes' > > @@ -55,6 +62,7 @@ export type AgentLaunchSettingsMutation = agent?: TuiAgent /** Change only agents Auto doesn't cover. Sent only to hosts with the Auto capability. */ exceptAuto?: true + syncNativeChat?: true } | { type: 'command'; agent: TuiAgent; value: string } | { type: 'arguments'; agent: TuiAgent; value: string } @@ -88,6 +96,28 @@ export function projectAgentLaunchSettings( } } +function nativeChatModesForPreset( + settings: AgentLaunchSettingsSource, + mode: AgentChatPermissionMode, + autoOnly: boolean +): Partial> { + return Object.fromEntries( + [...Object.keys(YOLO_TUI_AGENT_ARGS), ...Object.keys(YOLO_TUI_AGENT_ENV)] + .filter((agent): agent is TuiAgent => isTuiAgent(agent)) + .filter( + (agent) => + agent in AUTO_TUI_AGENT_ARGS === autoOnly && + agentChatPermissionModes(agent) && + resolveTuiAgentPermissionMode({ + agent, + agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) + }) !== 'mixed' + ) + .map((agent) => [agent, mode]) + ) +} + export function agentLaunchSettingsMutationUpdates( settings: AgentLaunchSettingsSource, mutation: AgentLaunchSettingsMutation, @@ -106,6 +136,8 @@ export function agentLaunchSettingsMutationUpdates( mutation.enabled ) case 'permissions': { + const nativeMode = + mutation.mode === 'yolo' ? 'full-access' : mutation.mode === 'auto' ? 'auto' : 'ask' if (mutation.agent) { const agent = mutation.agent if (!(agent in YOLO_TUI_AGENT_ARGS) && !(agent in YOLO_TUI_AGENT_ENV)) { @@ -123,17 +155,54 @@ export function agentLaunchSettingsMutationUpdates( ) { throw new Error('Edit custom launch arguments before choosing a permission mode') } - return applyAgentPermissionMode({ ...settings, mode: mutation.mode, agent }) + return { + ...applyAgentPermissionMode({ ...settings, mode: mutation.mode, agent }), + ...(mutation.syncNativeChat && agentChatPermissionModes(agent) + ? { + agentNativeChatPermissionModes: { + ...settings.agentNativeChatPermissionModes, + [agent]: nativeMode + } + } + : {}) + } } if (mutation.exceptAuto) { if (mutation.mode === 'auto') { throw new Error('Auto cannot apply to agents without automatic permission review') } - return applyAgentPermissionMode({ ...settings, mode: mutation.mode, exceptAuto: true }) + return { + ...applyAgentPermissionMode({ ...settings, mode: mutation.mode, exceptAuto: true }), + ...(mutation.syncNativeChat + ? { + agentNativeChatPermissionModes: { + ...settings.agentNativeChatPermissionModes, + ...nativeChatModesForPreset(settings, nativeMode, false) + } + } + : {}) + } + } + if (mutation.mode === 'auto') { + return { + ...applyAgentPermissionMode({ ...settings, mode: mutation.mode }), + ...(mutation.syncNativeChat + ? { + agentNativeChatPermissionModes: { + ...settings.agentNativeChatPermissionModes, + ...nativeChatModesForPreset(settings, nativeMode, true) + } + } + : {}) + } + } + return { + ...applyAgentPermissionMode({ ...settings, mode: mutation.mode }), + ...(mutation.syncNativeChat + ? { nativeChatPermissionMode: nativeMode, agentNativeChatPermissionModes: {} } + : {}) } - return applyAgentPermissionMode({ ...settings, mode: mutation.mode }) } - case 'command': { const commands = { ...settings.agentCmdOverrides } if (mutation.value.trim()) { diff --git a/src/shared/agent-scoped-permission-defaults.test.ts b/src/shared/agent-scoped-permission-defaults.test.ts new file mode 100644 index 00000000000..38ee3bd89f2 --- /dev/null +++ b/src/shared/agent-scoped-permission-defaults.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest' +import { agentLaunchSettingsMutationUpdates } from './agent-launch-settings' +import { + newChatPermissionModeForSettings, + normalizeAgentNativeChatPermissionModes +} from './agent-chat-permission-mode' +import { structuredLaunchSeedOptions } from './native-chat-session-option-defaults' +import { AgentLaunchSettingsMutation } from './rpc-contract/agent-launch-settings-params' +import { YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' + +const settings = { + agentDefaultArgs: { ...YOLO_TUI_AGENT_ARGS }, + nativeChatPermissionMode: 'full-access' as const +} + +describe('scoped agent permission defaults', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'changes only Codex terminal and future chats on %s', + (platform) => { + const updates = agentLaunchSettingsMutationUpdates( + settings, + { type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'auto' }, + platform + ) + const after = { ...settings, ...updates } + expect(after.agentDefaultArgs.codex).toBe('--approve-for-me') + expect(after.agentDefaultArgs.claude).toBe(YOLO_TUI_AGENT_ARGS.claude) + expect(after.agentDefaultArgs.gemini).toBe(YOLO_TUI_AGENT_ARGS.gemini) + expect(updates).not.toHaveProperty('nativeChatPermissionMode') + expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'auto' }) + expect(structuredLaunchSeedOptions(after, 'claude')).toEqual({ + permissionMode: 'full-access' + }) + expect(structuredLaunchSeedOptions(after, 'omp')).toEqual({ permissionMode: 'full-access' }) + } + ) + + it('uses shipped launch defaults for an agent without a stored arguments key', () => { + const updates = agentLaunchSettingsMutationUpdates( + { nativeChatPermissionMode: 'ask' }, + { type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'auto' }, + 'linux' + ) + expect(updates.agentDefaultArgs).toEqual({ codex: '--approve-for-me' }) + expect(updates.agentNativeChatPermissionModes).toEqual({ codex: 'auto' }) + }) + + it('can configure Claude independently without adopting a model', () => { + const first = { + ...settings, + ...agentLaunchSettingsMutationUpdates( + settings, + { type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'auto' }, + 'linux' + ) + } + const after = { + ...first, + ...agentLaunchSettingsMutationUpdates( + first, + { type: 'permissions', syncNativeChat: true, agent: 'claude', mode: 'manual' }, + 'linux' + ) + } + expect(after.agentNativeChatPermissionModes).toEqual({ codex: 'auto', claude: 'ask' }) + expect(structuredLaunchSeedOptions(after, 'claude')).toEqual({ permissionMode: 'ask' }) + expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'auto' }) + expect(after).not.toHaveProperty('nativeChatSessionOptions') + }) + + it.each(['yolo', 'manual'] as const)('bulk %s resets scoped chat overrides', (mode) => { + const before = { ...settings, agentNativeChatPermissionModes: { codex: 'auto' as const } } + const after = { + ...before, + ...agentLaunchSettingsMutationUpdates( + before, + { type: 'permissions', syncNativeChat: true, mode }, + 'linux' + ) + } + expect(after.agentNativeChatPermissionModes).toEqual({}) + expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ + permissionMode: mode === 'yolo' ? 'full-access' : 'ask' + }) + }) + + it.each(['manual', 'yolo'] as const)('changes only other agents with exceptAuto %s', (mode) => { + const before = { + ...settings, + agentNativeChatPermissionModes: { + claude: 'auto' as const, + codex: 'ask' as const, + grok: 'full-access' as const, + opencode: 'full-access' as const + } + } + const updates = agentLaunchSettingsMutationUpdates( + before, + { type: 'permissions', mode, exceptAuto: true, syncNativeChat: true }, + 'linux' + ) + const after = { ...before, ...updates } + expect(updates).not.toHaveProperty('nativeChatPermissionMode') + expect(after.agentDefaultArgs.codex).toBe(settings.agentDefaultArgs.codex) + expect(after.agentDefaultArgs.claude).toBe(settings.agentDefaultArgs.claude) + expect(after.agentDefaultArgs.grok).toBe( + mode === 'manual' ? '' : settings.agentDefaultArgs.grok + ) + expect(after.agentNativeChatPermissionModes).toEqual({ + claude: 'auto', + codex: 'ask', + grok: mode === 'manual' ? 'ask' : 'full-access', + antigravity: mode === 'manual' ? 'ask' : 'full-access', + opencode: 'full-access' + }) + expect(structuredLaunchSeedOptions(after, 'claude')).toEqual({ permissionMode: 'auto' }) + expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'ask' }) + expect(structuredLaunchSeedOptions(after, 'omp')).toEqual({ permissionMode: 'full-access' }) + }) + + it('keeps Auto active when Other agents switches from Yolo to Manual', () => { + const automatic = { + ...settings, + ...agentLaunchSettingsMutationUpdates( + settings, + { type: 'permissions', mode: 'auto', syncNativeChat: true }, + 'linux' + ) + } + const after = { + ...automatic, + ...agentLaunchSettingsMutationUpdates( + automatic, + { type: 'permissions', mode: 'manual', exceptAuto: true, syncNativeChat: true }, + 'linux' + ) + } + expect(after.agentDefaultArgs).toMatchObject({ + codex: '--approve-for-me', + claude: '--permission-mode auto', + grok: '' + }) + expect(after.nativeChatPermissionMode).toBe('full-access') + expect(structuredLaunchSeedOptions(after, 'codex')).toEqual({ permissionMode: 'auto' }) + expect(structuredLaunchSeedOptions(after, 'claude')).toEqual({ permissionMode: 'auto' }) + expect(structuredLaunchSeedOptions(after, 'grok')).toEqual({ permissionMode: 'ask' }) + expect(structuredLaunchSeedOptions(after, 'omp')).toEqual({ permissionMode: 'full-access' }) + }) + + it('preserves custom Auto-capable terminal and native overrides under global Auto', () => { + const before = { + ...settings, + agentDefaultArgs: { ...settings.agentDefaultArgs, claude: '--model custom' }, + agentNativeChatPermissionModes: { claude: 'accept-edits' as const, grok: 'ask' as const } + } + const updates = agentLaunchSettingsMutationUpdates( + before, + { type: 'permissions', mode: 'auto', syncNativeChat: true }, + 'linux' + ) + expect(updates.agentDefaultArgs?.claude).toBe('--model custom') + expect(updates.agentNativeChatPermissionModes).toEqual({ + claude: 'accept-edits', + codex: 'auto', + grok: 'ask' + }) + expect(updates).not.toHaveProperty('nativeChatPermissionMode') + }) + + it('preserves custom other agents under exceptAuto and leaves legacy calls terminal-only', () => { + const before = { + ...settings, + agentDefaultArgs: { ...settings.agentDefaultArgs, grok: '--model custom' }, + agentNativeChatPermissionModes: { claude: 'auto' as const, grok: 'full-access' as const } + } + const updates = agentLaunchSettingsMutationUpdates( + before, + { type: 'permissions', mode: 'manual', exceptAuto: true, syncNativeChat: true }, + 'linux' + ) + expect(updates.agentDefaultArgs?.grok).toBe('--model custom') + expect(updates.agentNativeChatPermissionModes?.grok).toBe('full-access') + const legacy = agentLaunchSettingsMutationUpdates( + before, + { type: 'permissions', mode: 'manual', exceptAuto: true }, + 'linux' + ) + expect(legacy).not.toHaveProperty('nativeChatPermissionMode') + expect(legacy).not.toHaveProperty('agentNativeChatPermissionModes') + }) + + it('reapplies the same terminal choice to restore its scoped native default', () => { + const before = { + agentDefaultArgs: { codex: '--approve-for-me' }, + nativeChatPermissionMode: 'ask' as const + } + const updates = agentLaunchSettingsMutationUpdates( + before, + { type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'auto' }, + 'linux' + ) + expect(updates.agentDefaultArgs?.codex).toBe('--approve-for-me') + expect(structuredLaunchSeedOptions({ ...before, ...updates }, 'codex')).toEqual({ + permissionMode: 'auto' + }) + }) + + it('rejects custom arguments without losing options or changing native permissions', () => { + const before = { ...settings, agentDefaultArgs: { codex: '--model custom --approve-for-me' } } + expect(() => + agentLaunchSettingsMutationUpdates( + before, + { type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'manual' }, + 'linux' + ) + ).toThrow('custom launch arguments') + expect(before.agentDefaultArgs.codex).toBe('--model custom --approve-for-me') + expect(before.nativeChatPermissionMode).toBe('full-access') + }) + + it('rejects scoped modes for agents without configurable terminal permissions', () => { + expect(() => + agentLaunchSettingsMutationUpdates( + settings, + { type: 'permissions', syncNativeChat: true, agent: 'pi', mode: 'yolo' }, + 'linux' + ) + ).toThrow('no configurable') + }) + + it('refuses Auto for an agent without a reviewer', () => { + expect(() => + agentLaunchSettingsMutationUpdates( + settings, + { type: 'permissions', syncNativeChat: true, agent: 'gemini', mode: 'auto' }, + 'linux' + ) + ).toThrow('does not support') + }) + + it('accepts both scoped and legacy permission wire mutations', () => { + expect( + AgentLaunchSettingsMutation.parse({ + type: 'permissions', + syncNativeChat: true, + agent: 'codex', + mode: 'auto' + }) + ).toEqual({ type: 'permissions', syncNativeChat: true, agent: 'codex', mode: 'auto' }) + expect(AgentLaunchSettingsMutation.parse({ type: 'permissions', mode: 'manual' })).toEqual({ + type: 'permissions', + mode: 'manual' + }) + }) + + it.each([undefined, 'codex'] as const)( + 'keeps parent-client permissions terminal-only (agent %s)', + (agent) => { + const before = { ...settings, agentNativeChatPermissionModes: { claude: 'ask' as const } } + const updates = agentLaunchSettingsMutationUpdates( + before, + { type: 'permissions', mode: 'auto', ...(agent ? { agent } : {}) }, + 'linux' + ) + expect(updates).not.toHaveProperty('nativeChatPermissionMode') + expect(updates).not.toHaveProperty('agentNativeChatPermissionModes') + expect(structuredLaunchSeedOptions({ ...before, ...updates }, 'codex')).toEqual({ + permissionMode: 'full-access' + }) + } + ) + + it('normalizes persisted overrides to only offered agent modes', () => { + expect( + normalizeAgentNativeChatPermissionModes({ + codex: 'auto', + claude: 'accept-edits', + omp: 'auto', + mystery: 'full-access' + }) + ).toEqual({ codex: 'auto', claude: 'accept-edits' }) + expect( + newChatPermissionModeForSettings('codex', { + nativeChatPermissionMode: 'full-access', + agentNativeChatPermissionModes: { codex: 'ask' } + }) + ).toBe('ask') + }) +}) diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 12f4b82367f..77ea0fbf87d 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -237,6 +237,8 @@ export type GlobalSettings = GlobalSettingsBase & { nativeChatSessionOptions?: PersistedNativeChatSessionOptions /** New chats' permission mode, for agents whose chats offer modes; seeded once from Agent Permissions. */ nativeChatPermissionMode?: AgentChatPermissionMode + /** Per-agent new-chat defaults chosen alongside that agent's terminal permission mode. */ + agentNativeChatPermissionModes?: Partial> /** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */ openInApplications?: OpenInApplication[] /** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */ diff --git a/src/shared/native-chat-session-option-defaults.ts b/src/shared/native-chat-session-option-defaults.ts index 82b0c0efefa..b24be0d610f 100644 --- a/src/shared/native-chat-session-option-defaults.ts +++ b/src/shared/native-chat-session-option-defaults.ts @@ -2,7 +2,7 @@ import type { AgentType } from './agent-status-types' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, isAgentChatPermissionMode, - newChatPermissionMode + newChatPermissionModeForSettings } from './agent-chat-permission-mode' import type { GlobalSettings } from './global-settings-types' import { sessionOptionValueIsValid } from './agent-session-option-catalog' @@ -118,13 +118,16 @@ 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' + > | null | undefined, agent: AgentType ): Record | undefined { const selection = resolveStructuredLaunchSeedOptions(settings?.nativeChatSessionOptions, agent) - const permissionMode = newChatPermissionMode(agent, settings?.nativeChatPermissionMode) + const permissionMode = newChatPermissionModeForSettings(agent, settings) return permissionMode ? { ...selection, [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: permissionMode } : selection diff --git a/src/shared/rpc-contract/agent-launch-settings-params.ts b/src/shared/rpc-contract/agent-launch-settings-params.ts index 24fec536c62..6b8cb9f14d8 100644 --- a/src/shared/rpc-contract/agent-launch-settings-params.ts +++ b/src/shared/rpc-contract/agent-launch-settings-params.ts @@ -27,7 +27,8 @@ export const AgentLaunchSettingsMutation = z.discriminatedUnion('type', [ type: z.literal('permissions'), mode: z.enum(['manual', 'yolo', 'auto']), agent: Agent.optional(), - exceptAuto: z.literal(true).optional() + exceptAuto: z.literal(true).optional(), + syncNativeChat: z.literal(true).optional() }) .strict(), z.object({ type: z.literal('command'), agent: Agent, value: Value }).strict(), diff --git a/tests/e2e/agent-auto-permissions.spec.ts b/tests/e2e/agent-auto-permissions.spec.ts index e1c1d24ca16..92641f63cb7 100644 --- a/tests/e2e/agent-auto-permissions.spec.ts +++ b/tests/e2e/agent-auto-permissions.spec.ts @@ -3,11 +3,10 @@ import { waitForSessionReady } from './helpers/store' test.use({ seedTestRepo: false }) -test('global Auto applies to supporting agents, other agents switch separately, and it persists', async ({ +test('global Auto syncs native defaults only for supporting agents and persists', async ({ orcaPage }) => { await waitForSessionReady(orcaPage) - // Terminal permission changes must leave the native chat default alone. await orcaPage.evaluate(() => { const store = window.__store if (!store) { @@ -26,7 +25,10 @@ test('global Auto applies to supporting agents, other agents switch separately, claude: settings.agentDefaultArgs?.claude, codex: settings.agentDefaultArgs?.codex, gemini: settings.agentDefaultArgs?.gemini, - native: settings.nativeChatPermissionMode + native: settings.nativeChatPermissionMode, + claudeNative: settings.agentNativeChatPermissionModes?.claude, + codexNative: settings.agentNativeChatPermissionModes?.codex, + grokNative: settings.agentNativeChatPermissionModes?.grok } }) @@ -45,7 +47,10 @@ test('global Auto applies to supporting agents, other agents switch separately, claude: '--permission-mode auto', codex: '--approve-for-me', gemini: '--yolo', - native: 'accept-edits' + native: 'full-access', + claudeNative: 'auto', + codexNative: 'auto', + grokNative: undefined }) await others.getByRole('radio', { name: 'Manual', exact: true }).click() @@ -53,7 +58,10 @@ test('global Auto applies to supporting agents, other agents switch separately, claude: '--permission-mode auto', codex: '--approve-for-me', gemini: '', - native: 'accept-edits' + native: 'full-access', + claudeNative: 'auto', + codexNative: 'auto', + grokNative: 'ask' }) await orcaPage.reload() @@ -68,7 +76,21 @@ test('global Auto applies to supporting agents, other agents switch separately, 'aria-checked', 'true' ) + await expect.poll(readArgs).toMatchObject({ + native: 'full-access', + claudeNative: 'auto', + codexNative: 'auto', + grokNative: 'ask' + }) await group.getByRole('radio', { name: 'Manual', exact: true }).click() - await expect.poll(readArgs).toEqual({ claude: '', codex: '', gemini: '', native: 'accept-edits' }) + await expect.poll(readArgs).toEqual({ + claude: '', + codex: '', + gemini: '', + native: 'ask', + claudeNative: undefined, + codexNative: undefined, + grokNative: undefined + }) }) diff --git a/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts b/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts index 0ad8be0104d..939bbcbd499 100644 --- a/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts +++ b/tests/e2e/host-agent-launch-settings-hook-bookkeeping.unit.test.ts @@ -27,7 +27,8 @@ vi.mock('../../src/main/agent-hooks/managed-agent-hook-controls', () => ({ vi.mock('../../src/renderer/src/components/settings/agent-launch-settings-transport', () => ({ readHostAgentLaunchSettings: transport.read, mutateHostAgentLaunchSettings: transport.mutate, - hostSupportsAutoPermissions: async () => true + hostSupportsAutoPermissions: async () => true, + hostSupportsNativeChatPermissions: async () => true })) vi.mock('@/hooks/useDetectedAgents', () => ({ useDetectedAgents: () => ({