diff --git a/desktop/src/components/controls/PermissionModeSelector.test.tsx b/desktop/src/components/controls/PermissionModeSelector.test.tsx index f1561696..d4642715 100644 --- a/desktop/src/components/controls/PermissionModeSelector.test.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.test.tsx @@ -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() + + 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) diff --git a/desktop/src/components/controls/PermissionModeSelector.tsx b/desktop/src/components/controls/PermissionModeSelector.tsx index 0cacfb82..d3c5d004 100644 --- a/desktop/src/components/controls/PermissionModeSelector.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.tsx @@ -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 || diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 2587f693..1abd1605 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -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( - behavior: 'confirm' | 'reject' | 'acknowledge' | 'status-before-reject', + behavior: 'confirm' | 'reject' | 'acknowledge' | 'status-before-reject' | 'unavailable', callback: () => Promise, ): Promise { 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((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 = [] + 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((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) => { - 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((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((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', }, ]) diff --git a/src/server/__tests__/fixtures/mock-sdk-cli.ts b/src/server/__tests__/fixtures/mock-sdk-cli.ts index db1d243e..7d2c70de 100644 --- a/src/server/__tests__/fixtures/mock-sdk-cli.ts +++ b/src/server/__tests__/fixtures/mock-sdk-cli.ts @@ -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', diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index 1638e8b7..2aaaad9d 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -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) }) }) diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 91dc989d..b8dc4580 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -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 } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 2d892f9f..7c622f16 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -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, {