mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix(permission): fix bypass switching and Auto confirmation #978
This commit is contained in:
parent
c550dbc07a
commit
f44442033e
@ -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)
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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',
|
||||
},
|
||||
])
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 记忆冲掉:若 bypass→plan 走重启,
|
||||
* 新 CLI 直接以 plan 启动、prePlanMode 为空,ExitPlanMode 只能恢复成 default 而非进入前的
|
||||
* bypassPermissions。保持进程不变、走 setPermissionMode 做进程内 transition,CLI 才会像 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, {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user