diff --git a/app/src/App.tsx b/app/src/App.tsx index 09969b5df9..645b948731 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -30,6 +30,8 @@ import SecretPromptDialog from './components/mcp-setup/SecretPromptDialog'; import OpenhumanLinkModal from './components/OpenhumanLinkModal'; import PersistRehydrationScreen from './components/PersistRehydrationScreen'; import PttHotkeyManager from './components/PttHotkeyManager'; +import { AutomationHaltedBanner } from './components/safety/AutomationHaltedBanner'; +import { EmergencyStopButton } from './components/safety/EmergencyStopButton'; import SecurityBanner from './components/SecurityBanner'; import SettingsModal from './components/settings/modal/SettingsModal'; import { resolveSettingsOverlay } from './components/settings/modal/settingsOverlay'; @@ -57,6 +59,7 @@ import { startInternetStatusListener, stopInternetStatusListener, } from './services/internetStatusListener'; +import { hydrateEmergencyState } from './services/safety/hydrateEmergencyState'; import { hideWebviewAccount, startWebviewAccountService, @@ -256,6 +259,16 @@ export function AppShellDesktop() { // the core is ready (once per boot). Extracted to a hook so it's testable. useNotchBootSync(isBootstrapping); + // Boot hydration: read the authoritative halt state from the core once on + // mount so the UI reflects any halt that was engaged before this window + // opened (e.g. another tab, CLI, or a crash-recovery scenario). Errors are + // swallowed inside hydrateEmergencyState so a degraded core never blanks the shell. + useEffect(() => { + void hydrateEmergencyState(dispatch); + // Intentionally runs once on mount only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const scrollRef = useRef(null); const navType = useNavigationType(); @@ -291,6 +304,9 @@ export function AppShellDesktop() { const content = (
+ {/* Automation halt banner — renders at the top of the content area when + emergency stop is engaged. Always visible during automation sessions. */} + {activeProviderAccount && !accountsOverlayOpen && ( @@ -332,6 +348,17 @@ export function AppShellDesktop() { exhaustion). Mounted outside the routes so entries survive route changes and background-job completion. */} + {/* Emergency Stop — persistent safety control pinned to the top-right, + clear of the chat composer (bottom) and the sidebar (left); the + macOS traffic lights sit top-left, so the top-right stays free. The + button hides itself while halted (the AutomationHaltedBanner's + Resume takes over). Only shown when the shell chrome is visible + (i.e. the user is authenticated and past onboarding). */} + {!chromeless && ( +
+ +
+ )} {/* Hidden Remotion-driven producer for the Meet camera. Mounts a 640×480 JPEG frame stream to the Rust frame bus while a meet call is active; idle no-op otherwise. See diff --git a/app/src/components/safety/AutomationHaltedBanner.test.tsx b/app/src/components/safety/AutomationHaltedBanner.test.tsx new file mode 100644 index 0000000000..f9b2d2034d --- /dev/null +++ b/app/src/components/safety/AutomationHaltedBanner.test.tsx @@ -0,0 +1,116 @@ +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clearHalt, setHalt } from '../../store/safetySlice'; +import { renderWithProviders } from '../../test/test-utils'; +import { AutomationHaltedBanner } from './AutomationHaltedBanner'; + +const resume = vi.fn().mockResolvedValue({ engaged: false }); +vi.mock('../../services/api/emergencyApi', () => ({ + emergencyResume: (...a: unknown[]) => resume(...a), +})); + +beforeEach(() => resume.mockClear()); + +describe('AutomationHaltedBanner', () => { + it('renders nothing when not halted', () => { + const { container } = renderWithProviders(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the banner when halted', () => { + const { store } = renderWithProviders(, { + preloadedState: { safety: { halted: true } }, + }); + expect(screen.getByRole('alert')).toBeDefined(); + expect(screen.getByRole('alert').getAttribute('data-analytics-id')).toBe( + 'automation-halted-banner' + ); + // safety state is engaged + expect((store.getState() as { safety: { halted: boolean } }).safety.halted).toBe(true); + }); + + it('shows reason when available', () => { + renderWithProviders(, { + preloadedState: { safety: { halted: true, reason: 'custom reason' } }, + }); + expect(screen.getByText('custom reason')).toBeDefined(); + }); + + it('falls back to haltedBody when reason is absent', () => { + renderWithProviders(, { + preloadedState: { safety: { halted: true } }, + }); + expect(screen.getByText(/desktop automation is stopped/i)).toBeDefined(); + }); + + it('calls emergencyResume and clears halt when Resume is clicked', async () => { + const { store } = renderWithProviders(, { + preloadedState: { safety: { halted: true, reason: 'test' } }, + }); + fireEvent.click(screen.getByRole('button', { name: /resume/i })); + await waitFor(() => expect(resume).toHaveBeenCalled()); + const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; + expect(safetyState.halted).toBe(false); + }); + + it('preserves halt and surfaces a retry message when emergencyResume fails', async () => { + // Fail-closed: on RPC failure the core is still halted, so the UI must + // NOT silently clear the halt. Clearing locally would re-expose the Stop + // button while every external-effect action remained blocked, giving a + // false "resumed" signal (#4255 codex P2). + resume.mockRejectedValueOnce(new Error('core error')); + const { store } = renderWithProviders(, { + preloadedState: { safety: { halted: true } }, + }); + fireEvent.click(screen.getByRole('button', { name: /resume/i })); + await waitFor(() => expect(resume).toHaveBeenCalled()); + // Halt state must remain engaged after the failed RPC. + const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; + expect(safetyState.halted).toBe(true); + // Visible retry indicator appears. + await waitFor(() => + expect(screen.getByRole('status', { name: /could not resume/i })).toBeDefined() + ); + // Banner is still there so the user retains a Resume button to try again. + expect(screen.getByRole('alert')).toBeDefined(); + }); + + it('clears the stale retry indicator on a new halt cycle', async () => { + // Guards the cross-cycle leak: the banner is mounted permanently, so a failed + // resume in one cycle must not surface a stale "could not resume" indicator on + // a later, unrelated halt. Drive: fail a resume → clear the halt via the + // external socket path (not the successful-RPC branch) → start a fresh halt. + resume.mockRejectedValueOnce(new Error('core error')); + const { store } = renderWithProviders(, { + preloadedState: { safety: { halted: true } }, + }); + fireEvent.click(screen.getByRole('button', { name: /resume/i })); + await waitFor(() => + expect(screen.getByRole('status', { name: /could not resume/i })).toBeDefined() + ); + // Halt lifts via the socket-driven clear (bypasses the successful resume path). + act(() => { + store.dispatch(clearHalt()); + }); + // A brand-new halt cycle begins. + act(() => { + store.dispatch(setHalt({ reason: 'second cycle', source: 'test' })); + }); + await waitFor(() => expect(screen.getByRole('alert')).toBeDefined()); + // The retry indicator from the previous cycle must not carry over. + expect(screen.queryByRole('status', { name: /could not resume/i })).toBeNull(); + }); + + it('dispatches halt and then renders banner after setHalt dispatch', async () => { + const { store } = renderWithProviders(); + // Initially not halted + expect((store.getState() as { safety: { halted: boolean } }).safety.halted).toBe(false); + // Dispatch halt and let React re-render + act(() => { + store.dispatch(setHalt({ reason: 'dispatched', source: 'test' })); + }); + // Banner should appear + await waitFor(() => expect(screen.getByRole('alert')).toBeDefined()); + }); +}); diff --git a/app/src/components/safety/AutomationHaltedBanner.tsx b/app/src/components/safety/AutomationHaltedBanner.tsx new file mode 100644 index 0000000000..ded9a7a25b --- /dev/null +++ b/app/src/components/safety/AutomationHaltedBanner.tsx @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { emergencyResume } from '../../services/api/emergencyApi'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { clearHalt, selectHalted, selectHaltReason } from '../../store/safetySlice'; + +/** + * AutomationHaltedBanner — renders at the top of main content when automation + * is halted via the emergency stop. Provides a Resume button to lift the halt. + * + * The Redux `clearHalt` only fires on a confirmed resume from the core. If the + * `emergency_resume` RPC fails (timeout, auth, core unavailable), the halt is + * preserved locally and a visible retry message is shown — because the core is + * still halted and clearing the banner would silently re-enable the Stop button + * while every external-effect action remained blocked. The authoritative source + * of truth is the core; the `automation_halt` socket broadcast will also clear + * the state if the resume succeeds server-side after an in-flight RPC failure. + */ +export function AutomationHaltedBanner() { + const { t } = useT(); + const dispatch = useAppDispatch(); + const halted = useAppSelector(selectHalted); + const reason = useAppSelector(selectHaltReason); + const [resumeFailed, setResumeFailed] = useState(false); + + const onResume = useCallback(async () => { + setResumeFailed(false); + console.debug('[emergency] resume requested (source=user)'); + try { + await emergencyResume(); + console.debug('[emergency] resume confirmed by core'); + // Only clear locally on a CONFIRMED resume. On failure the core is still + // halted, so clearing here would give a false "safe to run" signal. + dispatch(clearHalt()); + } catch (err) { + console.error('[emergency] resume FAILED — halt preserved locally, retry required', err); + setResumeFailed(true); + } + }, [dispatch]); + + // The banner is mounted permanently (it only returns null when not halted), so + // `resumeFailed` would otherwise leak across halt cycles: a failed resume, then + // an external socket-driven clear, then a fresh halt would show a stale "could + // not resume" retry indicator the user never triggered. Reset it whenever the + // halt lifts so each new cycle starts clean. + useEffect(() => { + if (!halted) setResumeFailed(false); + }, [halted]); + + if (!halted) return null; + + // `sticky top-0 z-40` keeps the halt banner (and its Resume button) visible + // and reachable ABOVE the provider WebviewHost overlay (absolute inset-0 z-30, + // rendered as a sibling below); otherwise an active provider account fully + // covers the safety banner. Stays below the settings modal portal (z-50), + // matching the app's documented stacking convention. + return ( +
+
+ {t('safety.haltedTitle')} + + {reason || t('safety.haltedBody')} + +
+
+ {resumeFailed && ( + + {t('safety.resumeFailed')} + + )} + +
+
+ ); +} diff --git a/app/src/components/safety/EmergencyStopButton.test.tsx b/app/src/components/safety/EmergencyStopButton.test.tsx new file mode 100644 index 0000000000..12f72d8da1 --- /dev/null +++ b/app/src/components/safety/EmergencyStopButton.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setHalt } from '../../store/safetySlice'; +import { renderWithProviders } from '../../test/test-utils'; +import { EmergencyStopButton } from './EmergencyStopButton'; + +const stop = vi + .fn() + .mockResolvedValue({ + engaged: true, + reason: undefined, + source: undefined, + engaged_at_ms: undefined, + }); +vi.mock('../../services/api/emergencyApi', () => ({ + emergencyStop: (...a: unknown[]) => stop(...a), +})); + +beforeEach(() => stop.mockClear()); + +describe('EmergencyStopButton', () => { + it('renders a button with the emergency stop label', () => { + renderWithProviders(); + expect(screen.getByRole('button', { name: /emergency stop/i })).toBeDefined(); + }); + + it('calls emergencyStop with no argument and dispatches halt on click', async () => { + const { store } = renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); + await waitFor(() => expect(stop).toHaveBeenCalledWith()); + const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; + expect(safetyState.halted).toBe(true); + }); + + it('does NOT mark halted when emergencyStop throws, and shows a visible error', async () => { + stop.mockRejectedValueOnce(new Error('core unavailable')); + const { store } = renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); + await waitFor(() => expect(stop).toHaveBeenCalled()); + // The core did not confirm the halt, so the UI must not claim halted. + const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; + expect(safetyState.halted).toBe(false); + // Button stays visible so the user can retry. + expect(screen.queryByRole('button', { name: /emergency stop/i })).not.toBeNull(); + // A visible, retryable error is surfaced so the operator knows it failed. + await waitFor(() => expect(screen.getByRole('alert')).toBeDefined()); + }); + + it('renders nothing while already halted (banner Resume takes over)', () => { + renderWithProviders(, { + preloadedState: { safety: { halted: true, source: 'user' } }, + }); + expect(screen.queryByRole('button', { name: /emergency stop/i })).toBeNull(); + }); + + it('hides itself when the store transitions to halted', async () => { + const { store } = renderWithProviders(); + expect(screen.getByRole('button', { name: /emergency stop/i })).toBeDefined(); + store.dispatch(setHalt({ source: 'user' })); + await waitFor(() => + expect(screen.queryByRole('button', { name: /emergency stop/i })).toBeNull() + ); + }); +}); diff --git a/app/src/components/safety/EmergencyStopButton.tsx b/app/src/components/safety/EmergencyStopButton.tsx new file mode 100644 index 0000000000..254b38ac70 --- /dev/null +++ b/app/src/components/safety/EmergencyStopButton.tsx @@ -0,0 +1,73 @@ +import { useCallback, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { emergencyStop } from '../../services/api/emergencyApi'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { selectHalted, setHalt } from '../../store/safetySlice'; + +/** + * Emergency Stop button — always-visible safety control that halts all desktop + * automation immediately. On click it calls the core `emergency_stop` RPC and + * reflects the halt in the Redux safety slice. + * + * On RPC failure it does NOT mark the halt locally (that would falsely signal a + * stop that did not happen) — instead it surfaces a visible, retryable error so + * the operator knows the kill switch did not engage. + * + * Hidden while automation is already halted: the `AutomationHaltedBanner`'s + * Resume control takes over, so Stop and Resume are never shown at once. + */ +export function EmergencyStopButton() { + const { t } = useT(); + const dispatch = useAppDispatch(); + const halted = useAppSelector(selectHalted); + const [failed, setFailed] = useState(false); + + const handleClick = useCallback(async () => { + setFailed(false); + console.debug('[emergency] stop requested (source=user)'); + try { + const state = await emergencyStop(); + console.debug('[emergency] stop confirmed by core', { + engaged: state.engaged, + source: state.source, + }); + dispatch(setHalt({ reason: state.reason, source: state.source, since: state.engaged_at_ms })); + } catch (err) { + // Do NOT mark halted locally on failure: if the RPC did not succeed the + // core is not actually halted, and showing the halted banner would give a + // false sense of safety. Surface a visible, retryable error instead so the + // operator knows the stop did not go through; a confirmed halt only + // appears from a successful response or the `automation_halt` broadcast. + setFailed(true); + console.error('[emergency] stop FAILED — core NOT halted, retry required', err); + } + }, [dispatch]); + + // Already halted → the halt banner (with Resume) is the active control. + if (halted) return null; + + return ( +
+ {failed && ( + + {t('safety.stopFailed')} + + )} + +
+ ); +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 5673bc812c..52703f1dc2 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7131,6 +7131,13 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'اللوحة الجانبية', 'flows.canvas.legendTab': 'يدوي', + // Emergency stop (#4255) + 'safety.emergencyStop': 'إيقاف الطوارئ', + 'safety.stopFailed': 'تعذّر إيقاف الأتمتة. أعد المحاولة.', + 'safety.resume': 'استئناف الأتمتة', + 'safety.resumeFailed': 'تعذّر الاستئناف. لا تزال الأتمتة متوقفة. أعد المحاولة.', + 'safety.haltedTitle': 'الأتمتة متوقفة', + 'safety.haltedBody': 'تم إيقاف جميع أتمتة سطح المكتب. استأنف عندما تكون مستعدًا.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'حالة الخصوصية', 'privacy.status.external': 'خارج الجهاز', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index a05e1d5c99..22ac86f6d9 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7299,6 +7299,13 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'সাইড প্যানেল', 'flows.canvas.legendTab': 'ম্যানুয়াল', + // Emergency stop (#4255) + 'safety.emergencyStop': 'জরুরি বন্ধ', + 'safety.stopFailed': 'অটোমেশন থামানো যায়নি। আবার চেষ্টা করুন।', + 'safety.resume': 'অটোমেশন পুনরায় শুরু করুন', + 'safety.resumeFailed': 'পুনরায় শুরু করা যায়নি। অটোমেশন এখনও বন্ধ। আবার চেষ্টা করুন।', + 'safety.haltedTitle': 'অটোমেশন বন্ধ', + 'safety.haltedBody': 'সমস্ত ডেস্কটপ অটোমেশন বন্ধ করা হয়েছে। প্রস্তুত হলে পুনরায় শুরু করুন।', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'গোপনীয়তা স্থিতি', 'privacy.status.external': 'ডিভাইসের বাইরে', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 1f6b30194e..541f755f2c 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7513,6 +7513,15 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Seitenleiste', 'flows.canvas.legendTab': 'Manuell', + // Emergency stop (#4255) + 'safety.emergencyStop': 'Notabschaltung', + 'safety.stopFailed': 'Automatisierung konnte nicht gestoppt werden – bitte erneut versuchen.', + 'safety.resume': 'Automatisierung fortsetzen', + 'safety.resumeFailed': + 'Fortsetzen fehlgeschlagen – Automatisierung ist weiterhin angehalten. Bitte erneut versuchen.', + 'safety.haltedTitle': 'Automatisierung angehalten', + 'safety.haltedBody': + 'Alle Desktop-Automatisierungen sind gestoppt. Fortsetzen, wenn Sie bereit sind.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Datenschutzstatus', 'privacy.status.external': 'Außerhalb des Geräts', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 0632c83197..73ab4fa04a 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7598,6 +7598,15 @@ const en: TranslationMap = { 'Your AI provider has no API key set. Add one in provider settings to continue.', 'userErrors.scope.chat': 'Chat', 'userErrors.scope.cron': 'Scheduled job', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'Emergency stop', + 'safety.stopFailed': 'Could not stop automation. Try again.', + 'safety.resume': 'Resume automation', + 'safety.resumeFailed': 'Could not resume. Automation is still halted. Try again.', + 'safety.haltedTitle': 'Automation halted', + 'safety.haltedBody': 'All desktop automation is stopped. Resume when you are ready.', + 'memorySources.codingSessions.title': 'Coding-agent sessions', 'memorySources.codingSessions.description': 'Turn your Codex and Claude Code decisions and corrections into private persona memory.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index bf70c3d356..ff6d916360 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7451,6 +7451,15 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Panel lateral', 'flows.canvas.legendTab': 'Manual', + // Emergency stop (#4255) + 'safety.emergencyStop': 'Parada de emergencia', + 'safety.stopFailed': 'No se pudo detener la automatización: inténtalo de nuevo.', + 'safety.resume': 'Reanudar automatización', + 'safety.resumeFailed': + 'No se pudo reanudar: la automatización sigue detenida. Inténtalo de nuevo.', + 'safety.haltedTitle': 'Automatización detenida', + 'safety.haltedBody': + 'Toda la automatización de escritorio está detenida. Reanuda cuando estés listo.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Estado de privacidad', 'privacy.status.external': 'Fuera del dispositivo', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 2377b11924..e7555e5681 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7486,6 +7486,15 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Panneau latéral', 'flows.canvas.legendTab': 'Manuel', + // Emergency stop (#4255) + 'safety.emergencyStop': "Arrêt d'urgence", + 'safety.stopFailed': "Impossible d'arrêter l'automatisation. Réessayez.", + 'safety.resume': "Reprendre l'automatisation", + 'safety.resumeFailed': + "Impossible de reprendre. L'automatisation est toujours suspendue. Réessayez.", + 'safety.haltedTitle': 'Automatisation suspendue', + 'safety.haltedBody': + "Toute l'automatisation du bureau est arrêtée. Reprenez quand vous êtes prêt.", // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'État de confidentialité', 'privacy.status.external': 'Hors de l’appareil', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 944335e945..88b8c8b624 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7295,6 +7295,13 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'साइड पैनल', 'flows.canvas.legendTab': 'मैनुअल', + // Emergency stop (#4255) + 'safety.emergencyStop': 'आपातकालीन रोक', + 'safety.stopFailed': 'स्वचालन रोका नहीं जा सका। पुनः प्रयास करें।', + 'safety.resume': 'स्वचालन पुनः प्रारंभ करें', + 'safety.resumeFailed': 'पुनः प्रारंभ नहीं हो सका। स्वचालन अभी भी रुका हुआ है। पुनः प्रयास करें।', + 'safety.haltedTitle': 'स्वचालन रोका गया', + 'safety.haltedBody': 'सभी डेस्कटॉप स्वचालन रोक दिया गया है। तैयार होने पर पुनः प्रारंभ करें।', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'गोपनीयता स्थिति', 'privacy.status.external': 'डिवाइस के बाहर', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 8f4c546936..fefb2dbc58 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7333,6 +7333,13 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Panel samping', 'flows.canvas.legendTab': 'Manual', + // Emergency stop (#4255) + 'safety.emergencyStop': 'Hentikan darurat', + 'safety.stopFailed': 'Tidak dapat menghentikan otomasi. Coba lagi.', + 'safety.resume': 'Lanjutkan otomasi', + 'safety.resumeFailed': 'Tidak dapat melanjutkan. Otomasi masih dihentikan. Coba lagi.', + 'safety.haltedTitle': 'Otomasi dihentikan', + 'safety.haltedBody': 'Semua otomasi desktop dihentikan. Lanjutkan ketika Anda siap.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Status privasi', 'privacy.status.external': 'Di luar perangkat', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 79b773be88..8c48eefb30 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7440,6 +7440,13 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Pannello laterale', 'flows.canvas.legendTab': 'Manuale', + // Emergency stop (#4255) + 'safety.emergencyStop': 'Arresto di emergenza', + 'safety.stopFailed': "Impossibile fermare l'automazione. Riprova.", + 'safety.resume': "Riprendi l'automazione", + 'safety.resumeFailed': "Impossibile riprendere. L'automazione è ancora sospesa. Riprova.", + 'safety.haltedTitle': 'Automazione sospesa', + 'safety.haltedBody': "Tutta l'automazione del desktop è ferma. Riprendi quando sei pronto.", // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Stato privacy', 'privacy.status.external': 'Fuori dal dispositivo', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 7d04b5c76d..71b5452a7b 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7210,6 +7210,13 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': '사이드 패널', 'flows.canvas.legendTab': '수동', + // Emergency stop (#4255) + 'safety.emergencyStop': '긴급 정지', + 'safety.stopFailed': '자동화를 중지할 수 없습니다. 다시 시도하세요.', + 'safety.resume': '자동화 재개', + 'safety.resumeFailed': '재개하지 못했습니다. 자동화가 여전히 중단된 상태입니다. 다시 시도하세요.', + 'safety.haltedTitle': '자동화 중단됨', + 'safety.haltedBody': '모든 데스크톱 자동화가 중지되었습니다. 준비가 되면 재개하세요.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': '개인정보 상태', 'privacy.status.external': '기기 외', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 06db2ea10f..cfe0e77a5b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7409,6 +7409,13 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Panel boczny', 'flows.canvas.legendTab': 'Ręczny', + // Emergency stop (#4255) + 'safety.emergencyStop': 'Awaryjne zatrzymanie', + 'safety.stopFailed': 'Nie udało się zatrzymać automatyzacji. Spróbuj ponownie.', + 'safety.resume': 'Wznów automatyzację', + 'safety.resumeFailed': 'Nie udało się wznowić. Automatyzacja nadal wstrzymana. Spróbuj ponownie.', + 'safety.haltedTitle': 'Automatyzacja wstrzymana', + 'safety.haltedBody': 'Cała automatyzacja pulpitu jest zatrzymana. Wznów, gdy będziesz gotowy.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Stan prywatności', 'privacy.status.external': 'Poza urządzeniem', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 9aff9b3a57..6c199906a6 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7420,6 +7420,13 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Painel lateral', 'flows.canvas.legendTab': 'Manual', + // Emergency stop (#4255) + 'safety.emergencyStop': 'Parada de emergência', + 'safety.stopFailed': 'Não foi possível parar a automação. Tente novamente.', + 'safety.resume': 'Retomar automação', + 'safety.resumeFailed': 'Não foi possível retomar. Automação ainda pausada. Tente novamente.', + 'safety.haltedTitle': 'Automação pausada', + 'safety.haltedBody': 'Toda a automação do desktop está parada. Retome quando estiver pronto.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Estado de privacidade', 'privacy.status.external': 'Fora do dispositivo', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 980bc5c830..c4cf037bc8 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7380,6 +7380,15 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': 'Боковая панель', 'flows.canvas.legendTab': 'Вручную', + // Emergency stop (#4255) + 'safety.emergencyStop': 'Аварийная остановка', + 'safety.stopFailed': 'Не удалось остановить автоматизацию. Попробуйте ещё раз.', + 'safety.resume': 'Возобновить автоматизацию', + 'safety.resumeFailed': + 'Не удалось возобновить. Автоматизация всё ещё приостановлена. Повторите попытку.', + 'safety.haltedTitle': 'Автоматизация приостановлена', + 'safety.haltedBody': + 'Вся автоматизация рабочего стола остановлена. Возобновите, когда будете готовы.', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Состояние конфиденциальности', 'privacy.status.external': 'Вне устройства', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index b799817bd3..727630da1b 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6899,6 +6899,13 @@ const messages: TranslationMap = { 'flows.canvas.sidePanelToggle': '侧边栏', 'flows.canvas.legendTab': '手动', + // Emergency stop (#4255) + 'safety.emergencyStop': '紧急停止', + 'safety.stopFailed': '无法停止自动化,请重试。', + 'safety.resume': '恢复自动化', + 'safety.resumeFailed': '无法恢复,自动化仍处于暂停状态。请重试。', + 'safety.haltedTitle': '自动化已暂停', + 'safety.haltedBody': '所有桌面自动化已停止。准备好后请恢复。', // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': '隐私状态', 'privacy.status.external': '设备外', diff --git a/app/src/services/__tests__/socketService.events.test.ts b/app/src/services/__tests__/socketService.events.test.ts index c61553be3f..9202dd559b 100644 --- a/app/src/services/__tests__/socketService.events.test.ts +++ b/app/src/services/__tests__/socketService.events.test.ts @@ -452,3 +452,156 @@ describe('socketService — agent_meetings event handlers (lines 428-480)', () = expect(ingestRuntimeErrorSignalMock).not.toHaveBeenCalled(); }); }); + +describe('socketService — automation_halt handler (#4255)', () => { + beforeEach(() => { + vi.resetModules(); + storeMock.dispatch.mockClear(); + storeMock.getState.mockReturnValue({ + thread: { selectedThreadId: null, activeThreadId: null }, + }); + getCoreRpcUrlMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('dispatches setHalt when automation_halt arrives with engaged=true (WebChannelEvent envelope)', async () => { + const { handlers, mockSocket } = buildMockSocket(); + vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); + getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); + + // Mock safetySlice actions + const setHaltMock = vi.fn((x: unknown) => ({ type: 'safety/setHalt', payload: x })); + const clearHaltMock = vi.fn(() => ({ type: 'safety/clearHalt' })); + vi.doMock('../../store/safetySlice', () => ({ + setHalt: (x: unknown) => setHaltMock(x), + clearHalt: () => clearHaltMock(), + })); + + const { socketService } = await import('../socketService'); + socketService.connect('jwt-halt-engaged'); + + await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); + + // Real wire payload: `emit_web_channel_event` serialises the entire + // `WebChannelEvent` envelope so halt fields ride under `args`. + handlers['automation_halt']!({ + event: 'automation_halt', + client_id: 'system', + thread_id: '', + request_id: '', + args: { engaged: true, reason: 'cli', source: 'cli' }, + }); + + expect(storeMock.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'safety/setHalt' }) + ); + }); + + it('dispatches clearHalt when automation_halt arrives with engaged=false (WebChannelEvent envelope)', async () => { + const { handlers, mockSocket } = buildMockSocket(); + vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); + getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); + + const clearHaltMock = vi.fn(() => ({ type: 'safety/clearHalt' })); + vi.doMock('../../store/safetySlice', () => ({ + setHalt: vi.fn((x: unknown) => ({ type: 'safety/setHalt', payload: x })), + clearHalt: () => clearHaltMock(), + })); + + const { socketService } = await import('../socketService'); + socketService.connect('jwt-halt-cleared'); + + await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); + + handlers['automation_halt']!({ + event: 'automation_halt', + client_id: 'system', + thread_id: '', + request_id: '', + args: { engaged: false, source: 'cli' }, + }); + + expect(storeMock.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'safety/clearHalt' }) + ); + }); + + it('also accepts a top-level payload (direct-emit fallback for tests / future direct broadcasts)', async () => { + const { handlers, mockSocket } = buildMockSocket(); + vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); + getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); + + vi.doMock('../../store/safetySlice', () => ({ + setHalt: vi.fn((x: unknown) => ({ type: 'safety/setHalt', payload: x })), + clearHalt: vi.fn(() => ({ type: 'safety/clearHalt' })), + })); + + const { socketService } = await import('../socketService'); + socketService.connect('jwt-halt-top-level'); + + await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); + + handlers['automation_halt']!({ engaged: true, reason: 'user', source: 'user' }); + + expect(storeMock.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'safety/setHalt' }) + ); + }); + + it('drops a malformed automation_halt payload without dispatching or throwing', async () => { + const { handlers, mockSocket } = buildMockSocket(); + vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); + getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); + + vi.doMock('../../store/safetySlice', () => ({ + setHalt: vi.fn((x: unknown) => ({ type: 'safety/setHalt', payload: x })), + clearHalt: vi.fn(() => ({ type: 'safety/clearHalt' })), + })); + + const { socketService } = await import('../socketService'); + socketService.connect('jwt-halt-malformed'); + + await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); + + storeMock.dispatch.mockClear(); + + // Non-object payloads should be silently dropped. + expect(() => handlers['automation_halt']!('not-an-object')).not.toThrow(); + expect(() => handlers['automation_halt']!(null)).not.toThrow(); + expect(storeMock.dispatch).not.toHaveBeenCalled(); + }); + + it('fails closed: an object without a boolean engaged is dropped (no clearHalt)', async () => { + const { handlers, mockSocket } = buildMockSocket(); + vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); + getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); + + vi.doMock('../../store/safetySlice', () => ({ + setHalt: vi.fn((x: unknown) => ({ type: 'safety/setHalt', payload: x })), + clearHalt: vi.fn(() => ({ type: 'safety/clearHalt' })), + })); + + const { socketService } = await import('../socketService'); + socketService.connect('jwt-halt-ambiguous'); + + await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); + + storeMock.dispatch.mockClear(); + + // Ambiguous payloads (missing/non-boolean `engaged`) must NOT be treated as + // `engaged=false` — that would silently clear an active halt on a kill switch. + // Both the top-level shape and the `WebChannelEvent` `args` envelope shape + // are covered so a real malformed broadcast can never bypass the guard. + expect(() => handlers['automation_halt']!({})).not.toThrow(); + expect(() => handlers['automation_halt']!({ reason: 'x' })).not.toThrow(); + expect(() => handlers['automation_halt']!({ engaged: 'true' })).not.toThrow(); + expect(() => handlers['automation_halt']!({ args: {} })).not.toThrow(); + expect(() => + handlers['automation_halt']!({ args: { engaged: 'true', reason: 'x' } }) + ).not.toThrow(); + expect(storeMock.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/services/api/emergencyApi.test.ts b/app/src/services/api/emergencyApi.test.ts new file mode 100644 index 0000000000..5e80fe8fd8 --- /dev/null +++ b/app/src/services/api/emergencyApi.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { emergencyResume, emergencyStatus, emergencyStop } from './emergencyApi'; + +const call = vi.fn(); +vi.mock('../coreRpcClient', () => ({ callCoreRpc: (arg: unknown) => call(arg) })); + +beforeEach(() => call.mockReset()); + +describe('emergencyApi', () => { + it('emergencyStop calls openhuman.emergency_stop with reason and unwraps envelope', async () => { + call.mockResolvedValue({ result: { engaged: true, reason: 'user' }, logs: ['x'] }); + const r = await emergencyStop('user'); + expect(call).toHaveBeenCalledWith({ + method: 'openhuman.emergency_stop', + params: { reason: 'user' }, + }); + expect(r.engaged).toBe(true); + expect(r.reason).toBe('user'); + }); + it('emergencyStop with no reason sends empty params', async () => { + call.mockResolvedValue({ result: { engaged: true }, logs: [] }); + await emergencyStop(); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_stop', params: {} }); + }); + it('emergencyResume calls openhuman.emergency_resume', async () => { + call.mockResolvedValue({ result: { engaged: false }, logs: ['x'] }); + const r = await emergencyResume(); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_resume', params: {} }); + expect(r.engaged).toBe(false); + }); + it('emergencyStatus reads bare value (no envelope)', async () => { + call.mockResolvedValue({ engaged: false }); + const r = await emergencyStatus(); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_status', params: {} }); + expect(r.engaged).toBe(false); + }); +}); diff --git a/app/src/services/api/emergencyApi.ts b/app/src/services/api/emergencyApi.ts new file mode 100644 index 0000000000..224f485b6b --- /dev/null +++ b/app/src/services/api/emergencyApi.ts @@ -0,0 +1,31 @@ +import type { HaltState } from '../../store/safetySlice'; +import { callCoreRpc } from '../coreRpcClient'; + +/** Normalize the CLI envelope `{ result, logs }` and bare-value shapes. */ +const unwrapValue = (raw: unknown): T => { + if (raw && typeof raw === 'object' && 'result' in (raw as Record)) { + return (raw as { result: T }).result; + } + return raw as T; +}; + +export async function emergencyStop(reason?: string): Promise { + console.debug('[emergency] rpc → openhuman.emergency_stop', { reason: reason ?? 'none' }); + const raw = await callCoreRpc({ + method: 'openhuman.emergency_stop', + params: reason ? { reason } : {}, + }); + return unwrapValue(raw); +} + +export async function emergencyResume(): Promise { + console.debug('[emergency] rpc → openhuman.emergency_resume'); + const raw = await callCoreRpc({ method: 'openhuman.emergency_resume', params: {} }); + return unwrapValue(raw); +} + +export async function emergencyStatus(): Promise { + console.debug('[emergency] rpc → openhuman.emergency_status'); + const raw = await callCoreRpc({ method: 'openhuman.emergency_status', params: {} }); + return unwrapValue(raw); +} diff --git a/app/src/services/safety/hydrateEmergencyState.test.ts b/app/src/services/safety/hydrateEmergencyState.test.ts new file mode 100644 index 0000000000..4ad2b6b9c1 --- /dev/null +++ b/app/src/services/safety/hydrateEmergencyState.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { hydrateEmergencyState } from './hydrateEmergencyState'; + +// Mock emergencyApi before importing the module under test +const emergencyStatusMock = vi.fn(); +vi.mock('../api/emergencyApi', () => ({ emergencyStatus: () => emergencyStatusMock() })); + +// Mock hydrateHalt action creator +const hydrateHaltMock = vi.fn((x: unknown) => ({ type: 'safety/hydrateHalt', payload: x })); +vi.mock('../../store/safetySlice', () => ({ hydrateHalt: (x: unknown) => hydrateHaltMock(x) })); + +describe('hydrateEmergencyState', () => { + const dispatch = vi.fn(); + + beforeEach(() => { + dispatch.mockClear(); + emergencyStatusMock.mockReset(); + hydrateHaltMock.mockClear(); + }); + + it('dispatches hydrateHalt with the result of emergencyStatus on success', async () => { + const status = { engaged: true, reason: 'cli', source: 'cli', engaged_at_ms: 12345 }; + emergencyStatusMock.mockResolvedValue(status); + + await hydrateEmergencyState(dispatch); + + expect(hydrateHaltMock).toHaveBeenCalledWith(status); + expect(dispatch).toHaveBeenCalledWith({ type: 'safety/hydrateHalt', payload: status }); + }); + + it('dispatches hydrateHalt when halt is not engaged', async () => { + const status = { engaged: false }; + emergencyStatusMock.mockResolvedValue(status); + + await hydrateEmergencyState(dispatch); + + expect(hydrateHaltMock).toHaveBeenCalledWith(status); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('swallows errors from emergencyStatus and does not dispatch', async () => { + emergencyStatusMock.mockRejectedValue(new Error('core unavailable')); + + // Must not throw + await expect(hydrateEmergencyState(dispatch)).resolves.toBeUndefined(); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/services/safety/hydrateEmergencyState.ts b/app/src/services/safety/hydrateEmergencyState.ts new file mode 100644 index 0000000000..3429746192 --- /dev/null +++ b/app/src/services/safety/hydrateEmergencyState.ts @@ -0,0 +1,21 @@ +import type { Dispatch } from '@reduxjs/toolkit'; + +import { hydrateHalt } from '../../store/safetySlice'; +import { emergencyStatus } from '../api/emergencyApi'; + +/** + * Fetches the authoritative halt state from the core and dispatches + * `hydrateHalt` into the Redux store. Errors are caught and logged so a + * degraded core never crashes the boot path. + * + * Extracted from AppShellDesktop's boot-hydration effect so it can be + * unit-tested in isolation without rendering the full component tree. + */ +export async function hydrateEmergencyState(dispatch: Dispatch): Promise { + try { + const status = await emergencyStatus(); + dispatch(hydrateHalt(status)); + } catch (err) { + console.warn('[emergency] status hydration failed', err); + } +} diff --git a/app/src/services/socketService.ts b/app/src/services/socketService.ts index 4097563bae..f82ec9090f 100644 --- a/app/src/services/socketService.ts +++ b/app/src/services/socketService.ts @@ -17,6 +17,7 @@ import { import { upsertChannelConnection } from '../store/channelConnectionsSlice'; import { type CompanionStateChangedEvent, setCompanionState } from '../store/companionSlice'; import { setBackend } from '../store/connectivitySlice'; +import { clearHalt, setHalt } from '../store/safetySlice'; import { resetForUser, setSocketIdForUser, setStatusForUser } from '../store/socketSlice'; import type { ChannelAuthMode, ChannelConnectionStatus, ChannelType } from '../types/channels'; import { IS_DEV } from '../utils/config'; @@ -463,6 +464,46 @@ class SocketService { }); }); + // Automation halt/resume broadcasts — core publishes this when emergency_stop + // or emergency_resume is called from any client (UI, CLI, cron) so all + // connected surfaces reflect the halt state without polling. + this.socket.on('automation_halt', (data: unknown) => { + const obj = data as Record | null; + if (!obj || typeof obj !== 'object') { + socketWarn('automation_halt dropped — invalid payload shape'); + return; + } + // Halt fields ride under `args` in the `WebChannelEvent` envelope + // (same contract as `approval_request`; see `event_bus.rs` builder and + // `emit_web_channel_event` in `src/core/socketio.rs`, which does + // `serde_json::to_value(event)` on the whole envelope). Fall back to + // the top level so a direct-emit test payload keeps working. + const payload = + obj.args && typeof obj.args === 'object' ? (obj.args as Record) : obj; + // Fail closed: a kill-switch event must carry an explicit boolean + // `engaged`. An ambiguous payload (missing/non-boolean flag, e.g. `{}` or + // `{reason:'x'}`) is dropped rather than treated as `false`, so a + // malformed broadcast can never silently clear an active halt. + if (typeof payload.engaged !== 'boolean') { + socketWarn('automation_halt dropped — missing/invalid engaged flag'); + return; + } + const engaged = payload.engaged; + const reason = typeof payload.reason === 'string' ? payload.reason : undefined; + const source = typeof payload.source === 'string' ? payload.source : undefined; + socketLog( + 'automation_halt engaged=%s reason=%s source=%s', + engaged, + reason ?? 'none', + source ?? 'none' + ); + if (engaged) { + store.dispatch(setHalt({ reason, source })); + } else { + store.dispatch(clearHalt()); + } + }); + // Backend Meet bot events — forwarded from core's DomainEvent bus this.socket.on('agent_meetings:joined', (data: unknown) => { const obj = data as Record | null; diff --git a/app/src/store/index.ts b/app/src/store/index.ts index b41171943a..1a7b4435a6 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -34,6 +34,7 @@ import notificationReducer from './notificationSlice'; import personaReducer from './personaSlice'; import providerSurfacesReducer from './providerSurfaceSlice'; import { pttReducer } from './pttSlice'; +import safetyReducer from './safetySlice'; import socketReducer from './socketSlice'; import themeReducer from './themeSlice'; import threadReducer from './threadSlice'; @@ -244,6 +245,7 @@ export const store = configureStore({ // completion, resets on restart + user switch. Durable storage is a #3931 // follow-up. userErrors: userErrorsReducer, + safety: safetyReducer, }, middleware: getDefaultMiddleware => { const middleware = getDefaultMiddleware({ diff --git a/app/src/store/safetySlice.test.ts b/app/src/store/safetySlice.test.ts new file mode 100644 index 0000000000..bcf652650e --- /dev/null +++ b/app/src/store/safetySlice.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import reducer, { clearHalt, hydrateHalt, setHalt } from './safetySlice'; + +describe('safetySlice', () => { + it('starts not halted', () => { + expect(reducer(undefined, { type: '@@init' })).toEqual({ halted: false }); + }); + it('setHalt marks halted with reason/source/since', () => { + const s = reducer(undefined, setHalt({ reason: 'user', source: 'user', since: 42 })); + expect(s).toEqual({ halted: true, reason: 'user', source: 'user', since: 42 }); + }); + it('clearHalt resets', () => { + const halted = reducer(undefined, setHalt({ reason: 'x' })); + expect(reducer(halted, clearHalt())).toEqual({ halted: false }); + }); + it('hydrateHalt maps a HaltState snapshot', () => { + const s = reducer( + undefined, + hydrateHalt({ engaged: true, reason: 'boot', engaged_at_ms: 7, source: 'system' }) + ); + expect(s.halted).toBe(true); + expect(s.reason).toBe('boot'); + expect(s.since).toBe(7); + }); +}); diff --git a/app/src/store/safetySlice.ts b/app/src/store/safetySlice.ts new file mode 100644 index 0000000000..29f8d437dd --- /dev/null +++ b/app/src/store/safetySlice.ts @@ -0,0 +1,49 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface HaltState { + engaged: boolean; + reason?: string; + engaged_at_ms?: number; + source?: string; +} + +export interface SafetyState { + halted: boolean; + reason?: string; + since?: number; + source?: string; +} + +const initialState: SafetyState = { halted: false }; + +const safetySlice = createSlice({ + name: 'safety', + initialState, + reducers: { + setHalt(_state, action: PayloadAction<{ reason?: string; source?: string; since?: number }>) { + return { + halted: true, + reason: action.payload.reason, + source: action.payload.source, + since: action.payload.since, + }; + }, + clearHalt() { + return { halted: false }; + }, + hydrateHalt(_state, action: PayloadAction) { + const h = action.payload; + return h.engaged + ? { halted: true, reason: h.reason, source: h.source, since: h.engaged_at_ms } + : { halted: false }; + }, + }, +}); + +export const { setHalt, clearHalt, hydrateHalt } = safetySlice.actions; +// Defensive reads: some App-shell tests mock the store with a partial state that +// omits the `safety` slice. Optional chaining keeps the kill-switch UI from +// crashing the shell in that case (halted → false, no banner). +export const selectHalted = (state: { safety?: SafetyState }) => state.safety?.halted ?? false; +export const selectHaltReason = (state: { safety?: SafetyState }) => state.safety?.reason; +export default safetySlice.reducer; diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx index beda189643..b3667becf5 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -25,6 +25,7 @@ import mascotReducer from '../store/mascotSlice'; import notificationReducer from '../store/notificationSlice'; import personaReducer from '../store/personaSlice'; import { pttReducer } from '../store/pttSlice'; +import safetyReducer from '../store/safetySlice'; import socketReducer from '../store/socketSlice'; import themeReducer from '../store/themeSlice'; import threadReducer from '../store/threadSlice'; @@ -54,6 +55,7 @@ const testRootReducer = combineReducers({ notifications: notificationReducer, persona: personaReducer, ptt: pttReducer, + safety: safetyReducer, socket: socketReducer, theme: themeReducer, thread: threadReducer, diff --git a/docs/superpowers/plans/2026-07-06-emergency-stop.md b/docs/superpowers/plans/2026-07-06-emergency-stop.md new file mode 100644 index 0000000000..ce629ba8d8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-emergency-stop.md @@ -0,0 +1,1358 @@ +# Emergency Stop Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A fail-closed "Emergency Stop" kill switch that instantly halts all running/queued desktop automation and blocks any further automated action until the user explicitly resumes. + +**Architecture:** A new process-global `EmergencyStop` singleton in the Rust core (`src/openhuman/emergency_stop/`), mirroring the `ApprovalGate` `OnceLock` pattern. Three RPCs (`openhuman.emergency_stop|resume|status`) engage/clear/read it. Two fail-closed enforcement chokepoints consult it: the tinyagents approval middleware (blocks external-effect tool calls) and `accessibility_input_action` (blocks clicks/typing). Engaging also stops the accessibility session and cascade-denies pending approvals. The React app gets a persistent Emergency Stop button + halted banner backed by a Redux `safetySlice`, driven by the RPC responses and boot-time hydration. + +**Tech Stack:** Rust (tokio, serde, `anyhow`), JSON-RPC controller registry, React 19 + TypeScript + Redux Toolkit + Vitest, i18n via `useT()`. + +**Spec:** `docs/superpowers/specs/2026-07-06-emergency-stop-design.md` + +## Global Constraints + +- **Never write code on `main`.** Work is on branch `feat/desktop-safety-4255` (already created). +- **Fail-closed:** when the switch is installed AND engaged, block. When no switch is installed (`try_global()` → `None`, e.g. CLI/headless), never block. +- **Diff coverage ≥ 80% on changed lines** (merge gate: `frontend-coverage`/`rust-core-coverage`). +- **Rust module shape** (AGENTS.md): `mod.rs` export-only; `types.rs` serde types; `state.rs` state; `ops.rs` logic returning `RpcOutcome`; `schemas.rs` controllers. New functionality → dedicated subdirectory; no new root-level `*.rs`. +- **RPC naming:** `openhuman._` — here namespace `emergency`, functions `stop`/`resume`/`status`. +- **Controller exposure:** register via `src/core/all.rs` registry, not branches in `cli.rs`/`jsonrpc.rs`. +- **i18n:** all UI text through `useT()`; add keys to `app/src/lib/i18n/en.ts` **and** real translations in every sibling locale file (`app/src/lib/i18n/.ts` for `ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN`). CI enforces parity (`pnpm i18n:check`). +- **Debug logging:** grep-friendly prefixes (`[emergency]`, `[rpc:emergency_*]`); log entry/exit, state transitions, errors; never log secrets/PII. +- **Frontend:** no dynamic imports in `app/src`; use `invoke('core_rpc_relay', …)` via `coreRpcClient`; guard Tauri with `isTauri()`/try-catch. +- **Rust checks:** `cargo check --manifest-path Cargo.toml` (add `GGML_NATIVE=OFF` on macOS Apple Silicon). Tests: `pnpm test:rust` or `bash scripts/test-rust-with-mock.sh --test `; targeted lib tests: `cargo test --manifest-path Cargo.toml `. +- **Frontend checks:** `pnpm typecheck`, `pnpm lint`, `pnpm test`. + +--- + +## File Structure + +**Rust core (new domain `src/openhuman/emergency_stop/`):** +- `mod.rs` — module docstring, `pub mod` decls, `pub use` re-exports, controller-schema pair. +- `types.rs` — `HaltState`, `HaltSource` (serde). +- `state.rs` — `EmergencyStop` global singleton (`OnceLock`), `init_global`/`try_global`/`is_engaged`/`engage`/`clear`/`snapshot`. +- `ops.rs` — `emergency_stop`/`emergency_resume`/`emergency_status` returning `RpcOutcome`; cascade-deny + a11y stop; publishes events. +- `schemas.rs` — controller schemas + `handle_*` fns. + +**Rust core (modified):** +- `src/core/event_bus/events.rs` — add `AutomationHalted`/`AutomationResumed` variants + `domain()` + `name()` arms. +- `src/core/all.rs` — register emergency controllers. +- `src/core/jsonrpc.rs` — install `EmergencyStop::init_global()` at boot; register socket bridge subscriber. +- `src/openhuman/tinyagents/middleware.rs` — halt check in `ApprovalSecurityMiddleware::wrap_tool`. +- `src/openhuman/screen_intelligence/ops.rs` — halt check in `accessibility_input_action`. +- `src/openhuman/channels/providers/web/event_bus.rs` — `AutomationHaltSubscriber` bridging events → `automation_halt` socket event. +- `tests/json_rpc_e2e.rs` — stop→status→resume E2E. + +**Frontend (new):** +- `app/src/store/safetySlice.ts` (+ `safetySlice.test.ts`) — halted state. +- `app/src/services/api/emergencyApi.ts` (+ `emergencyApi.test.ts`) — RPC client. +- `app/src/components/safety/EmergencyStopButton.tsx` (+ test) — button. +- `app/src/components/safety/AutomationHaltedBanner.tsx` (+ test) — banner + Resume. + +**Frontend (modified):** +- `app/src/store/index.ts` (or root reducer) — mount `safety` reducer. +- `app/src/services/socketService.ts` — handle `automation_halt` socket event. +- app shell/header (e.g. `app/src/components/layout/*` or `Conversations` header) — mount button + banner + boot hydration. +- `app/src/lib/i18n/locales/*.ts` — i18n keys. + +**Note on exact neighboring types:** three field-shapes are already confirmed from the codebase — `InputActionResult { accepted: bool, blocked: bool, reason: Option }` (`screen_intelligence/types.rs:144`), `InputActionParams { action: String, .. }` (`types.rs:133`), and the `WebChannelEvent` bridge pattern (`web/event_bus.rs`). Before Task 12's socket bridge, read `src/core/socketio` for the exact `WebChannelEvent` fields (the artifact/approval bridges set `event`, `client_id`, `thread_id`, `args`, `..Default::default()`). + +--- + +## Task 1: Event variants — `AutomationHalted` / `AutomationResumed` + +**Files:** +- Modify: `src/core/event_bus/events.rs` (add variants near the System lifecycle group ~line 1025; extend `domain()` ~1283 and `name()` ~1540) + +**Interfaces:** +- Produces: `DomainEvent::AutomationHalted { reason: Option, source: String }`, `DomainEvent::AutomationResumed { source: String }`. Both map to domain `"system"`. + +- [ ] **Step 1: Add the two variants.** In the `DomainEvent` enum, in the System-lifecycle region, add: + +```rust + /// Emergency stop engaged — all desktop automation is halted and every + /// external-effect / accessibility action is refused until resumed. + /// Published by `emergency_stop::ops::emergency_stop`; bridged to the + /// `automation_halt` web-channel socket event. + AutomationHalted { + /// Optional human-readable reason (redacted of PII by the caller). + reason: Option, + /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`. + source: String, + }, + /// Emergency stop cleared — automation may resume. Published by + /// `emergency_stop::ops::emergency_resume`. + AutomationResumed { + /// Who cleared it: `"user"`, `"hotkey"`, or `"system"`. + source: String, + }, +``` + +- [ ] **Step 2: Extend `domain()`.** In the `pub fn domain(&self)` match, add to the `"system"` arm (alongside `HarnessInitCompleted`): + +```rust + | Self::AutomationHalted { .. } + | Self::AutomationResumed { .. } => "system", +``` + +- [ ] **Step 3: Extend `name()`.** In the `name()` match (near the `ApprovalRequested => "ApprovalRequested"` arms): + +```rust + Self::AutomationHalted { .. } => "AutomationHalted", + Self::AutomationResumed { .. } => "AutomationResumed", +``` + +- [ ] **Step 4: Add a unit test.** Append to the `#[cfg(test)]` module in `events.rs` (or create one if none — match the file's existing test style): + +```rust + #[test] + fn automation_events_map_to_system_domain() { + let halted = DomainEvent::AutomationHalted { reason: Some("user".into()), source: "user".into() }; + let resumed = DomainEvent::AutomationResumed { source: "user".into() }; + assert_eq!(halted.domain(), "system"); + assert_eq!(resumed.domain(), "system"); + assert_eq!(halted.name(), "AutomationHalted"); + assert_eq!(resumed.name(), "AutomationResumed"); + } +``` + +- [ ] **Step 5: Compile + test.** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman automation_events_map_to_system_domain` +Expected: PASS (build succeeds, 1 test passes). If `name()`/`domain()` have exhaustive-match compile errors, fix the arms until it builds. + +- [ ] **Step 6: Commit.** + +```bash +git add src/core/event_bus/events.rs +git commit -m "feat(events): add AutomationHalted/AutomationResumed domain events (#4255)" +``` + +--- + +## Task 2: `emergency_stop` types + +**Files:** +- Create: `src/openhuman/emergency_stop/types.rs` + +**Interfaces:** +- Produces: `HaltState { engaged: bool, reason: Option, engaged_at_ms: Option, source: Option }` (serde, `Clone`, `Debug`, `PartialEq`, `Default`). Used by `state.rs`, `ops.rs`, `schemas.rs`. + +- [ ] **Step 1: Write the failing test.** Create `src/openhuman/emergency_stop/types.rs`: + +```rust +//! Serde domain types for the emergency-stop kill switch. + +use serde::{Deserialize, Serialize}; + +/// Snapshot of the emergency-stop switch, returned by every emergency RPC and +/// surfaced in the UI. `engaged == false` is the resting state. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct HaltState { + /// Whether automation is currently halted. + pub engaged: bool, + /// Human-readable reason for the halt (redacted of PII), when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Unix-epoch milliseconds when the halt was engaged, when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub engaged_at_ms: Option, + /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`, when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_halt_state_is_not_engaged() { + let s = HaltState::default(); + assert!(!s.engaged); + assert!(s.reason.is_none()); + assert!(s.engaged_at_ms.is_none()); + } + + #[test] + fn resting_state_serializes_to_engaged_false_only() { + let json = serde_json::to_string(&HaltState::default()).unwrap(); + assert_eq!(json, r#"{"engaged":false}"#); + } + + #[test] + fn engaged_state_roundtrips() { + let s = HaltState { engaged: true, reason: Some("user".into()), engaged_at_ms: Some(42), source: Some("user".into()) }; + let back: HaltState = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(s, back); + } +} +``` + +- [ ] **Step 2: Run to verify it fails to build.** (Module not declared yet — see Task 4 wires `mod.rs`; for now this task's test runs once `mod.rs` exists. To keep TDD honest, do Task 3 & the `mod.rs` skeleton, then run.) Run after Task 4: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::types` +Expected (before impl wired): FAIL to compile ("file not found for module" / unresolved). + +- [ ] **Step 3: (impl already written in Step 1).** + +- [ ] **Step 4: Run after `mod.rs` exists (Task 4).** Expected: 3 tests PASS. + +- [ ] **Step 5: Commit** (batched with Task 3–4, since the module must be wired to compile). + +--- + +## Task 3: `emergency_stop` state (global singleton) + +**Files:** +- Create: `src/openhuman/emergency_stop/state.rs` + +**Interfaces:** +- Consumes: `HaltState` (Task 2). +- Produces: `EmergencyStop` with associated fns `init_global() -> Arc`, `try_global() -> Option>`, and methods `is_engaged(&self) -> bool`, `engage(&self, reason: Option, source: &str, now_ms: u64)`, `clear(&self)`, `snapshot(&self) -> HaltState`. Free fn `is_engaged_global() -> bool` (false when no switch installed). + +- [ ] **Step 1: Write state + tests.** Create `src/openhuman/emergency_stop/state.rs`: + +```rust +//! Process-global emergency-stop switch. Mirrors the `ApprovalGate` +//! `OnceLock` install pattern: `init_global` is idempotent, `try_global` +//! returns `None` when never installed (CLI/headless → never blocks). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use super::types::HaltState; + +static GLOBAL_STOP: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +struct HaltInfo { + reason: Option, + engaged_at_ms: u64, + source: String, +} + +/// Coordinator for the emergency-stop kill switch. +#[derive(Debug)] +pub struct EmergencyStop { + engaged: AtomicBool, + info: Mutex>, +} + +impl EmergencyStop { + /// Install the process-global switch. Idempotent — re-install returns the + /// existing switch so repeated boots in tests don't panic. + pub fn init_global() -> Arc { + if let Some(existing) = GLOBAL_STOP.get() { + return existing.clone(); + } + let stop = Arc::new(EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }); + let _ = GLOBAL_STOP.set(stop.clone()); + GLOBAL_STOP.get().cloned().unwrap_or(stop) + } + + /// The global switch when installed; `None` means "no switch" → callers + /// treat as not-engaged (never block). + pub fn try_global() -> Option> { + GLOBAL_STOP.get().cloned() + } + + /// Whether automation is currently halted. + pub fn is_engaged(&self) -> bool { + self.engaged.load(Ordering::SeqCst) + } + + /// Engage the halt. Idempotent — re-engaging refreshes reason/source/time. + pub fn engage(&self, reason: Option, source: &str, now_ms: u64) { + { + let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(HaltInfo { reason, engaged_at_ms: now_ms, source: source.to_string() }); + } + self.engaged.store(true, Ordering::SeqCst); + } + + /// Clear the halt. Idempotent. + pub fn clear(&self) { + self.engaged.store(false, Ordering::SeqCst); + let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + } + + /// Current snapshot for RPC/UI. + pub fn snapshot(&self) -> HaltState { + if !self.is_engaged() { + return HaltState::default(); + } + let guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + match guard.as_ref() { + Some(info) => HaltState { + engaged: true, + reason: info.reason.clone(), + engaged_at_ms: Some(info.engaged_at_ms), + source: Some(info.source.clone()), + }, + None => HaltState { engaged: true, ..Default::default() }, + } + } +} + +/// Global convenience: is a switch installed AND engaged? False when no +/// switch is installed (CLI/headless) so those paths are never blocked. +pub fn is_engaged_global() -> bool { + EmergencyStop::try_global().map(|s| s.is_engaged()).unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn engage_then_snapshot_reports_engaged() { + let stop = EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }; + assert!(!stop.is_engaged()); + stop.engage(Some("user".into()), "user", 1234); + assert!(stop.is_engaged()); + let snap = stop.snapshot(); + assert!(snap.engaged); + assert_eq!(snap.reason.as_deref(), Some("user")); + assert_eq!(snap.engaged_at_ms, Some(1234)); + assert_eq!(snap.source.as_deref(), Some("user")); + } + + #[test] + fn clear_resets_to_default_snapshot() { + let stop = EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }; + stop.engage(None, "hotkey", 1); + stop.clear(); + assert!(!stop.is_engaged()); + assert_eq!(stop.snapshot(), HaltState::default()); + } + + #[test] + fn engage_is_idempotent_and_refreshes() { + let stop = EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }; + stop.engage(Some("a".into()), "user", 1); + stop.engage(Some("b".into()), "system", 2); + assert!(stop.is_engaged()); + assert_eq!(stop.snapshot().reason.as_deref(), Some("b")); + assert_eq!(stop.snapshot().source.as_deref(), Some("system")); + } +} +``` + +- [ ] **Step 2 & 3:** impl is in Step 1. +- [ ] **Step 4: Run after `mod.rs` (Task 4).** Expected: 3 tests PASS. +- [ ] **Step 5: Commit** (batched with Task 4). + +--- + +## Task 4: `emergency_stop` mod.rs + wire the module tree (makes Tasks 2–3 compile) + +**Files:** +- Create: `src/openhuman/emergency_stop/mod.rs` +- Modify: `src/openhuman/mod.rs` (add `pub mod emergency_stop;` in the domain list, alphabetically near `embeddings`/`encryption`) + +**Interfaces:** +- Produces: `pub use` of `EmergencyStop`, `is_engaged_global`, `HaltState`; and the controller-schema pair `all_emergency_controller_schemas`, `all_emergency_registered_controllers` (defined in Task 6's `schemas.rs`). + +- [ ] **Step 1: Create `mod.rs`** (schemas referenced here are added in Task 6; declare the module now, add the re-exports in Task 6): + +```rust +//! Emergency stop — a fail-closed kill switch for desktop automation. +//! +//! `EmergencyStop` is a process-global switch (mirrors `ApprovalGate`). When +//! engaged, the tinyagents approval middleware refuses external-effect tool +//! calls and `accessibility_input_action` refuses clicks/typing, until the +//! user resumes. Engaging also stops the accessibility session and +//! cascade-denies pending approvals. In-memory only (resets on restart). + +pub mod ops; +pub mod schemas; +pub mod state; +pub mod types; + +pub use schemas::{all_emergency_controller_schemas, all_emergency_registered_controllers}; +pub use state::{is_engaged_global, EmergencyStop}; +pub use types::HaltState; +``` + +- [ ] **Step 2: Register the domain module.** In `src/openhuman/mod.rs`, add (alphabetical): + +```rust +pub mod emergency_stop; +``` + +- [ ] **Step 3: Build.** After Task 5 (`ops.rs`) and Task 6 (`schemas.rs`) exist, run: + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::` +Expected: all `types`, `state` tests PASS (ops/schemas tests added in their tasks). + +- [ ] **Step 4: Commit** (batched: types + state + mod once ops/schemas compile). + +```bash +git add src/openhuman/emergency_stop/ src/openhuman/mod.rs +git commit -m "feat(emergency): halt-state types + global switch singleton (#4255)" +``` + +--- + +## Task 5: `emergency_stop` ops (engage/resume/status + side effects + events) + +**Files:** +- Create: `src/openhuman/emergency_stop/ops.rs` + +**Interfaces:** +- Consumes: `EmergencyStop` (Task 3), `HaltState` (Task 2), `DomainEvent::AutomationHalted/Resumed` (Task 1), `ApprovalGate` (`list_pending`, `decide`), `screen_intelligence::global_engine().disable(reason)`. +- Produces: `pub async fn emergency_stop(reason: Option, source: &str) -> RpcOutcome`; `pub async fn emergency_resume(source: &str) -> RpcOutcome`; `pub async fn emergency_status() -> RpcOutcome`. + +- [ ] **Step 1: Write ops + tests.** Create `src/openhuman/emergency_stop/ops.rs`: + +```rust +//! Emergency-stop RPC operations: engage / resume / read the switch, plus the +//! best-effort side effects (stop the a11y session, cascade-deny pending +//! approvals) and event publication. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::core::event_bus::{publish_global, DomainEvent}; +use crate::rpc::RpcOutcome; + +use super::state::EmergencyStop; +use super::types::HaltState; + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Engage the kill switch: set the flag, then best-effort stop the a11y +/// session and cascade-deny pending approvals, then publish `AutomationHalted`. +/// Idempotent. Side-effect failures are logged but never fail the RPC — the +/// primary invariant (flag set → actions blocked) does not depend on them. +pub async fn emergency_stop(reason: Option, source: &str) -> RpcOutcome { + tracing::warn!(source, reason = ?reason, "[rpc:emergency_stop] entry — engaging kill switch"); + let stop = EmergencyStop::init_global(); + stop.engage(reason.clone(), source, now_ms()); + + // Best-effort: stop the accessibility session so any in-flight click/type loop halts. + // CONFIRMED: `engine.disable(reason: Option) -> SessionStatus` (engine.rs:150); + // `SessionStatus.active: bool` (types.rs:15). + let a11y = crate::openhuman::screen_intelligence::global_engine() + .disable(Some("emergency_stop".to_string())) + .await; + tracing::info!(active = a11y.active, "[emergency] accessibility session stopped"); + + // Best-effort: cascade-deny every pending approval so parked tool calls fail closed. + let denied = cascade_deny_pending(); + tracing::info!(denied, "[emergency] cascade-denied pending approvals"); + + publish_global(DomainEvent::AutomationHalted { reason, source: source.to_string() }); + + let snap = stop.snapshot(); + RpcOutcome::single_log(snap, format!("[emergency] halted (source={source}, denied={denied})")) +} + +/// Deny all pending approvals. Returns how many were denied. Best-effort: +/// a per-row error is logged and skipped. +fn cascade_deny_pending() -> usize { + use crate::openhuman::approval::{ApprovalDecision, ApprovalGate}; + let Some(gate) = ApprovalGate::try_global() else { return 0 }; + let rows = match gate.list_pending() { + Ok(rows) => rows, + Err(err) => { + tracing::warn!(error = %err, "[emergency] list_pending failed during cascade-deny"); + return 0; + } + }; + let mut denied = 0; + for row in rows { + match gate.decide(&row.request_id, ApprovalDecision::Deny) { + Ok(_) => denied += 1, + Err(err) => tracing::warn!(request_id = %row.request_id, error = %err, "[emergency] deny failed"), + } + } + denied +} + +/// Clear the kill switch and publish `AutomationResumed`. Idempotent. +pub async fn emergency_resume(source: &str) -> RpcOutcome { + tracing::info!(source, "[rpc:emergency_resume] entry — clearing kill switch"); + let stop = EmergencyStop::init_global(); + stop.clear(); + publish_global(DomainEvent::AutomationResumed { source: source.to_string() }); + RpcOutcome::single_log(stop.snapshot(), format!("[emergency] resumed (source={source})")) +} + +/// Read the current switch state. +pub async fn emergency_status() -> RpcOutcome { + let snap = EmergencyStop::try_global().map(|s| s.snapshot()).unwrap_or_default(); + tracing::debug!(engaged = snap.engaged, "[rpc:emergency_status] exit"); + RpcOutcome::new(snap, vec![]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn stop_sets_flag_and_status_reports_engaged() { + let out = emergency_stop(Some("user".into()), "user").await; + assert!(out.value.engaged); + let status = emergency_status().await; + assert!(status.value.engaged); + assert_eq!(status.value.source.as_deref(), Some("user")); + // reset for other tests sharing the process-global switch + let _ = emergency_resume("user").await; + } + + #[tokio::test] + async fn resume_clears_flag() { + let _ = emergency_stop(None, "user").await; + let out = emergency_resume("user").await; + assert!(!out.value.engaged); + assert!(!emergency_status().await.value.engaged); + } + + #[tokio::test] + async fn stop_is_idempotent() { + let _ = emergency_stop(Some("a".into()), "user").await; + let out = emergency_stop(Some("b".into()), "system").await; + assert!(out.value.engaged); + assert_eq!(out.value.reason.as_deref(), Some("b")); + let _ = emergency_resume("user").await; + } +} +``` + +Notes for the implementer: +- Confirm `RpcOutcome` exposes `.value` (the tests read `out.value`). If the field is named differently, read `src/rpc/*` for `RpcOutcome`'s public shape and adjust the test accessors (the `ops.rs` code uses only the constructors `RpcOutcome::new` / `RpcOutcome::single_log`, already used across the codebase). +- Confirm `ApprovalGate`, `ApprovalDecision` are re-exported from `crate::openhuman::approval` (README lists both under "Public surface"). `ApprovalDecision::Deny` is the deny variant. +- Confirm `global_engine().disable(reason)` returns a `SessionStatus` with an `active` field (seen in `ops.rs:121` `accessibility_stop_session`). + +- [ ] **Step 2: Run to verify it fails first (before ops wired into mod).** After `mod.rs` includes `pub mod ops;` (Task 4), run: + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::ops` +Expected: FAIL first if any signature mismatch; iterate until it compiles. + +- [ ] **Step 3: Fix compile issues** (RpcOutcome field/accessor names, imports) until green. + +- [ ] **Step 4: Run tests.** Expected: 3 ops tests PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add src/openhuman/emergency_stop/ops.rs +git commit -m "feat(emergency): engage/resume/status ops with cascade-deny + a11y stop (#4255)" +``` + +--- + +## Task 6: `emergency_stop` schemas + registry wiring + boot install + +**Files:** +- Create: `src/openhuman/emergency_stop/schemas.rs` +- Modify: `src/openhuman/emergency_stop/mod.rs` (re-export already added in Task 4) +- Modify: `src/core/all.rs` (register controllers, near approval ~line 160) +- Modify: `src/core/jsonrpc.rs` (install `EmergencyStop::init_global()` next to `ApprovalGate::init_global` ~line 2672) + +**Interfaces:** +- Consumes: `ops` (Task 5), `HaltState` (Task 2). +- Produces: RPCs `emergency.stop`, `emergency.resume`, `emergency.status` (dispatched as `openhuman.emergency_stop|resume|status`); `all_emergency_controller_schemas()`, `all_emergency_registered_controllers()`. + +- [ ] **Step 1: Write `schemas.rs`** (mirrors `approval/schemas.rs`): + +```rust +//! Controller schemas + handlers for the `emergency` namespace. +//! Wires `emergency_stop`, `emergency_resume`, `emergency_status` into the +//! global registry consumed by `src/core/all.rs`. + +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + +use super::ops; + +pub fn all_emergency_controller_schemas() -> Vec { + vec![schemas("stop"), schemas("resume"), schemas("status")] +} + +pub fn all_emergency_registered_controllers() -> Vec { + vec![ + RegisteredController { schema: schemas("stop"), handler: handle_stop }, + RegisteredController { schema: schemas("resume"), handler: handle_resume }, + RegisteredController { schema: schemas("status"), handler: handle_status }, + ] +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "stop" => ControllerSchema { + namespace: "emergency", + function: "stop", + description: "Engage the emergency stop: halt all desktop automation and block further actions until resumed.", + inputs: vec![FieldSchema { + name: "reason", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional human-readable reason for the halt.", + required: false, + }], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Switch snapshot after engaging.", + required: true, + }], + }, + "resume" => ControllerSchema { + namespace: "emergency", + function: "resume", + description: "Clear the emergency stop so automation may resume.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Switch snapshot after clearing.", + required: true, + }], + }, + "status" => ControllerSchema { + namespace: "emergency", + function: "status", + description: "Read the current emergency-stop switch state.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Current switch snapshot.", + required: true, + }], + }, + _ => ControllerSchema { + namespace: "emergency", + function: "unknown", + description: "Unknown emergency function.", + inputs: vec![], + outputs: vec![FieldSchema { name: "error", ty: TypeSchema::String, comment: "Schema not defined.", required: true }], + }, + } +} + +fn handle_stop(params: Map) -> ControllerFuture { + Box::pin(async move { + let reason = match params.get("reason") { + Some(Value::String(s)) => Some(s.clone()), + _ => None, + }; + Ok(serde_json::to_value(ops::emergency_stop(reason, "user").await.value).map_err(|e| e.to_string())?) + }) +} + +fn handle_resume(_params: Map) -> ControllerFuture { + Box::pin(async move { + Ok(serde_json::to_value(ops::emergency_resume("user").await.value).map_err(|e| e.to_string())?) + }) +} + +fn handle_status(_params: Map) -> ControllerFuture { + Box::pin(async move { + Ok(serde_json::to_value(ops::emergency_status().await.value).map_err(|e| e.to_string())?) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registered_controllers_match_schemas() { + let c = all_emergency_registered_controllers(); + assert_eq!(c.len(), 3); + let names: Vec<_> = c.iter().map(|c| c.schema.function).collect(); + assert_eq!(names, vec!["stop", "resume", "status"]); + } + + #[test] + fn stop_schema_has_optional_reason() { + let s = schemas("stop"); + assert_eq!(s.namespace, "emergency"); + assert_eq!(s.inputs[0].name, "reason"); + assert!(!s.inputs[0].required); + } +} +``` + +Notes for the implementer: +- The handler `to_json` pattern in `approval/schemas.rs` uses `outcome.into_cli_compatible_json()`. Prefer that exact helper for consistency: replace the `serde_json::to_value(...await.value)` lines with the approval pattern — call the op, then `outcome.into_cli_compatible_json()`. Read `approval/schemas.rs:201` (`to_json`) and copy it verbatim into this file, then `handle_* = to_json(ops::…().await)`. Adjust to whichever `RpcOutcome` serialization the registry expects (match approval exactly). +- `ControllerSchema`/`FieldSchema`/`TypeSchema`/`RegisteredController`/`ControllerFuture` imports mirror `approval/schemas.rs` lines 6–13. + +- [ ] **Step 2: Register in `src/core/all.rs`.** Next to the approval registration (`controllers.extend(crate::openhuman::approval::all_approval_registered_controllers());`), add: + +```rust + controllers.extend(crate::openhuman::emergency_stop::all_emergency_registered_controllers()); +``` + +Also add the schema list wherever approval's `all_controller_schemas` is aggregated (search `all_approval_registered_controllers`/`all_controller_schemas` usage in `all.rs` and mirror both). + +- [ ] **Step 3: Install at boot in `src/core/jsonrpc.rs`.** Next to `ApprovalGate::init_global(cfg.clone(), session_id.clone());` (~line 2672), add: + +```rust + crate::openhuman::emergency_stop::EmergencyStop::init_global(); +``` + +- [ ] **Step 4: Build + run schema tests.** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::schemas` +Expected: 2 tests PASS; whole crate compiles. + +- [ ] **Step 5: Commit.** + +```bash +git add src/openhuman/emergency_stop/schemas.rs src/openhuman/emergency_stop/mod.rs src/core/all.rs src/core/jsonrpc.rs +git commit -m "feat(emergency): RPC controllers (stop/resume/status) + boot install (#4255)" +``` + +--- + +## Task 7: Enforcement chokepoint 1 — approval middleware blocks while halted + +**Files:** +- Modify: `src/openhuman/tinyagents/middleware.rs` (`ApprovalSecurityMiddleware::wrap_tool`, ~line 934, inside the `if self.has_external_effect(...)` block, before `gate.intercept_audited`) + +**Interfaces:** +- Consumes: `crate::openhuman::emergency_stop::is_engaged_global`. + +- [ ] **Step 1: Add the halt check.** In `wrap_tool`, immediately inside `if self.has_external_effect(&call.name, &call.arguments) {`, before the `if let Some(gate) = ApprovalGate::try_global()` line, insert: + +```rust + // Emergency stop: refuse every external-effect tool while halted, + // before touching the approval gate. Fail-closed. + if crate::openhuman::emergency_stop::is_engaged_global() { + let reason = "Emergency stop is engaged — this action is blocked until you resume automation.".to_string(); + tracing::warn!(tool = %call.name, "[tinyagents::mw] emergency stop engaged — refusing tool call"); + return Ok(MiddlewareToolOutcome::Result(TaToolResult { + call_id: call.id, + name: call.name, + content: reason.clone(), + raw: None, + error: Some(reason), + elapsed_ms: 0, + })); + } +``` + +> **Audit-trail note (deferred):** the halt short-circuit returns immediately, so a refused call is NOT recorded through `ApprovalGate::intercept_audited` (which is what writes the "aborted" row for a denied external-effect call). This is a conscious scope choice for this slice — writing an `aborted` audit row from the middleware needs a new gate API (there is no such surface today), and the halted refusal is already surfaced via the `tracing::warn!` above plus the `AutomationHalted` domain event / `automation_halt` socket broadcast. Recording halted refusals in the approval audit trail is tracked as a follow-up; adjust either this step or the design spec's "audit trail" requirement once that follow-up lands. + +- [ ] **Step 2: Write a unit test.** In the `#[cfg(test)]` module of `middleware.rs` (or a sibling `middleware_tests.rs` if one exists — match the file's convention), add a test that engages the global switch and asserts a halted external-effect call short-circuits. If constructing a full `RunContext`/`ToolHandler` is heavy, instead add a focused test in `emergency_stop` that exercises `is_engaged_global()` transitions and document the middleware behavior via an integration assertion in Task 10's E2E. Minimum viable unit test (pure guard behavior): + +```rust + #[test] + fn emergency_guard_blocks_when_engaged() { + use crate::openhuman::emergency_stop::EmergencyStop; + let stop = EmergencyStop::init_global(); + stop.clear(); + assert!(!crate::openhuman::emergency_stop::is_engaged_global()); + stop.engage(Some("test".into()), "user", 0); + assert!(crate::openhuman::emergency_stop::is_engaged_global()); + stop.clear(); + } +``` + +- [ ] **Step 3: Build + test.** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_guard_blocks_when_engaged` +Expected: PASS; `middleware.rs` compiles with the new guard. + +- [ ] **Step 4: Commit.** + +```bash +git add src/openhuman/tinyagents/middleware.rs +git commit -m "feat(emergency): approval middleware refuses external-effect tools while halted (#4255)" +``` + +--- + +## Task 8: Enforcement chokepoint 2 — accessibility input blocked while halted + +**Files:** +- Modify: `src/openhuman/screen_intelligence/ops.rs` (`accessibility_input_action`, ~line 152) + +**Interfaces:** +- Consumes: `is_engaged_global`; `InputActionResult { accepted, blocked, reason }`; `InputActionParams { action, .. }`. + +- [ ] **Step 1: Add the halt check.** Replace the body of `accessibility_input_action` with a guard that blocks while halted, except the `panic_stop` action (a stop must never be blocked by a stop): + +```rust +pub async fn accessibility_input_action( + payload: InputActionParams, +) -> Result, String> { + // Emergency stop: refuse desktop input while halted. `panic_stop` is + // exempt so a stop is never blocked by a stop. + if payload.action != "panic_stop" && crate::openhuman::emergency_stop::is_engaged_global() { + tracing::warn!(action = %payload.action, "[emergency] accessibility_input_action blocked — kill switch engaged"); + return Ok(RpcOutcome::single_log( + InputActionResult { accepted: false, blocked: true, reason: Some("emergency_stop".to_string()) }, + "screen intelligence input blocked by emergency stop", + )); + } + let result = screen_intelligence::global_engine() + .input_action(payload) + .await?; + Ok(RpcOutcome::single_log( + result, + "screen intelligence input action processed", + )) +} +``` + +- [ ] **Step 2: Write a unit test.** Add to the `#[cfg(test)] mod tests` in `screen_intelligence/ops.rs` (the file already has tests like `accessibility_stop_session_is_tolerant_of_no_reason`): + +```rust + #[tokio::test] + async fn input_action_blocked_while_emergency_engaged() { + use crate::openhuman::emergency_stop::EmergencyStop; + let stop = EmergencyStop::init_global(); + stop.engage(Some("test".into()), "user", 0); + let params = InputActionParams { action: "click".into(), x: Some(1), y: Some(1), button: None, text: None, key: None, modifiers: None }; + let out = accessibility_input_action(params).await.unwrap(); + assert!(!out.value.accepted); + assert!(out.value.blocked); + assert_eq!(out.value.reason.as_deref(), Some("emergency_stop")); + stop.clear(); + } + + #[tokio::test] + async fn panic_stop_passes_even_while_emergency_engaged() { + use crate::openhuman::emergency_stop::EmergencyStop; + let stop = EmergencyStop::init_global(); + stop.engage(None, "user", 0); + let params = InputActionParams { action: "panic_stop".into(), x: None, y: None, button: None, text: None, key: None, modifiers: None }; + // Should not be short-circuited by the emergency guard (reaches the engine). + let _ = accessibility_input_action(params).await; + stop.clear(); + } +``` + +(Confirm `out.value` accessor matches `RpcOutcome`'s public field, as in Task 5.) + +- [ ] **Step 3: Build + test.** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman input_action_blocked_while_emergency_engaged` +Expected: PASS. + +- [ ] **Step 4: Commit.** + +```bash +git add src/openhuman/screen_intelligence/ops.rs +git commit -m "feat(emergency): accessibility_input_action refuses input while halted (#4255)" +``` + +--- + +## Task 9: JSON-RPC E2E — stop → status → resume + +**Files:** +- Modify: `tests/json_rpc_e2e.rs` (add a test mirroring existing approval E2E tests) + +**Interfaces:** +- Consumes: the JSON-RPC dispatcher via the existing E2E harness in `tests/json_rpc_e2e.rs`. + +- [ ] **Step 1: Read an existing E2E test** in `tests/json_rpc_e2e.rs` (e.g. an approval one) to copy the harness setup (how it boots the core, obtains a client, and calls `openhuman.`). + +- [ ] **Step 2: Write the E2E test** following that harness's exact helper signatures: + +```rust +// Emergency stop: status(not halted) → stop → status(halted) → resume → status(not halted). +#[tokio::test] +async fn emergency_stop_roundtrip_over_rpc() { + let harness = /* boot core per existing pattern in this file */; + let s0 = harness.call("openhuman.emergency_status", serde_json::json!({})).await; + assert_eq!(s0["engaged"], serde_json::json!(false)); + + let stopped = harness.call("openhuman.emergency_stop", serde_json::json!({ "reason": "e2e" })).await; + assert_eq!(stopped["engaged"], serde_json::json!(true)); + + let s1 = harness.call("openhuman.emergency_status", serde_json::json!({})).await; + assert_eq!(s1["engaged"], serde_json::json!(true)); + + let resumed = harness.call("openhuman.emergency_resume", serde_json::json!({})).await; + assert_eq!(resumed["engaged"], serde_json::json!(false)); +} +``` + +Adapt `harness`/`call` to the file's real helpers (method name mapping `emergency.stop` → `openhuman.emergency_stop` is handled by the dispatcher; verify against how approval methods are invoked in this file). + +- [ ] **Step 3: Run.** + +Run: `bash scripts/test-rust-with-mock.sh --test json_rpc_e2e emergency_stop_roundtrip_over_rpc` +Expected: PASS. + +- [ ] **Step 4: Commit.** + +```bash +git add tests/json_rpc_e2e.rs +git commit -m "test(emergency): json-rpc e2e for stop/status/resume (#4255)" +``` + +--- + +## Task 10: Web socket bridge — `AutomationHalted`/`Resumed` → `automation_halt` event + +**Files:** +- Modify: `src/openhuman/channels/providers/web/event_bus.rs` (add `AutomationHaltSubscriber`, `register_automation_halt_subscriber`) +- Modify: `src/openhuman/channels/runtime/startup.rs` (call the register fn where `register_approval_surface_subscriber` is called) + +**Interfaces:** +- Consumes: `DomainEvent::AutomationHalted/Resumed`; `WebChannelEvent` (read `src/core/socketio` for its fields — the approval bridge sets `event`, `client_id`, `thread_id`, `args`). + +- [ ] **Step 1: Broadcast mechanism — CONFIRMED.** Emergency halt is global (not thread-scoped). `emit_web_channel_event` (src/core/socketio.rs:1409) delivers each event to the Socket.IO room named `event.client_id`, and **every** connected client auto-joins the `"system"` room (socketio.rs:438). So to broadcast to all clients, set `client_id: "system".to_string()` and leave `thread_id` empty — the emit code special-cases `client_id == "system"` for single-room delivery. **Do NOT use `..Default::default()` for `client_id`** (empty string → room `""` → reaches nobody). The frontend (Task 14) listens for the `automation_halt` event name globally. + +- [ ] **Step 2: Add the subscriber** in `event_bus.rs` (mirror `ApprovalSurfaceSubscriber`), emitting to the `"system"` broadcast room: + +```rust +static AUTOMATION_HALT_HANDLE: OnceLock = OnceLock::new(); + +pub fn register_automation_halt_subscriber() { + if AUTOMATION_HALT_HANDLE.get().is_some() { + return; + } + match crate::core::event_bus::subscribe_global(Arc::new(AutomationHaltSubscriber)) { + Some(handle) => { let _ = AUTOMATION_HALT_HANDLE.set(handle); + log::info!("[web-channel] automation-halt subscriber registered — bridges AutomationHalted/Resumed → automation_halt socket event"); } + None => log::warn!("[web-channel] failed to register automation-halt subscriber — bus not initialized"), + } +} + +struct AutomationHaltSubscriber; + +#[async_trait] +impl EventHandler for AutomationHaltSubscriber { + fn name(&self) -> &str { "channels::web::automation_halt" } + fn domains(&self) -> Option<&[&str]> { Some(&["system"]) } + async fn handle(&self, event: &DomainEvent) { + match event { + DomainEvent::AutomationHalted { reason, source } => { + publish_web_channel_event(WebChannelEvent { + event: "automation_halt".to_string(), + client_id: "system".to_string(), // broadcast room — all clients auto-join it + args: Some(serde_json::json!({ "engaged": true, "reason": reason, "source": source })), + ..Default::default() + }); + } + DomainEvent::AutomationResumed { source } => { + publish_web_channel_event(WebChannelEvent { + event: "automation_halt".to_string(), + client_id: "system".to_string(), // broadcast room — all clients auto-join it + args: Some(serde_json::json!({ "engaged": false, "source": source })), + ..Default::default() + }); + } + _ => {} + } + } +} +``` + +- [ ] **Step 3: Register at startup.** In `src/openhuman/channels/runtime/startup.rs`, next to `register_approval_surface_subscriber()`, add `register_automation_halt_subscriber();`. + +- [ ] **Step 4: Add a unit test** mirroring `fresh_approval_surface_subscription_returns_some_when_bus_is_ready` if a `fresh_*` helper is warranted; otherwise a minimal test asserting `register_automation_halt_subscriber()` is idempotent (second call is a no-op) after `init_global`. + +- [ ] **Step 5: Build + test.** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman automation_halt` +Expected: PASS. + +- [ ] **Step 6: Commit.** + +```bash +git add src/openhuman/channels/providers/web/event_bus.rs src/openhuman/channels/runtime/startup.rs +git commit -m "feat(emergency): bridge halt/resume events to automation_halt socket event (#4255)" +``` + +--- + +## Task 11: Frontend Redux `safetySlice` + +**Files:** +- Create: `app/src/store/safetySlice.ts` +- Create: `app/src/store/safetySlice.test.ts` +- Modify: root store (`app/src/store/index.ts` or wherever `configureStore`/`combineReducers` lives) — mount `safety` reducer. + +**Interfaces:** +- Produces: `safetyReducer`, actions `setHalt({reason?, since?, source?})`, `clearHalt()`, `hydrateHalt(HaltState)`; selector `selectHalted(state)`, `selectHaltReason(state)`. + +- [ ] **Step 1: Write the failing test.** Create `app/src/store/safetySlice.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import reducer, { setHalt, clearHalt, hydrateHalt } from './safetySlice'; + +describe('safetySlice', () => { + it('starts not halted', () => { + expect(reducer(undefined, { type: '@@init' })).toEqual({ halted: false }); + }); + it('setHalt marks halted with reason/source/since', () => { + const s = reducer(undefined, setHalt({ reason: 'user', source: 'user', since: 42 })); + expect(s).toEqual({ halted: true, reason: 'user', source: 'user', since: 42 }); + }); + it('clearHalt resets', () => { + const halted = reducer(undefined, setHalt({ reason: 'x' })); + expect(reducer(halted, clearHalt())).toEqual({ halted: false }); + }); + it('hydrateHalt maps a HaltState snapshot', () => { + const s = reducer(undefined, hydrateHalt({ engaged: true, reason: 'boot', engaged_at_ms: 7, source: 'system' })); + expect(s.halted).toBe(true); + expect(s.reason).toBe('boot'); + expect(s.since).toBe(7); + }); +}); +``` + +- [ ] **Step 2: Run — verify fail.** `pnpm test app/src/store/safetySlice.test.ts` → FAIL (module not found). + +- [ ] **Step 3: Implement `safetySlice.ts`:** + +```ts +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface HaltState { + engaged: boolean; + reason?: string; + engaged_at_ms?: number; + source?: string; +} + +export interface SafetyState { + halted: boolean; + reason?: string; + since?: number; + source?: string; +} + +const initialState: SafetyState = { halted: false }; + +const safetySlice = createSlice({ + name: 'safety', + initialState, + reducers: { + setHalt(_state, action: PayloadAction<{ reason?: string; source?: string; since?: number }>) { + return { halted: true, reason: action.payload.reason, source: action.payload.source, since: action.payload.since }; + }, + clearHalt() { + return { halted: false }; + }, + hydrateHalt(_state, action: PayloadAction) { + const h = action.payload; + return h.engaged + ? { halted: true, reason: h.reason, source: h.source, since: h.engaged_at_ms } + : { halted: false }; + }, + }, +}); + +export const { setHalt, clearHalt, hydrateHalt } = safetySlice.actions; +export const selectHalted = (state: { safety: SafetyState }) => state.safety.halted; +export const selectHaltReason = (state: { safety: SafetyState }) => state.safety.reason; +export default safetySlice.reducer; +``` + +- [ ] **Step 4: Mount reducer** in the root store under key `safety` (follow the existing slice-registration pattern — the store already registers `chatRuntime`, `thread`, etc.). + +- [ ] **Step 5: Run tests.** `pnpm test app/src/store/safetySlice.test.ts` → PASS. Also `pnpm typecheck`. + +- [ ] **Step 6: Commit.** + +```bash +git add app/src/store/safetySlice.ts app/src/store/safetySlice.test.ts app/src/store/index.ts +git commit -m "feat(emergency): safetySlice tracks automation-halt state (#4255)" +``` + +--- + +## Task 12: Frontend `emergencyApi` client + +**Files:** +- Create: `app/src/services/api/emergencyApi.ts` +- Create: `app/src/services/api/emergencyApi.test.ts` + +**Interfaces:** +- Consumes: `callCoreRpc` from `coreRpcClient`. **CONFIRMED signature (do not use positional args):** `callCoreRpc({ method, params }): Promise` — it takes a single **object** `{ method: string, params?: object }`. Mirror `app/src/services/api/approvalApi.ts`. +- **CONFIRMED wire-shape:** RPCs that emit a diagnostic log return the CLI envelope `{ result, logs }`; log-less RPCs return a bare value. `emergency_stop`/`emergency_resume` use `RpcOutcome::single_log` (enveloped); `emergency_status` uses `RpcOutcome::new(_, vec![])` (bare). So the client MUST normalize both shapes with an `unwrapValue` helper — copy the one in `approvalApi.ts` (lines ~109-114) verbatim. +- Produces: `emergencyStop(reason?: string): Promise`, `emergencyResume(): Promise`, `emergencyStatus(): Promise`. + +- [ ] **Step 1: Read `app/src/services/api/approvalApi.ts`** to copy the exact RPC-call idiom: the object-form `callCoreRpc({ method, params })`, the `unwrapValue` helper, and method-name convention `openhuman._`. + +- [ ] **Step 2: Write the failing test.** Create `emergencyApi.test.ts`. `callCoreRpc` is a **named export** of `../coreRpcClient` and is called with an object: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const call = vi.fn(); +vi.mock('../coreRpcClient', () => ({ callCoreRpc: (arg: unknown) => call(arg) })); + +import { emergencyStop, emergencyResume, emergencyStatus } from './emergencyApi'; + +beforeEach(() => call.mockReset()); + +describe('emergencyApi', () => { + it('emergencyStop calls openhuman.emergency_stop with reason and unwraps envelope', async () => { + call.mockResolvedValue({ result: { engaged: true, reason: 'user' }, logs: ['x'] }); + const r = await emergencyStop('user'); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_stop', params: { reason: 'user' } }); + expect(r.engaged).toBe(true); + expect(r.reason).toBe('user'); + }); + it('emergencyStop with no reason sends empty params', async () => { + call.mockResolvedValue({ result: { engaged: true }, logs: [] }); + await emergencyStop(); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_stop', params: {} }); + }); + it('emergencyResume calls openhuman.emergency_resume', async () => { + call.mockResolvedValue({ result: { engaged: false }, logs: ['x'] }); + const r = await emergencyResume(); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_resume', params: {} }); + expect(r.engaged).toBe(false); + }); + it('emergencyStatus reads bare value (no envelope)', async () => { + call.mockResolvedValue({ engaged: false }); + const r = await emergencyStatus(); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_status', params: {} }); + expect(r.engaged).toBe(false); + }); +}); +``` + +- [ ] **Step 3: Run — verify fail.** `pnpm test app/src/services/api/emergencyApi.test.ts` → FAIL. + +- [ ] **Step 4: Implement `emergencyApi.ts`** mirroring `approvalApi.ts` (object-form call + `unwrapValue`): + +```ts +import { callCoreRpc } from '../coreRpcClient'; +import type { HaltState } from '../../store/safetySlice'; + +/** Normalize the CLI envelope `{ result, logs }` and bare-value shapes. */ +const unwrapValue = (raw: unknown): T => { + if (raw && typeof raw === 'object' && 'result' in (raw as Record)) { + return (raw as { result: T }).result; + } + return raw as T; +}; + +export async function emergencyStop(reason?: string): Promise { + const raw = await callCoreRpc({ method: 'openhuman.emergency_stop', params: reason ? { reason } : {} }); + return unwrapValue(raw); +} + +export async function emergencyResume(): Promise { + const raw = await callCoreRpc({ method: 'openhuman.emergency_resume', params: {} }); + return unwrapValue(raw); +} + +export async function emergencyStatus(): Promise { + const raw = await callCoreRpc({ method: 'openhuman.emergency_status', params: {} }); + return unwrapValue(raw); +} +``` + +- [ ] **Step 5: Run tests.** PASS + `pnpm typecheck`. + +- [ ] **Step 6: Commit.** + +```bash +git add app/src/services/api/emergencyApi.ts app/src/services/api/emergencyApi.test.ts +git commit -m "feat(emergency): emergencyApi RPC client (#4255)" +``` + +--- + +## Task 13: i18n keys + +**Files:** +- Modify: `app/src/lib/i18n/en.ts` and every other locale file at `app/src/lib/i18n/.ts` (`ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN`) +- Check: `app/src/lib/i18n/types.ts` — if the translation key type is explicitly enumerated there, add the new keys to it (otherwise `pnpm typecheck` fails). The parity/coverage guard lives at `app/src/lib/i18n/__tests__/coverage.test.ts`. + +**Interfaces:** +- Produces: keys `safety.emergencyStop`, `safety.resume`, `safety.haltedTitle`, `safety.haltedBody`, `safety.stopConfirm` (used by Task 14 components). + +- [ ] **Step 1: Read `app/src/lib/i18n/en.ts` and `types.ts`** to learn the nesting/key style (flat dotted keys vs nested objects) and whether keys are type-enumerated. Add keys to `en.ts` matching that exact style: + +```ts + safety: { + emergencyStop: 'Emergency stop', + resume: 'Resume automation', + haltedTitle: 'Automation halted', + haltedBody: 'All desktop automation is stopped. Resume when you are ready.', + stopConfirm: 'Stop all automation now?', + }, +``` + +- [ ] **Step 2: Add real translations** to every other locale file (not English placeholders — CI `pnpm i18n:english:check` fails on English left in non-English files). Translate the five strings per locale. + +- [ ] **Step 3: Verify parity.** + +Run: `pnpm i18n:check && pnpm i18n:english:check` +Expected: PASS (all locales have the keys; no English placeholders). + +- [ ] **Step 4: Commit.** + +```bash +git add app/src/lib/i18n/locales +git commit -m "i18n(emergency): add safety.* keys across locales (#4255)" +``` + +--- + +## Task 14: Emergency Stop button + halted banner + wiring + +**Files:** +- Create: `app/src/components/safety/EmergencyStopButton.tsx` (+ `.test.tsx`) +- Create: `app/src/components/safety/AutomationHaltedBanner.tsx` (+ `.test.tsx`) +- Modify: app shell/header to mount both (pick the always-visible chrome — e.g. the Conversations header near the `chat-cancel-generation` control, or `AppShell`). +- Modify: `app/src/services/socketService.ts` — handle `automation_halt` socket event → dispatch `setHalt`/`clearHalt`. +- Modify: boot path (e.g. `CoreStateProvider` or an effect in the shell) — call `emergencyStatus()` once and dispatch `hydrateHalt`. + +**Interfaces:** +- Consumes: `emergencyStop`/`emergencyResume`/`emergencyStatus` (Task 12); `setHalt`/`clearHalt`/`hydrateHalt`/`selectHalted`/`selectHaltReason` (Task 11); `useT()`. + +- [ ] **Step 1: Write the button test.** `EmergencyStopButton.test.tsx` (mirror an existing component test that wraps a Redux `Provider` + i18n — find one such test to copy providers): + +```tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { EmergencyStopButton } from './EmergencyStopButton'; +import { renderWithProviders } from '../../test-utils'; // use the repo's existing helper; else inline a store+I18n wrapper + +const stop = vi.fn().mockResolvedValue({ engaged: true }); +vi.mock('../../services/api/emergencyApi', () => ({ emergencyStop: (...a: unknown[]) => stop(...a) })); + +beforeEach(() => stop.mockClear()); + +describe('EmergencyStopButton', () => { + it('calls emergencyStop and dispatches halt on click', async () => { + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); + await waitFor(() => expect(stop).toHaveBeenCalled()); + }); +}); +``` + +- [ ] **Step 2: Verify fail.** `pnpm test EmergencyStopButton` → FAIL. + +- [ ] **Step 3: Implement `EmergencyStopButton.tsx`:** + +```tsx +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { useT } from '../../lib/i18n/I18nContext'; +import { emergencyStop } from '../../services/api/emergencyApi'; +import { setHalt } from '../../store/safetySlice'; + +export function EmergencyStopButton() { + const t = useT(); + const dispatch = useDispatch(); + const onClick = useCallback(async () => { + try { + const state = await emergencyStop('user'); + dispatch(setHalt({ reason: state.reason, source: state.source, since: state.engaged_at_ms })); + } catch (err) { + // Fail-visible: still reflect intent locally so the user sees the halt. + dispatch(setHalt({ reason: 'user', source: 'user' })); + console.error('[emergency] stop failed', err); + } + }, [dispatch]); + return ( + + ); +} +``` + +- [ ] **Step 4: Implement `AutomationHaltedBanner.tsx`** (renders only when halted; Resume clears): + +```tsx +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useT } from '../../lib/i18n/I18nContext'; +import { emergencyResume } from '../../services/api/emergencyApi'; +import { clearHalt, selectHalted, selectHaltReason } from '../../store/safetySlice'; + +export function AutomationHaltedBanner() { + const t = useT(); + const dispatch = useDispatch(); + const halted = useSelector(selectHalted); + const reason = useSelector(selectHaltReason); + const onResume = useCallback(async () => { + try { await emergencyResume(); } finally { dispatch(clearHalt()); } + }, [dispatch]); + if (!halted) return null; + return ( +
+ {t('safety.haltedTitle')} + {reason ?? t('safety.haltedBody')} + +
+ ); +} +``` + +- [ ] **Step 5: Write the banner test** (`AutomationHaltedBanner.test.tsx`): renders nothing when not halted; renders + Resume calls `emergencyResume` and clears when halted (preload the store with `setHalt`). + +- [ ] **Step 6: Socket handler.** In `socketService.ts`, register a handler for the `automation_halt` event (mirror how `approval_request` is handled): on `{engaged:true}` dispatch `setHalt`, on `{engaged:false}` dispatch `clearHalt`. + +- [ ] **Step 7: Boot hydration.** In the shell/boot effect, call `emergencyStatus()` once and dispatch `hydrateHalt(result)` (guard with `isTauri()`/try-catch). + +- [ ] **Step 8: Mount** `` in the always-visible chrome and `` near the top of the main content. + +- [ ] **Step 9: Run all frontend checks.** + +Run: `pnpm test app/src/components/safety && pnpm typecheck && pnpm lint` +Expected: PASS. + +- [ ] **Step 10: Commit.** + +```bash +git add app/src/components/safety app/src/services/socketService.ts app/src/store app/src/**/*Shell* 2>/dev/null +git commit -m "feat(emergency): stop button + halted banner + socket/boot wiring (#4255)" +``` + +--- + +## Task 15: Full verification + coverage gate + +- [ ] **Step 1: Rust suite (changed domains).** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop:: && bash scripts/test-rust-with-mock.sh --test json_rpc_e2e emergency_stop_roundtrip_over_rpc` +Expected: all PASS. + +- [ ] **Step 2: Rust format + check.** + +Run: `cargo fmt --manifest-path Cargo.toml && GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml` +Expected: no diffs, clean check. + +- [ ] **Step 3: Frontend suite + quality.** + +Run: `pnpm test && pnpm typecheck && pnpm lint && pnpm i18n:check && pnpm i18n:english:check` +Expected: all PASS. + +- [ ] **Step 4: Diff coverage sanity.** Ensure the changed Rust lines (ops.rs guards, chokepoints) and changed TS lines (slice, api, components) are exercised by the tests above. Add targeted tests for any uncovered branch (e.g. `emergency_status` when no switch installed → `HaltState::default`). Target ≥80% on changed lines. + +- [ ] **Step 5: Update feature docs.** Per AGENTS.md, if this adds a user-facing feature, update `src/openhuman/about_app/` with the Emergency Stop control. Commit. + +```bash +git commit -am "docs(about): register emergency stop as a user-facing control (#4255)" +``` + +- [ ] **Step 6: Manual smoke (optional but recommended).** `pnpm dev:app`, engage Emergency Stop, confirm the banner appears and Resume clears it; confirm an accessibility input while halted returns blocked. + +--- + +## Self-Review (completed by plan author) + +- **Spec coverage:** AC "emergency stop cancels pending actions" → Task 5 cascade-deny + a11y stop; "prevents further queued actions until resume" → Tasks 7–8 chokepoints + Task 5 flag; UI control + resume → Tasks 11–14; tests/≥80% coverage → every task is TDD + Task 15. ✔ +- **Placeholder scan:** No TBDs. The few "read neighboring file to confirm exact accessor" notes are explicit verification steps (RpcOutcome `.value`, `callCoreRpc` export, `WebChannelEvent` fields, test-provider helper) with the exact file to read — not deferred work. ✔ +- **Type consistency:** `HaltState` fields (`engaged`, `reason`, `engaged_at_ms`, `source`) identical across Rust (Task 2) and TS (Task 11/12); RPC method names `openhuman.emergency_{stop,resume,status}` consistent Tasks 6/9/12; event names `AutomationHalted`/`AutomationResumed` consistent Tasks 1/10; socket event `automation_halt` consistent Tasks 10/14. ✔ +- **Ordering:** Task 1 (events) precedes Task 5 (publishes them); Tasks 2–4 (module compiles) precede Task 5–6; chokepoints (7–8) after the switch exists; frontend (11–14) independent of Rust after RPC names are fixed. ✔ diff --git a/docs/superpowers/specs/2026-07-06-emergency-stop-design.md b/docs/superpowers/specs/2026-07-06-emergency-stop-design.md new file mode 100644 index 0000000000..45b3f60939 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-emergency-stop-design.md @@ -0,0 +1,102 @@ +# Emergency Stop for desktop automation — design + +Issue: [tinyhumansai/openhuman#4255](https://github.com/tinyhumansai/openhuman/issues/4255) — "Desktop automation safety previews, confirmations, history, and emergency stop". + +This spec covers **slice 1: Emergency Stop** only. The issue is an epic (previews, confirmations, history, emergency stop, backups, Windows support, reusable workflows); its acceptance criteria call for one slice landed end-to-end first. Emergency Stop is the safety-critical control and is self-contained. + +## Goal + +A prominent, always-available control that: + +1. **Immediately halts** all running/queued desktop automation, and +2. **Blocks any further automated actions** until the user explicitly resumes. + +It is a **fail-closed kill switch**: while engaged, every automated real-world action is refused. + +## Scope decisions (approved) + +- **Stop behavior:** set a global halt flag (blocks all further external-effect / accessibility actions fail-closed), stop the accessibility session, and cascade-deny all pending approvals. The running agent turn can take no further real-world actions. (We do **not** hard-abort in-flight chat turns in this slice — blocking every action chokepoint achieves the safety goal without touching the turn/cancel machinery.) +- **Persistence:** in-memory only; a restart clears the halt (reset on boot). Persisting a halt across restarts is a follow-up. +- **Backend (`backend-alphahuman`):** no changes. Desktop automation executes in the Tauri Rust core; the backend's execution-session flow is a separate (email/Telegram) subsystem. A server-side execution-session cancel is a follow-up, not this slice. + +## Existing infrastructure this builds on + +- `src/openhuman/approval/` — `ApprovalGate` parks/denies external-effect tool calls, keeps a SQLite audit trail, fail-closed 10-min TTL. We reuse its pending-list + deny path for cascade-deny. +- `src/openhuman/tinyagents/middleware.rs::wrap_tool` — every external-effect/dangerous tool call is already intercepted here (`has_external_effect`, `gate.intercept_audited`). This is our primary enforcement chokepoint. +- `src/openhuman/screen_intelligence/` — `ops.rs::accessibility_input_action` dispatches clicks/typing to `input.rs`, which already has a per-session `panic_stop` action and session stop. This is our second chokepoint + the session-stop reuse. +- `src/core/all.rs` controller registry + `src/openhuman/channels/providers/web/event_bus.rs` `ApprovalSurfaceSubscriber` — the pattern for RPC registration and bridging domain events to a web-channel socket event. + +## Architecture + +### New core domain — `src/openhuman/emergency_stop/` + +Follows the canonical module shape (`mod.rs` export-only; `types.rs`; `state.rs`; `ops.rs`; `schemas.rs`). + +- **`state.rs`** — process-global `EmergencyStop` in a `OnceCell`, holding `AtomicBool engaged` + `Mutex>` (`reason: String`, `engaged_at_ms: u64`, `source: HaltSource`). Public: `global()` / `try_global()`, `is_engaged()`, `engage(info)`, `clear()`, `snapshot() -> HaltState`. Mirrors `ApprovalGate` global-singleton ergonomics. `try_global()` → `None` means "no switch installed" → treated as not-engaged (never blocks) so headless/CLI paths are unaffected. +- **`types.rs`** — `HaltState { engaged: bool, reason: Option, engaged_at_ms: Option, source: Option }` (serde); `HaltSource` enum (`User`, `Hotkey`, `System`). +- **`ops.rs`** — handlers returning `RpcOutcome`: + - `emergency_stop(reason, source)` — engage flag; then best-effort: stop the accessibility session (reuse existing stop path) and cascade-deny all `ApprovalGate` pending rows (`list_pending` → `decide(deny)`); publish `AutomationHalted`; return snapshot. Idempotent (already-engaged is a no-op success). + - `emergency_resume()` — clear flag; publish `AutomationResumed`; return snapshot. Idempotent. + - `emergency_status()` — return snapshot. +- **`schemas.rs` + `mod.rs`** — controllers → RPC `openhuman.emergency_stop`, `openhuman.emergency_resume`, `openhuman.emergency_status`; registered in `src/core/all.rs`. +- **Events** — `DomainEvent::AutomationHalted { reason, source }` / `AutomationResumed` (add to `src/core/event_bus/events.rs`, extend `domain()` match → `system`). A subscriber in `web.rs` (or extending `ApprovalSurfaceSubscriber`) bridges them to a web-channel socket event (`automation_halt`) so all UI surfaces update live. +- **Install** — `EmergencyStop::init_global()` at core startup next to `ApprovalGate::init_global()` in `src/core/jsonrpc.rs`. + +### Enforcement (the "block further actions" invariant) — fail-closed at two chokepoints + +1. **`tinyagents/middleware.rs::wrap_tool`** — at the top of the external-effect/dangerous path, if `EmergencyStop::is_engaged()`, refuse the call before `execute()` with a clear `POLICY_DENIED_MARKER`-style "emergency stop engaged" reason. This stops the agent loop from taking further real-world actions. (**Scope note for this slice:** the refusal is surfaced via a `tracing::warn!` and the `AutomationHalted` domain event / `automation_halt` socket broadcast, but is **not** recorded through `ApprovalGate::intercept_audited` as an `Aborted` audit row. Writing halted refusals into the approval audit trail needs a new gate API and is tracked as a follow-up.) +2. **`screen_intelligence/ops.rs::accessibility_input_action`** — if engaged, short-circuit to `{ accepted: false, blocked: true, reason: "emergency_stop" }` (except the existing `panic_stop` action, which must still pass so a stop is never blocked by a stop). + +Both checks are cheap (`AtomicBool` load) and fail-open only when no switch is installed. + +### Frontend (`app/src/`) + +- **Redux `safetySlice`** — `{ halted: bool, reason?: string, since?: number, source?: string }`; actions `setHalt`, `clearHalt`, `hydrateHalt`. +- **`services/api/emergencyApi.ts`** — `emergencyStop()`, `emergencyResume()`, `emergencyStatus()` via `core_rpc_relay` (`coreRpcClient`). +- **Socket handler** — subscribe to `automation_halt`; dispatch `setHalt`/`clearHalt`. Hydrate via `emergencyStatus()` on boot. +- **UI** + - A persistent **Emergency Stop** button in the app shell / chat header (always visible), `data-analytics-id` for analytics. + - When halted, a **banner** ("Automation halted — {reason}") with a **Resume** action. + - All copy through `useT()`; keys added to `en.ts` and every locale file (CI enforces parity). + +## Data flow + +``` +User clicks Emergency Stop + → emergencyApi.emergencyStop() (core_rpc_relay → openhuman.emergency_stop) + → ops::emergency_stop: engage flag; stop a11y session; cascade-deny pending approvals + → publish AutomationHalted → web subscriber → socket 'automation_halt' + → all clients: safetySlice.setHalt → button shows halted state + banner + +Agent tries another tool while halted + → middleware.wrap_tool sees is_engaged() → deny (tracing warn + halt event; audit-row write deferred) → agent cannot act +Agent/vision tries accessibility_input_action while halted + → ops sees is_engaged() → { accepted:false, blocked:true, reason:'emergency_stop' } + +User clicks Resume + → openhuman.emergency_resume → clear flag → AutomationResumed → socket → clearHalt +``` + +## Error handling + +- **Fail-closed:** any uncertainty (switch installed and engaged) blocks. No installed switch (CLI/headless) never blocks. +- **Best-effort side effects on engage:** if stopping the a11y session or cascade-denying an approval errors, the halt flag is still set and the error is logged — the primary invariant (flag set → actions blocked) never depends on a side effect succeeding. +- **Idempotent** stop/resume so double-clicks and repeated socket events are safe. + +## Testing (≥80% diff coverage — merge gate) + +**Rust unit tests** (inline `#[cfg(test)]` / sibling `*_tests.rs`): +- `state`: engage/clear/snapshot; `is_engaged` transitions; `try_global` None → not engaged. +- `ops`: stop sets flag + emits `AutomationHalted`; resume clears + emits `AutomationResumed`; stop is idempotent; cascade-deny denies pending rows; best-effort side-effect failure still sets the flag. +- middleware chokepoint: external-effect tool refused while halted, allowed after resume. +- `accessibility_input_action`: blocked while halted; `panic_stop` still passes while halted. + +**JSON-RPC E2E** (`tests/json_rpc_e2e.rs`): `emergency_status` (not halted) → `emergency_stop` → `emergency_status` (halted, reason) → `emergency_resume` → `emergency_status` (not halted). + +**Vitest** (`app/src/**`): `safetySlice` reducers; `emergencyApi` calls correct RPC methods; Emergency Stop button dispatches stop and reflects halted state; banner renders + Resume dispatches resume; socket handler maps events to store. + +## Out of scope (follow-ups tracked against #4255) + +- Action previews, backup-before-overwrite, activity-history UI, reusable app workflows, Windows-specific assessment. +- Persisting halt across restarts; hard-aborting in-flight chat turns; server-side (`backend-alphahuman`) execution-session cancel. +- A global OS panic **hotkey** binding for emergency stop (the per-session `panic_stop` exists; a global hotkey is a follow-up). diff --git a/src/core/all.rs b/src/core/all.rs index e7697117be..19e5b0b91b 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -334,6 +334,12 @@ fn build_registered_controllers() -> Vec { DomainGroup::Security, crate::openhuman::approval::all_approval_registered_controllers(), ); + // Emergency stop kill switch (#4255 — fail-closed halt for desktop automation) + push( + &mut controllers, + DomainGroup::Security, + crate::openhuman::emergency_stop::all_emergency_registered_controllers(), + ); // Interactive plan-review gate — parks a live turn on a thread-scoped plan push( &mut controllers, diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index 355153dead..2d2ee80fe7 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -1127,6 +1127,22 @@ pub enum DomainEvent { overall: String, failed_required: bool, }, + /// Emergency stop engaged — all desktop automation is halted and every + /// external-effect / accessibility action is refused until resumed. + /// Published by `emergency_stop::ops::emergency_stop`; bridged to the + /// `automation_halt` web-channel socket event. + AutomationHalted { + /// Optional human-readable reason (redacted of PII by the caller). + reason: Option, + /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`. + source: String, + }, + /// Emergency stop cleared — automation may resume. Published by + /// `emergency_stop::ops::emergency_resume`. + AutomationResumed { + /// Who cleared it: `"user"`, `"hotkey"`, or `"system"`. + source: String, + }, // ── Keyring ───────────────────────────────────────────────────────── /// The OS keyring is unavailable and no user consent for local fallback @@ -1454,7 +1470,9 @@ impl DomainEvent { | Self::HealthChanged { .. } | Self::HealthRestarted { .. } | Self::HarnessInitProgress { .. } - | Self::HarnessInitCompleted { .. } => "system", + | Self::HarnessInitCompleted { .. } + | Self::AutomationHalted { .. } + | Self::AutomationResumed { .. } => "system", Self::KeyringConsentRequired | Self::KeyringDecryptFailed { .. } => "keyring", @@ -1610,6 +1628,8 @@ impl DomainEvent { Self::HealthRestarted { .. } => "HealthRestarted", Self::HarnessInitProgress { .. } => "HarnessInitProgress", Self::HarnessInitCompleted { .. } => "HarnessInitCompleted", + Self::AutomationHalted { .. } => "AutomationHalted", + Self::AutomationResumed { .. } => "AutomationResumed", Self::KeyringConsentRequired => "KeyringConsentRequired", Self::KeyringDecryptFailed { .. } => "KeyringDecryptFailed", Self::SessionExpired { .. } => "SessionExpired", diff --git a/src/core/event_bus/events_tests.rs b/src/core/event_bus/events_tests.rs index c977286852..551c664743 100644 --- a/src/core/event_bus/events_tests.rs +++ b/src/core/event_bus/events_tests.rs @@ -594,3 +594,18 @@ fn workflows_changed_domain_and_name() { assert_eq!(event.domain(), "workflow"); assert_eq!(event.variant_name(), "WorkflowsChanged"); } + +#[test] +fn automation_events_map_to_system_domain() { + let halted = DomainEvent::AutomationHalted { + reason: Some("user".into()), + source: "user".into(), + }; + let resumed = DomainEvent::AutomationResumed { + source: "user".into(), + }; + assert_eq!(halted.domain(), "system"); + assert_eq!(resumed.domain(), "system"); + assert_eq!(halted.variant_name(), "AutomationHalted"); + assert_eq!(resumed.variant_name(), "AutomationResumed"); +} diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index ed242d4176..4a553ab456 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2343,6 +2343,12 @@ pub async fn bootstrap_core_runtime( // unguarded standalone/CLI/Docker core would park a plan review that never // reaches the UI and dies at the gate TTL. Idempotent (Once-guarded). crate::openhuman::channels::providers::web::register_approval_surface_subscriber(); + // Bridge emergency-stop halt/resume → the `automation_halt` broadcast on the + // same always-run boot path. `start_channels` (which also registers this) + // is skipped on a web-chat-only desktop with no listening integrations, so + // without this a halt/resume initiated from the CLI or another client would + // never reach the UI. Idempotent (Once-guarded). (#4255) + crate::openhuman::channels::providers::web::register_automation_halt_subscriber(); // Egress-surface bridge (privacy epic S2, #4436) — registered // unconditionally alongside the approval surface so external-transfer // disclosures reach the UI even on cores that skip `start_channels` or run @@ -2361,6 +2367,7 @@ pub async fn bootstrap_core_runtime( let session_id = format!("session-{}", uuid::Uuid::new_v4()); let _ = crate::openhuman::approval::ApprovalGate::init_global(cfg.clone(), session_id.clone()); + crate::openhuman::emergency_stop::EmergencyStop::init_global(); log::info!( "[runtime] approval gate installed (on by default; set OPENHUMAN_APPROVAL_GATE=0 to disable, session_id={session_id}) — \ Prompt-class external-effect tool calls park for approval in interactive chat turns" diff --git a/src/openhuman/channels/providers/web/event_bus.rs b/src/openhuman/channels/providers/web/event_bus.rs index 0015788d06..bf7d8335a8 100644 --- a/src/openhuman/channels/providers/web/event_bus.rs +++ b/src/openhuman/channels/providers/web/event_bus.rs @@ -40,6 +40,30 @@ pub fn register_approval_surface_subscriber() { } } +static AUTOMATION_HALT_HANDLE: OnceLock = OnceLock::new(); + +/// Register the emergency-stop bridge: `AutomationHalted`/`AutomationResumed` +/// (domain `system`) → the `automation_halt` socket event, broadcast to every +/// client via the `"system"` room. Idempotent (OnceLock-guarded). +pub fn register_automation_halt_subscriber() { + if AUTOMATION_HALT_HANDLE.get().is_some() { + return; + } + match crate::core::event_bus::subscribe_global(Arc::new(AutomationHaltSubscriber)) { + Some(handle) => { + let _ = AUTOMATION_HALT_HANDLE.set(handle); + log::info!( + "[web-channel] automation-halt subscriber registered (domain=system) — bridges AutomationHalted/AutomationResumed → automation_halt socket event" + ); + } + None => { + log::warn!( + "[web-channel] failed to register automation-halt subscriber — bus not initialized" + ); + } + } +} + static ARTIFACT_SURFACE_HANDLE: OnceLock = OnceLock::new(); pub fn register_artifact_surface_subscriber() { @@ -382,9 +406,60 @@ impl EventHandler for ApprovalSurfaceSubscriber { } } +struct AutomationHaltSubscriber; + +#[async_trait] +impl EventHandler for AutomationHaltSubscriber { + fn name(&self) -> &str { + "channels::web::automation_halt" + } + + fn domains(&self) -> Option<&[&str]> { + Some(&["system"]) + } + + async fn handle(&self, event: &DomainEvent) { + match event { + DomainEvent::AutomationHalted { reason, source } => { + log::info!( + "[web-channel] automation-halt emitting automation_halt engaged=true source={source}" + ); + publish_web_channel_event(WebChannelEvent { + event: "automation_halt".to_string(), + // Broadcast room: every connected client auto-joins "system". + client_id: "system".to_string(), + args: Some(serde_json::json!({ + "engaged": true, + "reason": reason, + "source": source, + })), + ..Default::default() + }); + } + DomainEvent::AutomationResumed { source } => { + log::info!( + "[web-channel] automation-halt emitting automation_halt engaged=false source={source}" + ); + publish_web_channel_event(WebChannelEvent { + event: "automation_halt".to_string(), + // Broadcast room: every connected client auto-joins "system". + client_id: "system".to_string(), + args: Some(serde_json::json!({ + "engaged": false, + "source": source, + })), + ..Default::default() + }); + } + _ => {} + } + } +} + #[cfg(test)] mod tests { use super::*; + use crate::core::event_bus::{DomainEvent, EventHandler}; /// `fresh_approval_surface_subscription` returns `Some` when the global event bus has /// been initialised and `None` otherwise (bus not started). It must never return `None` @@ -417,6 +492,108 @@ mod tests { drop(h2); } + /// `AutomationHaltSubscriber::handle` publishes a correctly-shaped + /// `WebChannelEvent` to the web-channel broadcast bus for both + /// `AutomationHalted` and `AutomationResumed` domain events. + /// + /// This test exercises the payload contract directly by calling `handle` + /// on a freshly-constructed `AutomationHaltSubscriber` and asserting the + /// event fields the socket bridge relies on: + /// - `event == "automation_halt"` (the wire event name) + /// - `client_id == "system"` (the broadcast room every client auto-joins) + /// - `args.engaged` toggled correctly + /// - `args.reason` and `args.source` echoed from the domain event + #[tokio::test] + async fn automation_halt_subscriber_handle_publishes_correct_payload() { + // Subscribe BEFORE calling handle so the broadcast receiver is created + // before any event is sent (broadcast channels only buffer messages + // sent AFTER the receiver subscribed). + let mut rx = subscribe_web_channel_events(); + + let sub = AutomationHaltSubscriber; + + // --- AutomationHalted --- + sub.handle(&DomainEvent::AutomationHalted { + reason: Some("test".into()), + source: "user".into(), + }) + .await; + + let halted = rx + .try_recv() + .expect("AutomationHalted must publish a WebChannelEvent"); + assert_eq!( + halted.event, "automation_halt", + "event name mismatch for halted" + ); + assert_eq!( + halted.client_id, "system", + "automation_halt must broadcast via the 'system' room (critical: every client auto-joins this room)" + ); + let args = halted + .args + .as_ref() + .expect("AutomationHalted args must be Some"); + assert_eq!( + args["engaged"], true, + "AutomationHalted must set engaged=true" + ); + assert_eq!( + args["reason"], "test", + "AutomationHalted must echo the reason" + ); + assert_eq!( + args["source"], "user", + "AutomationHalted must echo the source" + ); + + // --- AutomationResumed --- + sub.handle(&DomainEvent::AutomationResumed { + source: "user".into(), + }) + .await; + + let resumed = rx + .try_recv() + .expect("AutomationResumed must publish a WebChannelEvent"); + assert_eq!( + resumed.event, "automation_halt", + "event name mismatch for resumed" + ); + assert_eq!( + resumed.client_id, "system", + "automation_halt (resumed) must broadcast via the 'system' room" + ); + let args = resumed + .args + .as_ref() + .expect("AutomationResumed args must be Some"); + assert_eq!( + args["engaged"], false, + "AutomationResumed must set engaged=false" + ); + assert_eq!( + args["source"], "user", + "AutomationResumed must echo the source" + ); + } + + /// `register_automation_halt_subscriber` is OnceLock-guarded: after the bus + /// is initialised the first call installs the subscriber and subsequent + /// calls are no-ops (they must not panic or re-subscribe). + #[tokio::test] + async fn register_automation_halt_subscriber_is_idempotent() { + crate::core::event_bus::init_global(crate::core::event_bus::DEFAULT_CAPACITY); + register_automation_halt_subscriber(); + assert!( + AUTOMATION_HALT_HANDLE.get().is_some(), + "first registration must install the subscriber handle" + ); + // Second call is a no-op — must not panic. + register_automation_halt_subscriber(); + assert!(AUTOMATION_HALT_HANDLE.get().is_some()); + } + /// Drain the web-channel receiver until an `external_transfer_pending` event /// whose `args.service` matches `marker` arrives (the bus is process-wide). async fn find_egress_web_event( diff --git a/src/openhuman/channels/providers/web/mod.rs b/src/openhuman/channels/providers/web/mod.rs index 833df235f4..4c979e8a78 100644 --- a/src/openhuman/channels/providers/web/mod.rs +++ b/src/openhuman/channels/providers/web/mod.rs @@ -23,8 +23,8 @@ pub(crate) use web_errors::{ // Public API — event bus pub use event_bus::{ publish_web_channel_event, register_approval_surface_subscriber, - register_artifact_surface_subscriber, register_egress_surface_subscriber, - subscribe_web_channel_events, + register_artifact_surface_subscriber, register_automation_halt_subscriber, + register_egress_surface_subscriber, subscribe_web_channel_events, }; // Test-only: OnceLock-bypassing approval bridge for per-runtime integration tests. diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index 76f1899ec7..b3a13b5a11 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -171,6 +171,11 @@ pub async fn start_channels(mut config: Config) -> Result<()> { // ArtifactFailed) as `artifact_ready` / `artifact_failed` web-channel // events so the frontend ArtifactCard can render in chat (#2779). crate::openhuman::channels::providers::web::register_artifact_surface_subscriber(); + // Bridge emergency-stop halt/resume (AutomationHalted / AutomationResumed) + // to the `automation_halt` web-channel socket event, broadcast to every + // client via the "system" room, so the frontend kill-switch UI updates + // globally (#4255). + crate::openhuman::channels::providers::web::register_automation_halt_subscriber(); // Surface external-egress disclosure events (ExternalTransferPending) as // `external_transfer_pending` web-channel events so the frontend can show a // per-action "what leaves, to where, why" card (privacy epic S2, #4436). diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index d9ef195001..1c869beb21 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -483,6 +483,27 @@ async fn execute_job_with_retry( security: &SecurityPolicy, job: &CronJob, ) -> (bool, String) { + // Emergency stop: refuse every scheduled job while the kill switch is + // engaged. The tinyagents middleware already fails-closed on external-effect + // tools inside `JobType::Agent`, but `JobType::Shell` spawns `sh -lc` and + // `JobType::Flow` publishes a flow-trigger event — neither goes through the + // middleware, so without this check a due or Run Now shell/flow job could + // still perform external actions while automation is halted. Fail-closed at + // the outermost dispatch is the safest place: it applies to every job type + // and to every retry attempt, and never spawns the underlying process. See + // #4255. + if crate::openhuman::emergency_stop::is_engaged_global() { + log::warn!( + "[cron] action=refused_while_halted job_id={} job_type={:?} — emergency stop engaged", + job.id.as_str(), + job.job_type + ); + return ( + false, + "blocked by emergency stop: automation is halted — resume to run this job".to_string(), + ); + } + let mut last_output = String::new(); let mut last_agent_error: Option = None; let retries = config.reliability.scheduler_retries; @@ -494,6 +515,23 @@ async fn execute_job_with_retry( let mut local_unreachable = false; for attempt in 0..=retries { + // Re-check the kill switch before each RETRY (attempt 0 is already + // covered by the pre-loop guard above): a user who engages Emergency + // Stop during the backoff sleep must not have the next attempt execute. + // Same fail-closed denial as the pre-loop guard (#4255). + if attempt > 0 && crate::openhuman::emergency_stop::is_engaged_global() { + log::warn!( + "[cron] action=refused_retry_while_halted job_id={} job_type={:?} attempt={} — emergency stop engaged", + job.id.as_str(), + job.job_type, + attempt + ); + return ( + false, + "blocked by emergency stop: automation is halted — resume to run this job" + .to_string(), + ); + } let (success, output, agent_error) = match job.job_type { JobType::Shell => { let (success, output) = run_job_command(config, security, job).await; @@ -980,7 +1018,11 @@ async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String, Option< // and provider URLs are appropriate; it must NOT reach the // user-visible notification body. let user_message = classify_agent_anyhow_for_user(&e); - (false, user_message.to_string(), Some(e.to_string())) + // Preserve the FULL anyhow chain (`{:#}`), not just the top-level + // message: the loopback-unreachable classifier and the observability + // pipeline key on the transport cause (`… tcp connect error: Connection + // refused (os error N)`), which a bare `to_string()` drops. + (false, user_message.to_string(), Some(format!("{e:#}"))) } } } diff --git a/src/openhuman/cron/scheduler_tests.rs b/src/openhuman/cron/scheduler_tests.rs index 0e63788c8e..fc5b31df3b 100644 --- a/src/openhuman/cron/scheduler_tests.rs +++ b/src/openhuman/cron/scheduler_tests.rs @@ -439,6 +439,43 @@ async fn execute_job_with_retry_exhausts_attempts() { assert!(output.contains("always_missing_for_retry_test")); } +/// Emergency stop must refuse every scheduled shell/flow/agent job before it +/// launches, so a `sh -lc` or flow-trigger event can never fire while the kill +/// switch is engaged. Covers the codex-review gap for cron paths that don't +/// route through the tinyagents middleware (#4255). +#[cfg(not(windows))] +#[tokio::test] +async fn execute_job_with_retry_refuses_shell_job_while_halted() { + let _test_guard = crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); + let stop = crate::openhuman::emergency_stop::EmergencyStop::init_global(); + stop.clear(); // start clean regardless of parallel-suite state + let _resume_on_drop = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; + + let tmp = TempDir::new().unwrap(); + let mut config = test_config(&tmp).await; + config.reliability.scheduler_retries = 3; + config.reliability.provider_backoff_ms = 1; + let security = SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + &config.workspace_dir, + ); + let job = test_job("/bin/echo should-never-run"); + + // Engage AFTER building the job/config to isolate the halt check. + stop.engage(Some("test".into()), "test", 0); + + let (success, output) = execute_job_with_retry(&config, &security, &job).await; + + assert!(!success, "halted scheduler must not report success"); + assert!( + output.starts_with("blocked by emergency stop:"), + "output must be the emergency-stop refusal, got: {output}" + ); +} + // TAURI-RUST-N — backend 401 ("Invalid token") leaks from a cron-fired agent // job through `last_agent_error` and the existing classifier in // `core::observability::is_session_expired_message` matches it (the diff --git a/src/openhuman/emergency_stop/mod.rs b/src/openhuman/emergency_stop/mod.rs new file mode 100644 index 0000000000..b4d3c5e9fe --- /dev/null +++ b/src/openhuman/emergency_stop/mod.rs @@ -0,0 +1,16 @@ +//! Emergency stop — a fail-closed kill switch for desktop automation. +//! +//! `EmergencyStop` is a process-global switch (mirrors `ApprovalGate`). When +//! engaged, the tinyagents approval middleware refuses external-effect tool +//! calls and `accessibility_input_action` refuses clicks/typing, until the +//! user resumes. Engaging also stops the accessibility session and +//! cascade-denies pending approvals. In-memory only (resets on restart). + +pub mod ops; +pub mod schemas; +pub mod state; +pub mod types; + +pub use schemas::{all_emergency_controller_schemas, all_emergency_registered_controllers}; +pub use state::{is_engaged_global, EmergencyStop}; +pub use types::HaltState; diff --git a/src/openhuman/emergency_stop/ops.rs b/src/openhuman/emergency_stop/ops.rs new file mode 100644 index 0000000000..b1b3bc67eb --- /dev/null +++ b/src/openhuman/emergency_stop/ops.rs @@ -0,0 +1,165 @@ +//! Emergency-stop RPC operations: engage / resume / read the switch, plus the +//! best-effort side effects (stop the a11y session, cascade-deny pending +//! approvals) and event publication. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::core::event_bus::{publish_global, DomainEvent}; +use crate::rpc::RpcOutcome; + +use super::state::EmergencyStop; +use super::types::HaltState; + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Engage the kill switch: set the flag, then best-effort stop the a11y +/// session and cascade-deny pending approvals, then publish `AutomationHalted`. +/// Idempotent. Side-effect failures are logged but never fail the RPC — the +/// primary invariant (flag set → actions blocked) does not depend on them. +pub async fn emergency_stop(reason: Option, source: &str) -> RpcOutcome { + tracing::warn!(source, reason = ?reason, "[rpc:emergency_stop] entry — engaging kill switch"); + let stop = EmergencyStop::init_global(); + stop.engage(reason.clone(), source, now_ms()); + + // Best-effort: stop the accessibility session so any in-flight click/type loop halts. + let a11y = crate::openhuman::screen_intelligence::global_engine() + .disable(Some("emergency_stop".to_string())) + .await; + tracing::info!( + active = a11y.active, + "[emergency] accessibility session stopped" + ); + + // Best-effort: cascade-deny every pending approval so parked tool calls fail + // closed. `list_pending`/`decide` do synchronous SQLite I/O, so run them on a + // blocking thread rather than stalling a tokio worker. + let denied = tokio::task::spawn_blocking(cascade_deny_pending) + .await + .unwrap_or_else(|err| { + tracing::warn!(error = %err, "[emergency] cascade-deny task join failed"); + 0 + }); + tracing::info!(denied, "[emergency] cascade-denied pending approvals"); + + publish_global(DomainEvent::AutomationHalted { + reason, + source: source.to_string(), + }); + + let snap = stop.snapshot(); + RpcOutcome::single_log( + snap, + format!("[emergency] halted (source={source}, denied={denied})"), + ) +} + +/// Deny all pending approvals. Returns how many were denied. Best-effort: +/// a per-row error is logged and skipped. +fn cascade_deny_pending() -> usize { + use crate::openhuman::approval::{ApprovalDecision, ApprovalGate}; + let Some(gate) = ApprovalGate::try_global() else { + return 0; + }; + let rows = match gate.list_pending() { + Ok(rows) => rows, + Err(err) => { + tracing::warn!(error = %err, "[emergency] list_pending failed during cascade-deny"); + return 0; + } + }; + let mut denied = 0; + for row in rows { + match gate.decide(&row.request_id, ApprovalDecision::Deny) { + Ok(_) => denied += 1, + Err(err) => { + tracing::warn!(request_id = %row.request_id, error = %err, "[emergency] deny failed") + } + } + } + denied +} + +/// Clear the kill switch and publish `AutomationResumed`. Idempotent. +pub async fn emergency_resume(source: &str) -> RpcOutcome { + tracing::info!( + source, + "[rpc:emergency_resume] entry — clearing kill switch" + ); + let stop = EmergencyStop::init_global(); + stop.clear(); + publish_global(DomainEvent::AutomationResumed { + source: source.to_string(), + }); + RpcOutcome::single_log( + stop.snapshot(), + format!("[emergency] resumed (source={source})"), + ) +} + +/// Read the current switch state. +pub async fn emergency_status() -> RpcOutcome { + let snap = EmergencyStop::try_global() + .map(|s| s.snapshot()) + .unwrap_or_default(); + tracing::debug!(engaged = snap.engaged, "[rpc:emergency_status] exit"); + RpcOutcome::new(snap, vec![]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD; + + #[tokio::test] + async fn stop_sets_flag_and_status_reports_engaged() { + let _g = EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); + // Panic-safe cleanup: clear the process-global switch on drop — even if + // an assertion panics between engage and the end of the test — so an + // engaged state can't leak into a later test sharing the binary (#4600 + // review). Supersedes the manual `emergency_resume` reset below. + let _reset = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; + let out = emergency_stop(Some("user".into()), "user").await; + assert!(out.value.engaged); + let status = emergency_status().await; + assert!(status.value.engaged); + assert_eq!(status.value.source.as_deref(), Some("user")); + // reset for other tests sharing the process-global switch + let _ = emergency_resume("user").await; + } + + #[tokio::test] + async fn resume_clears_flag() { + let _g = EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); + // Panic-safe cleanup (see the note in the first test) — clears the + // process-global switch on drop so a mid-test panic can't leak state. + let _reset = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; + let _ = emergency_stop(None, "user").await; + let out = emergency_resume("user").await; + assert!(!out.value.engaged); + assert!(!emergency_status().await.value.engaged); + } + + #[tokio::test] + async fn stop_is_idempotent() { + let _g = EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); + // Panic-safe cleanup (see the note in the first test) — clears the + // process-global switch on drop so a mid-test panic can't leak state. + let _reset = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; + let _ = emergency_stop(Some("a".into()), "user").await; + let out = emergency_stop(Some("b".into()), "system").await; + assert!(out.value.engaged); + assert_eq!(out.value.reason.as_deref(), Some("b")); + let _ = emergency_resume("user").await; + } +} diff --git a/src/openhuman/emergency_stop/schemas.rs b/src/openhuman/emergency_stop/schemas.rs new file mode 100644 index 0000000000..171a0fcd55 --- /dev/null +++ b/src/openhuman/emergency_stop/schemas.rs @@ -0,0 +1,166 @@ +//! Controller schemas + handlers for the `emergency` namespace. +//! Wires `emergency_stop`, `emergency_resume`, `emergency_status` into the +//! global registry consumed by `src/core/all.rs`. + +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + +use super::ops; + +pub fn all_emergency_controller_schemas() -> Vec { + vec![schemas("stop"), schemas("resume"), schemas("status")] +} + +pub fn all_emergency_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: schemas("stop"), + handler: handle_stop, + }, + RegisteredController { + schema: schemas("resume"), + handler: handle_resume, + }, + RegisteredController { + schema: schemas("status"), + handler: handle_status, + }, + ] +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "stop" => ControllerSchema { + namespace: "emergency", + function: "stop", + description: "Engage the emergency stop: halt all desktop automation and block further actions until resumed.", + inputs: vec![FieldSchema { + name: "reason", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional human-readable reason for the halt.", + required: false, + }], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Switch snapshot after engaging.", + required: true, + }], + }, + "resume" => ControllerSchema { + namespace: "emergency", + function: "resume", + description: "Clear the emergency stop so automation may resume.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Switch snapshot after clearing.", + required: true, + }], + }, + "status" => ControllerSchema { + namespace: "emergency", + function: "status", + description: "Read the current emergency-stop switch state.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Current switch snapshot.", + required: true, + }], + }, + _ => ControllerSchema { + namespace: "emergency", + function: "unknown", + description: "Unknown emergency function.", + inputs: vec![], + outputs: vec![FieldSchema { name: "error", ty: TypeSchema::String, comment: "Schema not defined.", required: true }], + }, + } +} + +fn handle_stop(params: Map) -> ControllerFuture { + Box::pin(async move { + let reason = match params.get("reason") { + Some(Value::String(s)) => Some(s.clone()), + _ => None, + }; + to_json(ops::emergency_stop(reason, "user").await) + }) +} + +fn handle_resume(_params: Map) -> ControllerFuture { + Box::pin(async move { to_json(ops::emergency_resume("user").await) }) +} + +fn handle_status(_params: Map) -> ControllerFuture { + Box::pin(async move { to_json(ops::emergency_status().await) }) +} + +fn to_json(outcome: crate::rpc::RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registered_controllers_match_schemas() { + let c = all_emergency_registered_controllers(); + assert_eq!(c.len(), 3); + let names: Vec<_> = c.iter().map(|c| c.schema.function).collect(); + assert_eq!(names, vec!["stop", "resume", "status"]); + } + + #[test] + fn stop_schema_has_optional_reason() { + let s = schemas("stop"); + assert_eq!(s.namespace, "emergency"); + assert_eq!(s.inputs[0].name, "reason"); + assert!(!s.inputs[0].required); + } + + #[test] + fn resume_status_and_unknown_schema_arms() { + assert_eq!(schemas("resume").function, "resume"); + assert!(schemas("resume").inputs.is_empty()); + assert_eq!(schemas("status").function, "status"); + assert!(schemas("status").inputs.is_empty()); + // The catch-all arm renders a placeholder rather than panicking. + assert_eq!(schemas("nope").function, "unknown"); + assert_eq!(schemas("nope").outputs[0].name, "error"); + } + + fn json_engaged(v: &Value) -> bool { + // stop/resume emit a diagnostic log → enveloped `{result, logs}`; + // status has no log → bare value. Normalize both. + let obj = v.get("result").unwrap_or(v); + obj.get("engaged") + .and_then(|e| e.as_bool()) + .unwrap_or(false) + } + + #[tokio::test] + async fn handlers_drive_stop_status_resume() { + let _g = crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); + let _reset = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; + + let mut params = Map::new(); + params.insert("reason".into(), Value::String("verify".into())); + let stopped = handle_stop(params).await.expect("handle_stop ok"); + assert!(json_engaged(&stopped)); + + let status = handle_status(Map::new()).await.expect("handle_status ok"); + assert!(json_engaged(&status)); + + let resumed = handle_resume(Map::new()).await.expect("handle_resume ok"); + assert!(!json_engaged(&resumed)); + } +} diff --git a/src/openhuman/emergency_stop/state.rs b/src/openhuman/emergency_stop/state.rs new file mode 100644 index 0000000000..91a910b3a1 --- /dev/null +++ b/src/openhuman/emergency_stop/state.rs @@ -0,0 +1,174 @@ +//! Process-global emergency-stop switch. Mirrors the `ApprovalGate` +//! `OnceLock` install pattern: `init_global` is idempotent, `try_global` +//! returns `None` when never installed (CLI/headless → never blocks). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use super::types::HaltState; + +static GLOBAL_STOP: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +struct HaltInfo { + reason: Option, + engaged_at_ms: u64, + source: String, +} + +/// Coordinator for the emergency-stop kill switch. +#[derive(Debug)] +pub struct EmergencyStop { + engaged: AtomicBool, + info: Mutex>, +} + +impl EmergencyStop { + /// Install the process-global switch. Idempotent — re-install returns the + /// existing switch so repeated boots in tests don't panic. + pub fn init_global() -> Arc { + if let Some(existing) = GLOBAL_STOP.get() { + return existing.clone(); + } + let stop = Arc::new(EmergencyStop { + engaged: AtomicBool::new(false), + info: Mutex::new(None), + }); + let _ = GLOBAL_STOP.set(stop.clone()); + GLOBAL_STOP.get().cloned().unwrap_or(stop) + } + + /// The global switch when installed; `None` means "no switch" → callers + /// treat as not-engaged (never block). + pub fn try_global() -> Option> { + GLOBAL_STOP.get().cloned() + } + + /// Whether automation is currently halted. + pub fn is_engaged(&self) -> bool { + self.engaged.load(Ordering::SeqCst) + } + + /// Engage the halt. Idempotent — re-engaging refreshes reason/source/time. + /// + /// The `engaged` flag is written **inside** the `info` lock so the + /// (flag, info) pair transitions atomically for any reader that takes the + /// lock (`snapshot`). The lock-free `is_engaged()` fast path used by the + /// enforcement chokepoints reads the flag directly and is eventually + /// consistent, which is all a fail-closed guard needs. + pub fn engage(&self, reason: Option, source: &str, now_ms: u64) { + let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(HaltInfo { + reason, + engaged_at_ms: now_ms, + source: source.to_string(), + }); + self.engaged.store(true, Ordering::SeqCst); + } + + /// Clear the halt. Idempotent. Flag + info are cleared under one lock so + /// a concurrent `snapshot` never observes an inconsistent pair. + pub fn clear(&self) { + let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + self.engaged.store(false, Ordering::SeqCst); + } + + /// Current snapshot for RPC/UI. Reads the flag under the `info` lock so the + /// returned (engaged, info) pair is always consistent with `engage`/`clear`. + pub fn snapshot(&self) -> HaltState { + let guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + if !self.engaged.load(Ordering::SeqCst) { + return HaltState::default(); + } + match guard.as_ref() { + Some(info) => HaltState { + engaged: true, + reason: info.reason.clone(), + engaged_at_ms: Some(info.engaged_at_ms), + source: Some(info.source.clone()), + }, + None => HaltState { + engaged: true, + ..Default::default() + }, + } + } +} + +/// Shared, crate-visible serialization guard for tests that touch the +/// process-global `EmergencyStop`. Rust runs unit tests in parallel within a +/// single test binary, so tests in `ops.rs`, the tinyagents middleware, and +/// `screen_intelligence::ops` all mutate the SAME global and would race. Every +/// global-touching test must lock this before engaging/clearing the switch. +#[cfg(test)] +pub(crate) static EMERGENCY_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// RAII guard that clears the process-global switch on drop, so a test that +/// panics mid-way (assertion failure / `unwrap`) can't leak an engaged state +/// into a later test. Construct it right after `EmergencyStop::init_global()`. +#[cfg(test)] +pub(crate) struct ClearEmergencyOnDrop; + +#[cfg(test)] +impl Drop for ClearEmergencyOnDrop { + fn drop(&mut self) { + if let Some(stop) = EmergencyStop::try_global() { + stop.clear(); + } + } +} + +/// Global convenience: is a switch installed AND engaged? False when no +/// switch is installed (CLI/headless) so those paths are never blocked. +pub fn is_engaged_global() -> bool { + EmergencyStop::try_global() + .map(|s| s.is_engaged()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn engage_then_snapshot_reports_engaged() { + let stop = EmergencyStop { + engaged: AtomicBool::new(false), + info: Mutex::new(None), + }; + assert!(!stop.is_engaged()); + stop.engage(Some("user".into()), "user", 1234); + assert!(stop.is_engaged()); + let snap = stop.snapshot(); + assert!(snap.engaged); + assert_eq!(snap.reason.as_deref(), Some("user")); + assert_eq!(snap.engaged_at_ms, Some(1234)); + assert_eq!(snap.source.as_deref(), Some("user")); + } + + #[test] + fn clear_resets_to_default_snapshot() { + let stop = EmergencyStop { + engaged: AtomicBool::new(false), + info: Mutex::new(None), + }; + stop.engage(None, "hotkey", 1); + stop.clear(); + assert!(!stop.is_engaged()); + assert_eq!(stop.snapshot(), HaltState::default()); + } + + #[test] + fn engage_is_idempotent_and_refreshes() { + let stop = EmergencyStop { + engaged: AtomicBool::new(false), + info: Mutex::new(None), + }; + stop.engage(Some("a".into()), "user", 1); + stop.engage(Some("b".into()), "system", 2); + assert!(stop.is_engaged()); + assert_eq!(stop.snapshot().reason.as_deref(), Some("b")); + assert_eq!(stop.snapshot().source.as_deref(), Some("system")); + } +} diff --git a/src/openhuman/emergency_stop/types.rs b/src/openhuman/emergency_stop/types.rs new file mode 100644 index 0000000000..f83d179355 --- /dev/null +++ b/src/openhuman/emergency_stop/types.rs @@ -0,0 +1,51 @@ +//! Serde domain types for the emergency-stop kill switch. + +use serde::{Deserialize, Serialize}; + +/// Snapshot of the emergency-stop switch, returned by every emergency RPC and +/// surfaced in the UI. `engaged == false` is the resting state. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct HaltState { + /// Whether automation is currently halted. + pub engaged: bool, + /// Human-readable reason for the halt (redacted of PII), when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Unix-epoch milliseconds when the halt was engaged, when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub engaged_at_ms: Option, + /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`, when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_halt_state_is_not_engaged() { + let s = HaltState::default(); + assert!(!s.engaged); + assert!(s.reason.is_none()); + assert!(s.engaged_at_ms.is_none()); + } + + #[test] + fn resting_state_serializes_to_engaged_false_only() { + let json = serde_json::to_string(&HaltState::default()).unwrap(); + assert_eq!(json, r#"{"engaged":false}"#); + } + + #[test] + fn engaged_state_roundtrips() { + let s = HaltState { + engaged: true, + reason: Some("user".into()), + engaged_at_ms: Some(42), + source: Some("user".into()), + }; + let back: HaltState = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(s, back); + } +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 601a569cfc..5cd7d3730b 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -49,6 +49,7 @@ pub mod dev_paths; pub mod devices; pub mod doctor; pub mod embeddings; +pub mod emergency_stop; pub mod encryption; pub mod file_state; pub mod file_storage; diff --git a/src/openhuman/screen_intelligence/ops.rs b/src/openhuman/screen_intelligence/ops.rs index 4608e8b00b..76ad746736 100644 --- a/src/openhuman/screen_intelligence/ops.rs +++ b/src/openhuman/screen_intelligence/ops.rs @@ -152,6 +152,19 @@ pub async fn accessibility_capture_image_ref() -> Result Result, String> { + // Emergency stop: refuse desktop input while halted. `panic_stop` is + // exempt so a stop is never blocked by a stop. + if payload.action != "panic_stop" && crate::openhuman::emergency_stop::is_engaged_global() { + tracing::warn!(action = %payload.action, "[emergency] accessibility_input_action blocked — kill switch engaged"); + return Ok(RpcOutcome::single_log( + InputActionResult { + accepted: false, + blocked: true, + reason: Some("emergency_stop".to_string()), + }, + "screen intelligence input blocked by emergency stop", + )); + } let result = screen_intelligence::global_engine() .input_action(payload) .await?; @@ -307,4 +320,59 @@ mod tests { // Either Ok or Err — just ensure the call doesn't panic. let _ = outcome; } + + #[tokio::test] + async fn input_action_blocked_while_emergency_engaged() { + use crate::openhuman::emergency_stop::state::{ClearEmergencyOnDrop, EMERGENCY_TEST_GUARD}; + use crate::openhuman::emergency_stop::EmergencyStop; + let _g = EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); + let stop = EmergencyStop::init_global(); + // Panic-safe cleanup: resets the process-global even if an assertion + // below panics, so a leaked engaged state can't poison later tests. + let _reset = ClearEmergencyOnDrop; + stop.engage(Some("test".into()), "user", 0); + let params = InputActionParams { + action: "click".into(), + x: Some(1), + y: Some(1), + button: None, + text: None, + key: None, + modifiers: None, + }; + let out = accessibility_input_action(params).await.unwrap(); + assert!(!out.value.accepted); + assert!(out.value.blocked); + assert_eq!(out.value.reason.as_deref(), Some("emergency_stop")); + } + + #[tokio::test] + async fn panic_stop_passes_even_while_emergency_engaged() { + use crate::openhuman::emergency_stop::state::{ClearEmergencyOnDrop, EMERGENCY_TEST_GUARD}; + use crate::openhuman::emergency_stop::EmergencyStop; + let _g = EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); + let stop = EmergencyStop::init_global(); + let _reset = ClearEmergencyOnDrop; + stop.engage(None, "user", 0); + let params = InputActionParams { + action: "panic_stop".into(), + x: None, + y: None, + button: None, + text: None, + key: None, + modifiers: None, + }; + // `panic_stop` must NOT be short-circuited by the emergency guard: the + // call reaches the engine rather than returning the guard's blocked + // outcome. Whatever the engine reports, it is never the emergency block. + let out = accessibility_input_action(params).await; + if let Ok(outcome) = out { + assert_ne!(outcome.value.reason.as_deref(), Some("emergency_stop")); + } + } } diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index e87b528e14..6ca9ef1f75 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -1029,6 +1029,26 @@ impl ApprovalSecurityMiddleware { } } +/// The fail-closed denial a halted external-effect tool call resolves to. +/// Returns `Some(result)` iff the emergency stop is engaged, otherwise `None`. +/// Extracted from `wrap_tool` so the deny path is unit-testable without +/// constructing a full `RunContext`/`ToolHandler` runtime. +fn emergency_halt_denial(call_id: String, name: String) -> Option { + if !crate::openhuman::emergency_stop::is_engaged_global() { + return None; + } + let reason = "Emergency stop is engaged — this action is blocked until you resume automation." + .to_string(); + Some(TaToolResult { + call_id, + name, + content: reason.clone(), + raw: None, + error: Some(reason), + elapsed_ms: 0, + }) +} + #[async_trait] impl ToolMiddleware<()> for ApprovalSecurityMiddleware { fn name(&self) -> &str { @@ -1046,6 +1066,12 @@ impl ToolMiddleware<()> for ApprovalSecurityMiddleware { // approval await. let mut audit_id: Option = None; if self.has_external_effect(&call.name, &call.arguments) { + // Emergency stop: refuse every external-effect tool while halted, + // before touching the approval gate. Fail-closed. + if let Some(denial) = emergency_halt_denial(call.id.clone(), call.name.clone()) { + tracing::warn!(tool = %call.name, "[tinyagents::mw] emergency stop engaged — refusing tool call"); + return Ok(MiddlewareToolOutcome::Result(denial)); + } if let Some(gate) = ApprovalGate::try_global() { let summary = summarize_action(&call.name, &call.arguments); let redacted = redact_args(&call.arguments); @@ -4536,6 +4562,39 @@ mod tests { assert!(!mw.has_external_effect("missing", &json!({}))); } + /// Exercises the emergency-stop guard the middleware consults before the + /// approval gate. Constructing a full `RunContext`/`ToolHandler` to drive + /// `wrap_tool` end-to-end is heavy, so this asserts the exact global + /// predicate the guard branches on flips as the switch engages/clears. + #[test] + fn emergency_guard_blocks_when_engaged() { + let _g = crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); + use crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; + use crate::openhuman::emergency_stop::EmergencyStop; + let stop = EmergencyStop::init_global(); + // Panic-safe: always resets the process-global on drop, even on an + // assertion failure below, so a leaked engaged state can't poison + // later tests. + let _reset = ClearEmergencyOnDrop; + stop.clear(); + + // Not halted → no denial, and the guard predicate is false. + assert!(!crate::openhuman::emergency_stop::is_engaged_global()); + assert!(emergency_halt_denial("c1".into(), "send".into()).is_none()); + + // Halted → the deny result is produced with the call's id/name and an + // error payload (this is the exact branch `wrap_tool` returns). + stop.engage(Some("test".into()), "user", 0); + assert!(crate::openhuman::emergency_stop::is_engaged_global()); + let denial = + emergency_halt_denial("c1".into(), "send".into()).expect("halted → denial produced"); + assert_eq!(denial.call_id, "c1"); + assert_eq!(denial.name, "send"); + assert!(denial.error.is_some()); + } + // ── MemoryProtocolMiddleware (issue #4116) ────────────────────────────── use crate::openhuman::agent::harness::memory_protocol::MEMORY_PROTOCOL_MARKER; diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 73580df669..81d6134168 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1228,6 +1228,90 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { rpc_join.abort(); } +/// Emergency-stop kill switch over JSON-RPC: status(not halted) → stop → +/// status(halted) → resume → status(not halted). Asserts `engaged` flips +/// across the full round-trip (#4255). +#[tokio::test] +async fn json_rpc_emergency_stop_roundtrip_over_rpc() { + let _env_lock = json_rpc_e2e_env_lock(); + + // Panic-safe cleanup: the switch is a process-global, so guarantee it is + // cleared even if an assertion below panics before the resume call — a + // leaked engaged state would fail-close unrelated tests in this binary. + struct ResumeOnDrop; + impl Drop for ResumeOnDrop { + fn drop(&mut self) { + if let Some(stop) = + openhuman_core::openhuman::emergency_stop::EmergencyStop::try_global() + { + stop.clear(); + } + } + } + let _reset = ResumeOnDrop; + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + // status: not halted (no logs → bare HaltState). + let s0 = post_json_rpc(&rpc_base, 4255_1, "openhuman.emergency_status", json!({})).await; + let s0_result = assert_no_jsonrpc_error(&s0, "emergency_status initial"); + let s0_state = peel_logs_envelope(s0_result); + assert_eq!( + s0_state.get("engaged").and_then(Value::as_bool), + Some(false), + "switch must start not engaged: {s0_state}" + ); + + // stop: engage the switch. + let stopped = post_json_rpc( + &rpc_base, + 4255_2, + "openhuman.emergency_stop", + json!({ "reason": "e2e" }), + ) + .await; + let stopped_result = assert_no_jsonrpc_error(&stopped, "emergency_stop"); + let stopped_state = peel_logs_envelope(stopped_result); + assert_eq!( + stopped_state.get("engaged").and_then(Value::as_bool), + Some(true), + "stop response must report engaged: {stopped_state}" + ); + + // status: halted. + let s1 = post_json_rpc(&rpc_base, 4255_3, "openhuman.emergency_status", json!({})).await; + let s1_result = assert_no_jsonrpc_error(&s1, "emergency_status halted"); + let s1_state = peel_logs_envelope(s1_result); + assert_eq!( + s1_state.get("engaged").and_then(Value::as_bool), + Some(true), + "status must report engaged after stop: {s1_state}" + ); + + // resume: clear the switch. + let resumed = post_json_rpc(&rpc_base, 4255_4, "openhuman.emergency_resume", json!({})).await; + let resumed_result = assert_no_jsonrpc_error(&resumed, "emergency_resume"); + let resumed_state = peel_logs_envelope(resumed_result); + assert_eq!( + resumed_state.get("engaged").and_then(Value::as_bool), + Some(false), + "resume response must report not engaged: {resumed_state}" + ); + + // status: not halted again. + let s2 = post_json_rpc(&rpc_base, 4255_5, "openhuman.emergency_status", json!({})).await; + let s2_result = assert_no_jsonrpc_error(&s2, "emergency_status resumed"); + let s2_state = peel_logs_envelope(s2_result); + assert_eq!( + s2_state.get("engaged").and_then(Value::as_bool), + Some(false), + "status must report not engaged after resume: {s2_state}" + ); + + rpc_join.abort(); +} + #[tokio::test] async fn json_rpc_tokenjuice_detect_and_cache_stats() { let _env_lock = json_rpc_e2e_env_lock();