diff --git a/app/src/App.tsx b/app/src/App.tsx index 645b948731..09969b5df9 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -30,8 +30,6 @@ 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'; @@ -59,7 +57,6 @@ import { startInternetStatusListener, stopInternetStatusListener, } from './services/internetStatusListener'; -import { hydrateEmergencyState } from './services/safety/hydrateEmergencyState'; import { hideWebviewAccount, startWebviewAccountService, @@ -259,16 +256,6 @@ 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(); @@ -304,9 +291,6 @@ 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 && ( @@ -348,17 +332,6 @@ 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 deleted file mode 100644 index f9b2d2034d..0000000000 --- a/app/src/components/safety/AutomationHaltedBanner.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index ded9a7a25b..0000000000 --- a/app/src/components/safety/AutomationHaltedBanner.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 12f72d8da1..0000000000 --- a/app/src/components/safety/EmergencyStopButton.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 254b38ac70..0000000000 --- a/app/src/components/safety/EmergencyStopButton.tsx +++ /dev/null @@ -1,73 +0,0 @@ -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 818fb6c56e..86ccd740d5 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7204,13 +7204,6 @@ 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 9bc096d684..d67bba8a75 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7372,13 +7372,6 @@ 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 01e2dfdc84..0cf85fef8a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7592,15 +7592,6 @@ 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 b776dd18f7..54342cc03d 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7673,15 +7673,6 @@ 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 86a724d241..d9773b9e72 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7527,15 +7527,6 @@ 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 e3b1e8d046..3ad118d95e 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7561,15 +7561,6 @@ 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 3c38a98459..12facfb7a7 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7369,13 +7369,6 @@ 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 6ee9bae7a3..a2a7ce85b7 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7407,13 +7407,6 @@ 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 364d450e91..e92f9c2bed 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7515,13 +7515,6 @@ 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 ebd1a62cb3..6485cc380a 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7284,13 +7284,6 @@ 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 8cad5c5f63..df2555176b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7485,13 +7485,6 @@ 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 c8cb8a8a48..16707e454d 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7495,13 +7495,6 @@ 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 d36d802819..762abc76e0 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7455,15 +7455,6 @@ 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 663783f415..344bd1a704 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6970,13 +6970,6 @@ 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 9202dd559b..c61553be3f 100644 --- a/app/src/services/__tests__/socketService.events.test.ts +++ b/app/src/services/__tests__/socketService.events.test.ts @@ -452,156 +452,3 @@ 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 deleted file mode 100644 index 5e80fe8fd8..0000000000 --- a/app/src/services/api/emergencyApi.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 224f485b6b..0000000000 --- a/app/src/services/api/emergencyApi.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 4ad2b6b9c1..0000000000 --- a/app/src/services/safety/hydrateEmergencyState.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 3429746192..0000000000 --- a/app/src/services/safety/hydrateEmergencyState.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 f82ec9090f..4097563bae 100644 --- a/app/src/services/socketService.ts +++ b/app/src/services/socketService.ts @@ -17,7 +17,6 @@ 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'; @@ -464,46 +463,6 @@ 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 1a7b4435a6..b41171943a 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -34,7 +34,6 @@ 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'; @@ -245,7 +244,6 @@ 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 deleted file mode 100644 index bcf652650e..0000000000 --- a/app/src/store/safetySlice.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 29f8d437dd..0000000000 --- a/app/src/store/safetySlice.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 b3667becf5..beda189643 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -25,7 +25,6 @@ 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'; @@ -55,7 +54,6 @@ 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 deleted file mode 100644 index ce629ba8d8..0000000000 --- a/docs/superpowers/plans/2026-07-06-emergency-stop.md +++ /dev/null @@ -1,1358 +0,0 @@ -# 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 deleted file mode 100644 index 45b3f60939..0000000000 --- a/docs/superpowers/specs/2026-07-06-emergency-stop-design.md +++ /dev/null @@ -1,102 +0,0 @@ -# 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 8ead22c1df..b5821c13da 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -336,12 +336,6 @@ 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 bc9a7af811..c73172e5b7 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -1127,22 +1127,6 @@ 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 @@ -1470,9 +1454,7 @@ impl DomainEvent { | Self::HealthChanged { .. } | Self::HealthRestarted { .. } | Self::HarnessInitProgress { .. } - | Self::HarnessInitCompleted { .. } - | Self::AutomationHalted { .. } - | Self::AutomationResumed { .. } => "system", + | Self::HarnessInitCompleted { .. } => "system", Self::KeyringConsentRequired | Self::KeyringDecryptFailed { .. } => "keyring", @@ -1628,8 +1610,6 @@ 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 551c664743..c977286852 100644 --- a/src/core/event_bus/events_tests.rs +++ b/src/core/event_bus/events_tests.rs @@ -594,18 +594,3 @@ 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 2b0b10ef29..2b87bdcb2d 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2360,12 +2360,6 @@ 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::web_chat::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::web_chat::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 @@ -2384,7 +2378,6 @@ 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/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index bc777e88a2..8cd8ecfbfc 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -171,11 +171,6 @@ 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::web_chat::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::web_chat::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 115e28fdc5..623bbf09c6 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -483,27 +483,6 @@ 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; @@ -515,23 +494,6 @@ 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; diff --git a/src/openhuman/cron/scheduler_tests.rs b/src/openhuman/cron/scheduler_tests.rs index f14212282f..dff55e92ed 100644 --- a/src/openhuman/cron/scheduler_tests.rs +++ b/src/openhuman/cron/scheduler_tests.rs @@ -439,43 +439,6 @@ 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 deleted file mode 100644 index b4d3c5e9fe..0000000000 --- a/src/openhuman/emergency_stop/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! 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 deleted file mode 100644 index b1b3bc67eb..0000000000 --- a/src/openhuman/emergency_stop/ops.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! 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 deleted file mode 100644 index 171a0fcd55..0000000000 --- a/src/openhuman/emergency_stop/schemas.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! 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 deleted file mode 100644 index 91a910b3a1..0000000000 --- a/src/openhuman/emergency_stop/state.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! 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 deleted file mode 100644 index f83d179355..0000000000 --- a/src/openhuman/emergency_stop/types.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! 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/learning/startup.rs b/src/openhuman/learning/startup.rs index 2da5a5de62..fbde7c8f87 100644 --- a/src/openhuman/learning/startup.rs +++ b/src/openhuman/learning/startup.rs @@ -286,12 +286,32 @@ mod tests { "signature body must yield at least one identity candidate" ); - publish_email_doc(&source_id, &body); - let got = wait_for_candidates(&source_id, expected).await; - assert_eq!( - got, expected, + // The email-signature subscriber lives on the process-wide *global* + // event bus and pushes into the shared, bounded `candidate::global()` + // ring. Under the full-suite coverage run (thousands of tests in one + // process — the module filter widened when this tree stopped touching + // only `learning/`), that global bus is under heavy concurrent load: a + // single published event can be dropped to tokio broadcast lag, or its + // candidates evicted from the 1024-entry ring before we read them. That + // made the original single-publish assertion flaky (it passes in + // isolation but fails deterministically in busy CI). Re-publish on every + // poll tick until *this* source's candidates land. The subscriber is + // idempotent per event and we filter by our unique `source_id`, so this + // only ever proves the subscriber *fires* — it can never mask a missing + // one (a never-registered subscriber yields 0 forever and still fails). + let mut got = 0; + for _ in 0..200 { + publish_email_doc(&source_id, &body); + tokio::time::sleep(Duration::from_millis(20)).await; + got = candidates_for(&source_id); + if got >= expected { + break; + } + } + assert!( + got >= expected, "email-signature subscriber must push the parsed identity candidates \ - with no channel configured anywhere (#5003)" + with no channel configured anywhere (#5003); got {got}, expected >= {expected}" ); } diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index b890b5bcaa..8509fcabba 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -49,7 +49,6 @@ 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 76ad746736..4608e8b00b 100644 --- a/src/openhuman/screen_intelligence/ops.rs +++ b/src/openhuman/screen_intelligence/ops.rs @@ -152,19 +152,6 @@ 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?; @@ -320,59 +307,4 @@ 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 6ca9ef1f75..e87b528e14 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -1029,26 +1029,6 @@ 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 { @@ -1066,12 +1046,6 @@ 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); @@ -4562,39 +4536,6 @@ 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/src/openhuman/web_chat/event_bus.rs b/src/openhuman/web_chat/event_bus.rs index 0afdbe7646..63902b509c 100644 --- a/src/openhuman/web_chat/event_bus.rs +++ b/src/openhuman/web_chat/event_bus.rs @@ -40,30 +40,6 @@ 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() { @@ -406,60 +382,9 @@ impl EventHandler for ApprovalSurfaceSubscriber { } } -struct AutomationHaltSubscriber; - -#[async_trait] -impl EventHandler for AutomationHaltSubscriber { - fn name(&self) -> &str { - "web_chat::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` @@ -492,108 +417,6 @@ 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/web_chat/mod.rs b/src/openhuman/web_chat/mod.rs index 2e8527413d..764acd3ab9 100644 --- a/src/openhuman/web_chat/mod.rs +++ b/src/openhuman/web_chat/mod.rs @@ -22,8 +22,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_automation_halt_subscriber, - register_egress_surface_subscriber, subscribe_web_channel_events, + register_artifact_surface_subscriber, register_egress_surface_subscriber, + subscribe_web_channel_events, }; // Test-only: OnceLock-bypassing approval bridge for per-runtime integration tests. diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index a1463bb2e6..eba600df18 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1228,90 +1228,6 @@ 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();