From 3510b18814c2edb0173bcf584ea1feb146b7d274 Mon Sep 17 00:00:00 2001 From: Luigi Razon Date: Sun, 12 Jul 2026 19:42:44 -0700 Subject: [PATCH] feat(desktop): add profile-aware approval mode control --- .../src/app/gateway/hooks/use-gateway-boot.ts | 4 +- .../approval-mode-event.test.tsx | 104 ++++++++++++++++++ .../hooks/use-message-stream/gateway-event.ts | 16 +++ .../hooks/use-session-actions/utils.test.ts | 19 +++- .../hooks/use-session-actions/utils.ts | 5 + .../src/app/shell/approval-mode-menu.test.tsx | 89 +++++++++++++++ .../src/app/shell/approval-mode-menu.tsx | 81 ++++++++++++++ .../app/shell/hooks/use-statusbar-items.tsx | 71 ++---------- apps/desktop/src/i18n/en.ts | 10 ++ apps/desktop/src/i18n/ja.ts | 10 ++ apps/desktop/src/i18n/types.ts | 10 ++ apps/desktop/src/i18n/zh-hant.ts | 10 ++ apps/desktop/src/i18n/zh.ts | 10 ++ apps/desktop/src/lib/chat-messages.ts | 1 + apps/desktop/src/store/approval-mode.test.ts | 95 ++++++++++++++++ apps/desktop/src/store/approval-mode.ts | 100 +++++++++++++++++ apps/desktop/src/store/gateway.ts | 2 +- apps/desktop/src/store/updates.test.ts | 6 +- apps/desktop/src/store/updates.ts | 3 +- apps/desktop/src/types/hermes.ts | 2 + apps/shared/src/json-rpc-gateway.ts | 2 + tests/test_tui_gateway_server.py | 89 +++++++++++++++ tui_gateway/server.py | 48 ++++++-- 23 files changed, 713 insertions(+), 74 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/approval-mode-event.test.tsx create mode 100644 apps/desktop/src/app/shell/approval-mode-menu.test.tsx create mode 100644 apps/desktop/src/app/shell/approval-mode-menu.tsx create mode 100644 apps/desktop/src/store/approval-mode.test.ts create mode 100644 apps/desktop/src/store/approval-mode.ts diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index f1b040bec1e5..2f239569f15b 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -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. diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/approval-mode-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/approval-mode-event.test.tsx new file mode 100644 index 000000000000..4616bca54487 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/approval-mode-event.test.tsx @@ -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(ACTIVE_SID) + const sessionStateByRuntimeIdRef = useRef(new Map()) + 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() + 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') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 664e4b9d53ea..c4efb85d5b41 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -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 || '') diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index 680cc754286e..7ddc66679687 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -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 => 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) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index d299fe51b7e4..06458eb59025 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -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) } diff --git a/apps/desktop/src/app/shell/approval-mode-menu.test.tsx b/apps/desktop/src/app/shell/approval-mode-menu.test.tsx new file mode 100644 index 000000000000..05a122f8bf65 --- /dev/null +++ b/apps/desktop/src/app/shell/approval-mode-menu.test.tsx @@ -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) => Promise +}) { + const item = useApprovalModeStatusbarItem(profile, requestGateway) + + return ( + + + + ) +} + +describe('approval mode statusbar item', () => { + it('uses the shared statusbar menu trigger without a nested bespoke button', async () => { + const response = new Promise(() => undefined) + render( 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() + + 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(() => undefined) + render( + + response)} /> + + ) + + fireEvent.pointerDown(screen.getByRole('button', { name: 'スマート' }), { button: 0 }) + + expect(await screen.findByText('必要な場合にのみ確認します')).toBeTruthy() + expect(screen.getByText('承認プロンプトなしで実行します')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/shell/approval-mode-menu.tsx b/apps/desktop/src/app/shell/approval-mode-menu.tsx new file mode 100644 index 000000000000..40f9f236a180 --- /dev/null +++ b/apps/desktop/src/app/shell/approval-mode-menu.tsx @@ -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>( + () => ({ manual: copy.manual, smart: copy.smart, off: copy.off }), + [copy.manual, copy.off, copy.smart] + ) + + const descriptions = useMemo>( + () => ({ + 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' ? : , + id: 'approval-mode', + label: labels[mode], + menuAlign: 'end', + menuClassName: 'w-72 p-1', + menuContent: ( + <> + {copy.title} + + { + void setApprovalModeForProfile(requestGateway, profile, value as ApprovalMode).catch(() => undefined) + }} + value={mode} + > + {(['manual', 'smart', 'off'] as const).map(value => ( + + + {labels[value]} + + {descriptions[value]} + + + + ))} + + + ), + title: copy.ariaLabel(labels[mode]), + variant: 'menu' + } +} diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 64cf05cf20c1..021832021fd7 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -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 ? ( - - ) : ( - - ), - 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 ] ) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 9e82820e83bc..efeff291cbd6 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 2642973ba063..431ab28a49e3 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2009,6 +2009,16 @@ export const ja = defineLocale({ viewAllLogs: 'すべてのログを見る →', messagingPlatforms: 'メッセージングプラットフォーム' }, + approvalMode: { + title: '承認モード', + ariaLabel: mode => `承認モード: ${mode}`, + manual: '手動', + manualDescription: '承認が必要な操作の前に確認します', + smart: 'スマート', + smartDescription: '必要な場合にのみ確認します', + off: 'オフ', + offDescription: '承認プロンプトなしで実行します' + }, statusbar: { unknown: '不明', restart: '再起動', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 03f7b8949b51..3a9de3fd430a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index ed6f9325c2b9..12995a2e306e 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1945,6 +1945,16 @@ export const zhHant = defineLocale({ viewAllLogs: '查看全部記錄 →', messagingPlatforms: '訊息平台' }, + approvalMode: { + title: '核准模式', + ariaLabel: mode => `核准模式:${mode}`, + manual: '手動', + manualDescription: '執行需要核准的操作前詢問', + smart: '智慧', + smartDescription: '自動評估操作,並在需要時詢問', + off: '關閉', + offDescription: '不顯示核准提示,直接執行' + }, statusbar: { unknown: '未知', restart: '重新啟動', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index b08a0927ed38..62d7e978fab6 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2232,6 +2232,16 @@ export const zh: Translations = { viewAllLogs: '查看全部日志 →', messagingPlatforms: '消息平台' }, + approvalMode: { + title: '审批模式', + ariaLabel: mode => `审批模式:${mode}`, + manual: '手动', + manualDescription: '执行需要审批的操作前询问', + smart: '智能', + smartDescription: '自动评估操作,并在需要时询问', + off: '关闭', + offDescription: '不显示审批提示,直接运行' + }, statusbar: { unknown: '未知', restart: '重启', diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index c6829420a1de..49612bb98350 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -46,6 +46,7 @@ export type GatewayEventPayload = { reasoning_effort?: string service_tier?: string fast?: boolean + approval_mode?: string yolo?: boolean running?: boolean cwd?: string diff --git a/apps/desktop/src/store/approval-mode.test.ts b/apps/desktop/src/store/approval-mode.test.ts new file mode 100644 index 000000000000..5a15f6c6ff3b --- /dev/null +++ b/apps/desktop/src/store/approval-mode.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + $approvalModes, + approvalModeForProfile, + reconcileApprovalModeForProfile, + setApprovalModeForProfile, + syncApprovalModeForProfile +} from './approval-mode' + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + + const promise = new Promise((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') + }) +}) diff --git a/apps/desktop/src/store/approval-mode.ts b/apps/desktop/src/store/approval-mode.ts new file mode 100644 index 000000000000..1000f208d95f --- /dev/null +++ b/apps/desktop/src/store/approval-mode.ts @@ -0,0 +1,100 @@ +import { atom } from 'nanostores' + +export type ApprovalMode = 'manual' | 'off' | 'smart' +export type ApprovalModeRequester = ( + method: string, + params?: Record +) => Promise + +const APPROVAL_MODES = new Set(['manual', 'smart', 'off']) +const revisions = new Map() +const confirmedModes = new Map() + +export const $approvalModes = atom>({}) + +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 { + 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 { + 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 + } +} diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index ee51119dd786..04c024c22e48 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -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) diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index c2f5831bc555..7439a8a8345f 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -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) }) }) diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index b07347fa88b2..a7b0bbc8b94e 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -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 diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index aa527179fb18..24c29d1c5f3a 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -303,6 +303,7 @@ export interface PaginatedSessions { export interface RpcEvent { 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 diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index 2cf4ed1dff49..4707ffd8f615 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -23,6 +23,8 @@ export type GatewayEventName = export interface GatewayEvent

{ payload?: P + /** Renderer-side source tag added by the Desktop gateway registry. */ + profile?: string session_id?: string type: GatewayEventName } diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6b19000aad51..b8b0bbd77322 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 9ee351991741..0a87f133d4a0 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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 = (