fix(permission): fix bypass switching and Auto confirmation #978

This commit is contained in:
程序员阿江(Relakkes) 2026-07-13 14:59:41 +08:00
parent c550dbc07a
commit f44442033e
7 changed files with 244 additions and 159 deletions

View File

@ -59,6 +59,7 @@ import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
const initialSetSessionPermissionMode = useChatStore.getState().setSessionPermissionMode
const initialAcceptAutoModeOptIn = useSettingsStore.getState().acceptAutoModeOptIn
function makeChatSession(chatState: PerSessionState['chatState']): PerSessionState {
return {
@ -85,7 +86,11 @@ function makeChatSession(chatState: PerSessionState['chatState']): PerSessionSta
describe('PermissionModeSelector', () => {
beforeEach(() => {
viewportMocks.isMobile = false
useSettingsStore.setState({ permissionMode: 'default' })
useSettingsStore.setState({
permissionMode: 'default',
autoModeOptInAccepted: false,
acceptAutoModeOptIn: initialAcceptAutoModeOptIn,
})
useChatStore.setState({
sessions: {},
setSessionPermissionMode: initialSetSessionPermissionMode,
@ -486,6 +491,28 @@ describe('PermissionModeSelector', () => {
})
})
it('confirms every entry into Auto without rewriting prior consent', async () => {
const onChange = vi.fn()
const acceptAutoModeOptIn = vi.fn().mockResolvedValue(undefined)
useSettingsStore.setState({
autoModeOptInAccepted: true,
acceptAutoModeOptIn,
} as never)
render(<PermissionModeSelector value="default" onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' }))
fireEvent.click(screen.getByRole('menuitem', { name: /Auto mode/ }))
expect(onChange).not.toHaveBeenCalled()
expect(screen.getByRole('dialog', { name: 'Enable Auto mode?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Enable Auto mode' }))
await waitFor(() => expect(onChange).toHaveBeenCalledWith('auto'))
expect(acceptAutoModeOptIn).not.toHaveBeenCalled()
})
it('applies first-use Auto consent to the active session', async () => {
const setSessionPermissionMode = vi.fn()
const acceptAutoModeOptIn = vi.fn().mockResolvedValue(undefined)

View File

@ -190,7 +190,7 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
interactionTabIdRef.current = null
return
}
if (item.value === 'auto' && !autoModeOptInAccepted) {
if (item.value === 'auto' && item.value !== currentMode) {
setOpen(false)
setAutoDialog(true)
return
@ -384,7 +384,9 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
setAutoConsentPending(true)
try {
await acceptAutoModeOptIn()
if (!autoModeOptInAccepted) {
await acceptAutoModeOptIn()
}
const confirmedTabId = useTabStore.getState().activeTabId
if (
confirmedTabId !== interactionTabIdRef.current ||

View File

@ -429,11 +429,22 @@ describe('ConversationService', () => {
it('should not inject a desktop-specific ask override in default permission mode', () => {
const svc = new ConversationService()
expect((svc as any).getPermissionArgs('default', false)).toEqual([
'--allow-dangerously-skip-permissions',
'--permission-mode',
'default',
])
})
it('should keep an initially requested bypass session explicitly dangerous', () => {
const svc = new ConversationService()
expect((svc as any).getPermissionArgs('bypassPermissions', false)).toEqual([
'--dangerously-skip-permissions',
])
expect((svc as any).getPermissionArgs('default', true)).toEqual([
'--dangerously-skip-permissions',
])
})
it('should pass disabled thinking to the CLI runtime args', () => {
const svc = new ConversationService()
expect((svc as any).getRuntimeArgs({
@ -1394,7 +1405,7 @@ describe('WebSocket Chat Integration', () => {
}
async function withMockPermissionModeBehavior<T>(
behavior: 'confirm' | 'reject' | 'acknowledge' | 'status-before-reject',
behavior: 'confirm' | 'reject' | 'acknowledge' | 'status-before-reject' | 'unavailable',
callback: () => Promise<T>,
): Promise<T> {
const previousBehavior = process.env.MOCK_SDK_PERMISSION_MODE_BEHAVIOR
@ -3668,7 +3679,7 @@ describe('WebSocket Chat Integration', () => {
})
}, 20_000)
it('should defer bypass permission restarts until the active turn completes', async () => {
it('should defer bypass permission changes until the active turn completes without restarting', async () => {
await withMockStreamDelay(350, async () => {
await fetch(`${baseUrl}/api/permissions/mode`, {
method: 'PUT',
@ -3703,7 +3714,7 @@ describe('WebSocket Chat Integration', () => {
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
let switchTriggered = false
let turnComplete = false
let modeConfirmedBeforeTurnComplete = false
let modeConfirmed = false
let deferredInspectionChecked = false
try {
await new Promise<void>((resolve, reject) => {
@ -3757,34 +3768,24 @@ describe('WebSocket Chat Integration', () => {
return
}
if (msg.type === 'permission_mode_changed' && !turnComplete) {
modeConfirmedBeforeTurnComplete = true
}
if (
msg.type === 'status' &&
msg.state === 'idle' &&
switchTriggered &&
!turnComplete &&
startCalls.length > 1
) {
clearTimeout(timeout)
ws.close()
reject(new Error('Permission restart ran before the active turn completed'))
if (msg.type === 'permission_mode_changed' && msg.mode === 'bypassPermissions') {
modeConfirmed = true
if (turnComplete) {
clearTimeout(timeout)
resolve()
}
return
}
if (msg.type === 'message_complete' && switchTriggered && !turnComplete) {
turnComplete = true
expect(startCalls).toHaveLength(1)
if (modeConfirmed) {
clearTimeout(timeout)
resolve()
}
return
}
if (msg.type === 'status' && msg.state === 'idle' && turnComplete && startCalls.length > 1) {
clearTimeout(timeout)
ws.close()
resolve()
}
}
ws.onerror = () => {
@ -3795,21 +3796,16 @@ describe('WebSocket Chat Integration', () => {
expect(switchTriggered).toBe(true)
expect(turnComplete).toBe(true)
expect(modeConfirmed).toBe(true)
expect(deferredInspectionChecked).toBe(true)
expect(modeConfirmedBeforeTurnComplete).toBe(false)
expect(startCalls).toHaveLength(2)
expect(startCalls).toHaveLength(1)
expect(startCalls[0]).toMatchObject({
sessionId,
options: {
permissionMode: 'default',
},
})
expect(startCalls[1]).toMatchObject({
sessionId,
options: {
permissionMode: 'bypassPermissions',
},
})
expect(conversationService.getSessionPermissionMode(sessionId)).toBe('bypassPermissions')
} finally {
ws.close()
conversationService.startSession = originalStartSession
@ -3823,7 +3819,7 @@ describe('WebSocket Chat Integration', () => {
})
}, 20_000)
it('should keep the session idle in the UI while restarting for a bypass permission switch', async () => {
it('should enter bypass permissions without restarting the CLI', async () => {
await fetch(`${baseUrl}/api/permissions/mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@ -3892,29 +3888,21 @@ describe('WebSocket Chat Integration', () => {
}, `prewarmed slash commands for idle permission switch ${sessionId}`)
const switchStartIndex = messages.length
const switchStartedAt = performance.now()
ws.send(JSON.stringify({
type: 'set_permission_mode',
mode: 'bypassPermissions',
}))
await waitUntil(
async () => messages.slice(switchStartIndex).some((msg) => msg.type === 'status' && msg.state === 'idle'),
`idle permission switch completion for ${sessionId}`,
() => messages.slice(switchStartIndex).some((msg) =>
msg.type === 'permission_mode_changed' && msg.mode === 'bypassPermissions'
),
`in-process bypass permission switch completion for ${sessionId}`,
)
expect(startCalls).toHaveLength(2)
expect(startCalls[1]).toMatchObject({
sessionId,
options: {
permissionMode: 'bypassPermissions',
},
})
expect(
messages
.slice(switchStartIndex)
.filter((msg) => msg.type === 'status')
.map((msg) => msg.state),
).toEqual(['idle'])
expect(performance.now() - switchStartedAt).toBeLessThan(1_000)
expect(startCalls).toHaveLength(1)
expect(
messages
.slice(switchStartIndex)
@ -3933,6 +3921,71 @@ describe('WebSocket Chat Integration', () => {
}
}, 20_000)
it('should restart an already-running legacy session only when bypass capability is unavailable', async () => {
await withMockPermissionModeBehavior('unavailable', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'default' }),
})
expect(createRes.status).toBe(201)
const { sessionId } = await createRes.json() as { sessionId: string }
const originalStartSession = conversationService.startSession.bind(conversationService)
const startModes: Array<string | undefined> = []
conversationService.startSession = (async function patchedStartSession(
sid: string,
workDir: string,
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
startModes.push(options?.permissionMode)
return originalStartSession(sid, workDir, sdkUrl, options)
}) as typeof conversationService.startSession
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
const messages: any[] = []
try {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`Timed out prewarming ${sessionId}`)), 5_000)
ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string)
messages.push(msg)
if (msg.type === 'connected') {
clearTimeout(timeout)
ws.send(JSON.stringify({ type: 'prewarm_session' }))
resolve()
}
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error(`WebSocket error prewarming ${sessionId}`))
}
})
await waitUntil(
() => Boolean((conversationService as any).sessions.get(sessionId)?.sdkSocket),
`SDK control channel for legacy bypass fallback ${sessionId}`,
)
ws.send(JSON.stringify({ type: 'set_permission_mode', mode: 'bypassPermissions' }))
await waitUntil(
() => messages.some((msg) =>
msg.type === 'permission_mode_changed' && msg.mode === 'bypassPermissions'
),
`legacy bypass restart confirmation ${sessionId}`,
)
expect(startModes).toEqual(['default', 'bypassPermissions'])
expect(messages.some((msg) => msg.type === 'error')).toBe(false)
expect(conversationService.getSessionPermissionMode(sessionId)).toBe('bypassPermissions')
} finally {
ws.close()
conversationService.startSession = originalStartSession
conversationService.stopSession(sessionId)
}
})
}, 20_000)
it('should persist permission changes made before the CLI starts', async () => {
await fetch(`${baseUrl}/api/permissions/mode`, {
method: 'PUT',
@ -4312,62 +4365,61 @@ describe('WebSocket Chat Integration', () => {
}
}, 20_000)
it('should preserve safe permission metadata when a bypass restart fails', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'default' }),
})
expect(createRes.status).toBe(201)
const { sessionId } = await createRes.json() as { sessionId: string }
const originalStartSession = conversationService.startSession.bind(conversationService)
let startCount = 0
conversationService.startSession = (async (...args: Parameters<typeof conversationService.startSession>) => {
startCount += 1
if (startCount === 2) throw new Error('mock bypass restart failure')
return originalStartSession(...args)
}) as typeof conversationService.startSession
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
const messages: any[] = []
try {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`Timed out prewarming ${sessionId}`)), 5_000)
ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string)
messages.push(msg)
if (msg.type === 'connected') {
clearTimeout(timeout)
ws.send(JSON.stringify({ type: 'prewarm_session' }))
resolve()
}
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error(`WebSocket error prewarming ${sessionId}`))
}
it('should preserve safe permission metadata when a bypass change is rejected', async () => {
await withMockPermissionModeBehavior('reject', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'default' }),
})
await waitUntil(() => startCount === 1, `initial prewarm start for ${sessionId}`)
expect(createRes.status).toBe(201)
const { sessionId } = await createRes.json() as { sessionId: string }
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
const messages: any[] = []
ws.send(JSON.stringify({ type: 'set_permission_mode', mode: 'bypassPermissions' }))
await waitUntil(
() => messages.some((msg) => msg.type === 'error' && msg.code === 'CLI_RESTART_FAILED'),
`failed bypass restart for ${sessionId}`,
)
try {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`Timed out prewarming ${sessionId}`)), 5_000)
ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string)
messages.push(msg)
if (msg.type === 'connected') {
clearTimeout(timeout)
ws.send(JSON.stringify({ type: 'prewarm_session' }))
resolve()
}
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error(`WebSocket error prewarming ${sessionId}`))
}
})
await waitUntil(
() => Boolean((conversationService as any).sessions.get(sessionId)?.sdkSocket),
`SDK control channel for rejected bypass change ${sessionId}`,
)
const inspectionRes = await fetch(
`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`,
)
const inspection = await inspectionRes.json() as { status?: { permissionMode?: string } }
expect(inspection.status?.permissionMode).toBe('default')
expect(messages.some((msg) =>
msg.type === 'permission_mode_changed' && msg.mode === 'bypassPermissions'
)).toBe(false)
} finally {
ws.close()
conversationService.startSession = originalStartSession
conversationService.stopSession(sessionId)
}
ws.send(JSON.stringify({ type: 'set_permission_mode', mode: 'bypassPermissions' }))
await waitUntil(
() => messages.some((msg) =>
msg.type === 'error' && msg.code === 'PERMISSION_MODE_CHANGE_FAILED'
),
`rejected bypass change for ${sessionId}`,
)
const inspectionRes = await fetch(
`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`,
)
const inspection = await inspectionRes.json() as { status?: { permissionMode?: string } }
expect(inspection.status?.permissionMode).toBe('default')
expect(messages.some((msg) =>
msg.type === 'permission_mode_changed' && msg.mode === 'bypassPermissions'
)).toBe(false)
} finally {
ws.close()
conversationService.stopSession(sessionId)
}
})
}, 20_000)
it('should persist CLI-originated permission-mode broadcasts', async () => {
@ -5205,7 +5257,7 @@ describe('WebSocket Chat Integration', () => {
}
}, 20_000)
it('should wait for an in-flight permission restart before sending the next user turn', async () => {
it('should wait for an in-flight permission change before sending the next user turn', async () => {
await fetch(`${baseUrl}/api/permissions/mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@ -5304,19 +5356,13 @@ describe('WebSocket Chat Integration', () => {
}
})
expect(startCalls).toHaveLength(2)
expect(startCalls).toHaveLength(1)
expect(startCalls[0]).toMatchObject({
sessionId,
options: {
permissionMode: 'default',
},
})
expect(startCalls[1]).toMatchObject({
sessionId,
options: {
permissionMode: 'bypassPermissions',
},
})
expect(sendCalls).toMatchObject([
{
content: 'first turn before permission switch',
@ -5325,7 +5371,7 @@ describe('WebSocket Chat Integration', () => {
},
{
content: 'second turn immediately after permission switch',
startCallCount: 2,
startCallCount: 1,
permissionMode: 'bypassPermissions',
},
])

View File

@ -203,6 +203,19 @@ ws.addEventListener('message', (event) => {
}
if (parsed.type === 'control_request' && parsed.request?.subtype === 'set_permission_mode') {
if (permissionModeBehavior === 'unavailable') {
emit(ws, {
type: 'control_response',
response: {
subtype: 'error',
request_id: parsed.request_id,
error:
'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions',
},
session_id: sessionId,
})
continue
}
if (permissionModeBehavior === 'status-before-reject') {
emit(ws, {
type: 'system',

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'bun:test'
import {
createCurrentTurnLocalCommandForwarder,
shouldRestartForPermissionMode,
shouldFallbackToPermissionRestart,
translateCliMessage,
} from '../ws/handler.js'
import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
@ -318,29 +318,29 @@ describe('WebSocket compact events', () => {
})
})
describe('WebSocket permission mode restart policy', () => {
it('restarts the CLI only when entering bypassPermissions', () => {
// 进入 bypass 需要带 --dangerously-skip-permissions 重启子进程。
expect(shouldRestartForPermissionMode('default', 'bypassPermissions')).toBe(true)
expect(shouldRestartForPermissionMode('plan', 'bypassPermissions')).toBe(true)
expect(shouldRestartForPermissionMode('acceptEdits', 'bypassPermissions')).toBe(true)
describe('WebSocket permission mode compatibility fallback', () => {
it('restarts only when an old CLI session lacks the bypass launch capability', () => {
expect(shouldFallbackToPermissionRestart(
'bypassPermissions',
new Error(
'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions',
),
)).toBe(true)
})
it('does NOT restart when leaving bypassPermissions for a stricter mode', () => {
// 从 bypass 切出不重启——否则会冲掉进程内 prePlanMode导致 ExitPlanMode 后
// 恢复成 default 而非进入 plan 前的 bypassPermissions。这正是桌面端退出 plan
// 权限回不到 bypass 的根因。
expect(shouldRestartForPermissionMode('bypassPermissions', 'plan')).toBe(false)
expect(shouldRestartForPermissionMode('bypassPermissions', 'default')).toBe(false)
expect(shouldRestartForPermissionMode('bypassPermissions', 'acceptEdits')).toBe(false)
it('does not restart when bypass is disabled by user settings', () => {
expect(shouldFallbackToPermissionRestart(
'bypassPermissions',
new Error(
'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration',
),
)).toBe(false)
})
it('does not restart for non-bypass transitions or no-op changes', () => {
expect(shouldRestartForPermissionMode('default', 'plan')).toBe(false)
expect(shouldRestartForPermissionMode('plan', 'acceptEdits')).toBe(false)
// 同模式(含 bypass→bypass是 no-op不重启。
expect(shouldRestartForPermissionMode('bypassPermissions', 'bypassPermissions')).toBe(false)
expect(shouldRestartForPermissionMode('plan', 'plan')).toBe(false)
it('does not restart other permission failures', () => {
expect(shouldFallbackToPermissionRestart('auto', new Error('classifier unavailable'))).toBe(false)
expect(shouldFallbackToPermissionRestart('default', new Error('mock rejection'))).toBe(false)
expect(shouldFallbackToPermissionRestart('bypassPermissions', new Error('mock rejection'))).toBe(false)
})
})

View File

@ -1109,7 +1109,11 @@ export class ConversationService {
return ['--dangerously-skip-permissions']
}
const args = ['--permission-mode', resolvedMode]
const args = [
'--allow-dangerously-skip-permissions',
'--permission-mode',
resolvedMode,
]
return args
}

View File

@ -758,28 +758,26 @@ async function handleSetPermissionMode(
return
}
await applyPermissionModeToActiveSession(ws, sessionId, message.mode)
await enqueueRuntimeTransition(sessionId, () =>
applyPermissionModeToActiveSession(ws, sessionId, message.mode),
)
}
const BYPASS_CAPABILITY_UNAVAILABLE =
'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions'
/**
* CLI
*
* "进入 bypassPermissions"CLI --dangerously-skip-permissions
* set_permission_mode bypassPermissions
* flag
*
* "从 bypassPermissions 切到更严格的模式"**** flag
* prePlanMode bypassplan
* CLI plan prePlanMode ExitPlanMode default
* bypassPermissions setPermissionMode transitionCLI TUI
* prePlanMode='bypassPermissions'退 plan bypass
* Sessions launched by this desktop build can switch into bypass in-process.
* A session that was already running before an app update may lack that launch
* capability, so retain the old restart path only for that exact CLI error.
*/
export function shouldRestartForPermissionMode(
currentMode: string,
mode: string,
export function shouldFallbackToPermissionRestart(
mode: PermissionMode,
error: unknown,
): boolean {
if (currentMode === mode) return false
return mode === 'bypassPermissions'
if (mode !== 'bypassPermissions') return false
const message = error instanceof Error ? error.message : String(error)
return message.includes(BYPASS_CAPABILITY_UNAVAILABLE)
}
async function applyPermissionModeToActiveSession(
@ -797,15 +795,6 @@ async function applyPermissionModeToActiveSession(
sendToSession(sessionId, { type: 'permission_mode_changed', mode })
return
}
const needsRestart = shouldRestartForPermissionMode(currentMode, mode)
if (needsRestart) {
void enqueueRuntimeTransition(sessionId, () =>
restartSessionWithPermissionMode(ws, sessionId, mode),
)
return
}
try {
const ok = await conversationService.setPermissionMode(sessionId, mode)
if (!ok) {
@ -814,6 +803,10 @@ async function applyPermissionModeToActiveSession(
}
await commitConfirmedPermissionMode(sessionId, mode)
} catch (err) {
if (shouldFallbackToPermissionRestart(mode, err)) {
await restartSessionWithPermissionMode(ws, sessionId, mode)
return
}
const errMsg = err instanceof Error ? err.message : String(err)
console.warn(`[WS] Failed to set permission mode for ${sessionId}: ${errMsg}`)
sendMessage(ws, {