revert(safety): remove Emergency Stop for desktop automation (#4600) (#5073)

This commit is contained in:
Steven Enamakel
2026-07-21 12:39:46 +03:00
committed by GitHub
parent 00708c324e
commit 9420a2983a
50 changed files with 28 additions and 3447 deletions
-27
View File
@@ -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<HTMLDivElement>(null);
const navType = useNavigationType();
@@ -304,9 +291,6 @@ export function AppShellDesktop() {
const content = (
<div ref={scrollRef} className="relative h-full overflow-y-auto">
{/* Automation halt banner — renders at the top of the content area when
emergency stop is engaged. Always visible during automation sessions. */}
<AutomationHaltedBanner />
<GlobalUpsellBanner />
<AppRoutes location={baseLocation} />
{activeProviderAccount && !accountsOverlayOpen && (
@@ -348,17 +332,6 @@ export function AppShellDesktop() {
exhaustion). Mounted outside the routes so entries survive route
changes and background-job completion. */}
<UserErrorCenter />
{/* 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 && (
<div className="fixed top-3 right-4 z-50">
<EmergencyStopButton />
</div>
)}
{/* 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
@@ -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(<AutomationHaltedBanner />);
expect(container.firstChild).toBeNull();
});
it('renders the banner when halted', () => {
const { store } = renderWithProviders(<AutomationHaltedBanner />, {
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(<AutomationHaltedBanner />, {
preloadedState: { safety: { halted: true, reason: 'custom reason' } },
});
expect(screen.getByText('custom reason')).toBeDefined();
});
it('falls back to haltedBody when reason is absent', () => {
renderWithProviders(<AutomationHaltedBanner />, {
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(<AutomationHaltedBanner />, {
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(<AutomationHaltedBanner />, {
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(<AutomationHaltedBanner />, {
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(<AutomationHaltedBanner />);
// 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());
});
});
@@ -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 (
<div
role="alert"
data-analytics-id="automation-halted-banner"
className="sticky top-0 z-40 flex items-center justify-between gap-3 px-4 py-2.5 bg-[var(--color-coral-50,#fdf2f2)] border-b border-[var(--color-coral-200,#f5c6c6)] text-[var(--color-coral-900,#7c2d2d)]">
<div className="flex items-center gap-2 min-w-0">
<strong className="shrink-0 font-semibold">{t('safety.haltedTitle')}</strong>
<span className="truncate text-sm text-[var(--color-coral-700,#b94040)]">
{reason || t('safety.haltedBody')}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{resumeFailed && (
<span
role="status"
aria-label={t('safety.resumeFailed')}
data-analytics-id="emergency-resume-failed"
className="rounded-md bg-[var(--color-coral-100,#fce8e8)] px-2 py-1 text-xs font-medium text-[var(--color-coral-800,#8f3a3a)]">
{t('safety.resumeFailed')}
</span>
)}
<button
type="button"
data-analytics-id="emergency-resume"
onClick={() => void onResume()}
className="rounded-md px-3 py-1 text-sm font-medium border border-[var(--color-coral-400,#d97373)] hover:bg-[var(--color-coral-100,#fce8e8)] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-coral-500,#e05c5c)]">
{t('safety.resume')}
</button>
</div>
</div>
);
}
@@ -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(<EmergencyStopButton />);
expect(screen.getByRole('button', { name: /emergency stop/i })).toBeDefined();
});
it('calls emergencyStop with no argument and dispatches halt on click', async () => {
const { store } = renderWithProviders(<EmergencyStopButton />);
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(<EmergencyStopButton />);
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(<EmergencyStopButton />, {
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(<EmergencyStopButton />);
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()
);
});
});
@@ -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 (
<div className="flex items-center gap-2">
{failed && (
<span
role="alert"
data-analytics-id="emergency-stop-failed"
className="rounded-md bg-[var(--color-coral-50,#fdf2f2)] px-2 py-1 text-xs font-medium text-[var(--color-coral-800,#8f3a3a)] shadow-sm">
{t('safety.stopFailed')}
</span>
)}
<button
type="button"
data-analytics-id="emergency-stop"
onClick={() => void handleClick()}
className="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold shadow-md bg-[var(--color-coral-500,#e05c5c)] text-white hover:bg-[var(--color-coral-600,#c94f4f)] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-coral-500,#e05c5c)]"
aria-label={t('safety.emergencyStop')}>
<svg aria-hidden="true" viewBox="0 0 16 16" className="h-3 w-3" fill="currentColor">
<rect x="3" y="3" width="10" height="10" rx="2" />
</svg>
{t('safety.emergencyStop')}
</button>
</div>
);
}
-7
View File
@@ -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': 'خارج الجهاز',
-7
View File
@@ -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': 'ডিভাইসের বাইরে',
-9
View File
@@ -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',
-9
View File
@@ -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.',
-9
View File
@@ -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',
-9
View File
@@ -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 lappareil',
-7
View File
@@ -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': 'डिवाइस के बाहर',
-7
View File
@@ -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',
-7
View File
@@ -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',
-7
View File
@@ -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': '기기 외',
-7
View File
@@ -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',
-7
View File
@@ -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',
-9
View File
@@ -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': 'Вне устройства',
-7
View File
@@ -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': '设备外',
@@ -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();
});
});
-38
View File
@@ -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);
});
});
-31
View File
@@ -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 = <T>(raw: unknown): T => {
if (raw && typeof raw === 'object' && 'result' in (raw as Record<string, unknown>)) {
return (raw as { result: T }).result;
}
return raw as T;
};
export async function emergencyStop(reason?: string): Promise<HaltState> {
console.debug('[emergency] rpc → openhuman.emergency_stop', { reason: reason ?? 'none' });
const raw = await callCoreRpc<unknown>({
method: 'openhuman.emergency_stop',
params: reason ? { reason } : {},
});
return unwrapValue<HaltState>(raw);
}
export async function emergencyResume(): Promise<HaltState> {
console.debug('[emergency] rpc → openhuman.emergency_resume');
const raw = await callCoreRpc<unknown>({ method: 'openhuman.emergency_resume', params: {} });
return unwrapValue<HaltState>(raw);
}
export async function emergencyStatus(): Promise<HaltState> {
console.debug('[emergency] rpc → openhuman.emergency_status');
const raw = await callCoreRpc<unknown>({ method: 'openhuman.emergency_status', params: {} });
return unwrapValue<HaltState>(raw);
}
@@ -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();
});
});
@@ -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<void> {
try {
const status = await emergencyStatus();
dispatch(hydrateHalt(status));
} catch (err) {
console.warn('[emergency] status hydration failed', err);
}
}
-41
View File
@@ -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<string, unknown> | 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<string, unknown>) : 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<string, unknown> | null;
-2
View File
@@ -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({
-26
View File
@@ -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);
});
});
-49
View File
@@ -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<HaltState>) {
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;
-2
View File
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -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<Option<HaltInfo>>` (`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<String>, engaged_at_ms: Option<u64>, source: Option<HaltSource> }` (serde); `HaltSource` enum (`User`, `Hotkey`, `System`).
- **`ops.rs`** — handlers returning `RpcOutcome<HaltState>`:
- `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).
-6
View File
@@ -336,12 +336,6 @@ fn build_registered_controllers() -> Vec<GroupedController> {
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,
+1 -21
View File
@@ -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<String>,
/// 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",
-15
View File
@@ -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");
}
-7
View File
@@ -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"
@@ -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).
-38
View File
@@ -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<String> = 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;
-37
View File
@@ -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
-16
View File
@@ -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;
-165
View File
@@ -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<String>, source: &str) -> RpcOutcome<HaltState> {
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<HaltState> {
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<HaltState> {
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;
}
}
-166
View File
@@ -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<ControllerSchema> {
vec![schemas("stop"), schemas("resume"), schemas("status")]
}
pub fn all_emergency_registered_controllers() -> Vec<RegisteredController> {
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<String, Value>) -> 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<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(ops::emergency_resume("user").await) })
}
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(ops::emergency_status().await) })
}
fn to_json<T: serde::Serialize>(outcome: crate::rpc::RpcOutcome<T>) -> Result<Value, String> {
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));
}
}
-174
View File
@@ -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<Arc<EmergencyStop>> = OnceLock::new();
#[derive(Debug)]
struct HaltInfo {
reason: Option<String>,
engaged_at_ms: u64,
source: String,
}
/// Coordinator for the emergency-stop kill switch.
#[derive(Debug)]
pub struct EmergencyStop {
engaged: AtomicBool,
info: Mutex<Option<HaltInfo>>,
}
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<EmergencyStop> {
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<Arc<EmergencyStop>> {
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<String>, 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"));
}
}
-51
View File
@@ -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<String>,
/// Unix-epoch milliseconds when the halt was engaged, when engaged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engaged_at_ms: Option<u64>,
/// Who engaged it: `"user"`, `"hotkey"`, or `"system"`, when engaged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[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);
}
}
+25 -5
View File
@@ -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}"
);
}
-1
View File
@@ -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;
-68
View File
@@ -152,19 +152,6 @@ pub async fn accessibility_capture_image_ref() -> Result<RpcOutcome<CaptureImage
pub async fn accessibility_input_action(
payload: InputActionParams,
) -> Result<RpcOutcome<InputActionResult>, 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"));
}
}
}
-59
View File
@@ -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<TaToolResult> {
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<String> = 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;
-177
View File
@@ -40,30 +40,6 @@ pub fn register_approval_surface_subscriber() {
}
}
static AUTOMATION_HALT_HANDLE: OnceLock<SubscriptionHandle> = 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<SubscriptionHandle> = 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(
+2 -2
View File
@@ -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.
-84
View File
@@ -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();