From 3f4d731cc713bc52fd2c5dae04e6b35e68a04c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 27 May 2026 23:12:00 +0800 Subject: [PATCH] fix: scope permission mode to sessions Permission mode is a per-session runtime choice, while scheduled tasks cannot rely on a human approval loop. This removes the General settings permission surface, persists session permission metadata, and forces scheduled tasks to run with bypass permissions. Runtime permission changes now handle both directions across bypass boundaries, including startup/prewarm races, by persisting first and restarting only when the CLI launch mode must change. Constraint: Scheduled tasks must be able to execute without a human standing by for authorization. Constraint: The CLI only honors bypass permissions when launched with the skip-permissions flag, so switching to or from bypass requires a restart. Rejected: Keep a global General permission default | it can leak across sessions and scheduled runs in ways the user cannot reason about. Confidence: high Scope-risk: broad Directive: Do not reintroduce a global UI permission selector without proving it cannot affect unrelated sessions or automations. Tested: bun test src/server/__tests__/conversations.test.ts -t "permission" --timeout 30000 (10 pass, 0 fail) Tested: bun run check:server (858 pass, 0 fail) Tested: cd desktop && bun run check:desktop (760 tests plus production build passed) Tested: desktop/scripts/build-macos-arm64.sh and codesign verification passed Tested: Real DeepSeek smoke validated plan, bypass, and scheduled task permission behavior Not-tested: Did not repeat the full DeepSeek smoke after the final startup-race hardening; mock WebSocket permission regression and full server gate were rerun after that change. Fixes #632 --- desktop/src/api/sessions.ts | 2 + .../controls/PermissionModeSelector.test.tsx | 42 ++++ .../controls/PermissionModeSelector.tsx | 9 +- .../components/tasks/NewTaskModal.test.tsx | 1 + desktop/src/components/tasks/NewTaskModal.tsx | 6 +- desktop/src/components/tasks/PromptEditor.tsx | 23 +- desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + desktop/src/mocks/data.ts | 1 - desktop/src/pages/EmptySession.test.tsx | 12 +- desktop/src/pages/EmptySession.tsx | 19 +- desktop/src/pages/NewTaskModal.tsx | 19 +- desktop/src/pages/Settings.tsx | 53 +---- desktop/src/stores/chatStore.test.ts | 4 + desktop/src/stores/chatStore.ts | 1 + desktop/src/stores/sessionStore.ts | 13 ++ desktop/src/stores/uiStore.ts | 1 - desktop/src/types/session.ts | 1 + src/server/__tests__/conversations.test.ts | 196 ++++++++++++++++++ .../__tests__/cron-scheduler-launcher.test.ts | 68 ++++++ .../__tests__/e2e/business-flow.test.ts | 2 +- src/server/__tests__/sessions.test.ts | 45 ++++ src/server/api/sessions.ts | 16 +- src/server/services/conversationService.ts | 8 +- src/server/services/cronScheduler.ts | 37 +++- src/server/services/cronService.ts | 12 +- src/server/services/sessionService.ts | 40 ++++ src/server/ws/handler.ts | 88 ++++++-- 28 files changed, 594 insertions(+), 127 deletions(-) diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index d1ac7ffb..3c3a592b 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -1,6 +1,7 @@ import { api } from './client' import type { AgentTaskNotification } from '../types/chat' import type { SessionListItem, MessageEntry } from '../types/session' +import type { PermissionMode } from '../types/settings' type SessionsResponse = { sessions: SessionListItem[]; total: number } type MessagesResponse = { @@ -39,6 +40,7 @@ export type CreateSessionRepositoryOptions = { export type CreateSessionRequest = { workDir?: string repository?: CreateSessionRepositoryOptions + permissionMode?: PermissionMode } export type BranchSessionRequest = { targetMessageId: string diff --git a/desktop/src/components/controls/PermissionModeSelector.test.tsx b/desktop/src/components/controls/PermissionModeSelector.test.tsx index 03dc1efb..19fa1c54 100644 --- a/desktop/src/components/controls/PermissionModeSelector.test.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.test.tsx @@ -43,6 +43,7 @@ vi.mock('../../i18n', () => ({ })) import { PermissionModeSelector } from './PermissionModeSelector' +import { useChatStore } from '../../stores/chatStore' import { useSettingsStore } from '../../stores/settingsStore' import { useSessionStore } from '../../stores/sessionStore' import { useTabStore } from '../../stores/tabStore' @@ -55,6 +56,47 @@ describe('PermissionModeSelector', () => { useTabStore.setState({ activeTabId: null, tabs: [] }) }) + it('updates the active session without writing the global default mode', () => { + const setGlobalPermissionMode = vi.fn() + const setSessionPermissionMode = vi.fn() + useSettingsStore.setState({ + permissionMode: 'default', + setPermissionMode: setGlobalPermissionMode, + }) + useChatStore.setState({ + setSessionPermissionMode, + } as Partial>) + useSessionStore.setState({ + activeSessionId: 'current-tab', + sessions: [ + { + id: 'current-tab', + title: 'Current', + createdAt: '2026-05-24T00:00:00.000Z', + modifiedAt: '2026-05-24T00:00:00.000Z', + messageCount: 1, + projectPath: '/repo', + projectRoot: '/repo', + workDir: '/repo', + workDirExists: true, + permissionMode: 'default', + }, + ], + }) + useTabStore.setState({ + activeTabId: 'current-tab', + tabs: [{ sessionId: 'current-tab', title: 'Current', type: 'session', status: 'idle' }], + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' })) + fireEvent.click(screen.getByRole('menuitem', { name: /Auto accept edits/ })) + + expect(setGlobalPermissionMode).not.toHaveBeenCalled() + expect(setSessionPermissionMode).toHaveBeenCalledWith('current-tab', 'acceptEdits') + }) + it('labels the compact mobile trigger and opens a phone-sized menu sheet', () => { viewportMocks.isMobile = true diff --git a/desktop/src/components/controls/PermissionModeSelector.tsx b/desktop/src/components/controls/PermissionModeSelector.tsx index 814a1d5e..f695b7d6 100644 --- a/desktop/src/components/controls/PermissionModeSelector.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.tsx @@ -31,7 +31,7 @@ type Props = { export function PermissionModeSelector({ workDir: workDirProp, compact = false, value, onChange }: Props = {}) { const t = useTranslation() const isMobile = useMobileViewport() && !isTauriRuntime() - const { permissionMode: storeMode, setPermissionMode } = useSettingsStore() + const { permissionMode: storeMode } = useSettingsStore() const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode) const activeTabId = useTabStore((s) => s.activeTabId) const sessions = useSessionStore((s) => s.sessions) @@ -41,8 +41,6 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, const menuRef = useRef(null) const isControlled = value !== undefined - const currentMode = isControlled ? value : storeMode - const PERMISSION_ITEMS: Array<{ value: PermissionMode label: string @@ -89,6 +87,9 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, const activeSession = activeTabId ? sessions.find((s) => s.id === activeTabId) : null + const currentMode = isControlled + ? value + : (activeSession?.permissionMode as PermissionMode | undefined) || storeMode const workDir = workDirProp || activeSession?.workDir || '~' const compactButtonClass = compact ? isMobile @@ -135,7 +136,6 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, if (isControlled) { onChange?.(item.value) } else { - void setPermissionMode(item.value) if (activeTabId) setSessionPermissionMode(activeTabId, item.value) } setOpen(false) @@ -267,7 +267,6 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, if (isControlled) { onChange?.('bypassPermissions') } else { - void setPermissionMode('bypassPermissions') if (activeTabId) setSessionPermissionMode(activeTabId, 'bypassPermissions') } setConfirmDialog(false) diff --git a/desktop/src/components/tasks/NewTaskModal.test.tsx b/desktop/src/components/tasks/NewTaskModal.test.tsx index 8c24de79..1eef4fff 100644 --- a/desktop/src/components/tasks/NewTaskModal.test.tsx +++ b/desktop/src/components/tasks/NewTaskModal.test.tsx @@ -86,6 +86,7 @@ describe('NewTaskModal', () => { expect(createTask).toHaveBeenCalledWith(expect.objectContaining({ model: 'provider-fast', providerId: 'provider-a', + permissionMode: 'bypassPermissions', enabled: true, recurring: true, })) diff --git a/desktop/src/components/tasks/NewTaskModal.tsx b/desktop/src/components/tasks/NewTaskModal.tsx index 5ce49dec..c4bd00b0 100644 --- a/desktop/src/components/tasks/NewTaskModal.tsx +++ b/desktop/src/components/tasks/NewTaskModal.tsx @@ -9,7 +9,6 @@ import { PromptEditor } from './PromptEditor' import { DayOfWeekPicker } from './DayOfWeekPicker' import { useTranslation } from '../../i18n' import { describeCron, isValidCron, parseCron, type FrequencyKey } from '../../lib/cronDescribe' -import type { PermissionMode } from '../../types/settings' import type { CronTask } from '../../types/task' type NotificationChannel = 'desktop' | 'telegram' | 'feishu' @@ -94,7 +93,6 @@ export function NewTaskModal({ open, onClose, editTask }: Props) { const [time, setTime] = useState(parsed?.time || '09:00') const [model, setModel] = useState(editTask?.model || '') const [providerId, setProviderId] = useState(editTask?.providerId) - const [permissionMode, setPermissionMode] = useState((editTask?.permissionMode as PermissionMode) || 'default') const [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir) const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false) const [notifyEnabled, setNotifyEnabled] = useState(editTask?.notification?.enabled || false) @@ -134,7 +132,7 @@ export function NewTaskModal({ open, onClose, editTask }: Props) { prompt: prompt.trim(), model: model || undefined, providerId, - permissionMode: permissionMode !== 'default' ? permissionMode : undefined, + permissionMode: 'bypassPermissions', folderPath: folderPath.trim() || undefined, useWorktree: useWorktree || undefined, notification: notifyEnabled && notifyChannels.length > 0 @@ -200,8 +198,6 @@ export function NewTaskModal({ open, onClose, editTask }: Props) { value={prompt} onChange={setPrompt} placeholder={t('newTask.promptPlaceholder')} - permissionMode={permissionMode} - onPermissionModeChange={setPermissionMode} modelId={model} onModelChange={setModel} providerId={providerId} diff --git a/desktop/src/components/tasks/PromptEditor.tsx b/desktop/src/components/tasks/PromptEditor.tsx index b406751c..f70fe2b9 100644 --- a/desktop/src/components/tasks/PromptEditor.tsx +++ b/desktop/src/components/tasks/PromptEditor.tsx @@ -1,17 +1,12 @@ -import { PermissionModeSelector } from '../controls/PermissionModeSelector' import { ModelSelector } from '../controls/ModelSelector' import { DirectoryPicker } from '../shared/DirectoryPicker' import { useTranslation } from '../../i18n' -import type { PermissionMode } from '../../types/settings' type Props = { value: string onChange: (value: string) => void placeholder?: string - permissionMode: PermissionMode - onPermissionModeChange: (mode: PermissionMode) => void - modelId: string onModelChange: (modelId: string) => void providerId?: string | null @@ -28,8 +23,6 @@ export function PromptEditor({ value, onChange, placeholder, - permissionMode, - onPermissionModeChange, modelId, onModelChange, providerId, @@ -56,7 +49,10 @@ export function PromptEditor({
{/* Row 1: Permission + Model selectors */}
- +
+ gavel + {t('newTask.fullPermissions')} +
{ @@ -71,13 +67,10 @@ export function PromptEditor({
- {/* Bypass + no folder warning */} - {permissionMode === 'bypassPermissions' && ( -
- warning - {t('promptEditor.bypassWarning')}{folderPath ? ` ${t('promptEditor.within')} ${folderPath}` : ` ${t('promptEditor.selectFolder')}`}. -
- )} +
+ warning + {t('promptEditor.bypassWarning')}{folderPath ? ` ${t('promptEditor.within')} ${folderPath}` : ` ${t('promptEditor.selectFolder')}`}. +
) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index bbda80d4..5c12a29c 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1309,6 +1309,7 @@ export const en = { // ─── New Task Modal ────────────────────────────────────── 'newTask.title': 'New scheduled task', 'newTask.localWarning': 'Local tasks only run while your computer is awake.', + 'newTask.fullPermissions': 'Full permissions', 'newTask.name': 'Name', 'newTask.namePlaceholder': 'daily-code-review', 'newTask.description': 'Description', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 4d589fa9..41479fad 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1311,6 +1311,7 @@ export const zh: Record = { // ─── New Task Modal ────────────────────────────────────── 'newTask.title': '新建定时任务', 'newTask.localWarning': '本地任务仅在电脑唤醒时运行。', + 'newTask.fullPermissions': '所有权限', 'newTask.name': '名称', 'newTask.namePlaceholder': 'daily-code-review', 'newTask.description': '描述', diff --git a/desktop/src/mocks/data.ts b/desktop/src/mocks/data.ts index 4c09cbb8..28295704 100644 --- a/desktop/src/mocks/data.ts +++ b/desktop/src/mocks/data.ts @@ -185,7 +185,6 @@ export const mockToolInspection = { // ─── New Task Modal ─────────────────────────────────────────────── export const mockNewTaskDefaults = { - permissionModes: ['Restricted', 'Standard', 'Full Access'], models: ['Claude 3.5 Sonnet', 'Claude 3.5 Haiku', 'Claude 3.5 Opus'], frequencies: ['Hourly', 'Daily at 9:00 AM', 'Weekly', 'Monthly', 'Custom cron'], } diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 67bb8904..f2f565c9 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -174,7 +174,7 @@ describe('EmptySession', () => { mocks.webviewDragHandlers.length = 0 mocks.isMobile = false mocks.isTauriRuntime = false - useSettingsStore.setState({ locale: 'en', activeProviderName: null }) + useSettingsStore.setState({ locale: 'en', activeProviderName: null, permissionMode: 'default' }) useSessionStore.setState(initialSessionState, true) useChatStore.setState(initialChatState, true) useTabStore.setState(initialTabState, true) @@ -354,6 +354,7 @@ describe('EmptySession', () => { expect(mocks.createSession).toHaveBeenCalledWith({ workDir: '/workspace/project', repository: { branch: 'main', worktree: false }, + permissionMode: 'default', }) }) @@ -394,7 +395,7 @@ describe('EmptySession', () => { fireEvent.click(screen.getByRole('button', { name: /Run/i })) await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith({}) + expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' }) }) expect(useSessionRuntimeStore.getState().selections['draft-session']).toEqual({ @@ -436,7 +437,7 @@ describe('EmptySession', () => { fireEvent.click(screen.getByRole('button', { name: /Run/i })) await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith({}) + expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' }) }) expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', { type: 'user_message', @@ -488,7 +489,7 @@ describe('EmptySession', () => { fireEvent.click(screen.getByRole('button', { name: /Run/i })) await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith({}) + expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' }) }) expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', { type: 'user_message', @@ -563,6 +564,7 @@ describe('EmptySession', () => { await waitFor(() => { expect(mocks.createSession).toHaveBeenCalledWith({ workDir: '/workspace/project', + permissionMode: 'default', }) }) }) @@ -668,6 +670,7 @@ describe('EmptySession', () => { expect(mocks.createSession).toHaveBeenCalledWith({ workDir: '/workspace/project', repository: { branch: 'main', worktree: false }, + permissionMode: 'default', }) }) }) @@ -759,6 +762,7 @@ describe('EmptySession', () => { expect(mocks.createSession).toHaveBeenCalledWith({ workDir: '/workspace/project', repository: { branch: 'main', worktree: false }, + permissionMode: 'default', }) }) }) diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 410cb43b..ffb94de0 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -36,6 +36,7 @@ import { resolveSlashUiAction, } from '../components/chat/composerUtils' import type { AttachmentRef } from '../types/chat' +import type { PermissionMode } from '../types/settings' import type { SlashCommandOption } from '../components/chat/composerUtils' type Attachment = ComposerAttachment @@ -110,6 +111,8 @@ export function EmptySession() { const addToast = useUIStore((state) => state.addToast) const currentModel = useSettingsStore((state) => state.currentModel) const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior) + const defaultPermissionMode = useSettingsStore((state) => state.permissionMode) + const [draftPermissionMode, setDraftPermissionMode] = useState(defaultPermissionMode) const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary) const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY]) const draftRuntimeSelectionKey = draftRuntimeSelection @@ -275,9 +278,12 @@ export function EmptySession() { const explicitDraftSelection = useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY] const sessionId = await createSession( workDir || undefined, - selectedBranch - ? { repository: { branch: selectedBranch, worktree: useWorktree } } - : undefined, + { + ...(selectedBranch + ? { repository: { branch: selectedBranch, worktree: useWorktree } } + : {}), + permissionMode: draftPermissionMode, + }, ) if (explicitDraftSelection) { useSessionRuntimeStore.getState().setSelection(sessionId, explicitDraftSelection) @@ -731,7 +737,12 @@ export function EmptySession() { )} - +
diff --git a/desktop/src/pages/NewTaskModal.tsx b/desktop/src/pages/NewTaskModal.tsx index af580be7..6ec0b653 100644 --- a/desktop/src/pages/NewTaskModal.tsx +++ b/desktop/src/pages/NewTaskModal.tsx @@ -13,7 +13,6 @@ export default function NewTaskModal() { const [taskName, setTaskName] = useState('') const [description, setDescription] = useState('') const [systemPrompt, setSystemPrompt] = useState('') - const [permissionMode, setPermissionMode] = useState(mockNewTaskDefaults.permissionModes[0]) const [model, setModel] = useState(mockNewTaskDefaults.models[0]) const [rootFolder, setRootFolder] = useState('') const [frequency, setFrequency] = useState(mockNewTaskDefaults.frequencies[1]) @@ -189,21 +188,11 @@ export default function NewTaskModal() { -
- - - unfold_more +
+ + gavel + Full Access
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 2ef29416..52de6b24 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' -import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior } from '../types/settings' +import type { EffortLevel, ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior } from '../types/settings' import type { Locale } from '../i18n' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' @@ -152,7 +152,6 @@ export function Settings() {
setActiveTab('providers')} /> - setActiveTab('permissions')} /> setActiveTab('general')} /> setActiveTab('h5Access')} /> setActiveTab('adapters')} /> @@ -174,7 +173,6 @@ export function Settings() { {/* Tab content */}
{activeTab === 'providers' && } - {activeTab === 'permissions' && } {activeTab === 'activity' && } {activeTab === 'general' && } {activeTab === 'h5Access' && } @@ -1428,55 +1426,6 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF } -// ─── Permission Settings ────────────────────────────────────── - -function PermissionSettings() { - const { permissionMode, setPermissionMode } = useSettingsStore() - const t = useTranslation() - - const MODES: Array<{ mode: PermissionMode; icon: string; label: string; desc: string }> = [ - { mode: 'default', icon: 'verified_user', label: t('settings.permissions.default'), desc: t('settings.permissions.defaultDesc') }, - { mode: 'acceptEdits', icon: 'edit_note', label: t('settings.permissions.acceptEdits'), desc: t('settings.permissions.acceptEditsDesc') }, - { mode: 'plan', icon: 'architecture', label: t('settings.permissions.plan'), desc: t('settings.permissions.planDesc') }, - { mode: 'bypassPermissions', icon: 'bolt', label: t('settings.permissions.bypass'), desc: t('settings.permissions.bypassDesc') }, - ] - - return ( -
-

{t('settings.permissions.title')}

-

{t('settings.permissions.description')}

- -
- {MODES.map(({ mode, icon, label, desc }) => { - const isSelected = permissionMode === mode - return ( - - ) - })} -
-
- ) -} - // ─── General Settings ────────────────────────────────────── function GeneralSettings() { diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 2474343a..749aed03 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -19,6 +19,7 @@ const { updateTabTitleMock, updateTabStatusMock, updateSessionTitleMock, + updateSessionPermissionModeMock, sessionStoreSnapshot, cliTaskStoreSnapshot, } = vi.hoisted(() => ({ @@ -38,6 +39,7 @@ const { updateTabTitleMock: vi.fn(), updateTabStatusMock: vi.fn(), updateSessionTitleMock: vi.fn(), + updateSessionPermissionModeMock: vi.fn(), sessionStoreSnapshot: { sessions: [] as Array<{ id: string @@ -103,6 +105,7 @@ vi.mock('./sessionStore', () => ({ getState: () => ({ sessions: sessionStoreSnapshot.sessions, updateSessionTitle: updateSessionTitleMock, + updateSessionPermissionMode: updateSessionPermissionModeMock, }), }, })) @@ -1787,6 +1790,7 @@ describe('chatStore history mapping', () => { type: 'set_permission_mode', mode: 'acceptEdits', }) + expect(updateSessionPermissionModeMock).toHaveBeenCalledWith('session-1', 'acceptEdits') }) it('stores terminal task notifications for agent tool cards', () => { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index cfcc3a40..2c845807 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -1010,6 +1010,7 @@ export const useChatStore = create((set, get) => ({ setSessionPermissionMode: (sessionId, mode) => { if (!get().sessions[sessionId]) return + useSessionStore.getState().updateSessionPermissionMode(sessionId, mode) wsManager.send(sessionId, { type: 'set_permission_mode', mode }) }, diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index e987bb04..33fcef47 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -8,10 +8,12 @@ import { import { useSessionRuntimeStore } from './sessionRuntimeStore' import { useTabStore } from './tabStore' import type { SessionListItem } from '../types/session' +import type { PermissionMode } from '../types/settings' import { isPlaceholderSessionTitle } from '../lib/sessionTitle' type CreateSessionOptions = { repository?: CreateSessionRepositoryOptions + permissionMode?: PermissionMode } type BranchSessionResult = Pick @@ -41,6 +43,7 @@ type SessionStore = { clearSessionSelection: () => void renameSession: (id: string, title: string) => Promise updateSessionTitle: (id: string, title: string) => void + updateSessionPermissionMode: (id: string, mode: PermissionMode) => void setActiveSession: (id: string | null) => void } @@ -83,6 +86,7 @@ export const useSessionStore = create((set, get) => ({ const { sessionId: id, workDir: resolvedWorkDir } = await sessionsApi.create({ ...(workDir ? { workDir } : {}), ...(options?.repository ? { repository: options.repository } : {}), + ...(options?.permissionMode ? { permissionMode: options.permissionMode } : {}), }) const now = new Date().toISOString() const optimisticSession: SessionListItem = { @@ -95,6 +99,7 @@ export const useSessionStore = create((set, get) => ({ workDir: resolvedWorkDir ?? workDir ?? null, projectRoot: resolvedWorkDir ?? workDir ?? null, workDirExists: true, + permissionMode: options?.permissionMode, } set((state) => ({ @@ -209,6 +214,14 @@ export const useSessionStore = create((set, get) => ({ })) }, + updateSessionPermissionMode: (id, mode) => { + set((s) => ({ + sessions: s.sessions.map((session) => + session.id === id ? { ...session, permissionMode: mode } : session, + ), + })) + }, + setActiveSession: (id) => set({ activeSessionId: id }), })) diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index 686f0898..c40f885a 100644 --- a/desktop/src/stores/uiStore.ts +++ b/desktop/src/stores/uiStore.ts @@ -30,7 +30,6 @@ export type Toast = { export type SettingsTab = | 'providers' - | 'permissions' | 'activity' | 'general' | 'h5Access' diff --git a/desktop/src/types/session.ts b/desktop/src/types/session.ts index d094ae6e..6c83366e 100644 --- a/desktop/src/types/session.ts +++ b/desktop/src/types/session.ts @@ -10,6 +10,7 @@ export type SessionListItem = { projectRoot?: string | null workDir: string | null workDirExists: boolean + permissionMode?: string } export type MessageEntry = { diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 4965d061..501ff292 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -2365,6 +2365,202 @@ describe('WebSocket Chat Integration', () => { } }, 20_000) + it('should persist permission changes made before the CLI starts', async () => { + await fetch(`${baseUrl}/api/permissions/mode`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'default' }), + }) + + 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 startCalls: Array<{ + sessionId: string + options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | 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 }, + ) { + startCalls.push({ sessionId: sid, options }) + return originalStartSession(sid, workDir, sdkUrl, options) + }) as typeof conversationService.startSession + + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for inactive permission switch connection for session ${sessionId}`)) + }, 5000) + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + if (msg.type === 'connected') { + clearTimeout(timeout) + ws.send(JSON.stringify({ + type: 'set_permission_mode', + mode: 'acceptEdits', + })) + resolve() + } + if (msg.type === 'error') { + clearTimeout(timeout) + reject(new Error(msg.message)) + } + } + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error(`WebSocket error for inactive permission switch session ${sessionId}`)) + } + }) + + await waitUntil(async () => { + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`) + if (!res.ok) return false + const body = await res.json() as { status?: { permissionMode?: string } } + return body.status?.permissionMode === 'acceptEdits' + }, `persisted inactive permission switch for ${sessionId}`) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for first turn after inactive permission switch for session ${sessionId}`)) + }, 10_000) + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + if (msg.type === 'message_complete') { + clearTimeout(timeout) + resolve() + } + if (msg.type === 'error') { + clearTimeout(timeout) + reject(new Error(msg.message)) + } + } + ws.send(JSON.stringify({ type: 'user_message', content: 'first turn after permission switch' })) + }) + + expect(startCalls).toHaveLength(1) + expect(startCalls[0]).toMatchObject({ + sessionId, + options: { + permissionMode: 'acceptEdits', + }, + }) + } finally { + ws.close() + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) + await fetch(`${baseUrl}/api/permissions/mode`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'default' }), + }) + } + }, 20_000) + + it('should restart when switching from bypass permissions back to default', async () => { + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd(), permissionMode: 'bypassPermissions' }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + + const originalStartSession = conversationService.startSession.bind(conversationService) + const startCalls: Array<{ + sessionId: string + options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | 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 }, + ) { + startCalls.push({ sessionId: sid, options }) + 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 waiting for bypass-to-default permission switch connection for session ${sessionId}`)) + }, 5000) + 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() + } + if (msg.type === 'error') { + clearTimeout(timeout) + reject(new Error(msg.message)) + } + } + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error(`WebSocket error for bypass-to-default permission switch session ${sessionId}`)) + } + }) + + await waitUntil( + () => startCalls.length === 1 && conversationService.hasSession(sessionId), + `prewarmed CLI process for bypass-to-default permission switch ${sessionId}`, + ) + expect(startCalls[0]).toMatchObject({ + sessionId, + options: { + permissionMode: 'bypassPermissions', + }, + }) + + const switchStartIndex = messages.length + ws.send(JSON.stringify({ + type: 'set_permission_mode', + mode: 'default', + })) + + await waitUntil( + async () => messages.slice(switchStartIndex).some((msg) => msg.type === 'status' && msg.state === 'idle'), + `bypass-to-default permission switch completion for ${sessionId}`, + ) + + expect(startCalls).toHaveLength(2) + expect(startCalls[1]).toMatchObject({ + sessionId, + options: { + permissionMode: 'default', + }, + }) + await waitUntil(async () => { + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`) + if (!res.ok) return false + const body = await res.json() as { status?: { permissionMode?: string } } + return body.status?.permissionMode === 'default' + }, `persisted bypass-to-default permission switch for ${sessionId}`) + expect(messages.slice(switchStartIndex).some((msg) => msg.type === 'error')).toBe(false) + } finally { + ws.close() + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) + } + }, 20_000) + it('should ignore stale persisted runtime provider ids when resuming old sessions', async () => { const providerService = new ProviderService() const activeProvider = await providerService.addProvider({ diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index af227d4d..364b0495 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -330,6 +330,74 @@ describe('cron scheduler launcher resolution', () => { expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli') }) + unixOnly('executeTask launches scheduled tasks with full permissions', async () => { + const appRoot = path.join(tmpDir, 'app-root') + const sidecarPath = path.join(tmpDir, 'claude-sidecar') + const sidecarArgsPath = path.join(tmpDir, 'sidecar.args') + + await fs.mkdir(appRoot, { recursive: true }) + await fs.writeFile( + sidecarPath, + [ + '#!/bin/sh', + `printf '%s\\n' "$@" > "${sidecarArgsPath}"`, + '/bin/cat >/dev/null', + 'printf \'%s\\n\' \'{"type":"result","result":"permissions ok"}\'', + 'exit 0', + '', + ].join('\n'), + 'utf-8', + ) + await fs.chmod(sidecarPath, 0o755) + + process.env.CLAUDE_CLI_PATH = sidecarPath + process.env.CLAUDE_APP_ROOT = appRoot + + const cronService = new CronService() + const scheduler = new CronScheduler(cronService) + const createSessionCalls: unknown[][] = [] + const appendSessionMetadataCalls: unknown[][] = [] + ;(scheduler as unknown as { + sessionService: { + createSession: (...args: unknown[]) => Promise<{ sessionId: string }> + deleteSessionFile: (sessionId: string) => Promise + appendSessionMetadata: (...args: unknown[]) => Promise + } + }).sessionService = { + createSession: async (...args: unknown[]) => { + createSessionCalls.push(args) + return { sessionId: 'scheduled-session' } + }, + deleteSessionFile: async () => {}, + appendSessionMetadata: async (...args: unknown[]) => { + appendSessionMetadataCalls.push(args) + }, + } + const task = await cronService.createTask({ + cron: '* * * * *', + prompt: 'cron permission test', + name: 'Permission Task', + recurring: true, + folderPath: tmpDir, + permissionMode: 'default', + }) + + const run = await scheduler.executeTask(task, { createSession: true }) + const canonicalTmpDir = await fs.realpath(tmpDir) + + expect(run.status).toBe('completed') + expect(run.sessionId).toBe('scheduled-session') + expect(createSessionCalls).toEqual([[canonicalTmpDir, undefined, 'bypassPermissions']]) + expect(appendSessionMetadataCalls).toEqual([ + ['scheduled-session', { workDir: canonicalTmpDir, permissionMode: 'bypassPermissions' }], + ]) + const sidecarArgs = (await fs.readFile(sidecarArgsPath, 'utf-8')) + .trim() + .split('\n') + expect(sidecarArgs).toContain('--dangerously-skip-permissions') + expect(sidecarArgs).not.toContain('--permission-mode') + }) + unixOnly('executeTask inherits exported terminal shell variables', async () => { const appRoot = path.join(tmpDir, 'app-root') const sidecarPath = path.join(tmpDir, 'claude-sidecar') diff --git a/src/server/__tests__/e2e/business-flow.test.ts b/src/server/__tests__/e2e/business-flow.test.ts index 1f22455a..d894aee1 100644 --- a/src/server/__tests__/e2e/business-flow.test.ts +++ b/src/server/__tests__/e2e/business-flow.test.ts @@ -74,7 +74,7 @@ describe('Business Flow: Scheduled Tasks', () => { expect(data.task.cron).toBe('0 9 * * 1-5') expect(data.task.prompt).toContain('git log') expect(data.task.recurring).toBe(true) - expect(data.task.permissionMode).toBe('default') + expect(data.task.permissionMode).toBe('bypassPermissions') expect(data.task.model).toBe('claude-sonnet-4-6') expect(data.task.createdAt).toBeGreaterThan(0) }) diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index dc259673..dab1a973 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -1142,6 +1142,30 @@ describe('SessionService', () => { }) }) + it('should persist session permission mode in launch metadata', async () => { + const workDir = path.join(tmpDir, 'permission-workdir') + await fs.mkdir(workDir, { recursive: true }) + const { sessionId } = await (service.createSession as unknown as ( + workDir?: string, + repositoryOptions?: unknown, + permissionMode?: string, + ) => Promise<{ sessionId: string; workDir: string }>)(workDir, undefined, 'acceptEdits') + + let launchInfo = await service.getSessionLaunchInfo(sessionId) + expect(launchInfo?.permissionMode).toBe('acceptEdits') + + await (service.appendSessionMetadata as unknown as ( + sessionId: string, + metadata: { workDir: string; permissionMode?: string }, + ) => Promise)(sessionId, { + workDir, + permissionMode: 'plan', + }) + + launchInfo = await service.getSessionLaunchInfo(sessionId) + expect(launchInfo?.permissionMode).toBe('plan') + }) + it('should remove stale placeholder files after native CLI worktree startup', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const sourceFile = await writeSessionFile('-tmp-source', sessionId, [ @@ -1877,6 +1901,27 @@ describe('Sessions API', () => { ) }) + it('GET /api/sessions/:id/inspection should report persisted permission mode for inactive sessions', async () => { + const workDir = await fs.mkdtemp(path.join(tmpDir, 'api-session-permission-')) + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir, permissionMode: 'bypassPermissions' }), + }) + expect(createRes.status).toBe(201) + + const { sessionId } = (await createRes.json()) as { sessionId: string } + const inspectionRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`) + expect(inspectionRes.status).toBe(200) + + const inspection = (await inspectionRes.json()) as { + active: boolean + status: { permissionMode?: string } + } + expect(inspection.active).toBe(false) + expect(inspection.status.permissionMode).toBe('bypassPermissions') + }) + it('GET /api/sessions/repository-context should return branch launch metadata', async () => { const workDir = await createCleanGitRepo(tmpDir) const res = await fetch( diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index b71acc4e..8f78f3f9 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -281,9 +281,9 @@ async function handleSessionWorkspaceRoute( } async function createSession(req: Request): Promise { - let body: { workDir?: string; repository?: CreateSessionRepositoryOptions } + let body: { workDir?: string; repository?: CreateSessionRepositoryOptions; permissionMode?: string } try { - body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions } + body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions; permissionMode?: string } } catch { throw ApiError.badRequest('Invalid JSON body') } @@ -292,6 +292,10 @@ async function createSession(req: Request): Promise { throw ApiError.badRequest('workDir must be a string') } + if (body.permissionMode !== undefined && typeof body.permissionMode !== 'string') { + throw ApiError.badRequest('permissionMode must be a string') + } + if (body.repository !== undefined) { if (!body.repository || typeof body.repository !== 'object' || Array.isArray(body.repository)) { throw ApiError.badRequest('repository must be an object') @@ -304,7 +308,7 @@ async function createSession(req: Request): Promise { } } - const result = await sessionService.createSession(body.workDir, body.repository) + const result = await sessionService.createSession(body.workDir, body.repository, body.permissionMode) recentProjectsCache = null return Response.json(result, { status: 201 }) } @@ -499,6 +503,10 @@ async function getSessionInspection(sessionId: string, url: URL): Promise null) + const permissionMode = active + ? conversationService.getSessionPermissionMode(sessionId) + : launchInfo?.permissionMode ?? 'default' const initMessage = conversationService.getSessionInitMessage(sessionId) ?? [...conversationService.getRecentSdkMessages(sessionId)] .reverse() @@ -518,7 +526,7 @@ async function getSessionInspection(sessionId: string, url: URL): Promise { + if (!sessionId) return + await this.sessionService.appendSessionMetadata(sessionId, { + workDir, + permissionMode: 'bypassPermissions', + }).catch(() => { + // The task result is still valid even if session metadata refresh fails. + }) + } + + private resolveCanonicalWorkDir(workDir: string): string { + try { + return realpathSync(workDir) + } catch { + return workDir + } + } + private getRuntimeArgs(task: CronTask): string[] { const model = task.model?.trim() - return model ? ['--model', model] : [] + return [ + ...(model ? ['--model', model] : []), + '--dangerously-skip-permissions', + ] } private async buildTaskChildEnv( diff --git a/src/server/services/cronService.ts b/src/server/services/cronService.ts index 16767c51..2700c439 100644 --- a/src/server/services/cronService.ts +++ b/src/server/services/cronService.ts @@ -56,7 +56,10 @@ export class CronService { /** 获取所有任务 */ async listTasks(): Promise { const data = await this.readTasksFile() - return data.tasks + return data.tasks.map((task) => ({ + ...task, + permissionMode: 'bypassPermissions', + })) } /** 创建新任务 */ @@ -70,6 +73,7 @@ export class CronService { const data = await this.readTasksFile() const newTask: CronTask = { ...task, + permissionMode: 'bypassPermissions', id: crypto.randomBytes(4).toString('hex'), createdAt: Date.now(), } @@ -88,7 +92,11 @@ export class CronService { // 不允许修改 id 和 createdAt const { id: _id, createdAt: _ca, ...safeUpdates } = updates - data.tasks[index] = { ...data.tasks[index], ...safeUpdates } + data.tasks[index] = { + ...data.tasks[index], + ...safeUpdates, + permissionMode: 'bypassPermissions', + } await this.writeTasksFile(data) return data.tasks[index] } diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 50dfdd97..0ddd36f4 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -43,6 +43,7 @@ export type SessionListItem = { projectRoot: string | null workDir: string | null workDirExists: boolean + permissionMode?: string } export type DeleteSessionFailure = { @@ -68,6 +69,7 @@ export type SessionLaunchInfo = { worktreeSession?: PersistedWorktreeSession | null transcriptMessageCount: number customTitle: string | null + permissionMode?: string } export type TrimSessionResult = { @@ -200,6 +202,7 @@ type RawEntry = { timestamp?: string } customTitle?: string + permissionMode?: string worktreeSession?: PersistedWorktreeSession | null title?: string [key: string]: unknown @@ -217,6 +220,14 @@ type PersistedWorktreeSession = { hookBased?: boolean } +const VALID_SESSION_PERMISSION_MODES = new Set([ + 'default', + 'acceptEdits', + 'plan', + 'bypassPermissions', + 'dontAsk', +]) + type ContentBlock = Record const USER_INTERRUPTION_TEXTS = new Set([ @@ -317,6 +328,21 @@ export class SessionService { return undefined } + private resolvePermissionModeFromEntries(entries: RawEntry[]): string | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i] + if (entry?.type !== 'session-meta') continue + const permissionMode = entry.permissionMode + if ( + typeof permissionMode === 'string' && + VALID_SESSION_PERMISSION_MODES.has(permissionMode) + ) { + return permissionMode + } + } + return undefined + } + private resolveWorktreeSessionFromEntries(entries: RawEntry[]): PersistedWorktreeSession | null | undefined { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i] @@ -1308,6 +1334,7 @@ export class SessionService { try { const entries = await this.readJsonlFile(filePath) const workDir = this.resolveWorkDirFromEntries(entries, projectDir) + const permissionMode = this.resolvePermissionModeFromEntries(entries) const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir) const workDirExists = await this.pathExists(workDir) @@ -1337,6 +1364,7 @@ export class SessionService { projectRoot, workDir, workDirExists, + permissionMode, } } catch { // Skip unreadable files @@ -1365,6 +1393,7 @@ export class SessionService { ) const title = this.extractTitle(entries) const workDir = this.resolveWorkDirFromEntries(entries, projectDir) + const permissionMode = this.resolvePermissionModeFromEntries(entries) const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir) const workDirExists = await this.pathExists(workDir) @@ -1386,6 +1415,7 @@ export class SessionService { projectRoot, workDir, workDirExists, + permissionMode, messages, } } @@ -1413,6 +1443,7 @@ export class SessionService { async createSession( workDir?: string, repositoryOptions?: CreateSessionRepositoryOptions, + permissionMode?: string, ): Promise<{ sessionId: string; workDir: string }> { // Default to user home directory when no workDir specified const resolvedWorkDir = workDir || os.homedir() @@ -1464,6 +1495,9 @@ export class SessionService { isMeta: true, workDir: absWorkDir, repository: preparedWorkspace.repository, + ...(permissionMode && VALID_SESSION_PERMISSION_MODES.has(permissionMode) + ? { permissionMode } + : {}), timestamp: now, } @@ -1603,6 +1637,7 @@ export class SessionService { const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd() const repository = this.resolveRepositoryFromEntries(entries) const worktreeSession = this.resolveWorktreeSessionFromEntries(entries) + const permissionMode = this.resolvePermissionModeFromEntries(entries) let customTitle: string | null = null for (const entry of entries) { @@ -1620,6 +1655,7 @@ export class SessionService { worktreeSession, transcriptMessageCount, customTitle, + permissionMode, } } @@ -1682,6 +1718,7 @@ export class SessionService { workDir: string customTitle?: string | null repository?: PreparedSessionWorkspace['repository'] + permissionMode?: string } ): Promise { const matches = await this.findSessionFiles(sessionId) @@ -1708,6 +1745,9 @@ export class SessionService { isMeta: true, workDir: normalizedWorkDir, repository, + ...(metadata.permissionMode && VALID_SESSION_PERMISSION_MODES.has(metadata.permissionMode) + ? { permissionMode: metadata.permissionMode } + : {}), timestamp: new Date().toISOString(), }) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index f77d36df..465abb41 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -192,7 +192,7 @@ export const handleWebSocket = { break case 'set_permission_mode': - handleSetPermissionMode(ws, message) + void handleSetPermissionMode(ws, message) break case 'set_runtime_config': @@ -479,11 +479,38 @@ function handleComputerUsePermissionResponse( } } -function handleSetPermissionMode( +async function handleSetPermissionMode( ws: ServerWebSocket, message: Extract -) { +): Promise { const { sessionId } = ws.data + const pendingStartup = sessionStartupPromises.get(sessionId) + + if (pendingStartup) { + await persistSessionPermissionMode(sessionId, message.mode) + await enqueueRuntimeTransition(sessionId, async () => { + await pendingStartup.catch(() => undefined) + if (!conversationService.hasSession(sessionId)) return + await applyPermissionModeToActiveSession(ws, sessionId, message.mode) + }) + return + } + + if (!conversationService.hasSession(sessionId)) { + await persistSessionPermissionMode(sessionId, message.mode) + return + } + + await applyPermissionModeToActiveSession(ws, sessionId, message.mode) +} + +async function applyPermissionModeToActiveSession( + ws: ServerWebSocket, + sessionId: string, + mode: string, +): Promise { + const currentMode = conversationService.getSessionPermissionMode(sessionId) + if (currentMode === mode) return // Switching to/from bypassPermissions requires the CLI to be (re)started with // --dangerously-skip-permissions. The CLI rejects a runtime set_permission_mode @@ -491,20 +518,21 @@ function handleSetPermissionMode( // sending the SDK message (which would silently fail), restart the CLI subprocess // with the correct arguments so the new permission mode takes effect. const needsRestart = - conversationService.hasSession(sessionId) && - (message.mode === 'bypassPermissions' || conversationService.getSessionPermissionMode(sessionId) === 'bypassPermissions') + mode === 'bypassPermissions' || currentMode === 'bypassPermissions' if (needsRestart) { void enqueueRuntimeTransition(sessionId, () => - restartSessionWithPermissionMode(ws, sessionId, message.mode), + restartSessionWithPermissionMode(ws, sessionId, mode), ) return } - const ok = conversationService.setPermissionMode(sessionId, message.mode) + const ok = conversationService.setPermissionMode(sessionId, mode) if (!ok) { console.warn(`[WS] Ignored permission mode update for inactive session ${sessionId}`) + return } + await persistSessionPermissionMode(sessionId, mode) } async function handleSetRuntimeConfig( @@ -567,13 +595,11 @@ async function restartSessionWithPermissionMode( mode: string, ): Promise { try { - // Persist the new mode first so it's read on restart - await settingsService.setPermissionMode(mode) - const workDir = conversationService.getSessionWorkDir(sessionId) + await persistSessionPermissionMode(sessionId, mode, workDir) conversationService.stopSession(sessionId) - // Rebuild runtime settings (will pick up the persisted mode) + // Rebuild runtime settings (will pick up the session-scoped mode) const runtimeSettings = await getRuntimeSettings(sessionId) const sdkUrl = `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + @@ -604,6 +630,24 @@ async function restartSessionWithPermissionMode( } } +async function persistSessionPermissionMode( + sessionId: string, + mode: string, + knownWorkDir?: string | null, +): Promise { + const workDir = + knownWorkDir || + conversationService.getSessionWorkDir(sessionId) || + await sessionService.getSessionWorkDir(sessionId).catch(() => null) + + if (!workDir) return + + await sessionService.appendSessionMetadata(sessionId, { + workDir, + permissionMode: mode, + }) +} + async function restartSessionWithRuntimeConfig( ws: ServerWebSocket, sessionId: string, @@ -1815,6 +1859,9 @@ function isKnownRuntimeProviderId( } async function getRuntimeSettings(sessionId?: string): Promise { + const sessionPermissionMode = sessionId + ? await getSessionPermissionMode(sessionId) + : undefined const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined if (runtimeOverride) { if (typeof runtimeOverride.providerId === 'string') { @@ -1825,7 +1872,11 @@ async function getRuntimeSettings(sessionId?: string): Promise `[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`, ) runtimeOverrides.delete(sessionId!) - return getDefaultRuntimeSettings() + const defaults = await getDefaultRuntimeSettings() + return { + ...defaults, + permissionMode: sessionPermissionMode ?? defaults.permissionMode, + } } } @@ -1837,7 +1888,7 @@ async function getRuntimeSettings(sessionId?: string): Promise const thinking = resolveDesktopThinkingMode(userSettings) return { - permissionMode: await settingsService.getPermissionMode().catch(() => undefined), + permissionMode: sessionPermissionMode ?? await settingsService.getPermissionMode().catch(() => undefined), model: runtimeOverride.modelId, effort, thinking, @@ -1845,7 +1896,16 @@ async function getRuntimeSettings(sessionId?: string): Promise } } - return getDefaultRuntimeSettings() + const defaults = await getDefaultRuntimeSettings() + return { + ...defaults, + permissionMode: sessionPermissionMode ?? defaults.permissionMode, + } +} + +async function getSessionPermissionMode(sessionId: string): Promise { + const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null) + return launchInfo?.permissionMode } async function getDefaultRuntimeSettings(): Promise {