feat(native-chat): apply per-agent Auto permission defaults (#30853)

* feat(native-chat): sync per-agent permission defaults with explicit host support

* test: include native permission capability in host settings mock
This commit is contained in:
Neil
2026-10-19 19:58:00 -07:00
committed by GitHub
parent c822123b43
commit 9a79de59a5
40 changed files with 998 additions and 107 deletions
@@ -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
@@ -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'
}
}
@@ -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' })
})
})
@@ -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
@@ -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(
@@ -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,
{
@@ -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' }
: {})
}
}
@@ -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)
@@ -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({
@@ -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
@@ -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 }
@@ -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']
@@ -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
}
@@ -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<GlobalSettings>) => void | Promise<void>
@@ -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),
@@ -87,6 +87,7 @@ function LocalAgentLaunchSettings({
detectionTarget={LOCAL_DETECTION_TARGET}
hostName={null}
renderPermissions={renderPermissions}
nativeChatPermissionsSupported
local={{ settings, updateSettings }}
/>
)
@@ -34,7 +34,9 @@ export function ChatPermissionSetting({
<NativeChatPermissionModePicker
current={current}
modes={AGENT_CHAT_PERMISSION_MODES}
onSelect={(nativeChatPermissionMode) => updateSettings({ nativeChatPermissionMode })}
onSelect={(nativeChatPermissionMode) =>
updateSettings({ nativeChatPermissionMode, agentNativeChatPermissionModes: {} })
}
/>
}
/>
@@ -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(
<TooltipProvider>
<HostAgentLaunchSettings
environmentId="ssh-host"
pairingRevision={17}
hostName="SSH host"
renderPermissions={(props) => <AgentPermissionsSetting {...props} />}
/>
</TooltipProvider>
)
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 = () => (
<TooltipProvider>
<HostAgentLaunchSettings
@@ -127,7 +160,7 @@ describe('host launch settings form', () => {
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
})
})
@@ -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()}
/>
</fieldset>
@@ -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
})
})
@@ -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<boolean> {
return runtimeEnvironmentSupportsCapability(
owner.environmentId,
AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY
)
}
@@ -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.'
)
}))
+3 -6
View File
@@ -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.",
+1 -1
View File
@@ -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",
@@ -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)
)
}
)
}
@@ -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<GlobalSettings> = { 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: {} } })
@@ -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<StructuredChatHostPermissionVerdict> {
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.
@@ -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' })
})
@@ -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<GlobalSettings>
): Pick<GlobalSettings, 'nativeChatPermissionMode'> {
return isAgentChatPermissionMode(settings.nativeChatPermissionMode)
? { nativeChatPermissionMode: settings.nativeChatPermissionMode }
: {}
settings: Partial<GlobalSettings>,
includeAgentOverrides = true
): Pick<GlobalSettings, 'nativeChatPermissionMode' | 'agentNativeChatPermissionModes'> {
return {
...(isAgentChatPermissionMode(settings.nativeChatPermissionMode)
? { nativeChatPermissionMode: settings.nativeChatPermissionMode }
: {}),
...(includeAgentOverrides
? {
agentNativeChatPermissionModes: normalizeAgentNativeChatPermissionModes(
settings.agentNativeChatPermissionModes
)
}
: {})
}
}
@@ -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
}
+13 -1
View File
@@ -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) => {
+38 -4
View File
@@ -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<Pick<GlobalSettings, 'nativeChatPermissionMode' | 'agentNativeChatPermissionModes'>>
| 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<GlobalSettings['agentNativeChatPermissionModes']> {
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'
@@ -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,
@@ -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')
}
)
+73 -4
View File
@@ -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<Record<TuiAgent, AgentChatPermissionMode>> {
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()) {
@@ -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')
})
})
+2
View File
@@ -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<Record<TuiAgent, AgentChatPermissionMode>>
/** 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. */
@@ -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<GlobalSettings, 'nativeChatSessionOptions' | 'nativeChatPermissionMode'>
| Pick<
GlobalSettings,
'nativeChatSessionOptions' | 'nativeChatPermissionMode' | 'agentNativeChatPermissionModes'
>
| null
| undefined,
agent: AgentType
): Record<string, string> | 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
@@ -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(),
+28 -6
View File
@@ -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
})
})
@@ -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: () => ({