mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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': 'خارج الجهاز',
|
||||
|
||||
@@ -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': 'ডিভাইসের বাইরে',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -7561,15 +7561,6 @@ const messages: TranslationMap = {
|
||||
'flows.canvas.sidePanelToggle': 'Panneau latéral',
|
||||
'flows.canvas.legendTab': 'Manuel',
|
||||
|
||||
// Emergency stop (#4255)
|
||||
'safety.emergencyStop': "Arrêt d'urgence",
|
||||
'safety.stopFailed': "Impossible d'arrêter l'automatisation. Réessayez.",
|
||||
'safety.resume': "Reprendre l'automatisation",
|
||||
'safety.resumeFailed':
|
||||
"Impossible de reprendre. L'automatisation est toujours suspendue. Réessayez.",
|
||||
'safety.haltedTitle': 'Automatisation suspendue',
|
||||
'safety.haltedBody':
|
||||
"Toute l'automatisation du bureau est arrêtée. Reprenez quand vous êtes prêt.",
|
||||
// Privacy status pill + per-action egress disclosure (#4437 / S3)
|
||||
'privacy.status.ariaLabel': 'État de confidentialité',
|
||||
'privacy.status.external': 'Hors de l’appareil',
|
||||
|
||||
@@ -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': 'डिवाइस के बाहर',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '기기 외',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Вне устройства',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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).
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user