Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
d0ae213
docs(safety): design spec for emergency stop (#4255 slice 1)
CodeGhost21 Jul 6, 2026
0507956
docs(safety): implementation plan for emergency stop (#4255 slice 1)
CodeGhost21 Jul 6, 2026
ed6f297
docs(safety): correct callCoreRpc call-shape + i18n paths in plan (#4…
CodeGhost21 Jul 6, 2026
46f2b06
feat(emergency): safetySlice tracks automation-halt state (#4255)
CodeGhost21 Jul 6, 2026
ecb6129
docs(safety): fix engine.disable(Option) signature in plan Task 5 (#4…
CodeGhost21 Jul 6, 2026
afe57c3
feat(emergency): emergencyApi RPC client (#4255)
CodeGhost21 Jul 6, 2026
eb2cc95
feat(events): add AutomationHalted/AutomationResumed domain events (#…
CodeGhost21 Jul 6, 2026
d03e5c2
chore(sdd): add task-1 report for AutomationHalted/AutomationResumed
CodeGhost21 Jul 6, 2026
6d20b1d
i18n(emergency): add safety.* keys across locales (#4255)
CodeGhost21 Jul 6, 2026
e30afea
docs(safety): fix Task 10 broadcast client_id=system in plan (#4255)
CodeGhost21 Jul 6, 2026
991dc6f
feat(emergency): stop button + halted banner + socket/boot wiring (#4…
CodeGhost21 Jul 6, 2026
1710194
feat(emergency): halt-state types + global switch singleton (#4255)
CodeGhost21 Jul 6, 2026
c7a78bf
feat(emergency): RPC controllers (stop/resume/status) + boot install …
CodeGhost21 Jul 6, 2026
9d1b970
feat(emergency): approval middleware refuses external-effect tools wh…
CodeGhost21 Jul 6, 2026
a375da8
feat(emergency): accessibility_input_action refuses input while halte…
CodeGhost21 Jul 6, 2026
995b837
feat(emergency): bridge halt/resume events to automation_halt socket …
CodeGhost21 Jul 6, 2026
e3f8ac1
test(emergency): json-rpc e2e for stop/status/resume (#4255)
CodeGhost21 Jul 6, 2026
66ae765
fix(emergency): friendly halt reason + socket/hydration tests + clean…
CodeGhost21 Jul 6, 2026
eba9402
fix(emergency): move stop button to top-right + hide while halted (#4…
CodeGhost21 Jul 6, 2026
1da757c
chore: drop leaked SDD scratch report from feature branch (#4255)
CodeGhost21 Jul 6, 2026
a5cbd50
style(emergency): apply prettier formatting (#4255)
CodeGhost21 Jul 6, 2026
cdecdee
style(emergency): prettier-format all changed frontend files (#4255)
CodeGhost21 Jul 6, 2026
18e2800
fix(emergency): address review — fail-closed socket, no false halt, a…
CodeGhost21 Jul 6, 2026
44ab153
fix(emergency): unblock CI + register bridge at boot + visible stop-f…
CodeGhost21 Jul 6, 2026
10011c7
Merge remote-tracking branch 'origin/main' into feat/desktop-safety-4255
CodeGhost21 Jul 6, 2026
4f38787
style: prettier-format ChatRuntimeProvider.test.tsx (main-introduced,…
CodeGhost21 Jul 6, 2026
6525e34
fix(emergency): use typed store/hooks + defensive selectors so App-sh…
CodeGhost21 Jul 7, 2026
27ad55c
Merge remote-tracking branch 'origin/main' into feat/desktop-safety-4255
CodeGhost21 Jul 7, 2026
a69f2ca
fix: repair 2 pre-existing main test breakages exposed by full-suite …
CodeGhost21 Jul 7, 2026
690d2c5
Merge remote-tracking branch 'origin/main' into feat/desktop-safety-4255
CodeGhost21 Jul 8, 2026
bef5d7c
Merge remote-tracking branch 'upstream/main' into pr/4600
senamakel Jul 9, 2026
96e398e
Merge remote-tracking branch 'origin/main' into feat/desktop-safety-4…
CodeGhost21 Jul 9, 2026
59169cf
fix(emergency): address open review comments (blocker + majors)
CodeGhost21 Jul 9, 2026
8720fee
Merge upstream/main into feat/desktop-safety-4255
M3gA-Mind Jul 14, 2026
0398e2a
fix(safety): reset stale resumeFailed across halt cycles + reason fal…
M3gA-Mind Jul 14, 2026
e62240f
Merge branch 'main' into pr/4600
M3gA-Mind Jul 14, 2026
621b3cd
fix(safety): give the resume-failed status region an accessible name
M3gA-Mind Jul 14, 2026
e5ddf7c
Merge upstream/main into feat/desktop-safety-4255
M3gA-Mind Jul 14, 2026
bfd0c59
test(emergency-stop): panic-safe ClearEmergencyOnDrop in ops tests
M3gA-Mind Jul 14, 2026
cc56bf5
Merge remote-tracking branch 'upstream/main' into pr/4600
senamakel Jul 15, 2026
51da322
Merge branch 'main' into pr/4600
M3gA-Mind Jul 16, 2026
e5bcf46
fix(i18n): remove em dashes from safety.* translations
M3gA-Mind Jul 16, 2026
e340a8b
fix(safety): re-check kill switch on cron retries + keep halt banner …
M3gA-Mind Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import SecretPromptDialog from './components/mcp-setup/SecretPromptDialog';
import OpenhumanLinkModal from './components/OpenhumanLinkModal';
import PersistRehydrationScreen from './components/PersistRehydrationScreen';
import PttHotkeyManager from './components/PttHotkeyManager';
import { AutomationHaltedBanner } from './components/safety/AutomationHaltedBanner';
import { EmergencyStopButton } from './components/safety/EmergencyStopButton';
import SecurityBanner from './components/SecurityBanner';
import SettingsModal from './components/settings/modal/SettingsModal';
import { resolveSettingsOverlay } from './components/settings/modal/settingsOverlay';
Expand Down Expand Up @@ -57,6 +59,7 @@ import {
startInternetStatusListener,
stopInternetStatusListener,
} from './services/internetStatusListener';
import { hydrateEmergencyState } from './services/safety/hydrateEmergencyState';
import {
hideWebviewAccount,
startWebviewAccountService,
Expand Down Expand Up @@ -256,6 +259,16 @@ export function AppShellDesktop() {
// the core is ready (once per boot). Extracted to a hook so it's testable.
useNotchBootSync(isBootstrapping);

// Boot hydration: read the authoritative halt state from the core once on
// mount so the UI reflects any halt that was engaged before this window
// opened (e.g. another tab, CLI, or a crash-recovery scenario). Errors are
// swallowed inside hydrateEmergencyState so a degraded core never blanks the shell.
useEffect(() => {
void hydrateEmergencyState(dispatch);
// Intentionally runs once on mount only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const scrollRef = useRef<HTMLDivElement>(null);
const navType = useNavigationType();

Expand Down Expand Up @@ -291,6 +304,9 @@ 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 />
Comment thread
M3gA-Mind marked this conversation as resolved.
<GlobalUpsellBanner />
<AppRoutes location={baseLocation} />
{activeProviderAccount && !accountsOverlayOpen && (
Expand Down Expand Up @@ -332,6 +348,17 @@ 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
Expand Down
116 changes: 116 additions & 0 deletions app/src/components/safety/AutomationHaltedBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { clearHalt, setHalt } from '../../store/safetySlice';
import { renderWithProviders } from '../../test/test-utils';
import { AutomationHaltedBanner } from './AutomationHaltedBanner';

const resume = vi.fn().mockResolvedValue({ engaged: false });
vi.mock('../../services/api/emergencyApi', () => ({
emergencyResume: (...a: unknown[]) => resume(...a),
}));

beforeEach(() => resume.mockClear());

describe('AutomationHaltedBanner', () => {
it('renders nothing when not halted', () => {
const { container } = renderWithProviders(<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());
});
});
89 changes: 89 additions & 0 deletions app/src/components/safety/AutomationHaltedBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useState } from 'react';

import { useT } from '../../lib/i18n/I18nContext';
import { emergencyResume } from '../../services/api/emergencyApi';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { clearHalt, selectHalted, selectHaltReason } from '../../store/safetySlice';

/**
* AutomationHaltedBanner — renders at the top of main content when automation
* is halted via the emergency stop. Provides a Resume button to lift the halt.
*
* The Redux `clearHalt` only fires on a confirmed resume from the core. If the
* `emergency_resume` RPC fails (timeout, auth, core unavailable), the halt is
* preserved locally and a visible retry message is shown — because the core is
* still halted and clearing the banner would silently re-enable the Stop button
* while every external-effect action remained blocked. The authoritative source
* of truth is the core; the `automation_halt` socket broadcast will also clear
* the state if the resume succeeds server-side after an in-flight RPC failure.
*/
export function AutomationHaltedBanner() {
const { t } = useT();
const dispatch = useAppDispatch();
const halted = useAppSelector(selectHalted);
const reason = useAppSelector(selectHaltReason);
const [resumeFailed, setResumeFailed] = useState(false);

const onResume = useCallback(async () => {
setResumeFailed(false);
console.debug('[emergency] resume requested (source=user)');
try {
await emergencyResume();
console.debug('[emergency] resume confirmed by core');
// Only clear locally on a CONFIRMED resume. On failure the core is still
// halted, so clearing here would give a false "safe to run" signal.
dispatch(clearHalt());
} catch (err) {
console.error('[emergency] resume FAILED — halt preserved locally, retry required', err);
setResumeFailed(true);
}
}, [dispatch]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</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>
);
}
65 changes: 65 additions & 0 deletions app/src/components/safety/EmergencyStopButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { setHalt } from '../../store/safetySlice';
import { renderWithProviders } from '../../test/test-utils';
import { EmergencyStopButton } from './EmergencyStopButton';

const stop = vi
.fn()
.mockResolvedValue({
engaged: true,
reason: undefined,
source: undefined,
engaged_at_ms: undefined,
});
vi.mock('../../services/api/emergencyApi', () => ({
emergencyStop: (...a: unknown[]) => stop(...a),
}));

beforeEach(() => stop.mockClear());

describe('EmergencyStopButton', () => {
it('renders a button with the emergency stop label', () => {
renderWithProviders(<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()
);
});
});
Loading
Loading