mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
Reference in New Issue
Block a user