feat(desktop): add profile-aware approval mode control

This commit is contained in:
Luigi Razon 2026-07-12 19:42:44 -07:00 committed by Teknium
parent dfeedf613d
commit 3510b18814
23 changed files with 713 additions and 74 deletions

View file

@ -313,6 +313,7 @@ export function useGatewayBoot({
applyDesktopBootProgress(payload)
})
void desktop
.getBootProgress()
.then(snapshot => applyDesktopBootProgress(snapshot))
@ -356,7 +357,8 @@ export function useGatewayBoot({
}
})
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
const sourceProfile = normalizeProfileKey($activeGatewayProfile.get())
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile }))
// Wake signals: power resume (macOS/Windows), network coming back, and the
// window regaining focus/visibility. Each nudges an immediate reconnect.

View file

@ -0,0 +1,104 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
import { $activeGatewayProfile } from '@/store/profile'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
const ACTIVE_SID = 'session-active'
let handleEvent: ((event: RpcEvent) => void) | null = null
function Harness() {
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
describe('live session.info approval mode reconciliation', () => {
beforeEach(() => {
handleEvent = null
$approvalModes.set({})
$activeGatewayProfile.set('work')
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('reconciles an active-session event under its source gateway profile', async () => {
await mountStream()
act(() =>
handleEvent!({
payload: { approval_mode: 'off' },
profile: 'work',
session_id: ACTIVE_SID,
type: 'session.info'
})
)
expect(approvalModeForProfile('work')).toBe('off')
expect(approvalModeForProfile('default')).toBe('smart')
})
it('ignores stale session.info from a non-active session on the active gateway', async () => {
await mountStream()
act(() =>
handleEvent!({
payload: { approval_mode: 'off' },
profile: 'work',
session_id: 'session-stale',
type: 'session.info'
})
)
expect(approvalModeForProfile('work')).toBe('smart')
})
it('does not cache an event under a different active profile when its source profile is absent', async () => {
await mountStream()
$activeGatewayProfile.set('personal')
act(() =>
handleEvent!({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' })
)
expect(approvalModeForProfile('personal')).toBe('smart')
expect(approvalModeForProfile('work')).toBe('smart')
})
})

View file

@ -12,6 +12,7 @@ import { playCompletionSound } from '@/lib/completion-sound'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
@ -20,6 +21,7 @@ import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { followActiveSessionCwd } from '@/store/projects'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
@ -117,6 +119,20 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const providerChanged = typeof payload?.provider === 'string'
const runningChanged = typeof payload?.running === 'boolean'
// Config is profile-scoped, but session.info also arrives for background
// sessions. Only an active-session event from the currently active
// gateway may reconcile the foreground cache. Requiring the renderer's
// source tag prevents an event queued before a profile swap from being
// attributed to the newly active profile.
if (
isActiveEvent &&
typeof payload?.approval_mode === 'string' &&
event.profile &&
normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())
) {
reconcileApprovalModeForProfile(event.profile, payload.approval_mode)
}
if (apply) {
if (modelChanged) {
setCurrentModel(payload!.model || '')

View file

@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
import { $activeGatewayProfile } from '@/store/profile'
import type { SessionInfo } from '@/types/hermes'
import {
applyRuntimeInfo,
chatMessageArraysEquivalent,
isSessionGoneError,
reconcileResumeMessages,
@ -17,6 +20,20 @@ const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial
const session = (over: Partial<SessionInfo>): SessionInfo => over as SessionInfo
describe('applyRuntimeInfo approval mode', () => {
beforeEach(() => {
$approvalModes.set({})
$activeGatewayProfile.set('work')
})
it('reconciles session.info against the gateway profile', () => {
applyRuntimeInfo({ approval_mode: 'smart', desktop_contract: 3 })
expect(approvalModeForProfile('work')).toBe('smart')
expect(approvalModeForProfile('default')).toBe('smart')
})
})
describe('isSessionGoneError', () => {
it('is true for 404 / session-not-found, false otherwise', () => {
expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true)

View file

@ -2,6 +2,7 @@ import { getSession } from '@/hermes'
import { type ChatMessage, chatMessageText } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import {
@ -266,6 +267,10 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
reportBackendContract(info.desktop_contract)
if (info.approval_mode !== undefined) {
reconcileApprovalModeForProfile($activeGatewayProfile.get(), info.approval_mode)
}
if (info.credential_warning) {
requestDesktopOnboarding(info.credential_warning)
}

View file

@ -0,0 +1,89 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { StatusbarControls } from '@/app/shell/statusbar-controls'
import { I18nProvider } from '@/i18n'
import { $approvalModes } from '@/store/approval-mode'
import { useApprovalModeStatusbarItem } from './approval-mode-menu'
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
beforeAll(() => {
vi.stubGlobal('ResizeObserver', TestResizeObserver)
Element.prototype.hasPointerCapture ??= () => false
Element.prototype.setPointerCapture ??= () => undefined
Element.prototype.releasePointerCapture ??= () => undefined
HTMLElement.prototype.scrollIntoView ??= () => undefined
})
afterEach(() => {
cleanup()
$approvalModes.set({})
})
function Harness({
profile = 'default',
requestGateway
}: {
profile?: string
requestGateway: (method: string, params?: Record<string, unknown>) => Promise<unknown>
}) {
const item = useApprovalModeStatusbarItem(profile, requestGateway)
return (
<MemoryRouter>
<StatusbarControls items={[item]} />
</MemoryRouter>
)
}
describe('approval mode statusbar item', () => {
it('uses the shared statusbar menu trigger without a nested bespoke button', async () => {
const response = new Promise<never>(() => undefined)
render(<Harness requestGateway={vi.fn(() => response)} />)
const statusbar = screen.getByRole('contentinfo')
const trigger = within(statusbar).getByRole('button', { name: /smart/i })
expect(within(statusbar).getAllByRole('button')).toHaveLength(1)
fireEvent.pointerDown(trigger, { button: 0 })
expect(await screen.findByRole('menuitemradio', { name: /manual/i })).toBeTruthy()
expect(trigger.getAttribute('aria-haspopup')).toBe('menu')
expect(screen.getByRole('menuitemradio', { name: /smart/i })).toBeTruthy()
expect(screen.getByRole('menuitemradio', { name: /off/i })).toBeTruthy()
})
it('writes the selected mode through the gateway and updates its shared trigger label', async () => {
const requestGateway = vi.fn(async (_method, params) => ({ value: params?.value ?? 'smart' }))
render(<Harness profile="work" requestGateway={requestGateway} />)
fireEvent.pointerDown(screen.getByRole('button', { name: /smart/i }), { button: 0 })
fireEvent.click(await screen.findByRole('menuitemradio', { name: /manual/i }))
await waitFor(() => {
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'approvals.mode', value: 'manual' })
expect(screen.getByRole('button', { name: /manual/i })).toBeTruthy()
})
})
it('renders the shared trigger and menu in the active locale', async () => {
const response = new Promise<never>(() => undefined)
render(
<I18nProvider configClient={null} initialLocale="ja">
<Harness requestGateway={vi.fn(() => response)} />
</I18nProvider>
)
fireEvent.pointerDown(screen.getByRole('button', { name: 'スマート' }), { button: 0 })
expect(await screen.findByText('必要な場合にのみ確認します')).toBeTruthy()
expect(screen.getByText('承認プロンプトなしで実行します')).toBeTruthy()
})
})

View file

@ -0,0 +1,81 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo } from 'react'
import type { StatusbarItem } from '@/app/shell/statusbar-controls'
import {
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { Zap, ZapFilled } from '@/lib/icons'
import {
$approvalModes,
type ApprovalMode,
type ApprovalModeRequester,
setApprovalModeForProfile,
syncApprovalModeForProfile
} from '@/store/approval-mode'
export function useApprovalModeStatusbarItem(
profile: string,
requestGateway: ApprovalModeRequester
): StatusbarItem {
const { t } = useI18n()
const copy = t.shell.approvalMode
const modes = useStore($approvalModes)
const mode = modes[profile.trim() || 'default'] ?? 'smart'
const labels = useMemo<Record<ApprovalMode, string>>(
() => ({ manual: copy.manual, smart: copy.smart, off: copy.off }),
[copy.manual, copy.off, copy.smart]
)
const descriptions = useMemo<Record<ApprovalMode, string>>(
() => ({
manual: copy.manualDescription,
smart: copy.smartDescription,
off: copy.offDescription
}),
[copy.manualDescription, copy.offDescription, copy.smartDescription]
)
useEffect(() => {
void syncApprovalModeForProfile(requestGateway, profile).catch(() => undefined)
}, [profile, requestGateway])
return {
className: mode === 'off' ? 'bg-(--chrome-action-hover) text-foreground' : undefined,
icon: mode === 'off' ? <ZapFilled className="size-3.5" /> : <Zap className="size-3.5 opacity-70" />,
id: 'approval-mode',
label: labels[mode],
menuAlign: 'end',
menuClassName: 'w-72 p-1',
menuContent: (
<>
<DropdownMenuLabel>{copy.title}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
onValueChange={value => {
void setApprovalModeForProfile(requestGateway, profile, value as ApprovalMode).catch(() => undefined)
}}
value={mode}
>
{(['manual', 'smart', 'off'] as const).map(value => (
<DropdownMenuRadioItem className="items-start gap-2" key={value} value={value}>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs text-foreground">{labels[value]}</span>
<span className="text-[0.6875rem] leading-snug text-(--ui-text-tertiary)">
{descriptions[value]}
</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</>
),
title: copy.ariaLabel(labels[mode]),
variant: 'menu'
}
}

View file

@ -1,20 +1,21 @@
import { useStore } from '@nanostores/react'
import { useCallback, useMemo } from 'react'
import { useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { useApprovalModeStatusbarItem } from '@/app/shell/approval-mode-menu'
import { ContextUsagePanel } from '@/app/shell/context-usage-panel'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal, Zap, ZapFilled } from '@/lib/icons'
import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session'
import { copyFilePath, revealFile } from '@/store/file-actions'
import { revealFileInTree } from '@/store/layout'
import { $activeGatewayProfile } from '@/store/profile'
import {
$activeSessionId,
$busy,
@ -23,8 +24,6 @@ import {
$currentUsage,
$sessionStartedAt,
$turnStartedAt,
$yoloActive,
setYoloActive
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
import { $gatewayRestarting } from '@/store/system-actions'
@ -39,7 +38,7 @@ import {
import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE } from '../../routes'
import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls'
import type { StatusbarItem } from '../statusbar-controls'
function workspaceLabel(cwd: string): string {
const normalized = cwd.replace(/[\\/]+$/, '')
@ -74,7 +73,6 @@ export function useStatusbarItems({
inferenceStatus,
openAgents,
openCommandCenterSection,
freshDraftReady,
requestGateway,
statusSnapshot,
toggleCommandCenter
@ -83,8 +81,8 @@ export function useStatusbarItems({
const copy = t.shell.statusbar
const fileMenu = t.fileMenu
const activeSessionId = useStore($activeSessionId)
const activeGatewayProfile = useStore($activeGatewayProfile)
const terminalTakeover = useStore($terminalTakeover)
const yoloActive = useStore($yoloActive)
const busy = useStore($busy)
const currentCwd = useStore($currentCwd)
const currentUsage = useStore($currentUsage)
@ -101,45 +99,8 @@ export function useStatusbarItems({
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway)
// Per-session approval bypass (same scope as the TUI's Shift+Tab). On a
// new-chat draft (no runtime session yet) we arm locally; the session-create
// path applies it once the backend session exists.
//
// Shift+click flips the GLOBAL approvals.mode instead — a persistent,
// all-sessions/CLI/TUI/cron bypass that survives restarts.
const toggleYolo = useCallback(
async (modifiers?: StatusbarSelectModifiers) => {
const next = !$yoloActive.get()
setYoloActive(next)
if (modifiers?.shiftKey) {
try {
await setGlobalYolo(requestGateway, next)
} catch {
setYoloActive(!next)
}
return
}
const sid = $activeSessionId.get()
if (!sid) {
return
}
try {
await setSessionYolo(requestGateway, sid, next)
} catch {
setYoloActive(!next)
}
},
[requestGateway]
)
const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady)
const gatewayMenuContent = useMemo(
() => (close: () => void) => (
@ -429,17 +390,8 @@ export function useStatusbarItems({
variant: 'text'
},
{
className: cn('px-1', yoloActive && 'bg-(--chrome-action-hover)'),
hidden: !showYoloToggle,
icon: yoloActive ? (
<ZapFilled className="size-3.5 shrink-0" />
) : (
<Zap className="size-3.5 shrink-0 opacity-70" />
),
id: 'yolo',
onSelect: modifiers => void toggleYolo(modifiers),
title: yoloActive ? copy.yoloOn : copy.yoloOff,
variant: 'action'
...approvalModeItem,
hidden: gatewayState !== 'open',
},
{
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
@ -455,6 +407,7 @@ export function useStatusbarItems({
],
[
activeSessionId,
approvalModeItem,
backendVersionItem,
busy,
chatOpen,
@ -465,11 +418,9 @@ export function useStatusbarItems({
currentUsage,
requestGateway,
sessionStartedAt,
showYoloToggle,
gatewayState,
terminalTakeover,
toggleYolo,
turnStartedAt,
yoloActive
]
)

View file

@ -2068,6 +2068,16 @@ export const en: Translations = {
viewAllLogs: 'View all logs →',
messagingPlatforms: 'Messaging platforms'
},
approvalMode: {
title: 'Approval mode',
ariaLabel: mode => `Approval mode: ${mode}`,
manual: 'Manual',
manualDescription: 'Ask before actions that require approval',
smart: 'Smart',
smartDescription: 'Automatically assess actions and ask when needed',
off: 'Off',
offDescription: 'Run without approval prompts'
},
statusbar: {
unknown: 'unknown',
restart: 'restart',

View file

@ -2009,6 +2009,16 @@ export const ja = defineLocale({
viewAllLogs: 'すべてのログを見る →',
messagingPlatforms: 'メッセージングプラットフォーム'
},
approvalMode: {
title: '承認モード',
ariaLabel: mode => `承認モード: ${mode}`,
manual: '手動',
manualDescription: '承認が必要な操作の前に確認します',
smart: 'スマート',
smartDescription: '必要な場合にのみ確認します',
off: 'オフ',
offDescription: '承認プロンプトなしで実行します'
},
statusbar: {
unknown: '不明',
restart: '再起動',

View file

@ -1699,6 +1699,16 @@ export interface Translations {
viewAllLogs: string
messagingPlatforms: string
}
approvalMode: {
title: string
ariaLabel: (mode: string) => string
manual: string
manualDescription: string
smart: string
smartDescription: string
off: string
offDescription: string
}
statusbar: {
unknown: string
restart: string

View file

@ -1945,6 +1945,16 @@ export const zhHant = defineLocale({
viewAllLogs: '查看全部記錄 →',
messagingPlatforms: '訊息平台'
},
approvalMode: {
title: '核准模式',
ariaLabel: mode => `核准模式:${mode}`,
manual: '手動',
manualDescription: '執行需要核准的操作前詢問',
smart: '智慧',
smartDescription: '自動評估操作,並在需要時詢問',
off: '關閉',
offDescription: '不顯示核准提示,直接執行'
},
statusbar: {
unknown: '未知',
restart: '重新啟動',

View file

@ -2232,6 +2232,16 @@ export const zh: Translations = {
viewAllLogs: '查看全部日志 →',
messagingPlatforms: '消息平台'
},
approvalMode: {
title: '审批模式',
ariaLabel: mode => `审批模式:${mode}`,
manual: '手动',
manualDescription: '执行需要审批的操作前询问',
smart: '智能',
smartDescription: '自动评估操作,并在需要时询问',
off: '关闭',
offDescription: '不显示审批提示,直接运行'
},
statusbar: {
unknown: '未知',
restart: '重启',

View file

@ -46,6 +46,7 @@ export type GatewayEventPayload = {
reasoning_effort?: string
service_tier?: string
fast?: boolean
approval_mode?: string
yolo?: boolean
running?: boolean
cwd?: string

View file

@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
$approvalModes,
approvalModeForProfile,
reconcileApprovalModeForProfile,
setApprovalModeForProfile,
syncApprovalModeForProfile
} from './approval-mode'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, reject, resolve }
}
describe('profile-scoped approval mode cache', () => {
beforeEach(() => $approvalModes.set({}))
it('labels an unread profile Smart by default and adopts backend truth', async () => {
expect(approvalModeForProfile('default')).toBe('smart')
const request = vi.fn(async () => ({ value: 'manual' }))
await syncApprovalModeForProfile(request, 'default')
expect(request).toHaveBeenCalledWith('config.get', { key: 'approvals.mode' })
expect(approvalModeForProfile('default')).toBe('manual')
})
it('keeps profile values isolated', async () => {
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'manual' })), 'work')
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'off' })), 'personal')
expect(approvalModeForProfile('work')).toBe('manual')
expect(approvalModeForProfile('personal')).toBe('off')
expect(approvalModeForProfile('default')).toBe('smart')
})
it('rolls consecutive failed writes back to the last authoritative value', async () => {
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'smart' })), 'default')
const first = deferred<{ value: string }>()
const second = deferred<{ value: string }>()
const request = vi
.fn()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise)
const staleWrite = setApprovalModeForProfile(request, 'default', 'manual')
const currentWrite = setApprovalModeForProfile(request, 'default', 'off')
expect(approvalModeForProfile('default')).toBe('off')
first.reject(new Error('old failure'))
await expect(staleWrite).rejects.toThrow('old failure')
expect(approvalModeForProfile('default')).toBe('off')
second.reject(new Error('current failure'))
await expect(currentWrite).rejects.toThrow('current failure')
expect(approvalModeForProfile('default')).toBe('smart')
})
it('lets a backend event supersede an optimistic write and its later failure', async () => {
const write = deferred<{ value: string }>()
const pending = setApprovalModeForProfile(vi.fn(() => write.promise), 'work', 'off')
reconcileApprovalModeForProfile('work', 'smart')
expect(approvalModeForProfile('work')).toBe('smart')
write.reject(new Error('late failure'))
await expect(pending).rejects.toThrow('late failure')
expect(approvalModeForProfile('work')).toBe('smart')
})
it('ignores a stale initial read after a newer write succeeds', async () => {
const read = deferred<{ value: string }>()
const request = vi
.fn()
.mockImplementationOnce(() => read.promise)
.mockResolvedValueOnce({ value: 'off' })
const staleRead = syncApprovalModeForProfile(request, 'default')
await setApprovalModeForProfile(request, 'default', 'off')
read.resolve({ value: 'manual' })
await staleRead
expect(approvalModeForProfile('default')).toBe('off')
})
})

View file

@ -0,0 +1,100 @@
import { atom } from 'nanostores'
export type ApprovalMode = 'manual' | 'off' | 'smart'
export type ApprovalModeRequester = (
method: string,
params?: Record<string, unknown>
) => Promise<unknown>
const APPROVAL_MODES = new Set<ApprovalMode>(['manual', 'smart', 'off'])
const revisions = new Map<string, number>()
const confirmedModes = new Map<string, ApprovalMode>()
export const $approvalModes = atom<Record<string, ApprovalMode>>({})
function profileKey(profile: string): string {
return profile.trim() || 'default'
}
function nextRevision(profile: string): number {
const revision = (revisions.get(profile) ?? 0) + 1
revisions.set(profile, revision)
return revision
}
function normalizeApprovalMode(value: unknown): ApprovalMode {
const normalized = String(value ?? '')
.trim()
.toLowerCase() as ApprovalMode
return APPROVAL_MODES.has(normalized) ? normalized : 'manual'
}
export function approvalModeForProfile(profile: string): ApprovalMode {
return $approvalModes.get()[profileKey(profile)] ?? 'smart'
}
function cacheApprovalMode(profile: string, mode: ApprovalMode): void {
const key = profileKey(profile)
$approvalModes.set({ ...$approvalModes.get(), [key]: mode })
}
export function reconcileApprovalModeForProfile(profile: string, value: unknown): ApprovalMode {
const key = profileKey(profile)
const mode = normalizeApprovalMode(value)
nextRevision(key)
confirmedModes.set(key, mode)
cacheApprovalMode(key, mode)
return mode
}
export async function syncApprovalModeForProfile(
requestGateway: ApprovalModeRequester,
profile: string
): Promise<ApprovalMode> {
const key = profileKey(profile)
const revision = nextRevision(key)
const result = (await requestGateway('config.get', { key: 'approvals.mode' })) as { value?: string }
const mode = normalizeApprovalMode(result?.value)
if (revisions.get(key) === revision) {
confirmedModes.set(key, mode)
cacheApprovalMode(key, mode)
}
return mode
}
export async function setApprovalModeForProfile(
requestGateway: ApprovalModeRequester,
profile: string,
mode: ApprovalMode
): Promise<ApprovalMode> {
const key = profileKey(profile)
const revision = nextRevision(key)
cacheApprovalMode(key, mode)
try {
const result = (await requestGateway('config.set', {
key: 'approvals.mode',
value: mode
})) as { value?: string }
const authoritative = normalizeApprovalMode(result?.value)
if (revisions.get(key) === revision) {
confirmedModes.set(key, authoritative)
cacheApprovalMode(key, authoritative)
}
return authoritative
} catch (error) {
if (revisions.get(key) === revision) {
cacheApprovalMode(key, confirmedModes.get(key) ?? 'smart')
}
throw error
}
}

View file

@ -164,7 +164,7 @@ function createSecondary(profile: string): Secondary {
wantOpen: true
}
entry.offEvent = gateway.onEvent(event => config?.onEvent(event))
entry.offEvent = gateway.onEvent(event => config?.onEvent({ ...event, profile }))
entry.offState = gateway.onState(state => {
reportGatewayState(profile, state)

View file

@ -120,7 +120,7 @@ describe('reportBackendContract', () => {
})
it('dismisses the toast when the backend meets the contract', () => {
reportBackendContract(2)
reportBackendContract(3)
expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew')
expect(notifySpy).not.toHaveBeenCalled()
})
@ -160,8 +160,8 @@ describe('reportBackendContract', () => {
lastToast().onDismiss()
notifySpy.mockClear()
reportBackendContract(2) // backend updated → satisfied, snooze cleared
reportBackendContract(1) // a later regression must warn immediately
reportBackendContract(3) // backend updated → satisfied, snooze cleared
reportBackendContract(2) // a later regression must warn immediately
expect(notifySpy).toHaveBeenCalledTimes(1)
})
})

View file

@ -91,7 +91,8 @@ function isUpdateToastSnoozed(): boolean {
// against. The backend reports its own value in session runtime info; a lower
// value (or none — a pre-GUI checkout) means GUI<->backend skew.
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
const REQUIRED_BACKEND_CONTRACT = 2
// v3: requires approvals.mode config RPCs and session.info reconciliation.
const REQUIRED_BACKEND_CONTRACT = 3
const SKEW_TOAST_ID = 'backend-contract-skew'
// The contract check runs on every session.resume (applyRuntimeInfo), so
// without a snooze the warning re-popped on every thread the user opened, even

View file

@ -303,6 +303,7 @@ export interface PaginatedSessions {
export interface RpcEvent<T = unknown> {
payload?: T
profile?: string
session_id?: string
type: string
}
@ -393,6 +394,7 @@ export interface SessionResumeResponse {
}
export interface SessionRuntimeInfo {
approval_mode?: 'manual' | 'off' | 'smart'
branch?: string
config_warning?: string
credential_warning?: string

View file

@ -23,6 +23,8 @@ export type GatewayEventName =
export interface GatewayEvent<P = unknown> {
payload?: P
/** Renderer-side source tag added by the Desktop gateway registry. */
profile?: string
session_id?: string
type: GatewayEventName
}

View file

@ -2777,6 +2777,95 @@ def test_config_set_yolo_global_scope_writes_approvals_mode(tmp_path, monkeypatc
assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "manual"
def test_config_get_approval_mode_uses_smart_default_when_key_is_missing(
tmp_path, monkeypatch
):
import yaml
monkeypatch.setattr(server, "_hermes_home", tmp_path)
(tmp_path / "config.yaml").write_text(
yaml.safe_dump({"approvals": {"timeout": 15}})
)
response = server.handle_request(
{"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}}
)
assert response["result"]["value"] == "smart"
def test_config_get_approval_mode_fails_safe_to_manual_for_invalid_explicit_value(
tmp_path, monkeypatch
):
import yaml
monkeypatch.setattr(server, "_hermes_home", tmp_path)
(tmp_path / "config.yaml").write_text(
yaml.safe_dump({"approvals": {"mode": "sometimes"}})
)
response = server.handle_request(
{"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}}
)
assert response["result"]["value"] == "manual"
def test_config_get_approval_mode_normalizes_yaml_off(tmp_path, monkeypatch):
import yaml
monkeypatch.setattr(server, "_hermes_home", tmp_path)
(tmp_path / "config.yaml").write_text(
yaml.safe_dump({"approvals": {"mode": False}})
)
response = server.handle_request(
{"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}}
)
assert response["result"]["value"] == "off"
def test_config_set_approval_mode_persists_three_way_value_and_emits_live_status(
tmp_path, monkeypatch
):
import yaml
monkeypatch.setattr(server, "_hermes_home", tmp_path)
emitted = []
monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args))
server._sessions["sid"] = {"agent": object(), "session_key": "profile-session"}
try:
resp = server.handle_request(
{
"id": "1",
"method": "config.set",
"params": {"key": "approvals.mode", "value": "manual"},
}
)
finally:
server._sessions.clear()
assert resp["result"] == {"key": "approvals.mode", "value": "manual"}
assert yaml.safe_load((tmp_path / "config.yaml").read_text())["approvals"]["mode"] == "manual"
assert emitted and emitted[0][0:2] == ("session.info", "sid")
assert emitted[0][2]["approval_mode"] == "manual"
def test_desktop_contract_includes_approval_mode_rpc():
assert server.DESKTOP_BACKEND_CONTRACT >= 3
def test_config_set_approval_mode_rejects_unknown_value():
resp = server.handle_request(
{
"id": "1",
"method": "config.set",
"params": {"key": "approvals.mode", "value": "sometimes"},
}
)
assert resp["error"]["code"] == 4002
def test_config_set_yolo_global_scope_honors_explicit_value(tmp_path, monkeypatch):
"""An explicit value pins global approvals.mode regardless of prior state."""
import yaml

View file

@ -2486,6 +2486,19 @@ def _write_config_key(key_path: str, value):
_STATUSBAR_MODES = frozenset({"off", "top", "bottom"})
_APPROVAL_MODES = frozenset({"manual", "smart", "off"})
def _load_approval_mode() -> str:
from hermes_cli.config import DEFAULT_CONFIG, _deep_merge
from tools.approval import _normalize_approval_mode
raw_cfg = _load_cfg()
cfg = _deep_merge(DEFAULT_CONFIG, raw_cfg if isinstance(raw_cfg, dict) else {})
approvals = cfg.get("approvals")
raw = approvals.get("mode") if isinstance(approvals, dict) else None
mode = _normalize_approval_mode(raw)
return mode if mode in _APPROVAL_MODES else "manual"
def _coerce_statusbar(raw) -> str:
@ -3321,7 +3334,8 @@ def _current_profile_name() -> str:
# checkout), surfacing a one-click "update to align" prompt instead of failing
# cryptically downstream. Bump whenever the desktop's backend contract changes.
# v2: adds the file.attach RPC (remote-gateway non-image file upload).
DESKTOP_BACKEND_CONTRACT = 2
# v3: adds approvals.mode config RPCs and session.info reconciliation.
DESKTOP_BACKEND_CONTRACT = 3
def _session_info(agent, session: dict | None = None) -> dict:
@ -3355,17 +3369,15 @@ def _session_info(agent, session: dict | None = None) -> dict:
# the desktop status bar (it would show YOLO "off" while approvals.mode=off
# silently auto-approves every dangerous command).
yolo = False
approval_mode = "manual"
try:
from tools.approval import (
_YOLO_MODE_FROZEN,
_get_approval_mode,
is_session_yolo_enabled,
)
from tools.approval import _YOLO_MODE_FROZEN, is_session_yolo_enabled
session_yolo = (
bool(is_session_yolo_enabled(session_key)) if session_key else False
)
yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off"
approval_mode = _load_approval_mode()
yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or approval_mode == "off"
except Exception:
yolo = False
info: dict = {
@ -3375,6 +3387,7 @@ def _session_info(agent, session: dict | None = None) -> dict:
"service_tier": service_tier,
"fast": service_tier == "priority",
"yolo": yolo,
"approval_mode": approval_mode,
"tools": {},
"skills": {},
"cwd": cwd,
@ -10399,6 +10412,22 @@ def _(rid, params: dict) -> dict:
agent.verbose_logging = nv == "verbose"
return _ok(rid, {"key": key, "value": nv})
if key in {"approval_mode", "approvals.mode"}:
raw = str(value or "").strip().lower()
if raw not in _APPROVAL_MODES:
return _err(
rid,
4002,
f"unknown approval mode: {value}; pick one of manual|smart|off",
)
_write_config_key("approvals.mode", raw)
for sid, sess in list(_sessions.items()):
agent = sess.get("agent")
if agent is not None:
_emit("session.info", sid, _session_info(agent, sess))
return _ok(rid, {"key": "approvals.mode", "value": raw})
if key == "yolo":
# Approval bypass. Two scopes:
# scope="session" (default) — same as the TUI's Shift+Tab. Toggles
@ -11312,6 +11341,11 @@ def _(rid, params: dict) -> dict:
)
if key == "busy":
return _ok(rid, {"value": _load_busy_input_mode()})
if key in {"approval_mode", "approvals.mode"}:
try:
return _ok(rid, {"value": _load_approval_mode()})
except Exception as e:
return _err(rid, 5001, str(e))
if key == "details_mode":
allowed_dm = frozenset({"hidden", "collapsed", "expanded"})
raw = (