mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
This commit is contained in:
parent
62f1aa71e8
commit
3f4d731cc7
@ -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
|
||||
|
||||
@ -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<ReturnType<typeof useChatStore.getState>>)
|
||||
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(<PermissionModeSelector />)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -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<HTMLDivElement>(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)
|
||||
|
||||
@ -86,6 +86,7 @@ describe('NewTaskModal', () => {
|
||||
expect(createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
model: 'provider-fast',
|
||||
providerId: 'provider-a',
|
||||
permissionMode: 'bypassPermissions',
|
||||
enabled: true,
|
||||
recurring: true,
|
||||
}))
|
||||
|
||||
@ -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<string | null | undefined>(editTask?.providerId)
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>((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}
|
||||
|
||||
@ -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({
|
||||
<div className="border-t border-[var(--color-border)]/40 px-3 py-2 flex flex-col gap-2 bg-[var(--color-surface-container-low)] rounded-b-[var(--radius-lg)]">
|
||||
{/* Row 1: Permission + Model selectors */}
|
||||
<div className="flex items-center justify-between">
|
||||
<PermissionModeSelector value={permissionMode} onChange={onPermissionModeChange} workDir={folderPath || undefined} />
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full bg-[var(--color-error)]/8 px-2.5 py-1.5 text-xs font-medium text-[var(--color-error)]">
|
||||
<span className="material-symbols-outlined text-[14px]">gavel</span>
|
||||
{t('newTask.fullPermissions')}
|
||||
</div>
|
||||
<ModelSelector
|
||||
runtimeSelection={modelId ? { providerId: providerId ?? null, modelId } : undefined}
|
||||
onRuntimeSelectionChange={(selection) => {
|
||||
@ -71,13 +67,10 @@ export function PromptEditor({
|
||||
<DirectoryPicker value={folderPath} onChange={onFolderPathChange} />
|
||||
</div>
|
||||
|
||||
{/* Bypass + no folder warning */}
|
||||
{permissionMode === 'bypassPermissions' && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-[var(--color-error)]/8 text-[10px] text-[var(--color-error)]">
|
||||
<span className="material-symbols-outlined text-[12px]">warning</span>
|
||||
{t('promptEditor.bypassWarning')}{folderPath ? ` ${t('promptEditor.within')} ${folderPath}` : ` ${t('promptEditor.selectFolder')}`}.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-[var(--color-error)]/8 text-[10px] text-[var(--color-error)]">
|
||||
<span className="material-symbols-outlined text-[12px]">warning</span>
|
||||
{t('promptEditor.bypassWarning')}{folderPath ? ` ${t('promptEditor.within')} ${folderPath}` : ` ${t('promptEditor.selectFolder')}`}.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1311,6 +1311,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// ─── New Task Modal ──────────────────────────────────────
|
||||
'newTask.title': '新建定时任务',
|
||||
'newTask.localWarning': '本地任务仅在电脑唤醒时运行。',
|
||||
'newTask.fullPermissions': '所有权限',
|
||||
'newTask.name': '名称',
|
||||
'newTask.namePlaceholder': 'daily-code-review',
|
||||
'newTask.description': '描述',
|
||||
|
||||
@ -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'],
|
||||
}
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<PermissionMode>(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() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PermissionModeSelector workDir={workDir} compact={isMobileComposer} />
|
||||
<PermissionModeSelector
|
||||
workDir={workDir}
|
||||
compact={isMobileComposer}
|
||||
value={draftPermissionMode}
|
||||
onChange={setDraftPermissionMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${isMobileComposer ? 'flex min-w-0 flex-1 items-center justify-end gap-2' : 'flex items-center gap-3'}`}>
|
||||
|
||||
@ -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() {
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
|
||||
Permission Mode
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={permissionMode}
|
||||
onChange={(e) => setPermissionMode(e.target.value)}
|
||||
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm px-4 py-2.5 appearance-none cursor-pointer outline-none"
|
||||
>
|
||||
{mockNewTaskDefaults.permissionModes.map((pm) => (
|
||||
<option key={pm} value={pm}>
|
||||
{pm}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="material-symbols-outlined absolute right-3 top-2.5 pointer-events-none text-[var(--color-outline)] text-sm">
|
||||
unfold_more
|
||||
<div className="w-full bg-[var(--color-surface-container)] rounded-lg border-none text-sm px-4 py-2.5 outline-none flex items-center gap-2 text-[var(--color-on-surface)]">
|
||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-error)]">
|
||||
gavel
|
||||
</span>
|
||||
Full Access
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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() {
|
||||
<div className="w-[180px] border-r border-[var(--color-border)] py-3 flex-shrink-0 flex flex-col">
|
||||
<div className="flex-1">
|
||||
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
|
||||
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
|
||||
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
|
||||
<TabButton icon="qr_code_2" label={t('settings.tab.h5Access')} active={activeTab === 'h5Access'} onClick={() => setActiveTab('h5Access')} />
|
||||
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
|
||||
@ -174,7 +173,6 @@ export function Settings() {
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-y-auto px-8 py-6">
|
||||
{activeTab === 'providers' && <ProviderSettings />}
|
||||
{activeTab === 'permissions' && <PermissionSettings />}
|
||||
{activeTab === 'activity' && <ActivitySettings />}
|
||||
{activeTab === 'general' && <GeneralSettings />}
|
||||
{activeTab === 'h5Access' && <H5AccessSettings />}
|
||||
@ -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 (
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.permissions.title')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">{t('settings.permissions.description')}</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{MODES.map(({ mode, icon, label, desc }) => {
|
||||
const isSelected = permissionMode === mode
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setPermissionMode(mode)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${
|
||||
isSelected
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-secondary)]">{icon}</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{label}</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)]">{desc}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
check_circle
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── General Settings ──────────────────────────────────────
|
||||
|
||||
function GeneralSettings() {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -1010,6 +1010,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
|
||||
setSessionPermissionMode: (sessionId, mode) => {
|
||||
if (!get().sessions[sessionId]) return
|
||||
useSessionStore.getState().updateSessionPermissionMode(sessionId, mode)
|
||||
wsManager.send(sessionId, { type: 'set_permission_mode', mode })
|
||||
},
|
||||
|
||||
|
||||
@ -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<BranchSessionResponse, 'sessionId' | 'title' | 'workDir'>
|
||||
@ -41,6 +43,7 @@ type SessionStore = {
|
||||
clearSessionSelection: () => void
|
||||
renameSession: (id: string, title: string) => Promise<void>
|
||||
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<SessionStore>((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<SessionStore>((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<SessionStore>((set, get) => ({
|
||||
}))
|
||||
},
|
||||
|
||||
updateSessionPermissionMode: (id, mode) => {
|
||||
set((s) => ({
|
||||
sessions: s.sessions.map((session) =>
|
||||
session.id === id ? { ...session, permissionMode: mode } : session,
|
||||
),
|
||||
}))
|
||||
},
|
||||
|
||||
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||
}))
|
||||
|
||||
|
||||
@ -30,7 +30,6 @@ export type Toast = {
|
||||
|
||||
export type SettingsTab =
|
||||
| 'providers'
|
||||
| 'permissions'
|
||||
| 'activity'
|
||||
| 'general'
|
||||
| 'h5Access'
|
||||
|
||||
@ -10,6 +10,7 @@ export type SessionListItem = {
|
||||
projectRoot?: string | null
|
||||
workDir: string | null
|
||||
workDirExists: boolean
|
||||
permissionMode?: string
|
||||
}
|
||||
|
||||
export type MessageEntry = {
|
||||
|
||||
@ -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<void>((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<void>((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<void>((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({
|
||||
|
||||
@ -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<void>
|
||||
appendSessionMetadata: (...args: unknown[]) => Promise<void>
|
||||
}
|
||||
}).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')
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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<void>)(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(
|
||||
|
||||
@ -281,9 +281,9 @@ async function handleSessionWorkspaceRoute(
|
||||
}
|
||||
|
||||
async function createSession(req: Request): Promise<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
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<Respon
|
||||
}
|
||||
|
||||
const active = conversationService.hasSession(sessionId)
|
||||
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => 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<Respon
|
||||
status: {
|
||||
sessionId,
|
||||
workDir,
|
||||
permissionMode: conversationService.getSessionPermissionMode(sessionId),
|
||||
permissionMode,
|
||||
version: typeof initMessage?.claude_code_version === 'string' ? initMessage.claude_code_version : transcriptMetadata?.version,
|
||||
cwd: typeof initMessage?.cwd === 'string' ? initMessage.cwd : transcriptMetadata?.cwd ?? workDir,
|
||||
model: typeof initMessage?.model === 'string' ? initMessage.model : transcriptMetadata?.model,
|
||||
|
||||
@ -358,6 +358,7 @@ export class ConversationService {
|
||||
workDir: launchWorkDir,
|
||||
customTitle: launchInfo?.customTitle ?? null,
|
||||
repository: launchRepository,
|
||||
permissionMode: options?.permissionMode || launchInfo?.permissionMode,
|
||||
})
|
||||
}
|
||||
|
||||
@ -447,7 +448,7 @@ export class ConversationService {
|
||||
}
|
||||
|
||||
setPermissionMode(sessionId: string, mode: string): boolean {
|
||||
return this.sendSdkMessage(sessionId, {
|
||||
const sent = this.sendSdkMessage(sessionId, {
|
||||
type: 'control_request',
|
||||
request_id: crypto.randomUUID(),
|
||||
request: {
|
||||
@ -455,6 +456,11 @@ export class ConversationService {
|
||||
mode,
|
||||
},
|
||||
})
|
||||
if (sent) {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) session.permissionMode = mode
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
setMaxThinkingTokens(sessionId: string, maxThinkingTokens: number | null): boolean {
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import * as crypto from 'crypto'
|
||||
@ -453,13 +453,18 @@ export class CronScheduler {
|
||||
console.warn(`[cron] task ${task.id}: folderPath "${task.folderPath}" is not a valid directory, falling back to homedir`)
|
||||
workDir = os.homedir()
|
||||
}
|
||||
workDir = this.resolveCanonicalWorkDir(workDir)
|
||||
|
||||
// Only create a session when explicitly requested (manual "Run Now"),
|
||||
// not for automatic cron runs — avoids flooding the sidebar.
|
||||
let sessionId: string | undefined
|
||||
if (options?.createSession) {
|
||||
try {
|
||||
const result = await this.sessionService.createSession(workDir)
|
||||
const result = await this.sessionService.createSession(
|
||||
workDir,
|
||||
undefined,
|
||||
'bypassPermissions',
|
||||
)
|
||||
sessionId = result.sessionId
|
||||
// Delete the placeholder JSONL file so the CLI can create it fresh
|
||||
// with actual content. Same pattern as conversationService.ts.
|
||||
@ -596,6 +601,7 @@ export class CronScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
await this.persistScheduledSessionPermission(sessionId, workDir)
|
||||
await updateRun(completedRun)
|
||||
|
||||
// Send IM notification if configured
|
||||
@ -627,15 +633,40 @@ export class CronScheduler {
|
||||
new Date(completedAt).getTime() - new Date(startedAt).getTime(),
|
||||
}
|
||||
|
||||
await this.persistScheduledSessionPermission(sessionId, workDir)
|
||||
await updateRun(failedRun)
|
||||
|
||||
return failedRun
|
||||
}
|
||||
}
|
||||
|
||||
private async persistScheduledSessionPermission(
|
||||
sessionId: string | undefined,
|
||||
workDir: string,
|
||||
): Promise<void> {
|
||||
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(
|
||||
|
||||
@ -56,7 +56,10 @@ export class CronService {
|
||||
/** 获取所有任务 */
|
||||
async listTasks(): Promise<CronTask[]> {
|
||||
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]
|
||||
}
|
||||
|
||||
@ -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<string, unknown>
|
||||
|
||||
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<void> {
|
||||
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(),
|
||||
})
|
||||
|
||||
|
||||
@ -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<WebSocketData>,
|
||||
message: Extract<ClientMessage, { type: 'set_permission_mode' }>
|
||||
) {
|
||||
): Promise<void> {
|
||||
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<WebSocketData>,
|
||||
sessionId: string,
|
||||
mode: string,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<WebSocketData>,
|
||||
sessionId: string,
|
||||
@ -1815,6 +1859,9 @@ function isKnownRuntimeProviderId(
|
||||
}
|
||||
|
||||
async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings> {
|
||||
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<RuntimeSettings>
|
||||
`[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<RuntimeSettings>
|
||||
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<RuntimeSettings>
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultRuntimeSettings()
|
||||
const defaults = await getDefaultRuntimeSettings()
|
||||
return {
|
||||
...defaults,
|
||||
permissionMode: sessionPermissionMode ?? defaults.permissionMode,
|
||||
}
|
||||
}
|
||||
|
||||
async function getSessionPermissionMode(sessionId: string): Promise<string | undefined> {
|
||||
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
|
||||
return launchInfo?.permissionMode
|
||||
}
|
||||
|
||||
async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user