mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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 { api } from './client'
|
||||||
import type { AgentTaskNotification } from '../types/chat'
|
import type { AgentTaskNotification } from '../types/chat'
|
||||||
import type { SessionListItem, MessageEntry } from '../types/session'
|
import type { SessionListItem, MessageEntry } from '../types/session'
|
||||||
|
import type { PermissionMode } from '../types/settings'
|
||||||
|
|
||||||
type SessionsResponse = { sessions: SessionListItem[]; total: number }
|
type SessionsResponse = { sessions: SessionListItem[]; total: number }
|
||||||
type MessagesResponse = {
|
type MessagesResponse = {
|
||||||
@ -39,6 +40,7 @@ export type CreateSessionRepositoryOptions = {
|
|||||||
export type CreateSessionRequest = {
|
export type CreateSessionRequest = {
|
||||||
workDir?: string
|
workDir?: string
|
||||||
repository?: CreateSessionRepositoryOptions
|
repository?: CreateSessionRepositoryOptions
|
||||||
|
permissionMode?: PermissionMode
|
||||||
}
|
}
|
||||||
export type BranchSessionRequest = {
|
export type BranchSessionRequest = {
|
||||||
targetMessageId: string
|
targetMessageId: string
|
||||||
|
|||||||
@ -43,6 +43,7 @@ vi.mock('../../i18n', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import { PermissionModeSelector } from './PermissionModeSelector'
|
import { PermissionModeSelector } from './PermissionModeSelector'
|
||||||
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
import { useSettingsStore } from '../../stores/settingsStore'
|
import { useSettingsStore } from '../../stores/settingsStore'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
@ -55,6 +56,47 @@ describe('PermissionModeSelector', () => {
|
|||||||
useTabStore.setState({ activeTabId: null, tabs: [] })
|
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', () => {
|
it('labels the compact mobile trigger and opens a phone-sized menu sheet', () => {
|
||||||
viewportMocks.isMobile = true
|
viewportMocks.isMobile = true
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@ type Props = {
|
|||||||
export function PermissionModeSelector({ workDir: workDirProp, compact = false, value, onChange }: Props = {}) {
|
export function PermissionModeSelector({ workDir: workDirProp, compact = false, value, onChange }: Props = {}) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const isMobile = useMobileViewport() && !isTauriRuntime()
|
const isMobile = useMobileViewport() && !isTauriRuntime()
|
||||||
const { permissionMode: storeMode, setPermissionMode } = useSettingsStore()
|
const { permissionMode: storeMode } = useSettingsStore()
|
||||||
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
|
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
|
||||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||||
const sessions = useSessionStore((s) => s.sessions)
|
const sessions = useSessionStore((s) => s.sessions)
|
||||||
@ -41,8 +41,6 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
|
|||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const isControlled = value !== undefined
|
const isControlled = value !== undefined
|
||||||
const currentMode = isControlled ? value : storeMode
|
|
||||||
|
|
||||||
const PERMISSION_ITEMS: Array<{
|
const PERMISSION_ITEMS: Array<{
|
||||||
value: PermissionMode
|
value: PermissionMode
|
||||||
label: string
|
label: string
|
||||||
@ -89,6 +87,9 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
|
|||||||
const activeSession = activeTabId
|
const activeSession = activeTabId
|
||||||
? sessions.find((s) => s.id === activeTabId)
|
? sessions.find((s) => s.id === activeTabId)
|
||||||
: null
|
: null
|
||||||
|
const currentMode = isControlled
|
||||||
|
? value
|
||||||
|
: (activeSession?.permissionMode as PermissionMode | undefined) || storeMode
|
||||||
const workDir = workDirProp || activeSession?.workDir || '~'
|
const workDir = workDirProp || activeSession?.workDir || '~'
|
||||||
const compactButtonClass = compact
|
const compactButtonClass = compact
|
||||||
? isMobile
|
? isMobile
|
||||||
@ -135,7 +136,6 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
|
|||||||
if (isControlled) {
|
if (isControlled) {
|
||||||
onChange?.(item.value)
|
onChange?.(item.value)
|
||||||
} else {
|
} else {
|
||||||
void setPermissionMode(item.value)
|
|
||||||
if (activeTabId) setSessionPermissionMode(activeTabId, item.value)
|
if (activeTabId) setSessionPermissionMode(activeTabId, item.value)
|
||||||
}
|
}
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@ -267,7 +267,6 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
|
|||||||
if (isControlled) {
|
if (isControlled) {
|
||||||
onChange?.('bypassPermissions')
|
onChange?.('bypassPermissions')
|
||||||
} else {
|
} else {
|
||||||
void setPermissionMode('bypassPermissions')
|
|
||||||
if (activeTabId) setSessionPermissionMode(activeTabId, 'bypassPermissions')
|
if (activeTabId) setSessionPermissionMode(activeTabId, 'bypassPermissions')
|
||||||
}
|
}
|
||||||
setConfirmDialog(false)
|
setConfirmDialog(false)
|
||||||
|
|||||||
@ -86,6 +86,7 @@ describe('NewTaskModal', () => {
|
|||||||
expect(createTask).toHaveBeenCalledWith(expect.objectContaining({
|
expect(createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
model: 'provider-fast',
|
model: 'provider-fast',
|
||||||
providerId: 'provider-a',
|
providerId: 'provider-a',
|
||||||
|
permissionMode: 'bypassPermissions',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
recurring: true,
|
recurring: true,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { PromptEditor } from './PromptEditor'
|
|||||||
import { DayOfWeekPicker } from './DayOfWeekPicker'
|
import { DayOfWeekPicker } from './DayOfWeekPicker'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { describeCron, isValidCron, parseCron, type FrequencyKey } from '../../lib/cronDescribe'
|
import { describeCron, isValidCron, parseCron, type FrequencyKey } from '../../lib/cronDescribe'
|
||||||
import type { PermissionMode } from '../../types/settings'
|
|
||||||
import type { CronTask } from '../../types/task'
|
import type { CronTask } from '../../types/task'
|
||||||
|
|
||||||
type NotificationChannel = 'desktop' | 'telegram' | 'feishu'
|
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 [time, setTime] = useState(parsed?.time || '09:00')
|
||||||
const [model, setModel] = useState(editTask?.model || '')
|
const [model, setModel] = useState(editTask?.model || '')
|
||||||
const [providerId, setProviderId] = useState<string | null | undefined>(editTask?.providerId)
|
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 [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir)
|
||||||
const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false)
|
const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false)
|
||||||
const [notifyEnabled, setNotifyEnabled] = useState(editTask?.notification?.enabled || false)
|
const [notifyEnabled, setNotifyEnabled] = useState(editTask?.notification?.enabled || false)
|
||||||
@ -134,7 +132,7 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
|
|||||||
prompt: prompt.trim(),
|
prompt: prompt.trim(),
|
||||||
model: model || undefined,
|
model: model || undefined,
|
||||||
providerId,
|
providerId,
|
||||||
permissionMode: permissionMode !== 'default' ? permissionMode : undefined,
|
permissionMode: 'bypassPermissions',
|
||||||
folderPath: folderPath.trim() || undefined,
|
folderPath: folderPath.trim() || undefined,
|
||||||
useWorktree: useWorktree || undefined,
|
useWorktree: useWorktree || undefined,
|
||||||
notification: notifyEnabled && notifyChannels.length > 0
|
notification: notifyEnabled && notifyChannels.length > 0
|
||||||
@ -200,8 +198,6 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
|
|||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={setPrompt}
|
onChange={setPrompt}
|
||||||
placeholder={t('newTask.promptPlaceholder')}
|
placeholder={t('newTask.promptPlaceholder')}
|
||||||
permissionMode={permissionMode}
|
|
||||||
onPermissionModeChange={setPermissionMode}
|
|
||||||
modelId={model}
|
modelId={model}
|
||||||
onModelChange={setModel}
|
onModelChange={setModel}
|
||||||
providerId={providerId}
|
providerId={providerId}
|
||||||
|
|||||||
@ -1,17 +1,12 @@
|
|||||||
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
|
|
||||||
import { ModelSelector } from '../controls/ModelSelector'
|
import { ModelSelector } from '../controls/ModelSelector'
|
||||||
import { DirectoryPicker } from '../shared/DirectoryPicker'
|
import { DirectoryPicker } from '../shared/DirectoryPicker'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { PermissionMode } from '../../types/settings'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
|
||||||
permissionMode: PermissionMode
|
|
||||||
onPermissionModeChange: (mode: PermissionMode) => void
|
|
||||||
|
|
||||||
modelId: string
|
modelId: string
|
||||||
onModelChange: (modelId: string) => void
|
onModelChange: (modelId: string) => void
|
||||||
providerId?: string | null
|
providerId?: string | null
|
||||||
@ -28,8 +23,6 @@ export function PromptEditor({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
permissionMode,
|
|
||||||
onPermissionModeChange,
|
|
||||||
modelId,
|
modelId,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
providerId,
|
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)]">
|
<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 */}
|
{/* Row 1: Permission + Model selectors */}
|
||||||
<div className="flex items-center justify-between">
|
<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
|
<ModelSelector
|
||||||
runtimeSelection={modelId ? { providerId: providerId ?? null, modelId } : undefined}
|
runtimeSelection={modelId ? { providerId: providerId ?? null, modelId } : undefined}
|
||||||
onRuntimeSelectionChange={(selection) => {
|
onRuntimeSelectionChange={(selection) => {
|
||||||
@ -71,13 +67,10 @@ export function PromptEditor({
|
|||||||
<DirectoryPicker value={folderPath} onChange={onFolderPathChange} />
|
<DirectoryPicker value={folderPath} onChange={onFolderPathChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bypass + no folder warning */}
|
<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)]">
|
||||||
{permissionMode === 'bypassPermissions' && (
|
<span className="material-symbols-outlined text-[12px]">warning</span>
|
||||||
<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)]">
|
{t('promptEditor.bypassWarning')}{folderPath ? ` ${t('promptEditor.within')} ${folderPath}` : ` ${t('promptEditor.selectFolder')}`}.
|
||||||
<span className="material-symbols-outlined text-[12px]">warning</span>
|
</div>
|
||||||
{t('promptEditor.bypassWarning')}{folderPath ? ` ${t('promptEditor.within')} ${folderPath}` : ` ${t('promptEditor.selectFolder')}`}.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1309,6 +1309,7 @@ export const en = {
|
|||||||
// ─── New Task Modal ──────────────────────────────────────
|
// ─── New Task Modal ──────────────────────────────────────
|
||||||
'newTask.title': 'New scheduled task',
|
'newTask.title': 'New scheduled task',
|
||||||
'newTask.localWarning': 'Local tasks only run while your computer is awake.',
|
'newTask.localWarning': 'Local tasks only run while your computer is awake.',
|
||||||
|
'newTask.fullPermissions': 'Full permissions',
|
||||||
'newTask.name': 'Name',
|
'newTask.name': 'Name',
|
||||||
'newTask.namePlaceholder': 'daily-code-review',
|
'newTask.namePlaceholder': 'daily-code-review',
|
||||||
'newTask.description': 'Description',
|
'newTask.description': 'Description',
|
||||||
|
|||||||
@ -1311,6 +1311,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
// ─── New Task Modal ──────────────────────────────────────
|
// ─── New Task Modal ──────────────────────────────────────
|
||||||
'newTask.title': '新建定时任务',
|
'newTask.title': '新建定时任务',
|
||||||
'newTask.localWarning': '本地任务仅在电脑唤醒时运行。',
|
'newTask.localWarning': '本地任务仅在电脑唤醒时运行。',
|
||||||
|
'newTask.fullPermissions': '所有权限',
|
||||||
'newTask.name': '名称',
|
'newTask.name': '名称',
|
||||||
'newTask.namePlaceholder': 'daily-code-review',
|
'newTask.namePlaceholder': 'daily-code-review',
|
||||||
'newTask.description': '描述',
|
'newTask.description': '描述',
|
||||||
|
|||||||
@ -185,7 +185,6 @@ export const mockToolInspection = {
|
|||||||
|
|
||||||
// ─── New Task Modal ───────────────────────────────────────────────
|
// ─── New Task Modal ───────────────────────────────────────────────
|
||||||
export const mockNewTaskDefaults = {
|
export const mockNewTaskDefaults = {
|
||||||
permissionModes: ['Restricted', 'Standard', 'Full Access'],
|
|
||||||
models: ['Claude 3.5 Sonnet', 'Claude 3.5 Haiku', 'Claude 3.5 Opus'],
|
models: ['Claude 3.5 Sonnet', 'Claude 3.5 Haiku', 'Claude 3.5 Opus'],
|
||||||
frequencies: ['Hourly', 'Daily at 9:00 AM', 'Weekly', 'Monthly', 'Custom cron'],
|
frequencies: ['Hourly', 'Daily at 9:00 AM', 'Weekly', 'Monthly', 'Custom cron'],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -174,7 +174,7 @@ describe('EmptySession', () => {
|
|||||||
mocks.webviewDragHandlers.length = 0
|
mocks.webviewDragHandlers.length = 0
|
||||||
mocks.isMobile = false
|
mocks.isMobile = false
|
||||||
mocks.isTauriRuntime = false
|
mocks.isTauriRuntime = false
|
||||||
useSettingsStore.setState({ locale: 'en', activeProviderName: null })
|
useSettingsStore.setState({ locale: 'en', activeProviderName: null, permissionMode: 'default' })
|
||||||
useSessionStore.setState(initialSessionState, true)
|
useSessionStore.setState(initialSessionState, true)
|
||||||
useChatStore.setState(initialChatState, true)
|
useChatStore.setState(initialChatState, true)
|
||||||
useTabStore.setState(initialTabState, true)
|
useTabStore.setState(initialTabState, true)
|
||||||
@ -354,6 +354,7 @@ describe('EmptySession', () => {
|
|||||||
expect(mocks.createSession).toHaveBeenCalledWith({
|
expect(mocks.createSession).toHaveBeenCalledWith({
|
||||||
workDir: '/workspace/project',
|
workDir: '/workspace/project',
|
||||||
repository: { branch: 'main', worktree: false },
|
repository: { branch: 'main', worktree: false },
|
||||||
|
permissionMode: 'default',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -394,7 +395,7 @@ describe('EmptySession', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mocks.createSession).toHaveBeenCalledWith({})
|
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(useSessionRuntimeStore.getState().selections['draft-session']).toEqual({
|
expect(useSessionRuntimeStore.getState().selections['draft-session']).toEqual({
|
||||||
@ -436,7 +437,7 @@ describe('EmptySession', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mocks.createSession).toHaveBeenCalledWith({})
|
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
|
||||||
})
|
})
|
||||||
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
|
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
|
||||||
type: 'user_message',
|
type: 'user_message',
|
||||||
@ -488,7 +489,7 @@ describe('EmptySession', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mocks.createSession).toHaveBeenCalledWith({})
|
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
|
||||||
})
|
})
|
||||||
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
|
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
|
||||||
type: 'user_message',
|
type: 'user_message',
|
||||||
@ -563,6 +564,7 @@ describe('EmptySession', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mocks.createSession).toHaveBeenCalledWith({
|
expect(mocks.createSession).toHaveBeenCalledWith({
|
||||||
workDir: '/workspace/project',
|
workDir: '/workspace/project',
|
||||||
|
permissionMode: 'default',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -668,6 +670,7 @@ describe('EmptySession', () => {
|
|||||||
expect(mocks.createSession).toHaveBeenCalledWith({
|
expect(mocks.createSession).toHaveBeenCalledWith({
|
||||||
workDir: '/workspace/project',
|
workDir: '/workspace/project',
|
||||||
repository: { branch: 'main', worktree: false },
|
repository: { branch: 'main', worktree: false },
|
||||||
|
permissionMode: 'default',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -759,6 +762,7 @@ describe('EmptySession', () => {
|
|||||||
expect(mocks.createSession).toHaveBeenCalledWith({
|
expect(mocks.createSession).toHaveBeenCalledWith({
|
||||||
workDir: '/workspace/project',
|
workDir: '/workspace/project',
|
||||||
repository: { branch: 'main', worktree: false },
|
repository: { branch: 'main', worktree: false },
|
||||||
|
permissionMode: 'default',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import {
|
|||||||
resolveSlashUiAction,
|
resolveSlashUiAction,
|
||||||
} from '../components/chat/composerUtils'
|
} from '../components/chat/composerUtils'
|
||||||
import type { AttachmentRef } from '../types/chat'
|
import type { AttachmentRef } from '../types/chat'
|
||||||
|
import type { PermissionMode } from '../types/settings'
|
||||||
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
||||||
|
|
||||||
type Attachment = ComposerAttachment
|
type Attachment = ComposerAttachment
|
||||||
@ -110,6 +111,8 @@ export function EmptySession() {
|
|||||||
const addToast = useUIStore((state) => state.addToast)
|
const addToast = useUIStore((state) => state.addToast)
|
||||||
const currentModel = useSettingsStore((state) => state.currentModel)
|
const currentModel = useSettingsStore((state) => state.currentModel)
|
||||||
const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior)
|
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 lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary)
|
||||||
const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY])
|
const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY])
|
||||||
const draftRuntimeSelectionKey = draftRuntimeSelection
|
const draftRuntimeSelectionKey = draftRuntimeSelection
|
||||||
@ -275,9 +278,12 @@ export function EmptySession() {
|
|||||||
const explicitDraftSelection = useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY]
|
const explicitDraftSelection = useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY]
|
||||||
const sessionId = await createSession(
|
const sessionId = await createSession(
|
||||||
workDir || undefined,
|
workDir || undefined,
|
||||||
selectedBranch
|
{
|
||||||
? { repository: { branch: selectedBranch, worktree: useWorktree } }
|
...(selectedBranch
|
||||||
: undefined,
|
? { repository: { branch: selectedBranch, worktree: useWorktree } }
|
||||||
|
: {}),
|
||||||
|
permissionMode: draftPermissionMode,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if (explicitDraftSelection) {
|
if (explicitDraftSelection) {
|
||||||
useSessionRuntimeStore.getState().setSelection(sessionId, explicitDraftSelection)
|
useSessionRuntimeStore.getState().setSelection(sessionId, explicitDraftSelection)
|
||||||
@ -731,7 +737,12 @@ export function EmptySession() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PermissionModeSelector workDir={workDir} compact={isMobileComposer} />
|
<PermissionModeSelector
|
||||||
|
workDir={workDir}
|
||||||
|
compact={isMobileComposer}
|
||||||
|
value={draftPermissionMode}
|
||||||
|
onChange={setDraftPermissionMode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${isMobileComposer ? 'flex min-w-0 flex-1 items-center justify-end gap-2' : 'flex items-center gap-3'}`}>
|
<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 [taskName, setTaskName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [systemPrompt, setSystemPrompt] = useState('')
|
const [systemPrompt, setSystemPrompt] = useState('')
|
||||||
const [permissionMode, setPermissionMode] = useState(mockNewTaskDefaults.permissionModes[0])
|
|
||||||
const [model, setModel] = useState(mockNewTaskDefaults.models[0])
|
const [model, setModel] = useState(mockNewTaskDefaults.models[0])
|
||||||
const [rootFolder, setRootFolder] = useState('')
|
const [rootFolder, setRootFolder] = useState('')
|
||||||
const [frequency, setFrequency] = useState(mockNewTaskDefaults.frequencies[1])
|
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">
|
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
|
||||||
Permission Mode
|
Permission Mode
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<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)]">
|
||||||
<select
|
<span className="material-symbols-outlined text-[16px] text-[var(--color-error)]">
|
||||||
value={permissionMode}
|
gavel
|
||||||
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
|
|
||||||
</span>
|
</span>
|
||||||
|
Full Access
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog'
|
|||||||
import { Input } from '../components/shared/Input'
|
import { Input } from '../components/shared/Input'
|
||||||
import { Button } from '../components/shared/Button'
|
import { Button } from '../components/shared/Button'
|
||||||
import { Dropdown } from '../components/shared/Dropdown'
|
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 { Locale } from '../i18n'
|
||||||
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider'
|
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider'
|
||||||
import type { ProviderPreset } from '../types/providerPreset'
|
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="w-[180px] border-r border-[var(--color-border)] py-3 flex-shrink-0 flex flex-col">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
|
<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="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="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')} />
|
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
|
||||||
@ -174,7 +173,6 @@ export function Settings() {
|
|||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
<div className="flex-1 overflow-y-auto px-8 py-6">
|
<div className="flex-1 overflow-y-auto px-8 py-6">
|
||||||
{activeTab === 'providers' && <ProviderSettings />}
|
{activeTab === 'providers' && <ProviderSettings />}
|
||||||
{activeTab === 'permissions' && <PermissionSettings />}
|
|
||||||
{activeTab === 'activity' && <ActivitySettings />}
|
{activeTab === 'activity' && <ActivitySettings />}
|
||||||
{activeTab === 'general' && <GeneralSettings />}
|
{activeTab === 'general' && <GeneralSettings />}
|
||||||
{activeTab === 'h5Access' && <H5AccessSettings />}
|
{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 ──────────────────────────────────────
|
// ─── General Settings ──────────────────────────────────────
|
||||||
|
|
||||||
function GeneralSettings() {
|
function GeneralSettings() {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const {
|
|||||||
updateTabTitleMock,
|
updateTabTitleMock,
|
||||||
updateTabStatusMock,
|
updateTabStatusMock,
|
||||||
updateSessionTitleMock,
|
updateSessionTitleMock,
|
||||||
|
updateSessionPermissionModeMock,
|
||||||
sessionStoreSnapshot,
|
sessionStoreSnapshot,
|
||||||
cliTaskStoreSnapshot,
|
cliTaskStoreSnapshot,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
@ -38,6 +39,7 @@ const {
|
|||||||
updateTabTitleMock: vi.fn(),
|
updateTabTitleMock: vi.fn(),
|
||||||
updateTabStatusMock: vi.fn(),
|
updateTabStatusMock: vi.fn(),
|
||||||
updateSessionTitleMock: vi.fn(),
|
updateSessionTitleMock: vi.fn(),
|
||||||
|
updateSessionPermissionModeMock: vi.fn(),
|
||||||
sessionStoreSnapshot: {
|
sessionStoreSnapshot: {
|
||||||
sessions: [] as Array<{
|
sessions: [] as Array<{
|
||||||
id: string
|
id: string
|
||||||
@ -103,6 +105,7 @@ vi.mock('./sessionStore', () => ({
|
|||||||
getState: () => ({
|
getState: () => ({
|
||||||
sessions: sessionStoreSnapshot.sessions,
|
sessions: sessionStoreSnapshot.sessions,
|
||||||
updateSessionTitle: updateSessionTitleMock,
|
updateSessionTitle: updateSessionTitleMock,
|
||||||
|
updateSessionPermissionMode: updateSessionPermissionModeMock,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@ -1787,6 +1790,7 @@ describe('chatStore history mapping', () => {
|
|||||||
type: 'set_permission_mode',
|
type: 'set_permission_mode',
|
||||||
mode: 'acceptEdits',
|
mode: 'acceptEdits',
|
||||||
})
|
})
|
||||||
|
expect(updateSessionPermissionModeMock).toHaveBeenCalledWith('session-1', 'acceptEdits')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stores terminal task notifications for agent tool cards', () => {
|
it('stores terminal task notifications for agent tool cards', () => {
|
||||||
|
|||||||
@ -1010,6 +1010,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
|
|
||||||
setSessionPermissionMode: (sessionId, mode) => {
|
setSessionPermissionMode: (sessionId, mode) => {
|
||||||
if (!get().sessions[sessionId]) return
|
if (!get().sessions[sessionId]) return
|
||||||
|
useSessionStore.getState().updateSessionPermissionMode(sessionId, mode)
|
||||||
wsManager.send(sessionId, { type: 'set_permission_mode', mode })
|
wsManager.send(sessionId, { type: 'set_permission_mode', mode })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -8,10 +8,12 @@ import {
|
|||||||
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||||
import { useTabStore } from './tabStore'
|
import { useTabStore } from './tabStore'
|
||||||
import type { SessionListItem } from '../types/session'
|
import type { SessionListItem } from '../types/session'
|
||||||
|
import type { PermissionMode } from '../types/settings'
|
||||||
import { isPlaceholderSessionTitle } from '../lib/sessionTitle'
|
import { isPlaceholderSessionTitle } from '../lib/sessionTitle'
|
||||||
|
|
||||||
type CreateSessionOptions = {
|
type CreateSessionOptions = {
|
||||||
repository?: CreateSessionRepositoryOptions
|
repository?: CreateSessionRepositoryOptions
|
||||||
|
permissionMode?: PermissionMode
|
||||||
}
|
}
|
||||||
|
|
||||||
type BranchSessionResult = Pick<BranchSessionResponse, 'sessionId' | 'title' | 'workDir'>
|
type BranchSessionResult = Pick<BranchSessionResponse, 'sessionId' | 'title' | 'workDir'>
|
||||||
@ -41,6 +43,7 @@ type SessionStore = {
|
|||||||
clearSessionSelection: () => void
|
clearSessionSelection: () => void
|
||||||
renameSession: (id: string, title: string) => Promise<void>
|
renameSession: (id: string, title: string) => Promise<void>
|
||||||
updateSessionTitle: (id: string, title: string) => void
|
updateSessionTitle: (id: string, title: string) => void
|
||||||
|
updateSessionPermissionMode: (id: string, mode: PermissionMode) => void
|
||||||
setActiveSession: (id: string | null) => 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({
|
const { sessionId: id, workDir: resolvedWorkDir } = await sessionsApi.create({
|
||||||
...(workDir ? { workDir } : {}),
|
...(workDir ? { workDir } : {}),
|
||||||
...(options?.repository ? { repository: options.repository } : {}),
|
...(options?.repository ? { repository: options.repository } : {}),
|
||||||
|
...(options?.permissionMode ? { permissionMode: options.permissionMode } : {}),
|
||||||
})
|
})
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const optimisticSession: SessionListItem = {
|
const optimisticSession: SessionListItem = {
|
||||||
@ -95,6 +99,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
|||||||
workDir: resolvedWorkDir ?? workDir ?? null,
|
workDir: resolvedWorkDir ?? workDir ?? null,
|
||||||
projectRoot: resolvedWorkDir ?? workDir ?? null,
|
projectRoot: resolvedWorkDir ?? workDir ?? null,
|
||||||
workDirExists: true,
|
workDirExists: true,
|
||||||
|
permissionMode: options?.permissionMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
set((state) => ({
|
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 }),
|
setActiveSession: (id) => set({ activeSessionId: id }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,6 @@ export type Toast = {
|
|||||||
|
|
||||||
export type SettingsTab =
|
export type SettingsTab =
|
||||||
| 'providers'
|
| 'providers'
|
||||||
| 'permissions'
|
|
||||||
| 'activity'
|
| 'activity'
|
||||||
| 'general'
|
| 'general'
|
||||||
| 'h5Access'
|
| 'h5Access'
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export type SessionListItem = {
|
|||||||
projectRoot?: string | null
|
projectRoot?: string | null
|
||||||
workDir: string | null
|
workDir: string | null
|
||||||
workDirExists: boolean
|
workDirExists: boolean
|
||||||
|
permissionMode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageEntry = {
|
export type MessageEntry = {
|
||||||
|
|||||||
@ -2365,6 +2365,202 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
}
|
}
|
||||||
}, 20_000)
|
}, 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 () => {
|
it('should ignore stale persisted runtime provider ids when resuming old sessions', async () => {
|
||||||
const providerService = new ProviderService()
|
const providerService = new ProviderService()
|
||||||
const activeProvider = await providerService.addProvider({
|
const activeProvider = await providerService.addProvider({
|
||||||
|
|||||||
@ -330,6 +330,74 @@ describe('cron scheduler launcher resolution', () => {
|
|||||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli')
|
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 () => {
|
unixOnly('executeTask inherits exported terminal shell variables', async () => {
|
||||||
const appRoot = path.join(tmpDir, 'app-root')
|
const appRoot = path.join(tmpDir, 'app-root')
|
||||||
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
|
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.cron).toBe('0 9 * * 1-5')
|
||||||
expect(data.task.prompt).toContain('git log')
|
expect(data.task.prompt).toContain('git log')
|
||||||
expect(data.task.recurring).toBe(true)
|
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.model).toBe('claude-sonnet-4-6')
|
||||||
expect(data.task.createdAt).toBeGreaterThan(0)
|
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 () => {
|
it('should remove stale placeholder files after native CLI worktree startup', async () => {
|
||||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||||
const sourceFile = await writeSessionFile('-tmp-source', sessionId, [
|
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 () => {
|
it('GET /api/sessions/repository-context should return branch launch metadata', async () => {
|
||||||
const workDir = await createCleanGitRepo(tmpDir)
|
const workDir = await createCleanGitRepo(tmpDir)
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
|
|||||||
@ -281,9 +281,9 @@ async function handleSessionWorkspaceRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createSession(req: Request): Promise<Response> {
|
async function createSession(req: Request): Promise<Response> {
|
||||||
let body: { workDir?: string; repository?: CreateSessionRepositoryOptions }
|
let body: { workDir?: string; repository?: CreateSessionRepositoryOptions; permissionMode?: string }
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions }
|
body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions; permissionMode?: string }
|
||||||
} catch {
|
} catch {
|
||||||
throw ApiError.badRequest('Invalid JSON body')
|
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')
|
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 !== undefined) {
|
||||||
if (!body.repository || typeof body.repository !== 'object' || Array.isArray(body.repository)) {
|
if (!body.repository || typeof body.repository !== 'object' || Array.isArray(body.repository)) {
|
||||||
throw ApiError.badRequest('repository must be an object')
|
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
|
recentProjectsCache = null
|
||||||
return Response.json(result, { status: 201 })
|
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 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) ??
|
const initMessage = conversationService.getSessionInitMessage(sessionId) ??
|
||||||
[...conversationService.getRecentSdkMessages(sessionId)]
|
[...conversationService.getRecentSdkMessages(sessionId)]
|
||||||
.reverse()
|
.reverse()
|
||||||
@ -518,7 +526,7 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
|
|||||||
status: {
|
status: {
|
||||||
sessionId,
|
sessionId,
|
||||||
workDir,
|
workDir,
|
||||||
permissionMode: conversationService.getSessionPermissionMode(sessionId),
|
permissionMode,
|
||||||
version: typeof initMessage?.claude_code_version === 'string' ? initMessage.claude_code_version : transcriptMetadata?.version,
|
version: typeof initMessage?.claude_code_version === 'string' ? initMessage.claude_code_version : transcriptMetadata?.version,
|
||||||
cwd: typeof initMessage?.cwd === 'string' ? initMessage.cwd : transcriptMetadata?.cwd ?? workDir,
|
cwd: typeof initMessage?.cwd === 'string' ? initMessage.cwd : transcriptMetadata?.cwd ?? workDir,
|
||||||
model: typeof initMessage?.model === 'string' ? initMessage.model : transcriptMetadata?.model,
|
model: typeof initMessage?.model === 'string' ? initMessage.model : transcriptMetadata?.model,
|
||||||
|
|||||||
@ -358,6 +358,7 @@ export class ConversationService {
|
|||||||
workDir: launchWorkDir,
|
workDir: launchWorkDir,
|
||||||
customTitle: launchInfo?.customTitle ?? null,
|
customTitle: launchInfo?.customTitle ?? null,
|
||||||
repository: launchRepository,
|
repository: launchRepository,
|
||||||
|
permissionMode: options?.permissionMode || launchInfo?.permissionMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,7 +448,7 @@ export class ConversationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPermissionMode(sessionId: string, mode: string): boolean {
|
setPermissionMode(sessionId: string, mode: string): boolean {
|
||||||
return this.sendSdkMessage(sessionId, {
|
const sent = this.sendSdkMessage(sessionId, {
|
||||||
type: 'control_request',
|
type: 'control_request',
|
||||||
request_id: crypto.randomUUID(),
|
request_id: crypto.randomUUID(),
|
||||||
request: {
|
request: {
|
||||||
@ -455,6 +456,11 @@ export class ConversationService {
|
|||||||
mode,
|
mode,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if (sent) {
|
||||||
|
const session = this.sessions.get(sessionId)
|
||||||
|
if (session) session.permissionMode = mode
|
||||||
|
}
|
||||||
|
return sent
|
||||||
}
|
}
|
||||||
|
|
||||||
setMaxThinkingTokens(sessionId: string, maxThinkingTokens: number | null): boolean {
|
setMaxThinkingTokens(sessionId: string, maxThinkingTokens: number | null): boolean {
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs/promises'
|
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 path from 'path'
|
||||||
import * as os from 'os'
|
import * as os from 'os'
|
||||||
import * as crypto from 'crypto'
|
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`)
|
console.warn(`[cron] task ${task.id}: folderPath "${task.folderPath}" is not a valid directory, falling back to homedir`)
|
||||||
workDir = os.homedir()
|
workDir = os.homedir()
|
||||||
}
|
}
|
||||||
|
workDir = this.resolveCanonicalWorkDir(workDir)
|
||||||
|
|
||||||
// Only create a session when explicitly requested (manual "Run Now"),
|
// Only create a session when explicitly requested (manual "Run Now"),
|
||||||
// not for automatic cron runs — avoids flooding the sidebar.
|
// not for automatic cron runs — avoids flooding the sidebar.
|
||||||
let sessionId: string | undefined
|
let sessionId: string | undefined
|
||||||
if (options?.createSession) {
|
if (options?.createSession) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sessionService.createSession(workDir)
|
const result = await this.sessionService.createSession(
|
||||||
|
workDir,
|
||||||
|
undefined,
|
||||||
|
'bypassPermissions',
|
||||||
|
)
|
||||||
sessionId = result.sessionId
|
sessionId = result.sessionId
|
||||||
// Delete the placeholder JSONL file so the CLI can create it fresh
|
// Delete the placeholder JSONL file so the CLI can create it fresh
|
||||||
// with actual content. Same pattern as conversationService.ts.
|
// with actual content. Same pattern as conversationService.ts.
|
||||||
@ -596,6 +601,7 @@ export class CronScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.persistScheduledSessionPermission(sessionId, workDir)
|
||||||
await updateRun(completedRun)
|
await updateRun(completedRun)
|
||||||
|
|
||||||
// Send IM notification if configured
|
// Send IM notification if configured
|
||||||
@ -627,15 +633,40 @@ export class CronScheduler {
|
|||||||
new Date(completedAt).getTime() - new Date(startedAt).getTime(),
|
new Date(completedAt).getTime() - new Date(startedAt).getTime(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.persistScheduledSessionPermission(sessionId, workDir)
|
||||||
await updateRun(failedRun)
|
await updateRun(failedRun)
|
||||||
|
|
||||||
return 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[] {
|
private getRuntimeArgs(task: CronTask): string[] {
|
||||||
const model = task.model?.trim()
|
const model = task.model?.trim()
|
||||||
return model ? ['--model', model] : []
|
return [
|
||||||
|
...(model ? ['--model', model] : []),
|
||||||
|
'--dangerously-skip-permissions',
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildTaskChildEnv(
|
private async buildTaskChildEnv(
|
||||||
|
|||||||
@ -56,7 +56,10 @@ export class CronService {
|
|||||||
/** 获取所有任务 */
|
/** 获取所有任务 */
|
||||||
async listTasks(): Promise<CronTask[]> {
|
async listTasks(): Promise<CronTask[]> {
|
||||||
const data = await this.readTasksFile()
|
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 data = await this.readTasksFile()
|
||||||
const newTask: CronTask = {
|
const newTask: CronTask = {
|
||||||
...task,
|
...task,
|
||||||
|
permissionMode: 'bypassPermissions',
|
||||||
id: crypto.randomBytes(4).toString('hex'),
|
id: crypto.randomBytes(4).toString('hex'),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
}
|
}
|
||||||
@ -88,7 +92,11 @@ export class CronService {
|
|||||||
|
|
||||||
// 不允许修改 id 和 createdAt
|
// 不允许修改 id 和 createdAt
|
||||||
const { id: _id, createdAt: _ca, ...safeUpdates } = updates
|
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)
|
await this.writeTasksFile(data)
|
||||||
return data.tasks[index]
|
return data.tasks[index]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ export type SessionListItem = {
|
|||||||
projectRoot: string | null
|
projectRoot: string | null
|
||||||
workDir: string | null
|
workDir: string | null
|
||||||
workDirExists: boolean
|
workDirExists: boolean
|
||||||
|
permissionMode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeleteSessionFailure = {
|
export type DeleteSessionFailure = {
|
||||||
@ -68,6 +69,7 @@ export type SessionLaunchInfo = {
|
|||||||
worktreeSession?: PersistedWorktreeSession | null
|
worktreeSession?: PersistedWorktreeSession | null
|
||||||
transcriptMessageCount: number
|
transcriptMessageCount: number
|
||||||
customTitle: string | null
|
customTitle: string | null
|
||||||
|
permissionMode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TrimSessionResult = {
|
export type TrimSessionResult = {
|
||||||
@ -200,6 +202,7 @@ type RawEntry = {
|
|||||||
timestamp?: string
|
timestamp?: string
|
||||||
}
|
}
|
||||||
customTitle?: string
|
customTitle?: string
|
||||||
|
permissionMode?: string
|
||||||
worktreeSession?: PersistedWorktreeSession | null
|
worktreeSession?: PersistedWorktreeSession | null
|
||||||
title?: string
|
title?: string
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
@ -217,6 +220,14 @@ type PersistedWorktreeSession = {
|
|||||||
hookBased?: boolean
|
hookBased?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_SESSION_PERMISSION_MODES = new Set([
|
||||||
|
'default',
|
||||||
|
'acceptEdits',
|
||||||
|
'plan',
|
||||||
|
'bypassPermissions',
|
||||||
|
'dontAsk',
|
||||||
|
])
|
||||||
|
|
||||||
type ContentBlock = Record<string, unknown>
|
type ContentBlock = Record<string, unknown>
|
||||||
|
|
||||||
const USER_INTERRUPTION_TEXTS = new Set([
|
const USER_INTERRUPTION_TEXTS = new Set([
|
||||||
@ -317,6 +328,21 @@ export class SessionService {
|
|||||||
return undefined
|
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 {
|
private resolveWorktreeSessionFromEntries(entries: RawEntry[]): PersistedWorktreeSession | null | undefined {
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
const entry = entries[i]
|
const entry = entries[i]
|
||||||
@ -1308,6 +1334,7 @@ export class SessionService {
|
|||||||
try {
|
try {
|
||||||
const entries = await this.readJsonlFile(filePath)
|
const entries = await this.readJsonlFile(filePath)
|
||||||
const workDir = this.resolveWorkDirFromEntries(entries, projectDir)
|
const workDir = this.resolveWorkDirFromEntries(entries, projectDir)
|
||||||
|
const permissionMode = this.resolvePermissionModeFromEntries(entries)
|
||||||
const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir)
|
const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir)
|
||||||
const workDirExists = await this.pathExists(workDir)
|
const workDirExists = await this.pathExists(workDir)
|
||||||
|
|
||||||
@ -1337,6 +1364,7 @@ export class SessionService {
|
|||||||
projectRoot,
|
projectRoot,
|
||||||
workDir,
|
workDir,
|
||||||
workDirExists,
|
workDirExists,
|
||||||
|
permissionMode,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Skip unreadable files
|
// Skip unreadable files
|
||||||
@ -1365,6 +1393,7 @@ export class SessionService {
|
|||||||
)
|
)
|
||||||
const title = this.extractTitle(entries)
|
const title = this.extractTitle(entries)
|
||||||
const workDir = this.resolveWorkDirFromEntries(entries, projectDir)
|
const workDir = this.resolveWorkDirFromEntries(entries, projectDir)
|
||||||
|
const permissionMode = this.resolvePermissionModeFromEntries(entries)
|
||||||
const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir)
|
const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir)
|
||||||
const workDirExists = await this.pathExists(workDir)
|
const workDirExists = await this.pathExists(workDir)
|
||||||
|
|
||||||
@ -1386,6 +1415,7 @@ export class SessionService {
|
|||||||
projectRoot,
|
projectRoot,
|
||||||
workDir,
|
workDir,
|
||||||
workDirExists,
|
workDirExists,
|
||||||
|
permissionMode,
|
||||||
messages,
|
messages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1413,6 +1443,7 @@ export class SessionService {
|
|||||||
async createSession(
|
async createSession(
|
||||||
workDir?: string,
|
workDir?: string,
|
||||||
repositoryOptions?: CreateSessionRepositoryOptions,
|
repositoryOptions?: CreateSessionRepositoryOptions,
|
||||||
|
permissionMode?: string,
|
||||||
): Promise<{ sessionId: string; workDir: string }> {
|
): Promise<{ sessionId: string; workDir: string }> {
|
||||||
// Default to user home directory when no workDir specified
|
// Default to user home directory when no workDir specified
|
||||||
const resolvedWorkDir = workDir || os.homedir()
|
const resolvedWorkDir = workDir || os.homedir()
|
||||||
@ -1464,6 +1495,9 @@ export class SessionService {
|
|||||||
isMeta: true,
|
isMeta: true,
|
||||||
workDir: absWorkDir,
|
workDir: absWorkDir,
|
||||||
repository: preparedWorkspace.repository,
|
repository: preparedWorkspace.repository,
|
||||||
|
...(permissionMode && VALID_SESSION_PERMISSION_MODES.has(permissionMode)
|
||||||
|
? { permissionMode }
|
||||||
|
: {}),
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1603,6 +1637,7 @@ export class SessionService {
|
|||||||
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd()
|
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd()
|
||||||
const repository = this.resolveRepositoryFromEntries(entries)
|
const repository = this.resolveRepositoryFromEntries(entries)
|
||||||
const worktreeSession = this.resolveWorktreeSessionFromEntries(entries)
|
const worktreeSession = this.resolveWorktreeSessionFromEntries(entries)
|
||||||
|
const permissionMode = this.resolvePermissionModeFromEntries(entries)
|
||||||
let customTitle: string | null = null
|
let customTitle: string | null = null
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@ -1620,6 +1655,7 @@ export class SessionService {
|
|||||||
worktreeSession,
|
worktreeSession,
|
||||||
transcriptMessageCount,
|
transcriptMessageCount,
|
||||||
customTitle,
|
customTitle,
|
||||||
|
permissionMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1682,6 +1718,7 @@ export class SessionService {
|
|||||||
workDir: string
|
workDir: string
|
||||||
customTitle?: string | null
|
customTitle?: string | null
|
||||||
repository?: PreparedSessionWorkspace['repository']
|
repository?: PreparedSessionWorkspace['repository']
|
||||||
|
permissionMode?: string
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const matches = await this.findSessionFiles(sessionId)
|
const matches = await this.findSessionFiles(sessionId)
|
||||||
@ -1708,6 +1745,9 @@ export class SessionService {
|
|||||||
isMeta: true,
|
isMeta: true,
|
||||||
workDir: normalizedWorkDir,
|
workDir: normalizedWorkDir,
|
||||||
repository,
|
repository,
|
||||||
|
...(metadata.permissionMode && VALID_SESSION_PERMISSION_MODES.has(metadata.permissionMode)
|
||||||
|
? { permissionMode: metadata.permissionMode }
|
||||||
|
: {}),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -192,7 +192,7 @@ export const handleWebSocket = {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'set_permission_mode':
|
case 'set_permission_mode':
|
||||||
handleSetPermissionMode(ws, message)
|
void handleSetPermissionMode(ws, message)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'set_runtime_config':
|
case 'set_runtime_config':
|
||||||
@ -479,11 +479,38 @@ function handleComputerUsePermissionResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSetPermissionMode(
|
async function handleSetPermissionMode(
|
||||||
ws: ServerWebSocket<WebSocketData>,
|
ws: ServerWebSocket<WebSocketData>,
|
||||||
message: Extract<ClientMessage, { type: 'set_permission_mode' }>
|
message: Extract<ClientMessage, { type: 'set_permission_mode' }>
|
||||||
) {
|
): Promise<void> {
|
||||||
const { sessionId } = ws.data
|
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
|
// Switching to/from bypassPermissions requires the CLI to be (re)started with
|
||||||
// --dangerously-skip-permissions. The CLI rejects a runtime set_permission_mode
|
// --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
|
// sending the SDK message (which would silently fail), restart the CLI subprocess
|
||||||
// with the correct arguments so the new permission mode takes effect.
|
// with the correct arguments so the new permission mode takes effect.
|
||||||
const needsRestart =
|
const needsRestart =
|
||||||
conversationService.hasSession(sessionId) &&
|
mode === 'bypassPermissions' || currentMode === 'bypassPermissions'
|
||||||
(message.mode === 'bypassPermissions' || conversationService.getSessionPermissionMode(sessionId) === 'bypassPermissions')
|
|
||||||
|
|
||||||
if (needsRestart) {
|
if (needsRestart) {
|
||||||
void enqueueRuntimeTransition(sessionId, () =>
|
void enqueueRuntimeTransition(sessionId, () =>
|
||||||
restartSessionWithPermissionMode(ws, sessionId, message.mode),
|
restartSessionWithPermissionMode(ws, sessionId, mode),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = conversationService.setPermissionMode(sessionId, message.mode)
|
const ok = conversationService.setPermissionMode(sessionId, mode)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
console.warn(`[WS] Ignored permission mode update for inactive session ${sessionId}`)
|
console.warn(`[WS] Ignored permission mode update for inactive session ${sessionId}`)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
await persistSessionPermissionMode(sessionId, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSetRuntimeConfig(
|
async function handleSetRuntimeConfig(
|
||||||
@ -567,13 +595,11 @@ async function restartSessionWithPermissionMode(
|
|||||||
mode: string,
|
mode: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Persist the new mode first so it's read on restart
|
|
||||||
await settingsService.setPermissionMode(mode)
|
|
||||||
|
|
||||||
const workDir = conversationService.getSessionWorkDir(sessionId)
|
const workDir = conversationService.getSessionWorkDir(sessionId)
|
||||||
|
await persistSessionPermissionMode(sessionId, mode, workDir)
|
||||||
conversationService.stopSession(sessionId)
|
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 runtimeSettings = await getRuntimeSettings(sessionId)
|
||||||
const sdkUrl =
|
const sdkUrl =
|
||||||
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
|
`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(
|
async function restartSessionWithRuntimeConfig(
|
||||||
ws: ServerWebSocket<WebSocketData>,
|
ws: ServerWebSocket<WebSocketData>,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@ -1815,6 +1859,9 @@ function isKnownRuntimeProviderId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings> {
|
async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings> {
|
||||||
|
const sessionPermissionMode = sessionId
|
||||||
|
? await getSessionPermissionMode(sessionId)
|
||||||
|
: undefined
|
||||||
const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined
|
const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined
|
||||||
if (runtimeOverride) {
|
if (runtimeOverride) {
|
||||||
if (typeof runtimeOverride.providerId === 'string') {
|
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}`,
|
`[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`,
|
||||||
)
|
)
|
||||||
runtimeOverrides.delete(sessionId!)
|
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)
|
const thinking = resolveDesktopThinkingMode(userSettings)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
permissionMode: await settingsService.getPermissionMode().catch(() => undefined),
|
permissionMode: sessionPermissionMode ?? await settingsService.getPermissionMode().catch(() => undefined),
|
||||||
model: runtimeOverride.modelId,
|
model: runtimeOverride.modelId,
|
||||||
effort,
|
effort,
|
||||||
thinking,
|
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> {
|
async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user