From d0ae21360fce0a045457966cd8dcee393cd2412c Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 16:10:28 +0530 Subject: [PATCH 01/33] docs(safety): design spec for emergency stop (#4255 slice 1) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-06-emergency-stop-design.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-06-emergency-stop-design.md diff --git a/docs/superpowers/specs/2026-07-06-emergency-stop-design.md b/docs/superpowers/specs/2026-07-06-emergency-stop-design.md new file mode 100644 index 0000000000..e3ba58b7c3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-emergency-stop-design.md @@ -0,0 +1,102 @@ +# Emergency Stop for desktop automation — design + +Issue: [tinyhumansai/openhuman#4255](https://github.com/tinyhumansai/openhuman/issues/4255) — "Desktop automation safety previews, confirmations, history, and emergency stop". + +This spec covers **slice 1: Emergency Stop** only. The issue is an epic (previews, confirmations, history, emergency stop, backups, Windows support, reusable workflows); its acceptance criteria call for one slice landed end-to-end first. Emergency Stop is the safety-critical control and is self-contained. + +## Goal + +A prominent, always-available control that: + +1. **Immediately halts** all running/queued desktop automation, and +2. **Blocks any further automated actions** until the user explicitly resumes. + +It is a **fail-closed kill switch**: while engaged, every automated real-world action is refused. + +## Scope decisions (approved) + +- **Stop behavior:** set a global halt flag (blocks all further external-effect / accessibility actions fail-closed), stop the accessibility session, and cascade-deny all pending approvals. The running agent turn can take no further real-world actions. (We do **not** hard-abort in-flight chat turns in this slice — blocking every action chokepoint achieves the safety goal without touching the turn/cancel machinery.) +- **Persistence:** in-memory only; a restart clears the halt (reset on boot). Persisting a halt across restarts is a follow-up. +- **Backend (`backend-alphahuman`):** no changes. Desktop automation executes in the Tauri Rust core; the backend's execution-session flow is a separate (email/Telegram) subsystem. A server-side execution-session cancel is a follow-up, not this slice. + +## Existing infrastructure this builds on + +- `src/openhuman/approval/` — `ApprovalGate` parks/denies external-effect tool calls, keeps a SQLite audit trail, fail-closed 10-min TTL. We reuse its pending-list + deny path for cascade-deny. +- `src/openhuman/tinyagents/middleware.rs::wrap_tool` — every external-effect/dangerous tool call is already intercepted here (`has_external_effect`, `gate.intercept_audited`). This is our primary enforcement chokepoint. +- `src/openhuman/screen_intelligence/` — `ops.rs::accessibility_input_action` dispatches clicks/typing to `input.rs`, which already has a per-session `panic_stop` action and session stop. This is our second chokepoint + the session-stop reuse. +- `src/core/all.rs` controller registry + `src/openhuman/channels/providers/web.rs` `ApprovalSurfaceSubscriber` — the pattern for RPC registration and bridging domain events to a web-channel socket event. + +## Architecture + +### New core domain — `src/openhuman/emergency_stop/` + +Follows the canonical module shape (`mod.rs` export-only; `types.rs`; `state.rs`; `ops.rs`; `schemas.rs`). + +- **`state.rs`** — process-global `EmergencyStop` in a `OnceCell`, holding `AtomicBool engaged` + `Mutex>` (`reason: String`, `engaged_at_ms: u64`, `source: HaltSource`). Public: `global()` / `try_global()`, `is_engaged()`, `engage(info)`, `clear()`, `snapshot() -> HaltState`. Mirrors `ApprovalGate` global-singleton ergonomics. `try_global()` → `None` means "no switch installed" → treated as not-engaged (never blocks) so headless/CLI paths are unaffected. +- **`types.rs`** — `HaltState { engaged: bool, reason: Option, engaged_at_ms: Option, source: Option }` (serde); `HaltSource` enum (`User`, `Hotkey`, `System`). +- **`ops.rs`** — handlers returning `RpcOutcome`: + - `emergency_stop(reason, source)` — engage flag; then best-effort: stop the accessibility session (reuse existing stop path) and cascade-deny all `ApprovalGate` pending rows (`list_pending` → `decide(deny)`); publish `AutomationHalted`; return snapshot. Idempotent (already-engaged is a no-op success). + - `emergency_resume()` — clear flag; publish `AutomationResumed`; return snapshot. Idempotent. + - `emergency_status()` — return snapshot. +- **`schemas.rs` + `mod.rs`** — controllers → RPC `openhuman.emergency_stop`, `openhuman.emergency_resume`, `openhuman.emergency_status`; registered in `src/core/all.rs`. +- **Events** — `DomainEvent::AutomationHalted { reason, source }` / `AutomationResumed` (add to `src/core/event_bus/events.rs`, extend `domain()` match → `system`). A subscriber in `web.rs` (or extending `ApprovalSurfaceSubscriber`) bridges them to a web-channel socket event (`automation_halt`) so all UI surfaces update live. +- **Install** — `EmergencyStop::init_global()` at core startup next to `ApprovalGate::init_global()` in `src/core/jsonrpc.rs`. + +### Enforcement (the "block further actions" invariant) — fail-closed at two chokepoints + +1. **`tinyagents/middleware.rs::wrap_tool`** — at the top of the external-effect/dangerous path, if `EmergencyStop::is_engaged()`, refuse the call before `execute()` with a clear `POLICY_DENIED_MARKER`-style "emergency stop engaged" reason and record it in the audit trail (`record_execution` Aborted). This stops the agent loop from taking further real-world actions. +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 (audited Aborted) → 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). From 0507956a9fa0bb29bc7f3d338a2c032a75dcc828 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 16:18:10 +0530 Subject: [PATCH 02/33] docs(safety): implementation plan for emergency stop (#4255 slice 1) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-06-emergency-stop.md | 1335 +++++++++++++++++ 1 file changed, 1335 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-emergency-stop.md diff --git a/docs/superpowers/plans/2026-07-06-emergency-stop.md b/docs/superpowers/plans/2026-07-06-emergency-stop.md new file mode 100644 index 0000000000..aa53e82397 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-emergency-stop.md @@ -0,0 +1,1335 @@ +# Emergency Stop Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A fail-closed "Emergency Stop" kill switch that instantly halts all running/queued desktop automation and blocks any further automated action until the user explicitly resumes. + +**Architecture:** A new process-global `EmergencyStop` singleton in the Rust core (`src/openhuman/emergency_stop/`), mirroring the `ApprovalGate` `OnceLock` pattern. Three RPCs (`openhuman.emergency_stop|resume|status`) engage/clear/read it. Two fail-closed enforcement chokepoints consult it: the tinyagents approval middleware (blocks external-effect tool calls) and `accessibility_input_action` (blocks clicks/typing). Engaging also stops the accessibility session and cascade-denies pending approvals. The React app gets a persistent Emergency Stop button + halted banner backed by a Redux `safetySlice`, driven by the RPC responses and boot-time hydration. + +**Tech Stack:** Rust (tokio, serde, `anyhow`), JSON-RPC controller registry, React 19 + TypeScript + Redux Toolkit + Vitest, i18n via `useT()`. + +**Spec:** `docs/superpowers/specs/2026-07-06-emergency-stop-design.md` + +## Global Constraints + +- **Never write code on `main`.** Work is on branch `feat/desktop-safety-4255` (already created). +- **Fail-closed:** when the switch is installed AND engaged, block. When no switch is installed (`try_global()` → `None`, e.g. CLI/headless), never block. +- **Diff coverage ≥ 80% on changed lines** (merge gate: `frontend-coverage`/`rust-core-coverage`). +- **Rust module shape** (AGENTS.md): `mod.rs` export-only; `types.rs` serde types; `state.rs` state; `ops.rs` logic returning `RpcOutcome`; `schemas.rs` controllers. New functionality → dedicated subdirectory; no new root-level `*.rs`. +- **RPC naming:** `openhuman._` — here namespace `emergency`, functions `stop`/`resume`/`status`. +- **Controller exposure:** register via `src/core/all.rs` registry, not branches in `cli.rs`/`jsonrpc.rs`. +- **i18n:** all UI text through `useT()`; add keys to `app/src/lib/i18n/locales/en.ts` **and** real translations in every locale file (`ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN`). CI enforces parity (`pnpm i18n:check`). +- **Debug logging:** grep-friendly prefixes (`[emergency]`, `[rpc:emergency_*]`); log entry/exit, state transitions, errors; never log secrets/PII. +- **Frontend:** no dynamic imports in `app/src`; use `invoke('core_rpc_relay', …)` via `coreRpcClient`; guard Tauri with `isTauri()`/try-catch. +- **Rust checks:** `cargo check --manifest-path Cargo.toml` (add `GGML_NATIVE=OFF` on macOS Apple Silicon). Tests: `pnpm test:rust` or `bash scripts/test-rust-with-mock.sh --test `; targeted lib tests: `cargo test --manifest-path Cargo.toml `. +- **Frontend checks:** `pnpm typecheck`, `pnpm lint`, `pnpm test`. + +--- + +## File Structure + +**Rust core (new domain `src/openhuman/emergency_stop/`):** +- `mod.rs` — module docstring, `pub mod` decls, `pub use` re-exports, controller-schema pair. +- `types.rs` — `HaltState`, `HaltSource` (serde). +- `state.rs` — `EmergencyStop` global singleton (`OnceLock`), `init_global`/`try_global`/`is_engaged`/`engage`/`clear`/`snapshot`. +- `ops.rs` — `emergency_stop`/`emergency_resume`/`emergency_status` returning `RpcOutcome`; cascade-deny + a11y stop; publishes events. +- `schemas.rs` — controller schemas + `handle_*` fns. + +**Rust core (modified):** +- `src/core/event_bus/events.rs` — add `AutomationHalted`/`AutomationResumed` variants + `domain()` + `name()` arms. +- `src/core/all.rs` — register emergency controllers. +- `src/core/jsonrpc.rs` — install `EmergencyStop::init_global()` at boot; register socket bridge subscriber. +- `src/openhuman/tinyagents/middleware.rs` — halt check in `ApprovalSecurityMiddleware::wrap_tool`. +- `src/openhuman/screen_intelligence/ops.rs` — halt check in `accessibility_input_action`. +- `src/openhuman/channels/providers/web/event_bus.rs` — `AutomationHaltSubscriber` bridging events → `automation_halt` socket event. +- `tests/json_rpc_e2e.rs` — stop→status→resume E2E. + +**Frontend (new):** +- `app/src/store/safetySlice.ts` (+ `safetySlice.test.ts`) — halted state. +- `app/src/services/api/emergencyApi.ts` (+ `emergencyApi.test.ts`) — RPC client. +- `app/src/components/safety/EmergencyStopButton.tsx` (+ test) — button. +- `app/src/components/safety/AutomationHaltedBanner.tsx` (+ test) — banner + Resume. + +**Frontend (modified):** +- `app/src/store/index.ts` (or root reducer) — mount `safety` reducer. +- `app/src/services/socketService.ts` — handle `automation_halt` socket event. +- app shell/header (e.g. `app/src/components/layout/*` or `Conversations` header) — mount button + banner + boot hydration. +- `app/src/lib/i18n/locales/*.ts` — i18n keys. + +**Note on exact neighboring types:** three field-shapes are already confirmed from the codebase — `InputActionResult { accepted: bool, blocked: bool, reason: Option }` (`screen_intelligence/types.rs:144`), `InputActionParams { action: String, .. }` (`types.rs:133`), and the `WebChannelEvent` bridge pattern (`web/event_bus.rs`). Before Task 12's socket bridge, read `src/core/socketio` for the exact `WebChannelEvent` fields (the artifact/approval bridges set `event`, `client_id`, `thread_id`, `args`, `..Default::default()`). + +--- + +## Task 1: Event variants — `AutomationHalted` / `AutomationResumed` + +**Files:** +- Modify: `src/core/event_bus/events.rs` (add variants near the System lifecycle group ~line 1025; extend `domain()` ~1283 and `name()` ~1540) + +**Interfaces:** +- Produces: `DomainEvent::AutomationHalted { reason: Option, source: String }`, `DomainEvent::AutomationResumed { source: String }`. Both map to domain `"system"`. + +- [ ] **Step 1: Add the two variants.** In the `DomainEvent` enum, in the System-lifecycle region, add: + +```rust + /// Emergency stop engaged — all desktop automation is halted and every + /// external-effect / accessibility action is refused until resumed. + /// Published by `emergency_stop::ops::emergency_stop`; bridged to the + /// `automation_halt` web-channel socket event. + AutomationHalted { + /// Optional human-readable reason (redacted of PII by the caller). + reason: Option, + /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`. + source: String, + }, + /// Emergency stop cleared — automation may resume. Published by + /// `emergency_stop::ops::emergency_resume`. + AutomationResumed { + /// Who cleared it: `"user"`, `"hotkey"`, or `"system"`. + source: String, + }, +``` + +- [ ] **Step 2: Extend `domain()`.** In the `pub fn domain(&self)` match, add to the `"system"` arm (alongside `HarnessInitCompleted`): + +```rust + | Self::AutomationHalted { .. } + | Self::AutomationResumed { .. } => "system", +``` + +- [ ] **Step 3: Extend `name()`.** In the `name()` match (near the `ApprovalRequested => "ApprovalRequested"` arms): + +```rust + Self::AutomationHalted { .. } => "AutomationHalted", + Self::AutomationResumed { .. } => "AutomationResumed", +``` + +- [ ] **Step 4: Add a unit test.** Append to the `#[cfg(test)]` module in `events.rs` (or create one if none — match the file's existing test style): + +```rust + #[test] + fn automation_events_map_to_system_domain() { + let halted = DomainEvent::AutomationHalted { reason: Some("user".into()), source: "user".into() }; + let resumed = DomainEvent::AutomationResumed { source: "user".into() }; + assert_eq!(halted.domain(), "system"); + assert_eq!(resumed.domain(), "system"); + assert_eq!(halted.name(), "AutomationHalted"); + assert_eq!(resumed.name(), "AutomationResumed"); + } +``` + +- [ ] **Step 5: Compile + test.** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman automation_events_map_to_system_domain` +Expected: PASS (build succeeds, 1 test passes). If `name()`/`domain()` have exhaustive-match compile errors, fix the arms until it builds. + +- [ ] **Step 6: Commit.** + +```bash +git add src/core/event_bus/events.rs +git commit -m "feat(events): add AutomationHalted/AutomationResumed domain events (#4255)" +``` + +--- + +## Task 2: `emergency_stop` types + +**Files:** +- Create: `src/openhuman/emergency_stop/types.rs` + +**Interfaces:** +- Produces: `HaltState { engaged: bool, reason: Option, engaged_at_ms: Option, source: Option }` (serde, `Clone`, `Debug`, `PartialEq`, `Default`). Used by `state.rs`, `ops.rs`, `schemas.rs`. + +- [ ] **Step 1: Write the failing test.** Create `src/openhuman/emergency_stop/types.rs`: + +```rust +//! Serde domain types for the emergency-stop kill switch. + +use serde::{Deserialize, Serialize}; + +/// Snapshot of the emergency-stop switch, returned by every emergency RPC and +/// surfaced in the UI. `engaged == false` is the resting state. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct HaltState { + /// Whether automation is currently halted. + pub engaged: bool, + /// Human-readable reason for the halt (redacted of PII), when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Unix-epoch milliseconds when the halt was engaged, when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub engaged_at_ms: Option, + /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`, when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_halt_state_is_not_engaged() { + let s = HaltState::default(); + assert!(!s.engaged); + assert!(s.reason.is_none()); + assert!(s.engaged_at_ms.is_none()); + } + + #[test] + fn resting_state_serializes_to_engaged_false_only() { + let json = serde_json::to_string(&HaltState::default()).unwrap(); + assert_eq!(json, r#"{"engaged":false}"#); + } + + #[test] + fn engaged_state_roundtrips() { + let s = HaltState { engaged: true, reason: Some("user".into()), engaged_at_ms: Some(42), source: Some("user".into()) }; + let back: HaltState = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(s, back); + } +} +``` + +- [ ] **Step 2: Run to verify it fails to build.** (Module not declared yet — see Task 4 wires `mod.rs`; for now this task's test runs once `mod.rs` exists. To keep TDD honest, do Task 3 & the `mod.rs` skeleton, then run.) Run after Task 4: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::types` +Expected (before impl wired): FAIL to compile ("file not found for module" / unresolved). + +- [ ] **Step 3: (impl already written in Step 1).** + +- [ ] **Step 4: Run after `mod.rs` exists (Task 4).** Expected: 3 tests PASS. + +- [ ] **Step 5: Commit** (batched with Task 3–4, since the module must be wired to compile). + +--- + +## Task 3: `emergency_stop` state (global singleton) + +**Files:** +- Create: `src/openhuman/emergency_stop/state.rs` + +**Interfaces:** +- Consumes: `HaltState` (Task 2). +- Produces: `EmergencyStop` with associated fns `init_global() -> Arc`, `try_global() -> Option>`, and methods `is_engaged(&self) -> bool`, `engage(&self, reason: Option, source: &str, now_ms: u64)`, `clear(&self)`, `snapshot(&self) -> HaltState`. Free fn `is_engaged_global() -> bool` (false when no switch installed). + +- [ ] **Step 1: Write state + tests.** Create `src/openhuman/emergency_stop/state.rs`: + +```rust +//! Process-global emergency-stop switch. Mirrors the `ApprovalGate` +//! `OnceLock` install pattern: `init_global` is idempotent, `try_global` +//! returns `None` when never installed (CLI/headless → never blocks). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use super::types::HaltState; + +static GLOBAL_STOP: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +struct HaltInfo { + reason: Option, + engaged_at_ms: u64, + source: String, +} + +/// Coordinator for the emergency-stop kill switch. +#[derive(Debug)] +pub struct EmergencyStop { + engaged: AtomicBool, + info: Mutex>, +} + +impl EmergencyStop { + /// Install the process-global switch. Idempotent — re-install returns the + /// existing switch so repeated boots in tests don't panic. + pub fn init_global() -> Arc { + if let Some(existing) = GLOBAL_STOP.get() { + return existing.clone(); + } + let stop = Arc::new(EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }); + let _ = GLOBAL_STOP.set(stop.clone()); + GLOBAL_STOP.get().cloned().unwrap_or(stop) + } + + /// The global switch when installed; `None` means "no switch" → callers + /// treat as not-engaged (never block). + pub fn try_global() -> Option> { + GLOBAL_STOP.get().cloned() + } + + /// Whether automation is currently halted. + pub fn is_engaged(&self) -> bool { + self.engaged.load(Ordering::SeqCst) + } + + /// Engage the halt. Idempotent — re-engaging refreshes reason/source/time. + pub fn engage(&self, reason: Option, source: &str, now_ms: u64) { + { + let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(HaltInfo { reason, engaged_at_ms: now_ms, source: source.to_string() }); + } + self.engaged.store(true, Ordering::SeqCst); + } + + /// Clear the halt. Idempotent. + pub fn clear(&self) { + self.engaged.store(false, Ordering::SeqCst); + let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + } + + /// Current snapshot for RPC/UI. + pub fn snapshot(&self) -> HaltState { + if !self.is_engaged() { + return HaltState::default(); + } + let guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + match guard.as_ref() { + Some(info) => HaltState { + engaged: true, + reason: info.reason.clone(), + engaged_at_ms: Some(info.engaged_at_ms), + source: Some(info.source.clone()), + }, + None => HaltState { engaged: true, ..Default::default() }, + } + } +} + +/// Global convenience: is a switch installed AND engaged? False when no +/// switch is installed (CLI/headless) so those paths are never blocked. +pub fn is_engaged_global() -> bool { + EmergencyStop::try_global().map(|s| s.is_engaged()).unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn engage_then_snapshot_reports_engaged() { + let stop = EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }; + assert!(!stop.is_engaged()); + stop.engage(Some("user".into()), "user", 1234); + assert!(stop.is_engaged()); + let snap = stop.snapshot(); + assert!(snap.engaged); + assert_eq!(snap.reason.as_deref(), Some("user")); + assert_eq!(snap.engaged_at_ms, Some(1234)); + assert_eq!(snap.source.as_deref(), Some("user")); + } + + #[test] + fn clear_resets_to_default_snapshot() { + let stop = EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }; + stop.engage(None, "hotkey", 1); + stop.clear(); + assert!(!stop.is_engaged()); + assert_eq!(stop.snapshot(), HaltState::default()); + } + + #[test] + fn engage_is_idempotent_and_refreshes() { + let stop = EmergencyStop { engaged: AtomicBool::new(false), info: Mutex::new(None) }; + stop.engage(Some("a".into()), "user", 1); + stop.engage(Some("b".into()), "system", 2); + assert!(stop.is_engaged()); + assert_eq!(stop.snapshot().reason.as_deref(), Some("b")); + assert_eq!(stop.snapshot().source.as_deref(), Some("system")); + } +} +``` + +- [ ] **Step 2 & 3:** impl is in Step 1. +- [ ] **Step 4: Run after `mod.rs` (Task 4).** Expected: 3 tests PASS. +- [ ] **Step 5: Commit** (batched with Task 4). + +--- + +## Task 4: `emergency_stop` mod.rs + wire the module tree (makes Tasks 2–3 compile) + +**Files:** +- Create: `src/openhuman/emergency_stop/mod.rs` +- Modify: `src/openhuman/mod.rs` (add `pub mod emergency_stop;` in the domain list, alphabetically near `embeddings`/`encryption`) + +**Interfaces:** +- Produces: `pub use` of `EmergencyStop`, `is_engaged_global`, `HaltState`; and the controller-schema pair `all_emergency_controller_schemas`, `all_emergency_registered_controllers` (defined in Task 6's `schemas.rs`). + +- [ ] **Step 1: Create `mod.rs`** (schemas referenced here are added in Task 6; declare the module now, add the re-exports in Task 6): + +```rust +//! Emergency stop — a fail-closed kill switch for desktop automation. +//! +//! `EmergencyStop` is a process-global switch (mirrors `ApprovalGate`). When +//! engaged, the tinyagents approval middleware refuses external-effect tool +//! calls and `accessibility_input_action` refuses clicks/typing, until the +//! user resumes. Engaging also stops the accessibility session and +//! cascade-denies pending approvals. In-memory only (resets on restart). + +pub mod ops; +pub mod schemas; +pub mod state; +pub mod types; + +pub use schemas::{all_emergency_controller_schemas, all_emergency_registered_controllers}; +pub use state::{is_engaged_global, EmergencyStop}; +pub use types::HaltState; +``` + +- [ ] **Step 2: Register the domain module.** In `src/openhuman/mod.rs`, add (alphabetical): + +```rust +pub mod emergency_stop; +``` + +- [ ] **Step 3: Build.** After Task 5 (`ops.rs`) and Task 6 (`schemas.rs`) exist, run: + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::` +Expected: all `types`, `state` tests PASS (ops/schemas tests added in their tasks). + +- [ ] **Step 4: Commit** (batched: types + state + mod once ops/schemas compile). + +```bash +git add src/openhuman/emergency_stop/ src/openhuman/mod.rs +git commit -m "feat(emergency): halt-state types + global switch singleton (#4255)" +``` + +--- + +## Task 5: `emergency_stop` ops (engage/resume/status + side effects + events) + +**Files:** +- Create: `src/openhuman/emergency_stop/ops.rs` + +**Interfaces:** +- Consumes: `EmergencyStop` (Task 3), `HaltState` (Task 2), `DomainEvent::AutomationHalted/Resumed` (Task 1), `ApprovalGate` (`list_pending`, `decide`), `screen_intelligence::global_engine().disable(reason)`. +- Produces: `pub async fn emergency_stop(reason: Option, source: &str) -> RpcOutcome`; `pub async fn emergency_resume(source: &str) -> RpcOutcome`; `pub async fn emergency_status() -> RpcOutcome`. + +- [ ] **Step 1: Write ops + tests.** Create `src/openhuman/emergency_stop/ops.rs`: + +```rust +//! Emergency-stop RPC operations: engage / resume / read the switch, plus the +//! best-effort side effects (stop the a11y session, cascade-deny pending +//! approvals) and event publication. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::core::event_bus::{publish_global, DomainEvent}; +use crate::rpc::RpcOutcome; + +use super::state::EmergencyStop; +use super::types::HaltState; + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Engage the kill switch: set the flag, then best-effort stop the a11y +/// session and cascade-deny pending approvals, then publish `AutomationHalted`. +/// Idempotent. Side-effect failures are logged but never fail the RPC — the +/// primary invariant (flag set → actions blocked) does not depend on them. +pub async fn emergency_stop(reason: Option, source: &str) -> RpcOutcome { + tracing::warn!(source, reason = ?reason, "[rpc:emergency_stop] entry — engaging kill switch"); + let stop = EmergencyStop::init_global(); + stop.engage(reason.clone(), source, now_ms()); + + // Best-effort: stop the accessibility session so any in-flight click/type loop halts. + let a11y = crate::openhuman::screen_intelligence::global_engine() + .disable("emergency_stop".to_string()) + .await; + tracing::info!(active = a11y.active, "[emergency] accessibility session stopped"); + + // Best-effort: cascade-deny every pending approval so parked tool calls fail closed. + let denied = cascade_deny_pending(); + tracing::info!(denied, "[emergency] cascade-denied pending approvals"); + + publish_global(DomainEvent::AutomationHalted { reason, source: source.to_string() }); + + let snap = stop.snapshot(); + RpcOutcome::single_log(snap, format!("[emergency] halted (source={source}, denied={denied})")) +} + +/// Deny all pending approvals. Returns how many were denied. Best-effort: +/// a per-row error is logged and skipped. +fn cascade_deny_pending() -> usize { + use crate::openhuman::approval::{ApprovalDecision, ApprovalGate}; + let Some(gate) = ApprovalGate::try_global() else { return 0 }; + let rows = match gate.list_pending() { + Ok(rows) => rows, + Err(err) => { + tracing::warn!(error = %err, "[emergency] list_pending failed during cascade-deny"); + return 0; + } + }; + let mut denied = 0; + for row in rows { + match gate.decide(&row.request_id, ApprovalDecision::Deny) { + Ok(_) => denied += 1, + Err(err) => tracing::warn!(request_id = %row.request_id, error = %err, "[emergency] deny failed"), + } + } + denied +} + +/// Clear the kill switch and publish `AutomationResumed`. Idempotent. +pub async fn emergency_resume(source: &str) -> RpcOutcome { + tracing::info!(source, "[rpc:emergency_resume] entry — clearing kill switch"); + let stop = EmergencyStop::init_global(); + stop.clear(); + publish_global(DomainEvent::AutomationResumed { source: source.to_string() }); + RpcOutcome::single_log(stop.snapshot(), format!("[emergency] resumed (source={source})")) +} + +/// Read the current switch state. +pub async fn emergency_status() -> RpcOutcome { + let snap = EmergencyStop::try_global().map(|s| s.snapshot()).unwrap_or_default(); + tracing::debug!(engaged = snap.engaged, "[rpc:emergency_status] exit"); + RpcOutcome::new(snap, vec![]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn stop_sets_flag_and_status_reports_engaged() { + let out = emergency_stop(Some("user".into()), "user").await; + assert!(out.value.engaged); + let status = emergency_status().await; + assert!(status.value.engaged); + assert_eq!(status.value.source.as_deref(), Some("user")); + // reset for other tests sharing the process-global switch + let _ = emergency_resume("user").await; + } + + #[tokio::test] + async fn resume_clears_flag() { + let _ = emergency_stop(None, "user").await; + let out = emergency_resume("user").await; + assert!(!out.value.engaged); + assert!(!emergency_status().await.value.engaged); + } + + #[tokio::test] + async fn stop_is_idempotent() { + let _ = emergency_stop(Some("a".into()), "user").await; + let out = emergency_stop(Some("b".into()), "system").await; + assert!(out.value.engaged); + assert_eq!(out.value.reason.as_deref(), Some("b")); + let _ = emergency_resume("user").await; + } +} +``` + +Notes for the implementer: +- Confirm `RpcOutcome` exposes `.value` (the tests read `out.value`). If the field is named differently, read `src/rpc/*` for `RpcOutcome`'s public shape and adjust the test accessors (the `ops.rs` code uses only the constructors `RpcOutcome::new` / `RpcOutcome::single_log`, already used across the codebase). +- Confirm `ApprovalGate`, `ApprovalDecision` are re-exported from `crate::openhuman::approval` (README lists both under "Public surface"). `ApprovalDecision::Deny` is the deny variant. +- Confirm `global_engine().disable(reason)` returns a `SessionStatus` with an `active` field (seen in `ops.rs:121` `accessibility_stop_session`). + +- [ ] **Step 2: Run to verify it fails first (before ops wired into mod).** After `mod.rs` includes `pub mod ops;` (Task 4), run: + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::ops` +Expected: FAIL first if any signature mismatch; iterate until it compiles. + +- [ ] **Step 3: Fix compile issues** (RpcOutcome field/accessor names, imports) until green. + +- [ ] **Step 4: Run tests.** Expected: 3 ops tests PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add src/openhuman/emergency_stop/ops.rs +git commit -m "feat(emergency): engage/resume/status ops with cascade-deny + a11y stop (#4255)" +``` + +--- + +## Task 6: `emergency_stop` schemas + registry wiring + boot install + +**Files:** +- Create: `src/openhuman/emergency_stop/schemas.rs` +- Modify: `src/openhuman/emergency_stop/mod.rs` (re-export already added in Task 4) +- Modify: `src/core/all.rs` (register controllers, near approval ~line 160) +- Modify: `src/core/jsonrpc.rs` (install `EmergencyStop::init_global()` next to `ApprovalGate::init_global` ~line 2672) + +**Interfaces:** +- Consumes: `ops` (Task 5), `HaltState` (Task 2). +- Produces: RPCs `emergency.stop`, `emergency.resume`, `emergency.status` (dispatched as `openhuman.emergency_stop|resume|status`); `all_emergency_controller_schemas()`, `all_emergency_registered_controllers()`. + +- [ ] **Step 1: Write `schemas.rs`** (mirrors `approval/schemas.rs`): + +```rust +//! Controller schemas + handlers for the `emergency` namespace. +//! Wires `emergency_stop`, `emergency_resume`, `emergency_status` into the +//! global registry consumed by `src/core/all.rs`. + +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + +use super::ops; + +pub fn all_emergency_controller_schemas() -> Vec { + vec![schemas("stop"), schemas("resume"), schemas("status")] +} + +pub fn all_emergency_registered_controllers() -> Vec { + vec![ + RegisteredController { schema: schemas("stop"), handler: handle_stop }, + RegisteredController { schema: schemas("resume"), handler: handle_resume }, + RegisteredController { schema: schemas("status"), handler: handle_status }, + ] +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "stop" => ControllerSchema { + namespace: "emergency", + function: "stop", + description: "Engage the emergency stop: halt all desktop automation and block further actions until resumed.", + inputs: vec![FieldSchema { + name: "reason", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional human-readable reason for the halt.", + required: false, + }], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Switch snapshot after engaging.", + required: true, + }], + }, + "resume" => ControllerSchema { + namespace: "emergency", + function: "resume", + description: "Clear the emergency stop so automation may resume.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Switch snapshot after clearing.", + required: true, + }], + }, + "status" => ControllerSchema { + namespace: "emergency", + function: "status", + description: "Read the current emergency-stop switch state.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Current switch snapshot.", + required: true, + }], + }, + _ => ControllerSchema { + namespace: "emergency", + function: "unknown", + description: "Unknown emergency function.", + inputs: vec![], + outputs: vec![FieldSchema { name: "error", ty: TypeSchema::String, comment: "Schema not defined.", required: true }], + }, + } +} + +fn handle_stop(params: Map) -> ControllerFuture { + Box::pin(async move { + let reason = match params.get("reason") { + Some(Value::String(s)) => Some(s.clone()), + _ => None, + }; + Ok(serde_json::to_value(ops::emergency_stop(reason, "user").await.value).map_err(|e| e.to_string())?) + }) +} + +fn handle_resume(_params: Map) -> ControllerFuture { + Box::pin(async move { + Ok(serde_json::to_value(ops::emergency_resume("user").await.value).map_err(|e| e.to_string())?) + }) +} + +fn handle_status(_params: Map) -> ControllerFuture { + Box::pin(async move { + Ok(serde_json::to_value(ops::emergency_status().await.value).map_err(|e| e.to_string())?) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registered_controllers_match_schemas() { + let c = all_emergency_registered_controllers(); + assert_eq!(c.len(), 3); + let names: Vec<_> = c.iter().map(|c| c.schema.function).collect(); + assert_eq!(names, vec!["stop", "resume", "status"]); + } + + #[test] + fn stop_schema_has_optional_reason() { + let s = schemas("stop"); + assert_eq!(s.namespace, "emergency"); + assert_eq!(s.inputs[0].name, "reason"); + assert!(!s.inputs[0].required); + } +} +``` + +Notes for the implementer: +- The handler `to_json` pattern in `approval/schemas.rs` uses `outcome.into_cli_compatible_json()`. Prefer that exact helper for consistency: replace the `serde_json::to_value(...await.value)` lines with the approval pattern — call the op, then `outcome.into_cli_compatible_json()`. Read `approval/schemas.rs:201` (`to_json`) and copy it verbatim into this file, then `handle_* = to_json(ops::…().await)`. Adjust to whichever `RpcOutcome` serialization the registry expects (match approval exactly). +- `ControllerSchema`/`FieldSchema`/`TypeSchema`/`RegisteredController`/`ControllerFuture` imports mirror `approval/schemas.rs` lines 6–13. + +- [ ] **Step 2: Register in `src/core/all.rs`.** Next to the approval registration (`controllers.extend(crate::openhuman::approval::all_approval_registered_controllers());`), add: + +```rust + controllers.extend(crate::openhuman::emergency_stop::all_emergency_registered_controllers()); +``` + +Also add the schema list wherever approval's `all_controller_schemas` is aggregated (search `all_approval_registered_controllers`/`all_controller_schemas` usage in `all.rs` and mirror both). + +- [ ] **Step 3: Install at boot in `src/core/jsonrpc.rs`.** Next to `ApprovalGate::init_global(cfg.clone(), session_id.clone());` (~line 2672), add: + +```rust + crate::openhuman::emergency_stop::EmergencyStop::init_global(); +``` + +- [ ] **Step 4: Build + run schema tests.** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop::schemas` +Expected: 2 tests PASS; whole crate compiles. + +- [ ] **Step 5: Commit.** + +```bash +git add src/openhuman/emergency_stop/schemas.rs src/openhuman/emergency_stop/mod.rs src/core/all.rs src/core/jsonrpc.rs +git commit -m "feat(emergency): RPC controllers (stop/resume/status) + boot install (#4255)" +``` + +--- + +## Task 7: Enforcement chokepoint 1 — approval middleware blocks while halted + +**Files:** +- Modify: `src/openhuman/tinyagents/middleware.rs` (`ApprovalSecurityMiddleware::wrap_tool`, ~line 934, inside the `if self.has_external_effect(...)` block, before `gate.intercept_audited`) + +**Interfaces:** +- Consumes: `crate::openhuman::emergency_stop::is_engaged_global`. + +- [ ] **Step 1: Add the halt check.** In `wrap_tool`, immediately inside `if self.has_external_effect(&call.name, &call.arguments) {`, before the `if let Some(gate) = ApprovalGate::try_global()` line, insert: + +```rust + // Emergency stop: refuse every external-effect tool while halted, + // before touching the approval gate. Fail-closed. + if crate::openhuman::emergency_stop::is_engaged_global() { + let reason = "Emergency stop is engaged — this action is blocked until you resume automation.".to_string(); + tracing::warn!(tool = %call.name, "[tinyagents::mw] emergency stop engaged — refusing tool call"); + return Ok(MiddlewareToolOutcome::Result(TaToolResult { + call_id: call.id, + name: call.name, + content: reason.clone(), + raw: None, + error: Some(reason), + elapsed_ms: 0, + })); + } +``` + +- [ ] **Step 2: Write a unit test.** In the `#[cfg(test)]` module of `middleware.rs` (or a sibling `middleware_tests.rs` if one exists — match the file's convention), add a test that engages the global switch and asserts a halted external-effect call short-circuits. If constructing a full `RunContext`/`ToolHandler` is heavy, instead add a focused test in `emergency_stop` that exercises `is_engaged_global()` transitions and document the middleware behavior via an integration assertion in Task 10's E2E. Minimum viable unit test (pure guard behavior): + +```rust + #[test] + fn emergency_guard_blocks_when_engaged() { + use crate::openhuman::emergency_stop::EmergencyStop; + let stop = EmergencyStop::init_global(); + stop.clear(); + assert!(!crate::openhuman::emergency_stop::is_engaged_global()); + stop.engage(Some("test".into()), "user", 0); + assert!(crate::openhuman::emergency_stop::is_engaged_global()); + stop.clear(); + } +``` + +- [ ] **Step 3: Build + test.** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_guard_blocks_when_engaged` +Expected: PASS; `middleware.rs` compiles with the new guard. + +- [ ] **Step 4: Commit.** + +```bash +git add src/openhuman/tinyagents/middleware.rs +git commit -m "feat(emergency): approval middleware refuses external-effect tools while halted (#4255)" +``` + +--- + +## Task 8: Enforcement chokepoint 2 — accessibility input blocked while halted + +**Files:** +- Modify: `src/openhuman/screen_intelligence/ops.rs` (`accessibility_input_action`, ~line 152) + +**Interfaces:** +- Consumes: `is_engaged_global`; `InputActionResult { accepted, blocked, reason }`; `InputActionParams { action, .. }`. + +- [ ] **Step 1: Add the halt check.** Replace the body of `accessibility_input_action` with a guard that blocks while halted, except the `panic_stop` action (a stop must never be blocked by a stop): + +```rust +pub async fn accessibility_input_action( + payload: InputActionParams, +) -> Result, String> { + // Emergency stop: refuse desktop input while halted. `panic_stop` is + // exempt so a stop is never blocked by a stop. + if payload.action != "panic_stop" && crate::openhuman::emergency_stop::is_engaged_global() { + tracing::warn!(action = %payload.action, "[emergency] accessibility_input_action blocked — kill switch engaged"); + return Ok(RpcOutcome::single_log( + InputActionResult { accepted: false, blocked: true, reason: Some("emergency_stop".to_string()) }, + "screen intelligence input blocked by emergency stop", + )); + } + let result = screen_intelligence::global_engine() + .input_action(payload) + .await?; + Ok(RpcOutcome::single_log( + result, + "screen intelligence input action processed", + )) +} +``` + +- [ ] **Step 2: Write a unit test.** Add to the `#[cfg(test)] mod tests` in `screen_intelligence/ops.rs` (the file already has tests like `accessibility_stop_session_is_tolerant_of_no_reason`): + +```rust + #[tokio::test] + async fn input_action_blocked_while_emergency_engaged() { + use crate::openhuman::emergency_stop::EmergencyStop; + let stop = EmergencyStop::init_global(); + stop.engage(Some("test".into()), "user", 0); + let params = InputActionParams { action: "click".into(), x: Some(1), y: Some(1), button: None, text: None, key: None, modifiers: None }; + let out = accessibility_input_action(params).await.unwrap(); + assert!(!out.value.accepted); + assert!(out.value.blocked); + assert_eq!(out.value.reason.as_deref(), Some("emergency_stop")); + stop.clear(); + } + + #[tokio::test] + async fn panic_stop_passes_even_while_emergency_engaged() { + use crate::openhuman::emergency_stop::EmergencyStop; + let stop = EmergencyStop::init_global(); + stop.engage(None, "user", 0); + let params = InputActionParams { action: "panic_stop".into(), x: None, y: None, button: None, text: None, key: None, modifiers: None }; + // Should not be short-circuited by the emergency guard (reaches the engine). + let _ = accessibility_input_action(params).await; + stop.clear(); + } +``` + +(Confirm `out.value` accessor matches `RpcOutcome`'s public field, as in Task 5.) + +- [ ] **Step 3: Build + test.** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman input_action_blocked_while_emergency_engaged` +Expected: PASS. + +- [ ] **Step 4: Commit.** + +```bash +git add src/openhuman/screen_intelligence/ops.rs +git commit -m "feat(emergency): accessibility_input_action refuses input while halted (#4255)" +``` + +--- + +## Task 9: JSON-RPC E2E — stop → status → resume + +**Files:** +- Modify: `tests/json_rpc_e2e.rs` (add a test mirroring existing approval E2E tests) + +**Interfaces:** +- Consumes: the JSON-RPC dispatcher via the existing E2E harness in `tests/json_rpc_e2e.rs`. + +- [ ] **Step 1: Read an existing E2E test** in `tests/json_rpc_e2e.rs` (e.g. an approval one) to copy the harness setup (how it boots the core, obtains a client, and calls `openhuman.`). + +- [ ] **Step 2: Write the E2E test** following that harness's exact helper signatures: + +```rust +// Emergency stop: status(not halted) → stop → status(halted) → resume → status(not halted). +#[tokio::test] +async fn emergency_stop_roundtrip_over_rpc() { + let harness = /* boot core per existing pattern in this file */; + let s0 = harness.call("openhuman.emergency_status", serde_json::json!({})).await; + assert_eq!(s0["engaged"], serde_json::json!(false)); + + let stopped = harness.call("openhuman.emergency_stop", serde_json::json!({ "reason": "e2e" })).await; + assert_eq!(stopped["engaged"], serde_json::json!(true)); + + let s1 = harness.call("openhuman.emergency_status", serde_json::json!({})).await; + assert_eq!(s1["engaged"], serde_json::json!(true)); + + let resumed = harness.call("openhuman.emergency_resume", serde_json::json!({})).await; + assert_eq!(resumed["engaged"], serde_json::json!(false)); +} +``` + +Adapt `harness`/`call` to the file's real helpers (method name mapping `emergency.stop` → `openhuman.emergency_stop` is handled by the dispatcher; verify against how approval methods are invoked in this file). + +- [ ] **Step 3: Run.** + +Run: `bash scripts/test-rust-with-mock.sh --test json_rpc_e2e emergency_stop_roundtrip_over_rpc` +Expected: PASS. + +- [ ] **Step 4: Commit.** + +```bash +git add tests/json_rpc_e2e.rs +git commit -m "test(emergency): json-rpc e2e for stop/status/resume (#4255)" +``` + +--- + +## Task 10: Web socket bridge — `AutomationHalted`/`Resumed` → `automation_halt` event + +**Files:** +- Modify: `src/openhuman/channels/providers/web/event_bus.rs` (add `AutomationHaltSubscriber`, `register_automation_halt_subscriber`) +- Modify: `src/openhuman/channels/runtime/startup.rs` (call the register fn where `register_approval_surface_subscriber` is called) + +**Interfaces:** +- Consumes: `DomainEvent::AutomationHalted/Resumed`; `WebChannelEvent` (read `src/core/socketio` for its fields — the approval bridge sets `event`, `client_id`, `thread_id`, `args`). + +- [ ] **Step 1: Read `WebChannelEvent`** to confirm how to emit a **broadcast** (not thread-scoped) event. Emergency halt is global. If `WebChannelEvent` requires `client_id`/`thread_id`, check how the socket server treats empty values, or look for a broadcast helper. If no broadcast primitive exists, emit with empty `client_id`/`thread_id` and have the frontend socket handler (Task 14) listen for the event name globally — confirm the socket server forwards non-thread events. Document the choice in a code comment. + +- [ ] **Step 2: Add the subscriber** in `event_bus.rs` (mirror `ApprovalSurfaceSubscriber`): + +```rust +static AUTOMATION_HALT_HANDLE: OnceLock = OnceLock::new(); + +pub fn register_automation_halt_subscriber() { + if AUTOMATION_HALT_HANDLE.get().is_some() { + return; + } + match crate::core::event_bus::subscribe_global(Arc::new(AutomationHaltSubscriber)) { + Some(handle) => { let _ = AUTOMATION_HALT_HANDLE.set(handle); + log::info!("[web-channel] automation-halt subscriber registered — bridges AutomationHalted/Resumed → automation_halt socket event"); } + None => log::warn!("[web-channel] failed to register automation-halt subscriber — bus not initialized"), + } +} + +struct AutomationHaltSubscriber; + +#[async_trait] +impl EventHandler for AutomationHaltSubscriber { + fn name(&self) -> &str { "channels::web::automation_halt" } + fn domains(&self) -> Option<&[&str]> { Some(&["system"]) } + async fn handle(&self, event: &DomainEvent) { + match event { + DomainEvent::AutomationHalted { reason, source } => { + publish_web_channel_event(WebChannelEvent { + event: "automation_halt".to_string(), + args: Some(serde_json::json!({ "engaged": true, "reason": reason, "source": source })), + ..Default::default() + }); + } + DomainEvent::AutomationResumed { source } => { + publish_web_channel_event(WebChannelEvent { + event: "automation_halt".to_string(), + args: Some(serde_json::json!({ "engaged": false, "source": source })), + ..Default::default() + }); + } + _ => {} + } + } +} +``` + +- [ ] **Step 3: Register at startup.** In `src/openhuman/channels/runtime/startup.rs`, next to `register_approval_surface_subscriber()`, add `register_automation_halt_subscriber();`. + +- [ ] **Step 4: Add a unit test** mirroring `fresh_approval_surface_subscription_returns_some_when_bus_is_ready` if a `fresh_*` helper is warranted; otherwise a minimal test asserting `register_automation_halt_subscriber()` is idempotent (second call is a no-op) after `init_global`. + +- [ ] **Step 5: Build + test.** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman automation_halt` +Expected: PASS. + +- [ ] **Step 6: Commit.** + +```bash +git add src/openhuman/channels/providers/web/event_bus.rs src/openhuman/channels/runtime/startup.rs +git commit -m "feat(emergency): bridge halt/resume events to automation_halt socket event (#4255)" +``` + +--- + +## Task 11: Frontend Redux `safetySlice` + +**Files:** +- Create: `app/src/store/safetySlice.ts` +- Create: `app/src/store/safetySlice.test.ts` +- Modify: root store (`app/src/store/index.ts` or wherever `configureStore`/`combineReducers` lives) — mount `safety` reducer. + +**Interfaces:** +- Produces: `safetyReducer`, actions `setHalt({reason?, since?, source?})`, `clearHalt()`, `hydrateHalt(HaltState)`; selector `selectHalted(state)`, `selectHaltReason(state)`. + +- [ ] **Step 1: Write the failing test.** Create `app/src/store/safetySlice.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import reducer, { setHalt, clearHalt, hydrateHalt } from './safetySlice'; + +describe('safetySlice', () => { + it('starts not halted', () => { + expect(reducer(undefined, { type: '@@init' })).toEqual({ halted: false }); + }); + it('setHalt marks halted with reason/source/since', () => { + const s = reducer(undefined, setHalt({ reason: 'user', source: 'user', since: 42 })); + expect(s).toEqual({ halted: true, reason: 'user', source: 'user', since: 42 }); + }); + it('clearHalt resets', () => { + const halted = reducer(undefined, setHalt({ reason: 'x' })); + expect(reducer(halted, clearHalt())).toEqual({ halted: false }); + }); + it('hydrateHalt maps a HaltState snapshot', () => { + const s = reducer(undefined, hydrateHalt({ engaged: true, reason: 'boot', engaged_at_ms: 7, source: 'system' })); + expect(s.halted).toBe(true); + expect(s.reason).toBe('boot'); + expect(s.since).toBe(7); + }); +}); +``` + +- [ ] **Step 2: Run — verify fail.** `pnpm test app/src/store/safetySlice.test.ts` → FAIL (module not found). + +- [ ] **Step 3: Implement `safetySlice.ts`:** + +```ts +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface HaltState { + engaged: boolean; + reason?: string; + engaged_at_ms?: number; + source?: string; +} + +export interface SafetyState { + halted: boolean; + reason?: string; + since?: number; + source?: string; +} + +const initialState: SafetyState = { halted: false }; + +const safetySlice = createSlice({ + name: 'safety', + initialState, + reducers: { + setHalt(_state, action: PayloadAction<{ reason?: string; source?: string; since?: number }>) { + return { halted: true, reason: action.payload.reason, source: action.payload.source, since: action.payload.since }; + }, + clearHalt() { + return { halted: false }; + }, + hydrateHalt(_state, action: PayloadAction) { + const h = action.payload; + return h.engaged + ? { halted: true, reason: h.reason, source: h.source, since: h.engaged_at_ms } + : { halted: false }; + }, + }, +}); + +export const { setHalt, clearHalt, hydrateHalt } = safetySlice.actions; +export const selectHalted = (state: { safety: SafetyState }) => state.safety.halted; +export const selectHaltReason = (state: { safety: SafetyState }) => state.safety.reason; +export default safetySlice.reducer; +``` + +- [ ] **Step 4: Mount reducer** in the root store under key `safety` (follow the existing slice-registration pattern — the store already registers `chatRuntime`, `thread`, etc.). + +- [ ] **Step 5: Run tests.** `pnpm test app/src/store/safetySlice.test.ts` → PASS. Also `pnpm typecheck`. + +- [ ] **Step 6: Commit.** + +```bash +git add app/src/store/safetySlice.ts app/src/store/safetySlice.test.ts app/src/store/index.ts +git commit -m "feat(emergency): safetySlice tracks automation-halt state (#4255)" +``` + +--- + +## Task 12: Frontend `emergencyApi` client + +**Files:** +- Create: `app/src/services/api/emergencyApi.ts` +- Create: `app/src/services/api/emergencyApi.test.ts` + +**Interfaces:** +- Consumes: `callCoreRpc` from `coreRpcClient` (read `app/src/services/coreRpcClient.ts` for the exact export name — the approval client `app/src/services/api/approvalApi.ts` uses it; mirror it). +- Produces: `emergencyStop(reason?: string): Promise`, `emergencyResume(): Promise`, `emergencyStatus(): Promise`. + +- [ ] **Step 1: Read `app/src/services/api/approvalApi.ts`** to copy the exact RPC-call idiom (function name, method-name convention `openhuman._`, error handling). + +- [ ] **Step 2: Write the failing test.** Create `emergencyApi.test.ts`, mocking the core RPC module the same way `approvalApi.test.ts` does (find and mirror it): + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const call = vi.fn(); +vi.mock('../coreRpcClient', () => ({ callCoreRpc: (...a: unknown[]) => call(...a) })); + +import { emergencyStop, emergencyResume, emergencyStatus } from './emergencyApi'; + +beforeEach(() => call.mockReset()); + +describe('emergencyApi', () => { + it('emergencyStop calls openhuman.emergency_stop with reason', async () => { + call.mockResolvedValue({ engaged: true }); + const r = await emergencyStop('user'); + expect(call).toHaveBeenCalledWith('openhuman.emergency_stop', { reason: 'user' }); + expect(r.engaged).toBe(true); + }); + it('emergencyResume calls openhuman.emergency_resume', async () => { + call.mockResolvedValue({ engaged: false }); + await emergencyResume(); + expect(call).toHaveBeenCalledWith('openhuman.emergency_resume', {}); + }); + it('emergencyStatus calls openhuman.emergency_status', async () => { + call.mockResolvedValue({ engaged: false }); + await emergencyStatus(); + expect(call).toHaveBeenCalledWith('openhuman.emergency_status', {}); + }); +}); +``` + +(Adjust the mock path/exports to match what `approvalApi` actually imports.) + +- [ ] **Step 3: Run — verify fail.** `pnpm test app/src/services/api/emergencyApi.test.ts` → FAIL. + +- [ ] **Step 4: Implement `emergencyApi.ts`** mirroring `approvalApi.ts`: + +```ts +import { callCoreRpc } from '../coreRpcClient'; +import type { HaltState } from '../../store/safetySlice'; + +export async function emergencyStop(reason?: string): Promise { + return callCoreRpc('openhuman.emergency_stop', reason ? { reason } : {}); +} + +export async function emergencyResume(): Promise { + return callCoreRpc('openhuman.emergency_resume', {}); +} + +export async function emergencyStatus(): Promise { + return callCoreRpc('openhuman.emergency_status', {}); +} +``` + +(The `emergencyStop('user')` test expects `{ reason: 'user' }`; adjust the impl/test to agree — pass `{ reason }` when provided.) + +- [ ] **Step 5: Run tests.** PASS + `pnpm typecheck`. + +- [ ] **Step 6: Commit.** + +```bash +git add app/src/services/api/emergencyApi.ts app/src/services/api/emergencyApi.test.ts +git commit -m "feat(emergency): emergencyApi RPC client (#4255)" +``` + +--- + +## Task 13: i18n keys + +**Files:** +- Modify: `app/src/lib/i18n/locales/en.ts` and every other locale file (`ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN`) + +**Interfaces:** +- Produces: keys `safety.emergencyStop`, `safety.resume`, `safety.haltedTitle`, `safety.haltedBody`, `safety.stopConfirm` (used by Task 14 components). + +- [ ] **Step 1: Add keys to `en.ts`** (match the file's nesting/style): + +```ts + safety: { + emergencyStop: 'Emergency stop', + resume: 'Resume automation', + haltedTitle: 'Automation halted', + haltedBody: 'All desktop automation is stopped. Resume when you are ready.', + stopConfirm: 'Stop all automation now?', + }, +``` + +- [ ] **Step 2: Add real translations** to every other locale file (not English placeholders — CI `pnpm i18n:english:check` fails on English left in non-English files). Translate the five strings per locale. + +- [ ] **Step 3: Verify parity.** + +Run: `pnpm i18n:check && pnpm i18n:english:check` +Expected: PASS (all locales have the keys; no English placeholders). + +- [ ] **Step 4: Commit.** + +```bash +git add app/src/lib/i18n/locales +git commit -m "i18n(emergency): add safety.* keys across locales (#4255)" +``` + +--- + +## Task 14: Emergency Stop button + halted banner + wiring + +**Files:** +- Create: `app/src/components/safety/EmergencyStopButton.tsx` (+ `.test.tsx`) +- Create: `app/src/components/safety/AutomationHaltedBanner.tsx` (+ `.test.tsx`) +- Modify: app shell/header to mount both (pick the always-visible chrome — e.g. the Conversations header near the `chat-cancel-generation` control, or `AppShell`). +- Modify: `app/src/services/socketService.ts` — handle `automation_halt` socket event → dispatch `setHalt`/`clearHalt`. +- Modify: boot path (e.g. `CoreStateProvider` or an effect in the shell) — call `emergencyStatus()` once and dispatch `hydrateHalt`. + +**Interfaces:** +- Consumes: `emergencyStop`/`emergencyResume`/`emergencyStatus` (Task 12); `setHalt`/`clearHalt`/`hydrateHalt`/`selectHalted`/`selectHaltReason` (Task 11); `useT()`. + +- [ ] **Step 1: Write the button test.** `EmergencyStopButton.test.tsx` (mirror an existing component test that wraps a Redux `Provider` + i18n — find one such test to copy providers): + +```tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { EmergencyStopButton } from './EmergencyStopButton'; +import { renderWithProviders } from '../../test-utils'; // use the repo's existing helper; else inline a store+I18n wrapper + +const stop = vi.fn().mockResolvedValue({ engaged: true }); +vi.mock('../../services/api/emergencyApi', () => ({ emergencyStop: (...a: unknown[]) => stop(...a) })); + +beforeEach(() => stop.mockClear()); + +describe('EmergencyStopButton', () => { + it('calls emergencyStop and dispatches halt on click', async () => { + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); + await waitFor(() => expect(stop).toHaveBeenCalled()); + }); +}); +``` + +- [ ] **Step 2: Verify fail.** `pnpm test EmergencyStopButton` → FAIL. + +- [ ] **Step 3: Implement `EmergencyStopButton.tsx`:** + +```tsx +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { useT } from '../../lib/i18n/I18nContext'; +import { emergencyStop } from '../../services/api/emergencyApi'; +import { setHalt } from '../../store/safetySlice'; + +export function EmergencyStopButton() { + const t = useT(); + const dispatch = useDispatch(); + const onClick = useCallback(async () => { + try { + const state = await emergencyStop('user'); + dispatch(setHalt({ reason: state.reason, source: state.source, since: state.engaged_at_ms })); + } catch (err) { + // Fail-visible: still reflect intent locally so the user sees the halt. + dispatch(setHalt({ reason: 'user', source: 'user' })); + console.error('[emergency] stop failed', err); + } + }, [dispatch]); + return ( + + ); +} +``` + +- [ ] **Step 4: Implement `AutomationHaltedBanner.tsx`** (renders only when halted; Resume clears): + +```tsx +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useT } from '../../lib/i18n/I18nContext'; +import { emergencyResume } from '../../services/api/emergencyApi'; +import { clearHalt, selectHalted, selectHaltReason } from '../../store/safetySlice'; + +export function AutomationHaltedBanner() { + const t = useT(); + const dispatch = useDispatch(); + const halted = useSelector(selectHalted); + const reason = useSelector(selectHaltReason); + const onResume = useCallback(async () => { + try { await emergencyResume(); } finally { dispatch(clearHalt()); } + }, [dispatch]); + if (!halted) return null; + return ( +
+ {t('safety.haltedTitle')} + {reason ?? t('safety.haltedBody')} + +
+ ); +} +``` + +- [ ] **Step 5: Write the banner test** (`AutomationHaltedBanner.test.tsx`): renders nothing when not halted; renders + Resume calls `emergencyResume` and clears when halted (preload the store with `setHalt`). + +- [ ] **Step 6: Socket handler.** In `socketService.ts`, register a handler for the `automation_halt` event (mirror how `approval_request` is handled): on `{engaged:true}` dispatch `setHalt`, on `{engaged:false}` dispatch `clearHalt`. + +- [ ] **Step 7: Boot hydration.** In the shell/boot effect, call `emergencyStatus()` once and dispatch `hydrateHalt(result)` (guard with `isTauri()`/try-catch). + +- [ ] **Step 8: Mount** `` in the always-visible chrome and `` near the top of the main content. + +- [ ] **Step 9: Run all frontend checks.** + +Run: `pnpm test app/src/components/safety && pnpm typecheck && pnpm lint` +Expected: PASS. + +- [ ] **Step 10: Commit.** + +```bash +git add app/src/components/safety app/src/services/socketService.ts app/src/store app/src/**/*Shell* 2>/dev/null +git commit -m "feat(emergency): stop button + halted banner + socket/boot wiring (#4255)" +``` + +--- + +## Task 15: Full verification + coverage gate + +- [ ] **Step 1: Rust suite (changed domains).** + +Run: `GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman emergency_stop:: && bash scripts/test-rust-with-mock.sh --test json_rpc_e2e emergency_stop_roundtrip_over_rpc` +Expected: all PASS. + +- [ ] **Step 2: Rust format + check.** + +Run: `cargo fmt --manifest-path Cargo.toml && GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml` +Expected: no diffs, clean check. + +- [ ] **Step 3: Frontend suite + quality.** + +Run: `pnpm test && pnpm typecheck && pnpm lint && pnpm i18n:check && pnpm i18n:english:check` +Expected: all PASS. + +- [ ] **Step 4: Diff coverage sanity.** Ensure the changed Rust lines (ops.rs guards, chokepoints) and changed TS lines (slice, api, components) are exercised by the tests above. Add targeted tests for any uncovered branch (e.g. `emergency_status` when no switch installed → `HaltState::default`). Target ≥80% on changed lines. + +- [ ] **Step 5: Update feature docs.** Per AGENTS.md, if this adds a user-facing feature, update `src/openhuman/about_app/` with the Emergency Stop control. Commit. + +```bash +git commit -am "docs(about): register emergency stop as a user-facing control (#4255)" +``` + +- [ ] **Step 6: Manual smoke (optional but recommended).** `pnpm dev:app`, engage Emergency Stop, confirm the banner appears and Resume clears it; confirm an accessibility input while halted returns blocked. + +--- + +## Self-Review (completed by plan author) + +- **Spec coverage:** AC "emergency stop cancels pending actions" → Task 5 cascade-deny + a11y stop; "prevents further queued actions until resume" → Tasks 7–8 chokepoints + Task 5 flag; UI control + resume → Tasks 11–14; tests/≥80% coverage → every task is TDD + Task 15. ✔ +- **Placeholder scan:** No TBDs. The few "read neighboring file to confirm exact accessor" notes are explicit verification steps (RpcOutcome `.value`, `callCoreRpc` export, `WebChannelEvent` fields, test-provider helper) with the exact file to read — not deferred work. ✔ +- **Type consistency:** `HaltState` fields (`engaged`, `reason`, `engaged_at_ms`, `source`) identical across Rust (Task 2) and TS (Task 11/12); RPC method names `openhuman.emergency_{stop,resume,status}` consistent Tasks 6/9/12; event names `AutomationHalted`/`AutomationResumed` consistent Tasks 1/10; socket event `automation_halt` consistent Tasks 10/14. ✔ +- **Ordering:** Task 1 (events) precedes Task 5 (publishes them); Tasks 2–4 (module compiles) precede Task 5–6; chokepoints (7–8) after the switch exists; frontend (11–14) independent of Rust after RPC names are fixed. ✔ From ed6f29708ab43bcb028e421b91ee0053721e52aa Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 16:28:24 +0530 Subject: [PATCH 03/33] docs(safety): correct callCoreRpc call-shape + i18n paths in plan (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-06-emergency-stop.md | 63 ++++++++++++------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/plans/2026-07-06-emergency-stop.md b/docs/superpowers/plans/2026-07-06-emergency-stop.md index aa53e82397..c1ae86a1c6 100644 --- a/docs/superpowers/plans/2026-07-06-emergency-stop.md +++ b/docs/superpowers/plans/2026-07-06-emergency-stop.md @@ -1071,68 +1071,84 @@ git commit -m "feat(emergency): safetySlice tracks automation-halt state (#4255) - Create: `app/src/services/api/emergencyApi.test.ts` **Interfaces:** -- Consumes: `callCoreRpc` from `coreRpcClient` (read `app/src/services/coreRpcClient.ts` for the exact export name — the approval client `app/src/services/api/approvalApi.ts` uses it; mirror it). +- Consumes: `callCoreRpc` from `coreRpcClient`. **CONFIRMED signature (do not use positional args):** `callCoreRpc({ method, params }): Promise` — it takes a single **object** `{ method: string, params?: object }`. Mirror `app/src/services/api/approvalApi.ts`. +- **CONFIRMED wire-shape:** RPCs that emit a diagnostic log return the CLI envelope `{ result, logs }`; log-less RPCs return a bare value. `emergency_stop`/`emergency_resume` use `RpcOutcome::single_log` (enveloped); `emergency_status` uses `RpcOutcome::new(_, vec![])` (bare). So the client MUST normalize both shapes with an `unwrapValue` helper — copy the one in `approvalApi.ts` (lines ~109-114) verbatim. - Produces: `emergencyStop(reason?: string): Promise`, `emergencyResume(): Promise`, `emergencyStatus(): Promise`. -- [ ] **Step 1: Read `app/src/services/api/approvalApi.ts`** to copy the exact RPC-call idiom (function name, method-name convention `openhuman._`, error handling). +- [ ] **Step 1: Read `app/src/services/api/approvalApi.ts`** to copy the exact RPC-call idiom: the object-form `callCoreRpc({ method, params })`, the `unwrapValue` helper, and method-name convention `openhuman._`. -- [ ] **Step 2: Write the failing test.** Create `emergencyApi.test.ts`, mocking the core RPC module the same way `approvalApi.test.ts` does (find and mirror it): +- [ ] **Step 2: Write the failing test.** Create `emergencyApi.test.ts`. `callCoreRpc` is a **named export** of `../coreRpcClient` and is called with an object: ```ts import { describe, it, expect, vi, beforeEach } from 'vitest'; const call = vi.fn(); -vi.mock('../coreRpcClient', () => ({ callCoreRpc: (...a: unknown[]) => call(...a) })); +vi.mock('../coreRpcClient', () => ({ callCoreRpc: (arg: unknown) => call(arg) })); import { emergencyStop, emergencyResume, emergencyStatus } from './emergencyApi'; beforeEach(() => call.mockReset()); describe('emergencyApi', () => { - it('emergencyStop calls openhuman.emergency_stop with reason', async () => { - call.mockResolvedValue({ engaged: true }); + 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('openhuman.emergency_stop', { reason: '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({ engaged: false }); - await emergencyResume(); - expect(call).toHaveBeenCalledWith('openhuman.emergency_resume', {}); + 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 calls openhuman.emergency_status', async () => { + it('emergencyStatus reads bare value (no envelope)', async () => { call.mockResolvedValue({ engaged: false }); - await emergencyStatus(); - expect(call).toHaveBeenCalledWith('openhuman.emergency_status', {}); + const r = await emergencyStatus(); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_status', params: {} }); + expect(r.engaged).toBe(false); }); }); ``` -(Adjust the mock path/exports to match what `approvalApi` actually imports.) - - [ ] **Step 3: Run — verify fail.** `pnpm test app/src/services/api/emergencyApi.test.ts` → FAIL. -- [ ] **Step 4: Implement `emergencyApi.ts`** mirroring `approvalApi.ts`: +- [ ] **Step 4: Implement `emergencyApi.ts`** mirroring `approvalApi.ts` (object-form call + `unwrapValue`): ```ts import { callCoreRpc } from '../coreRpcClient'; import type { HaltState } from '../../store/safetySlice'; +/** Normalize the CLI envelope `{ result, logs }` and bare-value shapes. */ +const unwrapValue = (raw: unknown): T => { + if (raw && typeof raw === 'object' && 'result' in (raw as Record)) { + return (raw as { result: T }).result; + } + return raw as T; +}; + export async function emergencyStop(reason?: string): Promise { - return callCoreRpc('openhuman.emergency_stop', reason ? { reason } : {}); + const raw = await callCoreRpc({ method: 'openhuman.emergency_stop', params: reason ? { reason } : {} }); + return unwrapValue(raw); } export async function emergencyResume(): Promise { - return callCoreRpc('openhuman.emergency_resume', {}); + const raw = await callCoreRpc({ method: 'openhuman.emergency_resume', params: {} }); + return unwrapValue(raw); } export async function emergencyStatus(): Promise { - return callCoreRpc('openhuman.emergency_status', {}); + const raw = await callCoreRpc({ method: 'openhuman.emergency_status', params: {} }); + return unwrapValue(raw); } ``` -(The `emergencyStop('user')` test expects `{ reason: 'user' }`; adjust the impl/test to agree — pass `{ reason }` when provided.) - - [ ] **Step 5: Run tests.** PASS + `pnpm typecheck`. - [ ] **Step 6: Commit.** @@ -1147,12 +1163,13 @@ git commit -m "feat(emergency): emergencyApi RPC client (#4255)" ## Task 13: i18n keys **Files:** -- Modify: `app/src/lib/i18n/locales/en.ts` and every other locale file (`ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN`) +- Modify: `app/src/lib/i18n/en.ts` and every other locale file at `app/src/lib/i18n/.ts` (`ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN`) +- Check: `app/src/lib/i18n/types.ts` — if the translation key type is explicitly enumerated there, add the new keys to it (otherwise `pnpm typecheck` fails). The parity/coverage guard lives at `app/src/lib/i18n/__tests__/coverage.test.ts`. **Interfaces:** - Produces: keys `safety.emergencyStop`, `safety.resume`, `safety.haltedTitle`, `safety.haltedBody`, `safety.stopConfirm` (used by Task 14 components). -- [ ] **Step 1: Add keys to `en.ts`** (match the file's nesting/style): +- [ ] **Step 1: Read `app/src/lib/i18n/en.ts` and `types.ts`** to learn the nesting/key style (flat dotted keys vs nested objects) and whether keys are type-enumerated. Add keys to `en.ts` matching that exact style: ```ts safety: { From 46f2b06f9a1bdbc13bf038abc26ca88cf2b5a104 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 16:35:28 +0530 Subject: [PATCH 04/33] feat(emergency): safetySlice tracks automation-halt state (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/store/index.ts | 2 ++ app/src/store/safetySlice.test.ts | 22 +++++++++++++++++ app/src/store/safetySlice.ts | 41 +++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 app/src/store/safetySlice.test.ts create mode 100644 app/src/store/safetySlice.ts diff --git a/app/src/store/index.ts b/app/src/store/index.ts index c6184febb4..00d1b29f01 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -37,6 +37,7 @@ import { pttReducer } from './pttSlice'; import socketReducer from './socketSlice'; import themeReducer from './themeSlice'; import threadReducer from './threadSlice'; +import safetyReducer from './safetySlice'; import userErrorsReducer from './userErrorsSlice'; import { userScopedStorage } from './userScopedStorage'; @@ -243,6 +244,7 @@ export const store = configureStore({ // completion, resets on restart + user switch. Durable storage is a #3931 // follow-up. userErrors: userErrorsReducer, + safety: safetyReducer, }, middleware: getDefaultMiddleware => { const middleware = getDefaultMiddleware({ diff --git a/app/src/store/safetySlice.test.ts b/app/src/store/safetySlice.test.ts new file mode 100644 index 0000000000..064b80c7ee --- /dev/null +++ b/app/src/store/safetySlice.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import reducer, { setHalt, clearHalt, hydrateHalt } from './safetySlice'; + +describe('safetySlice', () => { + it('starts not halted', () => { + expect(reducer(undefined, { type: '@@init' })).toEqual({ halted: false }); + }); + it('setHalt marks halted with reason/source/since', () => { + const s = reducer(undefined, setHalt({ reason: 'user', source: 'user', since: 42 })); + expect(s).toEqual({ halted: true, reason: 'user', source: 'user', since: 42 }); + }); + it('clearHalt resets', () => { + const halted = reducer(undefined, setHalt({ reason: 'x' })); + expect(reducer(halted, clearHalt())).toEqual({ halted: false }); + }); + it('hydrateHalt maps a HaltState snapshot', () => { + const s = reducer(undefined, hydrateHalt({ engaged: true, reason: 'boot', engaged_at_ms: 7, source: 'system' })); + expect(s.halted).toBe(true); + expect(s.reason).toBe('boot'); + expect(s.since).toBe(7); + }); +}); diff --git a/app/src/store/safetySlice.ts b/app/src/store/safetySlice.ts new file mode 100644 index 0000000000..929a5bf75f --- /dev/null +++ b/app/src/store/safetySlice.ts @@ -0,0 +1,41 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface HaltState { + engaged: boolean; + reason?: string; + engaged_at_ms?: number; + source?: string; +} + +export interface SafetyState { + halted: boolean; + reason?: string; + since?: number; + source?: string; +} + +const initialState: SafetyState = { halted: false }; + +const safetySlice = createSlice({ + name: 'safety', + initialState, + reducers: { + setHalt(_state, action: PayloadAction<{ reason?: string; source?: string; since?: number }>) { + return { halted: true, reason: action.payload.reason, source: action.payload.source, since: action.payload.since }; + }, + clearHalt() { + return { halted: false }; + }, + hydrateHalt(_state, action: PayloadAction) { + const h = action.payload; + return h.engaged + ? { halted: true, reason: h.reason, source: h.source, since: h.engaged_at_ms } + : { halted: false }; + }, + }, +}); + +export const { setHalt, clearHalt, hydrateHalt } = safetySlice.actions; +export const selectHalted = (state: { safety: SafetyState }) => state.safety.halted; +export const selectHaltReason = (state: { safety: SafetyState }) => state.safety.reason; +export default safetySlice.reducer; From ecb6129da7b0169ce6d78717af0ba998ef6cc2f1 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 16:36:03 +0530 Subject: [PATCH 05/33] docs(safety): fix engine.disable(Option) signature in plan Task 5 (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-07-06-emergency-stop.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-06-emergency-stop.md b/docs/superpowers/plans/2026-07-06-emergency-stop.md index c1ae86a1c6..31f976f4dd 100644 --- a/docs/superpowers/plans/2026-07-06-emergency-stop.md +++ b/docs/superpowers/plans/2026-07-06-emergency-stop.md @@ -436,8 +436,10 @@ pub async fn emergency_stop(reason: Option, source: &str) -> RpcOutcome< stop.engage(reason.clone(), source, now_ms()); // Best-effort: stop the accessibility session so any in-flight click/type loop halts. + // CONFIRMED: `engine.disable(reason: Option) -> SessionStatus` (engine.rs:150); + // `SessionStatus.active: bool` (types.rs:15). let a11y = crate::openhuman::screen_intelligence::global_engine() - .disable("emergency_stop".to_string()) + .disable(Some("emergency_stop".to_string())) .await; tracing::info!(active = a11y.active, "[emergency] accessibility session stopped"); From afe57c37a16d68cadc6c26b45f6b8c3db8ffe4f5 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 16:36:27 +0530 Subject: [PATCH 06/33] feat(emergency): emergencyApi RPC client (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/services/api/emergencyApi.test.ts | 35 +++++++++++++++++++++++ app/src/services/api/emergencyApi.ts | 25 ++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 app/src/services/api/emergencyApi.test.ts create mode 100644 app/src/services/api/emergencyApi.ts diff --git a/app/src/services/api/emergencyApi.test.ts b/app/src/services/api/emergencyApi.test.ts new file mode 100644 index 0000000000..be8728a1de --- /dev/null +++ b/app/src/services/api/emergencyApi.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const call = vi.fn(); +vi.mock('../coreRpcClient', () => ({ callCoreRpc: (arg: unknown) => call(arg) })); + +import { emergencyStop, emergencyResume, emergencyStatus } from './emergencyApi'; + +beforeEach(() => call.mockReset()); + +describe('emergencyApi', () => { + it('emergencyStop calls openhuman.emergency_stop with reason and unwraps envelope', async () => { + call.mockResolvedValue({ result: { engaged: true, reason: 'user' }, logs: ['x'] }); + const r = await emergencyStop('user'); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_stop', params: { reason: 'user' } }); + expect(r.engaged).toBe(true); + expect(r.reason).toBe('user'); + }); + it('emergencyStop with no reason sends empty params', async () => { + call.mockResolvedValue({ result: { engaged: true }, logs: [] }); + await emergencyStop(); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_stop', params: {} }); + }); + it('emergencyResume calls openhuman.emergency_resume', async () => { + call.mockResolvedValue({ result: { engaged: false }, logs: ['x'] }); + const r = await emergencyResume(); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_resume', params: {} }); + expect(r.engaged).toBe(false); + }); + it('emergencyStatus reads bare value (no envelope)', async () => { + call.mockResolvedValue({ engaged: false }); + const r = await emergencyStatus(); + expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_status', params: {} }); + expect(r.engaged).toBe(false); + }); +}); diff --git a/app/src/services/api/emergencyApi.ts b/app/src/services/api/emergencyApi.ts new file mode 100644 index 0000000000..986dc1e0f8 --- /dev/null +++ b/app/src/services/api/emergencyApi.ts @@ -0,0 +1,25 @@ +import { callCoreRpc } from '../coreRpcClient'; +import type { HaltState } from '../../store/safetySlice'; + +/** Normalize the CLI envelope `{ result, logs }` and bare-value shapes. */ +const unwrapValue = (raw: unknown): T => { + if (raw && typeof raw === 'object' && 'result' in (raw as Record)) { + return (raw as { result: T }).result; + } + return raw as T; +}; + +export async function emergencyStop(reason?: string): Promise { + const raw = await callCoreRpc({ method: 'openhuman.emergency_stop', params: reason ? { reason } : {} }); + return unwrapValue(raw); +} + +export async function emergencyResume(): Promise { + const raw = await callCoreRpc({ method: 'openhuman.emergency_resume', params: {} }); + return unwrapValue(raw); +} + +export async function emergencyStatus(): Promise { + const raw = await callCoreRpc({ method: 'openhuman.emergency_status', params: {} }); + return unwrapValue(raw); +} From eb2cc95c6f1c742e5f679c0b97f97cbc62972584 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 16:36:46 +0530 Subject: [PATCH 07/33] feat(events): add AutomationHalted/AutomationResumed domain events (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/event_bus/events.rs | 22 +++++++++++++++++++++- src/core/event_bus/events_tests.rs | 10 ++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index dc3ace350a..4599bc7565 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -1064,6 +1064,22 @@ pub enum DomainEvent { overall: String, failed_required: bool, }, + /// Emergency stop engaged — all desktop automation is halted and every + /// external-effect / accessibility action is refused until resumed. + /// Published by `emergency_stop::ops::emergency_stop`; bridged to the + /// `automation_halt` web-channel socket event. + AutomationHalted { + /// Optional human-readable reason (redacted of PII by the caller). + reason: Option, + /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`. + source: String, + }, + /// Emergency stop cleared — automation may resume. Published by + /// `emergency_stop::ops::emergency_resume`. + AutomationResumed { + /// Who cleared it: `"user"`, `"hotkey"`, or `"system"`. + source: String, + }, // ── Keyring ───────────────────────────────────────────────────────── /// The OS keyring is unavailable and no user consent for local fallback @@ -1387,7 +1403,9 @@ impl DomainEvent { | Self::HealthChanged { .. } | Self::HealthRestarted { .. } | Self::HarnessInitProgress { .. } - | Self::HarnessInitCompleted { .. } => "system", + | Self::HarnessInitCompleted { .. } + | Self::AutomationHalted { .. } + | Self::AutomationResumed { .. } => "system", Self::KeyringConsentRequired | Self::KeyringDecryptFailed { .. } => "keyring", @@ -1540,6 +1558,8 @@ impl DomainEvent { Self::HealthRestarted { .. } => "HealthRestarted", Self::HarnessInitProgress { .. } => "HarnessInitProgress", Self::HarnessInitCompleted { .. } => "HarnessInitCompleted", + Self::AutomationHalted { .. } => "AutomationHalted", + Self::AutomationResumed { .. } => "AutomationResumed", Self::KeyringConsentRequired => "KeyringConsentRequired", Self::KeyringDecryptFailed { .. } => "KeyringDecryptFailed", Self::SessionExpired { .. } => "SessionExpired", diff --git a/src/core/event_bus/events_tests.rs b/src/core/event_bus/events_tests.rs index c977286852..336794e056 100644 --- a/src/core/event_bus/events_tests.rs +++ b/src/core/event_bus/events_tests.rs @@ -594,3 +594,13 @@ 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"); +} From d03e5c2de0f91a7b4f1255192782688473bb3d5c Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 16:36:50 +0530 Subject: [PATCH 08/33] chore(sdd): add task-1 report for AutomationHalted/AutomationResumed Co-Authored-By: Claude Opus 4.8 (1M context) --- .superpowers/sdd/task-1-report.md | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .superpowers/sdd/task-1-report.md diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md new file mode 100644 index 0000000000..53b93c6a7d --- /dev/null +++ b/.superpowers/sdd/task-1-report.md @@ -0,0 +1,57 @@ +## Task 1 Report — `AutomationHalted` / `AutomationResumed` domain events + +### What was implemented + +Added two new `DomainEvent` variants to `src/core/event_bus/events.rs` as specified in the brief, and a unit test in `src/core/event_bus/events_tests.rs`. + +**Variant placement:** Inserted after `HarnessInitCompleted` in the System lifecycle region (~line 1066), before the Keyring section. + +**Changes:** + +1. **`src/core/event_bus/events.rs`** — three edits: + - Added `AutomationHalted { reason: Option, source: String }` and `AutomationResumed { source: String }` with doc comments verbatim from the brief, in the System lifecycle block. + - Extended the `domain()` match arm: `| Self::AutomationHalted { .. } | Self::AutomationResumed { .. } => "system"` appended to the existing `HarnessInitCompleted` arm. + - Extended `variant_name()` with two arms: `Self::AutomationHalted { .. } => "AutomationHalted"` and `Self::AutomationResumed { .. } => "AutomationResumed"`. + +2. **`src/core/event_bus/events_tests.rs`** — appended `automation_events_map_to_system_domain` test. + +**Note on `name()` vs `variant_name()`:** The brief's Step 3/4 refer to a `name()` method, but the actual codebase uses `variant_name()`. The test was adapted to call `variant_name()` to match the existing test style (see `workflows_changed_domain_and_name` test which uses `.variant_name()`). The test function name `automation_events_map_to_system_domain` is unchanged from the brief. + +### TDD RED / GREEN evidence + +**Pre-implementation (RED):** Before adding the variants, the compiler would have produced `E0004` non-exhaustive match errors for both `domain()` and `variant_name()` since the enum is exhaustive. Not captured as a separate run — moved directly to GREEN after implementing. + +**GREEN run command:** +``` +GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman automation_events_map_to_system_domain +``` + +**GREEN output (key lines):** +``` + Compiling openhuman v0.58.11 (/Users/ghostscripter/Zerolend/openhuman) + Finished `test` profile [unoptimized + debuginfo] target(s) in 8m 08s + Running unittests src/lib.rs (target/debug/deps/openhuman_core-67f515be1d9557cd) + +running 1 test +test core::event_bus::events::tests::automation_events_map_to_system_domain ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 13231 filtered out; finished in 0.00s +``` + +### Files changed + +- `src/core/event_bus/events.rs` — 3 edits (enum body, `domain()` arm, `variant_name()` arms) +- `src/core/event_bus/events_tests.rs` — 1 edit (appended test function) + +### Self-review + +- Variants match the brief verbatim (field names, types, doc comments). +- `domain()` and `variant_name()` arms are exhaustive; the crate compiled with only pre-existing warnings (none new). +- Test verifies all four assertions (`domain()` and `variant_name()` for both variants). +- No logging added — correct per the brief: "this task adds only enum variants + a unit test (no runtime logging needed)." +- Submodules (`vendor/tinyagents`, `vendor/tinychannels`, etc.) were uninitialized and had to be cloned before the build could succeed; this is a one-time repo setup step, not a code concern. + +### Concerns + +- **`name()` vs `variant_name()` mismatch in brief:** The brief's Step 4 test uses `halted.name()` / `resumed.name()` — no such method exists on `DomainEvent`; the actual method is `variant_name()`. The test was adapted accordingly. This is a minor brief inaccuracy; the behavior under test is identical. +- No other concerns. The change is additive and self-contained. From 6d20b1d3737d16419e8ddafe957819153704c401 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 16:39:43 +0530 Subject: [PATCH 09/33] i18n(emergency): add safety.* keys across locales (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/lib/i18n/ar.ts | 7 +++++++ app/src/lib/i18n/bn.ts | 7 +++++++ app/src/lib/i18n/de.ts | 7 +++++++ app/src/lib/i18n/en.ts | 7 +++++++ app/src/lib/i18n/es.ts | 7 +++++++ app/src/lib/i18n/fr.ts | 7 +++++++ app/src/lib/i18n/hi.ts | 7 +++++++ app/src/lib/i18n/id.ts | 7 +++++++ app/src/lib/i18n/it.ts | 7 +++++++ app/src/lib/i18n/ko.ts | 7 +++++++ app/src/lib/i18n/pl.ts | 7 +++++++ app/src/lib/i18n/pt.ts | 7 +++++++ app/src/lib/i18n/ru.ts | 7 +++++++ app/src/lib/i18n/zh-CN.ts | 7 +++++++ 14 files changed, 98 insertions(+) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index f4df8f1b22..57611f794f 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -6736,6 +6736,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'حذف', 'flows.delete.deleting': 'جارٍ الحذف…', 'flows.canvas.renameLabel': 'إعادة تسمية سير العمل', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'إيقاف الطوارئ', + 'safety.resume': 'استئناف الأتمتة', + 'safety.haltedTitle': 'الأتمتة متوقفة', + 'safety.haltedBody': 'تم إيقاف جميع أتمتة سطح المكتب. استأنف عندما تكون مستعدًا.', + 'safety.stopConfirm': 'إيقاف جميع عمليات الأتمتة الآن؟', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 6529ed035b..953e47ab36 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -6889,6 +6889,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'মুছুন', 'flows.delete.deleting': 'মুছে ফেলা হচ্ছে…', 'flows.canvas.renameLabel': 'ওয়ার্কফ্লো পুনঃনামকরণ করুন', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'জরুরি বন্ধ', + 'safety.resume': 'অটোমেশন পুনরায় শুরু করুন', + 'safety.haltedTitle': 'অটোমেশন বন্ধ', + 'safety.haltedBody': 'সমস্ত ডেস্কটপ অটোমেশন বন্ধ করা হয়েছে। প্রস্তুত হলে পুনরায় শুরু করুন।', + 'safety.stopConfirm': 'এখন সমস্ত অটোমেশন বন্ধ করবেন?', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 8086d78728..592a6ceb6f 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7079,6 +7079,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Löschen', 'flows.delete.deleting': 'Wird gelöscht…', 'flows.canvas.renameLabel': 'Workflow umbenennen', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'Notabschaltung', + 'safety.resume': 'Automatisierung fortsetzen', + 'safety.haltedTitle': 'Automatisierung angehalten', + 'safety.haltedBody': 'Alle Desktop-Automatisierungen sind gestoppt. Fortsetzen, wenn Sie bereit sind.', + 'safety.stopConfirm': 'Alle Automatisierungen jetzt stoppen?', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index e5f753db45..bfca147581 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7185,6 +7185,13 @@ 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.resume': 'Resume automation', + 'safety.haltedTitle': 'Automation halted', + 'safety.haltedBody': 'All desktop automation is stopped. Resume when you are ready.', + 'safety.stopConfirm': 'Stop all automation now?', }; export default en; diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 72456f24bc..757f7af3d0 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7027,6 +7027,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Eliminar', 'flows.delete.deleting': 'Eliminando…', 'flows.canvas.renameLabel': 'Cambiar el nombre del flujo de trabajo', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'Parada de emergencia', + 'safety.resume': 'Reanudar automatización', + 'safety.haltedTitle': 'Automatización detenida', + 'safety.haltedBody': 'Toda la automatización de escritorio está detenida. Reanude cuando esté listo.', + 'safety.stopConfirm': '¿Detener toda la automatización ahora?', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index d1406b07d0..bdc148c06c 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7050,6 +7050,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Supprimer', 'flows.delete.deleting': 'Suppression…', 'flows.canvas.renameLabel': 'Renommer le workflow', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'Arrêt d\'urgence', + 'safety.resume': 'Reprendre l\'automatisation', + 'safety.haltedTitle': 'Automatisation suspendue', + 'safety.haltedBody': 'Toute l\'automatisation du bureau est arrêtée. Reprenez quand vous êtes prêt.', + 'safety.stopConfirm': 'Arrêter toute l\'automatisation maintenant ?', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index b98fb1ae93..f1e3657508 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -6888,6 +6888,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'हटाएं', 'flows.delete.deleting': 'हटाया जा रहा है…', 'flows.canvas.renameLabel': 'वर्कफ़्लो का नाम बदलें', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'आपातकालीन रोक', + 'safety.resume': 'स्वचालन पुनः प्रारंभ करें', + 'safety.haltedTitle': 'स्वचालन रोका गया', + 'safety.haltedBody': 'सभी डेस्कटॉप स्वचालन रोक दिया गया है। तैयार होने पर पुनः प्रारंभ करें।', + 'safety.stopConfirm': 'अभी सभी स्वचालन रोकें?', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 622dffcf23..ab19240436 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -6914,6 +6914,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Hapus', 'flows.delete.deleting': 'Menghapus…', 'flows.canvas.renameLabel': 'Ganti nama alur kerja', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'Hentikan darurat', + 'safety.resume': 'Lanjutkan otomasi', + 'safety.haltedTitle': 'Otomasi dihentikan', + 'safety.haltedBody': 'Semua otomasi desktop dihentikan. Lanjutkan ketika Anda siap.', + 'safety.stopConfirm': 'Hentikan semua otomasi sekarang?', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index c436e94a62..f166b2a744 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7014,6 +7014,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Elimina', 'flows.delete.deleting': 'Eliminazione…', 'flows.canvas.renameLabel': 'Rinomina flusso di lavoro', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'Arresto di emergenza', + 'safety.resume': 'Riprendi l\'automazione', + 'safety.haltedTitle': 'Automazione sospesa', + 'safety.haltedBody': 'Tutta l\'automazione del desktop è ferma. Riprendi quando sei pronto.', + 'safety.stopConfirm': 'Fermare tutta l\'automazione adesso?', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 8f004c741f..88ebe87c72 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -6812,6 +6812,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': '삭제', 'flows.delete.deleting': '삭제 중…', 'flows.canvas.renameLabel': '워크플로 이름 바꾸기', + + // Emergency stop (#4255) + 'safety.emergencyStop': '긴급 정지', + 'safety.resume': '자동화 재개', + 'safety.haltedTitle': '자동화 중단됨', + 'safety.haltedBody': '모든 데스크톱 자동화가 중지되었습니다. 준비가 되면 재개하세요.', + 'safety.stopConfirm': '지금 모든 자동화를 중지하시겠습니까?', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 5046645278..d4ab288b38 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -6989,6 +6989,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Usuń', 'flows.delete.deleting': 'Usuwanie…', 'flows.canvas.renameLabel': 'Zmień nazwę przepływu pracy', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'Awaryjne zatrzymanie', + 'safety.resume': 'Wznów automatyzację', + 'safety.haltedTitle': 'Automatyzacja wstrzymana', + 'safety.haltedBody': 'Cała automatyzacja pulpitu jest zatrzymana. Wznów, gdy będziesz gotowy.', + 'safety.stopConfirm': 'Zatrzymać całą automatyzację teraz?', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 70955a167b..a26d38bf8b 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7003,6 +7003,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Excluir', 'flows.delete.deleting': 'Excluindo…', 'flows.canvas.renameLabel': 'Renomear fluxo de trabalho', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'Parada de emergência', + 'safety.resume': 'Retomar automação', + 'safety.haltedTitle': 'Automação pausada', + 'safety.haltedBody': 'Toda a automação do desktop está parada. Retome quando estiver pronto.', + 'safety.stopConfirm': 'Parar toda a automação agora?', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 6c08997019..25d67fab94 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -6964,6 +6964,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Удалить', 'flows.delete.deleting': 'Удаление…', 'flows.canvas.renameLabel': 'Переименовать рабочий процесс', + + // Emergency stop (#4255) + 'safety.emergencyStop': 'Аварийная остановка', + 'safety.resume': 'Возобновить автоматизацию', + 'safety.haltedTitle': 'Автоматизация приостановлена', + 'safety.haltedBody': 'Вся автоматизация рабочего стола остановлена. Возобновите, когда будете готовы.', + 'safety.stopConfirm': 'Остановить всю автоматизацию сейчас?', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index d9fbe10367..eef41df671 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6515,6 +6515,13 @@ const messages: TranslationMap = { 'flows.delete.confirm': '删除', 'flows.delete.deleting': '正在删除…', 'flows.canvas.renameLabel': '重命名工作流', + + // Emergency stop (#4255) + 'safety.emergencyStop': '紧急停止', + 'safety.resume': '恢复自动化', + 'safety.haltedTitle': '自动化已暂停', + 'safety.haltedBody': '所有桌面自动化已停止。准备好后请恢复。', + 'safety.stopConfirm': '立即停止所有自动化?', }; export default messages; From e30afeae07005d2f76790d7c5774e1f79233be0e Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 16:47:50 +0530 Subject: [PATCH 10/33] docs(safety): fix Task 10 broadcast client_id=system in plan (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-07-06-emergency-stop.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-06-emergency-stop.md b/docs/superpowers/plans/2026-07-06-emergency-stop.md index 31f976f4dd..f076d74556 100644 --- a/docs/superpowers/plans/2026-07-06-emergency-stop.md +++ b/docs/superpowers/plans/2026-07-06-emergency-stop.md @@ -904,9 +904,9 @@ git commit -m "test(emergency): json-rpc e2e for stop/status/resume (#4255)" **Interfaces:** - Consumes: `DomainEvent::AutomationHalted/Resumed`; `WebChannelEvent` (read `src/core/socketio` for its fields — the approval bridge sets `event`, `client_id`, `thread_id`, `args`). -- [ ] **Step 1: Read `WebChannelEvent`** to confirm how to emit a **broadcast** (not thread-scoped) event. Emergency halt is global. If `WebChannelEvent` requires `client_id`/`thread_id`, check how the socket server treats empty values, or look for a broadcast helper. If no broadcast primitive exists, emit with empty `client_id`/`thread_id` and have the frontend socket handler (Task 14) listen for the event name globally — confirm the socket server forwards non-thread events. Document the choice in a code comment. +- [ ] **Step 1: Broadcast mechanism — CONFIRMED.** Emergency halt is global (not thread-scoped). `emit_web_channel_event` (src/core/socketio.rs:1409) delivers each event to the Socket.IO room named `event.client_id`, and **every** connected client auto-joins the `"system"` room (socketio.rs:438). So to broadcast to all clients, set `client_id: "system".to_string()` and leave `thread_id` empty — the emit code special-cases `client_id == "system"` for single-room delivery. **Do NOT use `..Default::default()` for `client_id`** (empty string → room `""` → reaches nobody). The frontend (Task 14) listens for the `automation_halt` event name globally. -- [ ] **Step 2: Add the subscriber** in `event_bus.rs` (mirror `ApprovalSurfaceSubscriber`): +- [ ] **Step 2: Add the subscriber** in `event_bus.rs` (mirror `ApprovalSurfaceSubscriber`), emitting to the `"system"` broadcast room: ```rust static AUTOMATION_HALT_HANDLE: OnceLock = OnceLock::new(); @@ -933,6 +933,7 @@ impl EventHandler for AutomationHaltSubscriber { DomainEvent::AutomationHalted { reason, source } => { publish_web_channel_event(WebChannelEvent { event: "automation_halt".to_string(), + client_id: "system".to_string(), // broadcast room — all clients auto-join it args: Some(serde_json::json!({ "engaged": true, "reason": reason, "source": source })), ..Default::default() }); @@ -940,6 +941,7 @@ impl EventHandler for AutomationHaltSubscriber { DomainEvent::AutomationResumed { source } => { publish_web_channel_event(WebChannelEvent { event: "automation_halt".to_string(), + client_id: "system".to_string(), // broadcast room — all clients auto-join it args: Some(serde_json::json!({ "engaged": false, "source": source })), ..Default::default() }); From 991dc6fd52e0f26f36b3363587339d9788c9ffb9 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 16:55:04 +0530 Subject: [PATCH 11/33] feat(emergency): stop button + halted banner + socket/boot wiring (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/App.tsx | 33 ++++++++ .../safety/AutomationHaltedBanner.test.tsx | 75 +++++++++++++++++++ .../safety/AutomationHaltedBanner.tsx | 54 +++++++++++++ .../safety/EmergencyStopButton.test.tsx | 34 +++++++++ .../components/safety/EmergencyStopButton.tsx | 41 ++++++++++ app/src/services/socketService.ts | 26 +++++++ app/src/test/test-utils.tsx | 2 + 7 files changed, 265 insertions(+) create mode 100644 app/src/components/safety/AutomationHaltedBanner.test.tsx create mode 100644 app/src/components/safety/AutomationHaltedBanner.tsx create mode 100644 app/src/components/safety/EmergencyStopButton.test.tsx create mode 100644 app/src/components/safety/EmergencyStopButton.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 1572a0e178..f1ba29c55a 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -32,6 +32,8 @@ import PttHotkeyManager from './components/PttHotkeyManager'; import SecurityBanner from './components/SecurityBanner'; import SettingsModal from './components/settings/modal/SettingsModal'; import { resolveSettingsOverlay } from './components/settings/modal/settingsOverlay'; +import { AutomationHaltedBanner } from './components/safety/AutomationHaltedBanner'; +import { EmergencyStopButton } from './components/safety/EmergencyStopButton'; import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner'; import UserErrorCenter from './components/userErrors/UserErrorCenter'; import AppWalkthrough from './components/walkthrough/AppWalkthrough'; @@ -52,6 +54,7 @@ import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider'; import SocketProvider from './providers/SocketProvider'; import ThemeProvider from './providers/ThemeProvider'; import { trackPageView } from './services/analytics'; +import { emergencyStatus } from './services/api/emergencyApi'; import { startCoreHealthMonitor, stopCoreHealthMonitor } from './services/coreHealthMonitor'; import { startInternetStatusListener, @@ -64,6 +67,7 @@ import { } from './services/webviewAccountService'; import { persistor, store } from './store'; import { setActiveAccount } from './store/accountsSlice'; +import { hydrateHalt } from './store/safetySlice'; import { useAppDispatch, useAppSelector } from './store/hooks'; import { AGENT_ACCOUNT_ID } from './utils/accountsFullscreen'; import { DEV_FORCE_ONBOARDING } from './utils/config'; @@ -260,6 +264,23 @@ 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). Wrapped in + // try/catch so a degraded core never blanks the shell. + useEffect(() => { + void (async () => { + try { + const status = await emergencyStatus(); + dispatch(hydrateHalt(status)); + } catch (err) { + console.warn('[emergency] status hydration failed', err); + } + })(); + // Intentionally runs once on mount only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const scrollRef = useRef(null); const navType = useNavigationType(); @@ -291,6 +312,9 @@ export function AppShellDesktop() { const content = (
+ {/* Automation halt banner — renders at the top of the content area when + emergency stop is engaged. Always visible during automation sessions. */} + {activeProviderAccount && !accountsOverlayOpen && ( @@ -324,6 +348,15 @@ export function AppShellDesktop() { exhaustion). Mounted outside the routes so entries survive route changes and background-job completion. */} + {/* Emergency Stop — fixed-position safety button visible during active + automation. Only shown when the shell chrome is visible (i.e. the + user is authenticated and past onboarding). Positioned bottom-right + so it never overlaps the chat composer or main content. */} + {!chromeless && ( +
+ +
+ )} {/* Hidden Remotion-driven producer for the Meet camera. Mounts a 640×480 JPEG frame stream to the Rust frame bus while a meet call is active; idle no-op otherwise. See diff --git a/app/src/components/safety/AutomationHaltedBanner.test.tsx b/app/src/components/safety/AutomationHaltedBanner.test.tsx new file mode 100644 index 0000000000..a9706fec59 --- /dev/null +++ b/app/src/components/safety/AutomationHaltedBanner.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { AutomationHaltedBanner } from './AutomationHaltedBanner'; +import { renderWithProviders } from '../../test/test-utils'; +import { setHalt } from '../../store/safetySlice'; + +const resume = vi.fn().mockResolvedValue({ engaged: false }); +vi.mock('../../services/api/emergencyApi', () => ({ emergencyResume: (...a: unknown[]) => resume(...a) })); + +beforeEach(() => resume.mockClear()); + +describe('AutomationHaltedBanner', () => { + it('renders nothing when not halted', () => { + const { container } = renderWithProviders(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the banner when halted', () => { + const { store } = renderWithProviders(, { + preloadedState: { safety: { halted: true } }, + }); + expect(screen.getByRole('alert')).toBeDefined(); + expect(screen.getByText('Automation halted')).toBeDefined(); + // safety state is engaged + expect((store.getState() as { safety: { halted: boolean } }).safety.halted).toBe(true); + }); + + it('shows reason when available', () => { + renderWithProviders(, { + preloadedState: { safety: { halted: true, reason: 'custom reason' } }, + }); + expect(screen.getByText('custom reason')).toBeDefined(); + }); + + it('falls back to haltedBody when reason is absent', () => { + renderWithProviders(, { + preloadedState: { safety: { halted: true } }, + }); + expect(screen.getByText(/desktop automation is stopped/i)).toBeDefined(); + }); + + it('calls emergencyResume and clears halt when Resume is clicked', async () => { + const { store } = renderWithProviders(, { + preloadedState: { safety: { halted: true, reason: 'test' } }, + }); + fireEvent.click(screen.getByRole('button', { name: /resume/i })); + await waitFor(() => expect(resume).toHaveBeenCalled()); + const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; + expect(safetyState.halted).toBe(false); + }); + + it('clears halt even if emergencyResume throws', async () => { + resume.mockRejectedValueOnce(new Error('core error')); + const { store } = renderWithProviders(, { + preloadedState: { safety: { halted: true } }, + }); + fireEvent.click(screen.getByRole('button', { name: /resume/i })); + await waitFor(() => { + const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; + expect(safetyState.halted).toBe(false); + }); + }); + + it('dispatches halt and then renders banner after setHalt dispatch', async () => { + const { store } = renderWithProviders(); + // Initially not halted + expect((store.getState() as { safety: { halted: boolean } }).safety.halted).toBe(false); + // Dispatch halt and let React re-render + act(() => { + store.dispatch(setHalt({ reason: 'dispatched', source: 'test' })); + }); + // Banner should appear + await waitFor(() => expect(screen.getByRole('alert')).toBeDefined()); + }); +}); diff --git a/app/src/components/safety/AutomationHaltedBanner.tsx b/app/src/components/safety/AutomationHaltedBanner.tsx new file mode 100644 index 0000000000..5f625e2aec --- /dev/null +++ b/app/src/components/safety/AutomationHaltedBanner.tsx @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { emergencyResume } from '../../services/api/emergencyApi'; +import { clearHalt, selectHalted, selectHaltReason } from '../../store/safetySlice'; + +/** + * 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 `finally` block in `onResume` ensures the UI clears the halt locally even + * if the core resume RPC fails, so the user is never stuck in a halted state + * they cannot escape from without a restart. + */ +export function AutomationHaltedBanner() { + const { t } = useT(); + const dispatch = useDispatch(); + const halted = useSelector(selectHalted); + const reason = useSelector(selectHaltReason); + + const onResume = useCallback(async () => { + try { + await emergencyResume(); + } catch (err) { + console.error('[emergency] resume failed', err); + } finally { + dispatch(clearHalt()); + } + }, [dispatch]); + + if (!halted) return null; + + return ( +
+
+ {t('safety.haltedTitle')} + + {reason ?? t('safety.haltedBody')} + +
+ +
+ ); +} diff --git a/app/src/components/safety/EmergencyStopButton.test.tsx b/app/src/components/safety/EmergencyStopButton.test.tsx new file mode 100644 index 0000000000..d0bebd87b1 --- /dev/null +++ b/app/src/components/safety/EmergencyStopButton.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { EmergencyStopButton } from './EmergencyStopButton'; +import { renderWithProviders } from '../../test/test-utils'; + +const stop = vi.fn().mockResolvedValue({ engaged: true, reason: undefined, source: undefined, engaged_at_ms: undefined }); +vi.mock('../../services/api/emergencyApi', () => ({ emergencyStop: (...a: unknown[]) => stop(...a) })); + +beforeEach(() => stop.mockClear()); + +describe('EmergencyStopButton', () => { + it('renders a button with the emergency stop label', () => { + renderWithProviders(); + expect(screen.getByRole('button', { name: /emergency stop/i })).toBeDefined(); + }); + + it('calls emergencyStop and dispatches halt on click', async () => { + const { store } = renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); + await waitFor(() => expect(stop).toHaveBeenCalled()); + const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; + expect(safetyState.halted).toBe(true); + }); + + it('dispatches halt locally if emergencyStop throws', async () => { + stop.mockRejectedValueOnce(new Error('core unavailable')); + const { store } = renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); + await waitFor(() => { + const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; + expect(safetyState.halted).toBe(true); + }); + }); +}); diff --git a/app/src/components/safety/EmergencyStopButton.tsx b/app/src/components/safety/EmergencyStopButton.tsx new file mode 100644 index 0000000000..9e0146a73f --- /dev/null +++ b/app/src/components/safety/EmergencyStopButton.tsx @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { emergencyStop } from '../../services/api/emergencyApi'; +import { setHalt } from '../../store/safetySlice'; + +/** + * 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. If the RPC fails the halt is + * committed locally anyway so the user always sees a response to their action. + */ +export function EmergencyStopButton() { + const { t } = useT(); + const dispatch = useDispatch(); + + const onClick = useCallback(async () => { + try { + const state = await emergencyStop('user'); + dispatch( + setHalt({ reason: state.reason, source: state.source, since: state.engaged_at_ms }) + ); + } catch (err) { + // Fail-visible: reflect intent locally even when the core is unreachable. + dispatch(setHalt({ reason: 'user', source: 'user' })); + console.error('[emergency] stop failed', err); + } + }, [dispatch]); + + return ( + + ); +} diff --git a/app/src/services/socketService.ts b/app/src/services/socketService.ts index 4097563bae..1f28915e82 100644 --- a/app/src/services/socketService.ts +++ b/app/src/services/socketService.ts @@ -17,6 +17,7 @@ import { import { upsertChannelConnection } from '../store/channelConnectionsSlice'; import { type CompanionStateChangedEvent, setCompanionState } from '../store/companionSlice'; import { setBackend } from '../store/connectivitySlice'; +import { clearHalt, setHalt } from '../store/safetySlice'; import { resetForUser, setSocketIdForUser, setStatusForUser } from '../store/socketSlice'; import type { ChannelAuthMode, ChannelConnectionStatus, ChannelType } from '../types/channels'; import { IS_DEV } from '../utils/config'; @@ -463,6 +464,31 @@ class SocketService { }); }); + // Automation halt/resume broadcasts — core publishes this when emergency_stop + // or emergency_resume is called from any client (UI, CLI, cron) so all + // connected surfaces reflect the halt state without polling. + this.socket.on('automation_halt', (data: unknown) => { + const obj = data as Record | null; + if (!obj || typeof obj !== 'object') { + socketWarn('automation_halt dropped — invalid payload shape'); + return; + } + const engaged = typeof obj.engaged === 'boolean' ? obj.engaged : false; + const reason = typeof obj.reason === 'string' ? obj.reason : undefined; + const source = typeof obj.source === 'string' ? obj.source : undefined; + socketLog( + 'automation_halt engaged=%s reason=%s source=%s', + engaged, + reason ?? 'none', + source ?? 'none' + ); + if (engaged) { + store.dispatch(setHalt({ reason, source })); + } else { + store.dispatch(clearHalt()); + } + }); + // Backend Meet bot events — forwarded from core's DomainEvent bus this.socket.on('agent_meetings:joined', (data: unknown) => { const obj = data as Record | null; diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx index beda189643..b3667becf5 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -25,6 +25,7 @@ import mascotReducer from '../store/mascotSlice'; import notificationReducer from '../store/notificationSlice'; import personaReducer from '../store/personaSlice'; import { pttReducer } from '../store/pttSlice'; +import safetyReducer from '../store/safetySlice'; import socketReducer from '../store/socketSlice'; import themeReducer from '../store/themeSlice'; import threadReducer from '../store/threadSlice'; @@ -54,6 +55,7 @@ const testRootReducer = combineReducers({ notifications: notificationReducer, persona: personaReducer, ptt: pttReducer, + safety: safetyReducer, socket: socketReducer, theme: themeReducer, thread: threadReducer, From 171019477a9b5f69881c6f59f877874745c4e588 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 17:02:22 +0530 Subject: [PATCH 12/33] feat(emergency): halt-state types + global switch singleton (#4255) Add HaltState serde type and EmergencyStop OnceLock singleton mirroring the ApprovalGate install pattern. Types: engaged flag, reason, timestamp, source. State: init_global/try_global, engage/clear/snapshot, is_engaged_global. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/openhuman/emergency_stop/mod.rs | 16 +++ src/openhuman/emergency_stop/ops.rs | 142 +++++++++++++++++++++++ src/openhuman/emergency_stop/schemas.rs | 127 +++++++++++++++++++++ src/openhuman/emergency_stop/state.rs | 145 ++++++++++++++++++++++++ src/openhuman/emergency_stop/types.rs | 51 +++++++++ src/openhuman/mod.rs | 1 + 6 files changed, 482 insertions(+) create mode 100644 src/openhuman/emergency_stop/mod.rs create mode 100644 src/openhuman/emergency_stop/ops.rs create mode 100644 src/openhuman/emergency_stop/schemas.rs create mode 100644 src/openhuman/emergency_stop/state.rs create mode 100644 src/openhuman/emergency_stop/types.rs diff --git a/src/openhuman/emergency_stop/mod.rs b/src/openhuman/emergency_stop/mod.rs new file mode 100644 index 0000000000..b4d3c5e9fe --- /dev/null +++ b/src/openhuman/emergency_stop/mod.rs @@ -0,0 +1,16 @@ +//! Emergency stop — a fail-closed kill switch for desktop automation. +//! +//! `EmergencyStop` is a process-global switch (mirrors `ApprovalGate`). When +//! engaged, the tinyagents approval middleware refuses external-effect tool +//! calls and `accessibility_input_action` refuses clicks/typing, until the +//! user resumes. Engaging also stops the accessibility session and +//! cascade-denies pending approvals. In-memory only (resets on restart). + +pub mod ops; +pub mod schemas; +pub mod state; +pub mod types; + +pub use schemas::{all_emergency_controller_schemas, all_emergency_registered_controllers}; +pub use state::{is_engaged_global, EmergencyStop}; +pub use types::HaltState; diff --git a/src/openhuman/emergency_stop/ops.rs b/src/openhuman/emergency_stop/ops.rs new file mode 100644 index 0000000000..86980b5b7f --- /dev/null +++ b/src/openhuman/emergency_stop/ops.rs @@ -0,0 +1,142 @@ +//! Emergency-stop RPC operations: engage / resume / read the switch, plus the +//! best-effort side effects (stop the a11y session, cascade-deny pending +//! approvals) and event publication. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::core::event_bus::{publish_global, DomainEvent}; +use crate::rpc::RpcOutcome; + +use super::state::EmergencyStop; +use super::types::HaltState; + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Engage the kill switch: set the flag, then best-effort stop the a11y +/// session and cascade-deny pending approvals, then publish `AutomationHalted`. +/// Idempotent. Side-effect failures are logged but never fail the RPC — the +/// primary invariant (flag set → actions blocked) does not depend on them. +pub async fn emergency_stop(reason: Option, source: &str) -> RpcOutcome { + tracing::warn!(source, reason = ?reason, "[rpc:emergency_stop] entry — engaging kill switch"); + let stop = EmergencyStop::init_global(); + stop.engage(reason.clone(), source, now_ms()); + + // Best-effort: stop the accessibility session so any in-flight click/type loop halts. + let a11y = crate::openhuman::screen_intelligence::global_engine() + .disable(Some("emergency_stop".to_string())) + .await; + tracing::info!( + active = a11y.active, + "[emergency] accessibility session stopped" + ); + + // Best-effort: cascade-deny every pending approval so parked tool calls fail closed. + let denied = cascade_deny_pending(); + tracing::info!(denied, "[emergency] cascade-denied pending approvals"); + + publish_global(DomainEvent::AutomationHalted { + reason, + source: source.to_string(), + }); + + let snap = stop.snapshot(); + RpcOutcome::single_log( + snap, + format!("[emergency] halted (source={source}, denied={denied})"), + ) +} + +/// Deny all pending approvals. Returns how many were denied. Best-effort: +/// a per-row error is logged and skipped. +fn cascade_deny_pending() -> usize { + use crate::openhuman::approval::{ApprovalDecision, ApprovalGate}; + let Some(gate) = ApprovalGate::try_global() else { + return 0; + }; + let rows = match gate.list_pending() { + Ok(rows) => rows, + Err(err) => { + tracing::warn!(error = %err, "[emergency] list_pending failed during cascade-deny"); + return 0; + } + }; + let mut denied = 0; + for row in rows { + match gate.decide(&row.request_id, ApprovalDecision::Deny) { + Ok(_) => denied += 1, + Err(err) => { + tracing::warn!(request_id = %row.request_id, error = %err, "[emergency] deny failed") + } + } + } + denied +} + +/// Clear the kill switch and publish `AutomationResumed`. Idempotent. +pub async fn emergency_resume(source: &str) -> RpcOutcome { + tracing::info!( + source, + "[rpc:emergency_resume] entry — clearing kill switch" + ); + let stop = EmergencyStop::init_global(); + stop.clear(); + publish_global(DomainEvent::AutomationResumed { + source: source.to_string(), + }); + RpcOutcome::single_log( + stop.snapshot(), + format!("[emergency] resumed (source={source})"), + ) +} + +/// Read the current switch state. +pub async fn emergency_status() -> RpcOutcome { + let snap = EmergencyStop::try_global() + .map(|s| s.snapshot()) + .unwrap_or_default(); + tracing::debug!(engaged = snap.engaged, "[rpc:emergency_status] exit"); + RpcOutcome::new(snap, vec![]) +} + +#[cfg(test)] +mod tests { + use super::*; + + static TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[tokio::test] + async fn stop_sets_flag_and_status_reports_engaged() { + let _g = TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + 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 = TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + 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 = TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + let _ = emergency_stop(Some("a".into()), "user").await; + let out = emergency_stop(Some("b".into()), "system").await; + assert!(out.value.engaged); + assert_eq!(out.value.reason.as_deref(), Some("b")); + let _ = emergency_resume("user").await; + } +} diff --git a/src/openhuman/emergency_stop/schemas.rs b/src/openhuman/emergency_stop/schemas.rs new file mode 100644 index 0000000000..6493baa540 --- /dev/null +++ b/src/openhuman/emergency_stop/schemas.rs @@ -0,0 +1,127 @@ +//! Controller schemas + handlers for the `emergency` namespace. +//! Wires `emergency_stop`, `emergency_resume`, `emergency_status` into the +//! global registry consumed by `src/core/all.rs`. + +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + +use super::ops; + +pub fn all_emergency_controller_schemas() -> Vec { + vec![schemas("stop"), schemas("resume"), schemas("status")] +} + +pub fn all_emergency_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: schemas("stop"), + handler: handle_stop, + }, + RegisteredController { + schema: schemas("resume"), + handler: handle_resume, + }, + RegisteredController { + schema: schemas("status"), + handler: handle_status, + }, + ] +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "stop" => ControllerSchema { + namespace: "emergency", + function: "stop", + description: "Engage the emergency stop: halt all desktop automation and block further actions until resumed.", + inputs: vec![FieldSchema { + name: "reason", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional human-readable reason for the halt.", + required: false, + }], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Switch snapshot after engaging.", + required: true, + }], + }, + "resume" => ControllerSchema { + namespace: "emergency", + function: "resume", + description: "Clear the emergency stop so automation may resume.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Switch snapshot after clearing.", + required: true, + }], + }, + "status" => ControllerSchema { + namespace: "emergency", + function: "status", + description: "Read the current emergency-stop switch state.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("HaltState"), + comment: "Current switch snapshot.", + required: true, + }], + }, + _ => ControllerSchema { + namespace: "emergency", + function: "unknown", + description: "Unknown emergency function.", + inputs: vec![], + outputs: vec![FieldSchema { name: "error", ty: TypeSchema::String, comment: "Schema not defined.", required: true }], + }, + } +} + +fn handle_stop(params: Map) -> ControllerFuture { + Box::pin(async move { + let reason = match params.get("reason") { + Some(Value::String(s)) => Some(s.clone()), + _ => None, + }; + to_json(ops::emergency_stop(reason, "user").await) + }) +} + +fn handle_resume(_params: Map) -> ControllerFuture { + Box::pin(async move { to_json(ops::emergency_resume("user").await) }) +} + +fn handle_status(_params: Map) -> ControllerFuture { + Box::pin(async move { to_json(ops::emergency_status().await) }) +} + +fn to_json(outcome: crate::rpc::RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registered_controllers_match_schemas() { + let c = all_emergency_registered_controllers(); + assert_eq!(c.len(), 3); + let names: Vec<_> = c.iter().map(|c| c.schema.function).collect(); + assert_eq!(names, vec!["stop", "resume", "status"]); + } + + #[test] + fn stop_schema_has_optional_reason() { + let s = schemas("stop"); + assert_eq!(s.namespace, "emergency"); + assert_eq!(s.inputs[0].name, "reason"); + assert!(!s.inputs[0].required); + } +} diff --git a/src/openhuman/emergency_stop/state.rs b/src/openhuman/emergency_stop/state.rs new file mode 100644 index 0000000000..46cd9d5edd --- /dev/null +++ b/src/openhuman/emergency_stop/state.rs @@ -0,0 +1,145 @@ +//! Process-global emergency-stop switch. Mirrors the `ApprovalGate` +//! `OnceLock` install pattern: `init_global` is idempotent, `try_global` +//! returns `None` when never installed (CLI/headless → never blocks). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use super::types::HaltState; + +static GLOBAL_STOP: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +struct HaltInfo { + reason: Option, + engaged_at_ms: u64, + source: String, +} + +/// Coordinator for the emergency-stop kill switch. +#[derive(Debug)] +pub struct EmergencyStop { + engaged: AtomicBool, + info: Mutex>, +} + +impl EmergencyStop { + /// Install the process-global switch. Idempotent — re-install returns the + /// existing switch so repeated boots in tests don't panic. + pub fn init_global() -> Arc { + if let Some(existing) = GLOBAL_STOP.get() { + return existing.clone(); + } + let stop = Arc::new(EmergencyStop { + engaged: AtomicBool::new(false), + info: Mutex::new(None), + }); + let _ = GLOBAL_STOP.set(stop.clone()); + GLOBAL_STOP.get().cloned().unwrap_or(stop) + } + + /// The global switch when installed; `None` means "no switch" → callers + /// treat as not-engaged (never block). + pub fn try_global() -> Option> { + GLOBAL_STOP.get().cloned() + } + + /// Whether automation is currently halted. + pub fn is_engaged(&self) -> bool { + self.engaged.load(Ordering::SeqCst) + } + + /// Engage the halt. Idempotent — re-engaging refreshes reason/source/time. + pub fn engage(&self, reason: Option, source: &str, now_ms: u64) { + { + let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(HaltInfo { + reason, + engaged_at_ms: now_ms, + source: source.to_string(), + }); + } + self.engaged.store(true, Ordering::SeqCst); + } + + /// Clear the halt. Idempotent. + pub fn clear(&self) { + self.engaged.store(false, Ordering::SeqCst); + let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + } + + /// Current snapshot for RPC/UI. + pub fn snapshot(&self) -> HaltState { + if !self.is_engaged() { + return HaltState::default(); + } + let guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + match guard.as_ref() { + Some(info) => HaltState { + engaged: true, + reason: info.reason.clone(), + engaged_at_ms: Some(info.engaged_at_ms), + source: Some(info.source.clone()), + }, + None => HaltState { + engaged: true, + ..Default::default() + }, + } + } +} + +/// Global convenience: is a switch installed AND engaged? False when no +/// switch is installed (CLI/headless) so those paths are never blocked. +pub fn is_engaged_global() -> bool { + EmergencyStop::try_global() + .map(|s| s.is_engaged()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn engage_then_snapshot_reports_engaged() { + let stop = EmergencyStop { + engaged: AtomicBool::new(false), + info: Mutex::new(None), + }; + assert!(!stop.is_engaged()); + stop.engage(Some("user".into()), "user", 1234); + assert!(stop.is_engaged()); + let snap = stop.snapshot(); + assert!(snap.engaged); + assert_eq!(snap.reason.as_deref(), Some("user")); + assert_eq!(snap.engaged_at_ms, Some(1234)); + assert_eq!(snap.source.as_deref(), Some("user")); + } + + #[test] + fn clear_resets_to_default_snapshot() { + let stop = EmergencyStop { + engaged: AtomicBool::new(false), + info: Mutex::new(None), + }; + stop.engage(None, "hotkey", 1); + stop.clear(); + assert!(!stop.is_engaged()); + assert_eq!(stop.snapshot(), HaltState::default()); + } + + #[test] + fn engage_is_idempotent_and_refreshes() { + let stop = EmergencyStop { + engaged: AtomicBool::new(false), + info: Mutex::new(None), + }; + stop.engage(Some("a".into()), "user", 1); + stop.engage(Some("b".into()), "system", 2); + assert!(stop.is_engaged()); + assert_eq!(stop.snapshot().reason.as_deref(), Some("b")); + assert_eq!(stop.snapshot().source.as_deref(), Some("system")); + } +} diff --git a/src/openhuman/emergency_stop/types.rs b/src/openhuman/emergency_stop/types.rs new file mode 100644 index 0000000000..f83d179355 --- /dev/null +++ b/src/openhuman/emergency_stop/types.rs @@ -0,0 +1,51 @@ +//! Serde domain types for the emergency-stop kill switch. + +use serde::{Deserialize, Serialize}; + +/// Snapshot of the emergency-stop switch, returned by every emergency RPC and +/// surfaced in the UI. `engaged == false` is the resting state. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct HaltState { + /// Whether automation is currently halted. + pub engaged: bool, + /// Human-readable reason for the halt (redacted of PII), when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Unix-epoch milliseconds when the halt was engaged, when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub engaged_at_ms: Option, + /// Who engaged it: `"user"`, `"hotkey"`, or `"system"`, when engaged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_halt_state_is_not_engaged() { + let s = HaltState::default(); + assert!(!s.engaged); + assert!(s.reason.is_none()); + assert!(s.engaged_at_ms.is_none()); + } + + #[test] + fn resting_state_serializes_to_engaged_false_only() { + let json = serde_json::to_string(&HaltState::default()).unwrap(); + assert_eq!(json, r#"{"engaged":false}"#); + } + + #[test] + fn engaged_state_roundtrips() { + let s = HaltState { + engaged: true, + reason: Some("user".into()), + engaged_at_ms: Some(42), + source: Some("user".into()), + }; + let back: HaltState = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(s, back); + } +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index c89f88ff85..6d8f095532 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -48,6 +48,7 @@ pub mod dev_paths; pub mod devices; pub mod doctor; pub mod embeddings; +pub mod emergency_stop; pub mod encryption; pub mod file_state; pub mod file_storage; From c7a78bfd3c2d6605ec2e8991fe552d099920a66b Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 17:03:10 +0530 Subject: [PATCH 13/33] feat(emergency): RPC controllers (stop/resume/status) + boot install (#4255) Register all_emergency_registered_controllers and all_emergency_controller_schemas in src/core/all.rs (next to approval). Install EmergencyStop::init_global() in jsonrpc.rs boot sequence next to ApprovalGate::init_global. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/all.rs | 3 +++ src/core/jsonrpc.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/src/core/all.rs b/src/core/all.rs index 8644d0ba30..9278612330 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -158,6 +158,8 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::security::all_security_registered_controllers()); // Interactive approval workflow (#1339 — gate external-effect tool calls) controllers.extend(crate::openhuman::approval::all_approval_registered_controllers()); + // Emergency stop kill switch (#4255 — fail-closed halt for desktop automation) + controllers.extend(crate::openhuman::emergency_stop::all_emergency_registered_controllers()); // Interactive plan-review gate — parks a live turn on a thread-scoped plan controllers.extend(crate::openhuman::plan_review::all_plan_review_registered_controllers()); // Agent-generated artifact storage, retrieval, and lifecycle management @@ -399,6 +401,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::keyring_consent::all_keyring_consent_controller_schemas()); schemas.extend(crate::openhuman::security::all_security_controller_schemas()); schemas.extend(crate::openhuman::approval::all_approval_controller_schemas()); + schemas.extend(crate::openhuman::emergency_stop::all_emergency_controller_schemas()); schemas.extend(crate::openhuman::plan_review::all_plan_review_controller_schemas()); schemas.extend(crate::openhuman::artifacts::all_artifacts_controller_schemas()); schemas.extend(crate::openhuman::heartbeat::all_heartbeat_controller_schemas()); diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index e8da0d2010..61fbad7366 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2670,6 +2670,7 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) { 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" From 9d1b970ca9f910f555ab5bf416da72c46005ea5d Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 17:22:32 +0530 Subject: [PATCH 14/33] feat(emergency): approval middleware refuses external-effect tools while halted (#4255) Also introduces a shared crate-visible EMERGENCY_TEST_GUARD in state.rs so all tests touching the process-global EmergencyStop (ops, middleware, screen intelligence) serialize against the same mutex under parallel execution. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/openhuman/emergency_stop/ops.rs | 15 ++++++++---- src/openhuman/emergency_stop/state.rs | 8 +++++++ src/openhuman/tinyagents/middleware.rs | 32 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/openhuman/emergency_stop/ops.rs b/src/openhuman/emergency_stop/ops.rs index 86980b5b7f..cb7ff21cba 100644 --- a/src/openhuman/emergency_stop/ops.rs +++ b/src/openhuman/emergency_stop/ops.rs @@ -106,12 +106,13 @@ pub async fn emergency_status() -> RpcOutcome { #[cfg(test)] mod tests { use super::*; - - static TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); + use crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD; #[tokio::test] async fn stop_sets_flag_and_status_reports_engaged() { - let _g = TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + let _g = EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); let out = emergency_stop(Some("user".into()), "user").await; assert!(out.value.engaged); let status = emergency_status().await; @@ -123,7 +124,9 @@ mod tests { #[tokio::test] async fn resume_clears_flag() { - let _g = TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + let _g = EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); let _ = emergency_stop(None, "user").await; let out = emergency_resume("user").await; assert!(!out.value.engaged); @@ -132,7 +135,9 @@ mod tests { #[tokio::test] async fn stop_is_idempotent() { - let _g = TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + let _g = EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); let _ = emergency_stop(Some("a".into()), "user").await; let out = emergency_stop(Some("b".into()), "system").await; assert!(out.value.engaged); diff --git a/src/openhuman/emergency_stop/state.rs b/src/openhuman/emergency_stop/state.rs index 46cd9d5edd..8ad646f72f 100644 --- a/src/openhuman/emergency_stop/state.rs +++ b/src/openhuman/emergency_stop/state.rs @@ -90,6 +90,14 @@ impl EmergencyStop { } } +/// 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(()); + /// 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 { diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index 2ee16b12d7..6b47f2b366 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -942,6 +942,20 @@ impl ToolMiddleware<()> for ApprovalSecurityMiddleware { // approval await. let mut audit_id: Option = None; if self.has_external_effect(&call.name, &call.arguments) { + // Emergency stop: refuse every external-effect tool while halted, + // before touching the approval gate. Fail-closed. + if crate::openhuman::emergency_stop::is_engaged_global() { + let reason = "Emergency stop is engaged — this action is blocked until you resume automation.".to_string(); + tracing::warn!(tool = %call.name, "[tinyagents::mw] emergency stop engaged — refusing tool call"); + return Ok(MiddlewareToolOutcome::Result(TaToolResult { + call_id: call.id, + name: call.name, + content: reason.clone(), + raw: None, + error: Some(reason), + elapsed_ms: 0, + })); + } if let Some(gate) = ApprovalGate::try_global() { let summary = summarize_action(&call.name, &call.arguments); let redacted = redact_args(&call.arguments); @@ -3913,6 +3927,24 @@ 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::EmergencyStop; + let stop = EmergencyStop::init_global(); + stop.clear(); + assert!(!crate::openhuman::emergency_stop::is_engaged_global()); + stop.engage(Some("test".into()), "user", 0); + assert!(crate::openhuman::emergency_stop::is_engaged_global()); + stop.clear(); + } + // ── MemoryProtocolMiddleware (issue #4116) ────────────────────────────── use crate::openhuman::agent::harness::memory_protocol::MEMORY_PROTOCOL_MARKER; From a375da82b8dead8e461a1bc6f3384270f9d93b6b Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 17:56:49 +0530 Subject: [PATCH 15/33] feat(emergency): accessibility_input_action refuses input while halted (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/openhuman/screen_intelligence/ops.rs | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/openhuman/screen_intelligence/ops.rs b/src/openhuman/screen_intelligence/ops.rs index 4608e8b00b..66ec73e29b 100644 --- a/src/openhuman/screen_intelligence/ops.rs +++ b/src/openhuman/screen_intelligence/ops.rs @@ -152,6 +152,19 @@ pub async fn accessibility_capture_image_ref() -> Result Result, String> { + // Emergency stop: refuse desktop input while halted. `panic_stop` is + // exempt so a stop is never blocked by a stop. + if payload.action != "panic_stop" && crate::openhuman::emergency_stop::is_engaged_global() { + tracing::warn!(action = %payload.action, "[emergency] accessibility_input_action blocked — kill switch engaged"); + return Ok(RpcOutcome::single_log( + InputActionResult { + accepted: false, + blocked: true, + reason: Some("emergency_stop".to_string()), + }, + "screen intelligence input blocked by emergency stop", + )); + } let result = screen_intelligence::global_engine() .input_action(payload) .await?; @@ -307,4 +320,57 @@ 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::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(); + stop.engage(Some("test".into()), "user", 0); + let params = InputActionParams { + action: "click".into(), + x: Some(1), + y: Some(1), + button: None, + text: None, + key: None, + modifiers: None, + }; + let out = accessibility_input_action(params).await.unwrap(); + assert!(!out.value.accepted); + assert!(out.value.blocked); + assert_eq!(out.value.reason.as_deref(), Some("emergency_stop")); + stop.clear(); + } + + #[tokio::test] + async fn panic_stop_passes_even_while_emergency_engaged() { + use crate::openhuman::emergency_stop::state::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(); + 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")); + } + stop.clear(); + } } From 995b83716b80ebd319a92a0a8b6ff8e2939e7360 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 18:24:54 +0530 Subject: [PATCH 16/33] feat(emergency): bridge halt/resume events to automation_halt socket event (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../channels/providers/web/event_bus.rs | 90 +++++++++++++++++++ src/openhuman/channels/providers/web/mod.rs | 3 +- src/openhuman/channels/runtime/startup.rs | 5 ++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/openhuman/channels/providers/web/event_bus.rs b/src/openhuman/channels/providers/web/event_bus.rs index 0cae90319b..df5402bbc2 100644 --- a/src/openhuman/channels/providers/web/event_bus.rs +++ b/src/openhuman/channels/providers/web/event_bus.rs @@ -40,6 +40,30 @@ pub fn register_approval_surface_subscriber() { } } +static AUTOMATION_HALT_HANDLE: OnceLock = OnceLock::new(); + +/// Register the emergency-stop bridge: `AutomationHalted`/`AutomationResumed` +/// (domain `system`) → the `automation_halt` socket event, broadcast to every +/// client via the `"system"` room. Idempotent (OnceLock-guarded). +pub fn register_automation_halt_subscriber() { + if AUTOMATION_HALT_HANDLE.get().is_some() { + return; + } + match crate::core::event_bus::subscribe_global(Arc::new(AutomationHaltSubscriber)) { + Some(handle) => { + let _ = AUTOMATION_HALT_HANDLE.set(handle); + log::info!( + "[web-channel] automation-halt subscriber registered (domain=system) — bridges AutomationHalted/AutomationResumed → automation_halt socket event" + ); + } + None => { + log::warn!( + "[web-channel] failed to register automation-halt subscriber — bus not initialized" + ); + } + } +} + static ARTIFACT_SURFACE_HANDLE: OnceLock = OnceLock::new(); pub fn register_artifact_surface_subscriber() { @@ -294,6 +318,56 @@ impl EventHandler for ApprovalSurfaceSubscriber { } } +struct AutomationHaltSubscriber; + +#[async_trait] +impl EventHandler for AutomationHaltSubscriber { + fn name(&self) -> &str { + "channels::web::automation_halt" + } + + fn domains(&self) -> Option<&[&str]> { + Some(&["system"]) + } + + async fn handle(&self, event: &DomainEvent) { + match event { + DomainEvent::AutomationHalted { reason, source } => { + log::info!( + "[web-channel] automation-halt emitting automation_halt engaged=true source={source}" + ); + publish_web_channel_event(WebChannelEvent { + event: "automation_halt".to_string(), + // Broadcast room: every connected client auto-joins "system". + client_id: "system".to_string(), + args: Some(serde_json::json!({ + "engaged": true, + "reason": reason, + "source": source, + })), + ..Default::default() + }); + } + DomainEvent::AutomationResumed { source } => { + log::info!( + "[web-channel] automation-halt emitting automation_halt engaged=false source={source}" + ); + publish_web_channel_event(WebChannelEvent { + event: "automation_halt".to_string(), + // Broadcast room: every connected client auto-joins "system". + client_id: "system".to_string(), + args: Some(serde_json::json!({ + "engaged": false, + "source": source, + })), + ..Default::default() + }); + } + _ => {} + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -328,4 +402,20 @@ mod tests { drop(h1); drop(h2); } + + /// `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()); + } } diff --git a/src/openhuman/channels/providers/web/mod.rs b/src/openhuman/channels/providers/web/mod.rs index 194caea514..9f16ccbec3 100644 --- a/src/openhuman/channels/providers/web/mod.rs +++ b/src/openhuman/channels/providers/web/mod.rs @@ -26,7 +26,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, subscribe_web_channel_events, + register_artifact_surface_subscriber, register_automation_halt_subscriber, + subscribe_web_channel_events, }; // Test-only: OnceLock-bypassing approval bridge for per-runtime integration tests. diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index d30d791437..efe2cbcf76 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -171,6 +171,11 @@ pub async fn start_channels(mut config: Config) -> Result<()> { // ArtifactFailed) as `artifact_ready` / `artifact_failed` web-channel // events so the frontend ArtifactCard can render in chat (#2779). crate::openhuman::channels::providers::web::register_artifact_surface_subscriber(); + // Bridge emergency-stop halt/resume (AutomationHalted / AutomationResumed) + // to the `automation_halt` web-channel socket event, broadcast to every + // client via the "system" room, so the frontend kill-switch UI updates + // globally (#4255). + crate::openhuman::channels::providers::web::register_automation_halt_subscriber(); // Spawn the per-toolkit provider periodic sync scheduler. This is // a thin tokio task that ticks every minute and dispatches into // any provider whose `sync_interval_secs` has elapsed for an From e3f8ac1407aca7d2c4b3dd8fedd1368afa166dc9 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 18:40:27 +0530 Subject: [PATCH 17/33] test(emergency): json-rpc e2e for stop/status/resume (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/json_rpc_e2e.rs | 68 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 773b65c93d..7c192f0588 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1192,6 +1192,74 @@ 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(); + 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(); From 66ae7657069b08a9fea36987a4b330dc09d8a7e2 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Mon, 6 Jul 2026 19:33:38 +0530 Subject: [PATCH 18/33] fix(emergency): friendly halt reason + socket/hydration tests + cleanup (#4255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EmergencyStopButton: call emergencyStop() with no argument so the halt reason stays undefined and the banner falls back to safety.haltedBody instead of displaying the literal string "user". Error-path setHalt no longer hardcodes reason: 'user'. onClick renamed to handleClick and wrapped in void to avoid passing an async fn as the event handler. - socketService: add automation_halt handler tests (engaged=true → setHalt, engaged=false → clearHalt, malformed payload → silent drop). - Extracted hydrateEmergencyState helper from AppShellDesktop boot effect for testability; App effect is now a one-liner. Tests cover success, not-engaged, and thrown emergencyStatus paths. - Removed unused safety.stopConfirm key from en.ts and all 13 locale files; pnpm i18n:check exits 0 with no missing or extra keys. - AutomationHaltedBanner.test: replaced brittle getByText('Automation halted') with role+data-analytics-id assertion. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/App.tsx | 16 +--- .../safety/AutomationHaltedBanner.test.tsx | 2 +- .../safety/EmergencyStopButton.test.tsx | 4 +- .../components/safety/EmergencyStopButton.tsx | 8 +- app/src/lib/i18n/ar.ts | 2 +- app/src/lib/i18n/bn.ts | 2 +- app/src/lib/i18n/de.ts | 2 +- app/src/lib/i18n/en.ts | 2 +- app/src/lib/i18n/es.ts | 2 +- app/src/lib/i18n/fr.ts | 2 +- app/src/lib/i18n/hi.ts | 2 +- app/src/lib/i18n/id.ts | 2 +- app/src/lib/i18n/it.ts | 2 +- app/src/lib/i18n/ko.ts | 2 +- app/src/lib/i18n/pl.ts | 2 +- app/src/lib/i18n/pt.ts | 2 +- app/src/lib/i18n/ru.ts | 2 +- app/src/lib/i18n/zh-CN.ts | 2 +- .../__tests__/socketService.events.test.ts | 86 ++++++++++++++++++ .../safety/hydrateEmergencyState.test.ts | 49 +++++++++++ .../services/safety/hydrateEmergencyState.ts | 21 +++++ src/core/event_bus/events_tests.rs | 9 +- .../channels/providers/web/event_bus.rs | 87 +++++++++++++++++++ src/openhuman/emergency_stop/ops.rs | 85 ++++++++++++++++++ 24 files changed, 360 insertions(+), 35 deletions(-) create mode 100644 app/src/services/safety/hydrateEmergencyState.test.ts create mode 100644 app/src/services/safety/hydrateEmergencyState.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index f1ba29c55a..f8ef9dfb7f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -54,7 +54,7 @@ import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider'; import SocketProvider from './providers/SocketProvider'; import ThemeProvider from './providers/ThemeProvider'; import { trackPageView } from './services/analytics'; -import { emergencyStatus } from './services/api/emergencyApi'; +import { hydrateEmergencyState } from './services/safety/hydrateEmergencyState'; import { startCoreHealthMonitor, stopCoreHealthMonitor } from './services/coreHealthMonitor'; import { startInternetStatusListener, @@ -67,7 +67,6 @@ import { } from './services/webviewAccountService'; import { persistor, store } from './store'; import { setActiveAccount } from './store/accountsSlice'; -import { hydrateHalt } from './store/safetySlice'; import { useAppDispatch, useAppSelector } from './store/hooks'; import { AGENT_ACCOUNT_ID } from './utils/accountsFullscreen'; import { DEV_FORCE_ONBOARDING } from './utils/config'; @@ -266,17 +265,10 @@ export function AppShellDesktop() { // 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). Wrapped in - // try/catch so a degraded core never blanks the shell. + // 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 (async () => { - try { - const status = await emergencyStatus(); - dispatch(hydrateHalt(status)); - } catch (err) { - console.warn('[emergency] status hydration failed', err); - } - })(); + void hydrateEmergencyState(dispatch); // Intentionally runs once on mount only. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/app/src/components/safety/AutomationHaltedBanner.test.tsx b/app/src/components/safety/AutomationHaltedBanner.test.tsx index a9706fec59..6ade581cc2 100644 --- a/app/src/components/safety/AutomationHaltedBanner.test.tsx +++ b/app/src/components/safety/AutomationHaltedBanner.test.tsx @@ -20,7 +20,7 @@ describe('AutomationHaltedBanner', () => { preloadedState: { safety: { halted: true } }, }); expect(screen.getByRole('alert')).toBeDefined(); - expect(screen.getByText('Automation halted')).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); }); diff --git a/app/src/components/safety/EmergencyStopButton.test.tsx b/app/src/components/safety/EmergencyStopButton.test.tsx index d0bebd87b1..4a8205fd8c 100644 --- a/app/src/components/safety/EmergencyStopButton.test.tsx +++ b/app/src/components/safety/EmergencyStopButton.test.tsx @@ -14,10 +14,10 @@ describe('EmergencyStopButton', () => { expect(screen.getByRole('button', { name: /emergency stop/i })).toBeDefined(); }); - it('calls emergencyStop and dispatches halt on click', async () => { + it('calls emergencyStop with no argument and dispatches halt on click', async () => { const { store } = renderWithProviders(); fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); - await waitFor(() => expect(stop).toHaveBeenCalled()); + await waitFor(() => expect(stop).toHaveBeenCalledWith()); const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; expect(safetyState.halted).toBe(true); }); diff --git a/app/src/components/safety/EmergencyStopButton.tsx b/app/src/components/safety/EmergencyStopButton.tsx index 9e0146a73f..c683b9527d 100644 --- a/app/src/components/safety/EmergencyStopButton.tsx +++ b/app/src/components/safety/EmergencyStopButton.tsx @@ -15,15 +15,15 @@ export function EmergencyStopButton() { const { t } = useT(); const dispatch = useDispatch(); - const onClick = useCallback(async () => { + const handleClick = useCallback(async () => { try { - const state = await emergencyStop('user'); + const state = await emergencyStop(); dispatch( setHalt({ reason: state.reason, source: state.source, since: state.engaged_at_ms }) ); } catch (err) { // Fail-visible: reflect intent locally even when the core is unreachable. - dispatch(setHalt({ reason: 'user', source: 'user' })); + dispatch(setHalt({ source: 'user' })); console.error('[emergency] stop failed', err); } }, [dispatch]); @@ -32,7 +32,7 @@ export function EmergencyStopButton() { ); From 1da757c6506cfc0d08e2ccb9faad79aab9d23636 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Tue, 7 Jul 2026 00:40:38 +0530 Subject: [PATCH 20/33] chore: drop leaked SDD scratch report from feature branch (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- .superpowers/sdd/task-1-report.md | 57 ------------------------------- 1 file changed, 57 deletions(-) delete mode 100644 .superpowers/sdd/task-1-report.md diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md deleted file mode 100644 index 53b93c6a7d..0000000000 --- a/.superpowers/sdd/task-1-report.md +++ /dev/null @@ -1,57 +0,0 @@ -## Task 1 Report — `AutomationHalted` / `AutomationResumed` domain events - -### What was implemented - -Added two new `DomainEvent` variants to `src/core/event_bus/events.rs` as specified in the brief, and a unit test in `src/core/event_bus/events_tests.rs`. - -**Variant placement:** Inserted after `HarnessInitCompleted` in the System lifecycle region (~line 1066), before the Keyring section. - -**Changes:** - -1. **`src/core/event_bus/events.rs`** — three edits: - - Added `AutomationHalted { reason: Option, source: String }` and `AutomationResumed { source: String }` with doc comments verbatim from the brief, in the System lifecycle block. - - Extended the `domain()` match arm: `| Self::AutomationHalted { .. } | Self::AutomationResumed { .. } => "system"` appended to the existing `HarnessInitCompleted` arm. - - Extended `variant_name()` with two arms: `Self::AutomationHalted { .. } => "AutomationHalted"` and `Self::AutomationResumed { .. } => "AutomationResumed"`. - -2. **`src/core/event_bus/events_tests.rs`** — appended `automation_events_map_to_system_domain` test. - -**Note on `name()` vs `variant_name()`:** The brief's Step 3/4 refer to a `name()` method, but the actual codebase uses `variant_name()`. The test was adapted to call `variant_name()` to match the existing test style (see `workflows_changed_domain_and_name` test which uses `.variant_name()`). The test function name `automation_events_map_to_system_domain` is unchanged from the brief. - -### TDD RED / GREEN evidence - -**Pre-implementation (RED):** Before adding the variants, the compiler would have produced `E0004` non-exhaustive match errors for both `domain()` and `variant_name()` since the enum is exhaustive. Not captured as a separate run — moved directly to GREEN after implementing. - -**GREEN run command:** -``` -GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml -p openhuman automation_events_map_to_system_domain -``` - -**GREEN output (key lines):** -``` - Compiling openhuman v0.58.11 (/Users/ghostscripter/Zerolend/openhuman) - Finished `test` profile [unoptimized + debuginfo] target(s) in 8m 08s - Running unittests src/lib.rs (target/debug/deps/openhuman_core-67f515be1d9557cd) - -running 1 test -test core::event_bus::events::tests::automation_events_map_to_system_domain ... ok - -test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 13231 filtered out; finished in 0.00s -``` - -### Files changed - -- `src/core/event_bus/events.rs` — 3 edits (enum body, `domain()` arm, `variant_name()` arms) -- `src/core/event_bus/events_tests.rs` — 1 edit (appended test function) - -### Self-review - -- Variants match the brief verbatim (field names, types, doc comments). -- `domain()` and `variant_name()` arms are exhaustive; the crate compiled with only pre-existing warnings (none new). -- Test verifies all four assertions (`domain()` and `variant_name()` for both variants). -- No logging added — correct per the brief: "this task adds only enum variants + a unit test (no runtime logging needed)." -- Submodules (`vendor/tinyagents`, `vendor/tinychannels`, etc.) were uninitialized and had to be cloned before the build could succeed; this is a one-time repo setup step, not a code concern. - -### Concerns - -- **`name()` vs `variant_name()` mismatch in brief:** The brief's Step 4 test uses `halted.name()` / `resumed.name()` — no such method exists on `DomainEvent`; the actual method is `variant_name()`. The test was adapted accordingly. This is a minor brief inaccuracy; the behavior under test is identical. -- No other concerns. The change is additive and self-contained. From a5cbd50c26bcdc104969b4aded6cb01cadce6e14 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Tue, 7 Jul 2026 00:45:15 +0530 Subject: [PATCH 21/33] style(emergency): apply prettier formatting (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/App.tsx | 6 ++--- .../safety/AutomationHaltedBanner.test.tsx | 17 +++++++++----- .../safety/EmergencyStopButton.test.tsx | 22 ++++++++++++++----- .../components/safety/EmergencyStopButton.tsx | 4 +--- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 2e2ebc5741..a3dd9a30a0 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -29,11 +29,11 @@ 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'; -import { AutomationHaltedBanner } from './components/safety/AutomationHaltedBanner'; -import { EmergencyStopButton } from './components/safety/EmergencyStopButton'; import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner'; import UserErrorCenter from './components/userErrors/UserErrorCenter'; import AppWalkthrough from './components/walkthrough/AppWalkthrough'; @@ -54,12 +54,12 @@ import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider'; import SocketProvider from './providers/SocketProvider'; import ThemeProvider from './providers/ThemeProvider'; import { trackPageView } from './services/analytics'; -import { hydrateEmergencyState } from './services/safety/hydrateEmergencyState'; import { startCoreHealthMonitor, stopCoreHealthMonitor } from './services/coreHealthMonitor'; import { startInternetStatusListener, stopInternetStatusListener, } from './services/internetStatusListener'; +import { hydrateEmergencyState } from './services/safety/hydrateEmergencyState'; import { hideWebviewAccount, startWebviewAccountService, diff --git a/app/src/components/safety/AutomationHaltedBanner.test.tsx b/app/src/components/safety/AutomationHaltedBanner.test.tsx index 6ade581cc2..f9ed7ef359 100644 --- a/app/src/components/safety/AutomationHaltedBanner.test.tsx +++ b/app/src/components/safety/AutomationHaltedBanner.test.tsx @@ -1,11 +1,14 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen, fireEvent, waitFor, act } from '@testing-library/react'; -import { AutomationHaltedBanner } from './AutomationHaltedBanner'; -import { renderWithProviders } from '../../test/test-utils'; +import { act, 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 { AutomationHaltedBanner } from './AutomationHaltedBanner'; const resume = vi.fn().mockResolvedValue({ engaged: false }); -vi.mock('../../services/api/emergencyApi', () => ({ emergencyResume: (...a: unknown[]) => resume(...a) })); +vi.mock('../../services/api/emergencyApi', () => ({ + emergencyResume: (...a: unknown[]) => resume(...a), +})); beforeEach(() => resume.mockClear()); @@ -20,7 +23,9 @@ describe('AutomationHaltedBanner', () => { preloadedState: { safety: { halted: true } }, }); expect(screen.getByRole('alert')).toBeDefined(); - expect(screen.getByRole('alert').getAttribute('data-analytics-id')).toBe('automation-halted-banner'); + 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); }); diff --git a/app/src/components/safety/EmergencyStopButton.test.tsx b/app/src/components/safety/EmergencyStopButton.test.tsx index bae6ad2f77..e66849cb6e 100644 --- a/app/src/components/safety/EmergencyStopButton.test.tsx +++ b/app/src/components/safety/EmergencyStopButton.test.tsx @@ -1,11 +1,21 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen, fireEvent, waitFor } from '@testing-library/react'; -import { EmergencyStopButton } from './EmergencyStopButton'; -import { renderWithProviders } from '../../test/test-utils'; +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) })); +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()); diff --git a/app/src/components/safety/EmergencyStopButton.tsx b/app/src/components/safety/EmergencyStopButton.tsx index d7ff8f57c0..2a984e9fba 100644 --- a/app/src/components/safety/EmergencyStopButton.tsx +++ b/app/src/components/safety/EmergencyStopButton.tsx @@ -22,9 +22,7 @@ export function EmergencyStopButton() { const handleClick = useCallback(async () => { try { const state = await emergencyStop(); - dispatch( - setHalt({ reason: state.reason, source: state.source, since: state.engaged_at_ms }) - ); + dispatch(setHalt({ reason: state.reason, source: state.source, since: state.engaged_at_ms })); } catch (err) { // Fail-visible: reflect intent locally even when the core is unreachable. dispatch(setHalt({ source: 'user' })); From cdecdee5aa9f6bd40a1579924e1ee72ac7959a67 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Tue, 7 Jul 2026 01:16:45 +0530 Subject: [PATCH 22/33] style(emergency): prettier-format all changed frontend files (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/lib/i18n/ar.ts | 1 - app/src/lib/i18n/bn.ts | 1 - app/src/lib/i18n/de.ts | 4 ++-- app/src/lib/i18n/en.ts | 1 - app/src/lib/i18n/es.ts | 4 ++-- app/src/lib/i18n/fr.ts | 8 ++++---- app/src/lib/i18n/hi.ts | 1 - app/src/lib/i18n/id.ts | 1 - app/src/lib/i18n/it.ts | 5 ++--- app/src/lib/i18n/ko.ts | 1 - app/src/lib/i18n/pl.ts | 1 - app/src/lib/i18n/pt.ts | 1 - app/src/lib/i18n/ru.ts | 4 ++-- app/src/lib/i18n/zh-CN.ts | 1 - app/src/services/api/emergencyApi.test.ts | 11 +++++++---- app/src/services/api/emergencyApi.ts | 7 +++++-- app/src/services/safety/hydrateEmergencyState.test.ts | 6 +++--- app/src/services/safety/hydrateEmergencyState.ts | 2 +- app/src/store/index.ts | 2 +- app/src/store/safetySlice.test.ts | 10 +++++++--- app/src/store/safetySlice.ts | 7 ++++++- 21 files changed, 42 insertions(+), 37 deletions(-) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index b0d40530bb..33fb3e228a 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -6742,7 +6742,6 @@ const messages: TranslationMap = { 'safety.resume': 'استئناف الأتمتة', 'safety.haltedTitle': 'الأتمتة متوقفة', 'safety.haltedBody': 'تم إيقاف جميع أتمتة سطح المكتب. استأنف عندما تكون مستعدًا.', - }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 3aed16a060..c2871e16c7 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -6895,7 +6895,6 @@ const messages: TranslationMap = { 'safety.resume': 'অটোমেশন পুনরায় শুরু করুন', 'safety.haltedTitle': 'অটোমেশন বন্ধ', 'safety.haltedBody': 'সমস্ত ডেস্কটপ অটোমেশন বন্ধ করা হয়েছে। প্রস্তুত হলে পুনরায় শুরু করুন।', - }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 60bb0a454c..0a032df213 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7084,8 +7084,8 @@ const messages: TranslationMap = { 'safety.emergencyStop': 'Notabschaltung', 'safety.resume': 'Automatisierung fortsetzen', 'safety.haltedTitle': 'Automatisierung angehalten', - 'safety.haltedBody': 'Alle Desktop-Automatisierungen sind gestoppt. Fortsetzen, wenn Sie bereit sind.', - + 'safety.haltedBody': + 'Alle Desktop-Automatisierungen sind gestoppt. Fortsetzen, wenn Sie bereit sind.', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 380afc43fc..ce84426191 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7191,7 +7191,6 @@ const en: TranslationMap = { 'safety.resume': 'Resume automation', 'safety.haltedTitle': 'Automation halted', 'safety.haltedBody': 'All desktop automation is stopped. Resume when you are ready.', - }; export default en; diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index df2a649357..588b7e536b 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7032,8 +7032,8 @@ const messages: TranslationMap = { 'safety.emergencyStop': 'Parada de emergencia', 'safety.resume': 'Reanudar automatización', 'safety.haltedTitle': 'Automatización detenida', - 'safety.haltedBody': 'Toda la automatización de escritorio está detenida. Reanude cuando esté listo.', - + 'safety.haltedBody': + 'Toda la automatización de escritorio está detenida. Reanude cuando esté listo.', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 1d93458912..d83a7815e8 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7052,11 +7052,11 @@ const messages: TranslationMap = { 'flows.canvas.renameLabel': 'Renommer le workflow', // Emergency stop (#4255) - 'safety.emergencyStop': 'Arrêt d\'urgence', - 'safety.resume': 'Reprendre l\'automatisation', + 'safety.emergencyStop': "Arrêt d'urgence", + 'safety.resume': "Reprendre l'automatisation", 'safety.haltedTitle': 'Automatisation suspendue', - 'safety.haltedBody': 'Toute l\'automatisation du bureau est arrêtée. Reprenez quand vous êtes prêt.', - + 'safety.haltedBody': + "Toute l'automatisation du bureau est arrêtée. Reprenez quand vous êtes prêt.", }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index d1d580951f..bee648dbeb 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -6894,7 +6894,6 @@ const messages: TranslationMap = { 'safety.resume': 'स्वचालन पुनः प्रारंभ करें', 'safety.haltedTitle': 'स्वचालन रोका गया', 'safety.haltedBody': 'सभी डेस्कटॉप स्वचालन रोक दिया गया है। तैयार होने पर पुनः प्रारंभ करें।', - }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index ba6a03c109..cc4d6807a1 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -6920,7 +6920,6 @@ const messages: TranslationMap = { 'safety.resume': 'Lanjutkan otomasi', 'safety.haltedTitle': 'Otomasi dihentikan', 'safety.haltedBody': 'Semua otomasi desktop dihentikan. Lanjutkan ketika Anda siap.', - }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 5143c2aaa4..ee8d12b175 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7017,10 +7017,9 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Arresto di emergenza', - 'safety.resume': 'Riprendi l\'automazione', + 'safety.resume': "Riprendi l'automazione", 'safety.haltedTitle': 'Automazione sospesa', - 'safety.haltedBody': 'Tutta l\'automazione del desktop è ferma. Riprendi quando sei pronto.', - + 'safety.haltedBody': "Tutta l'automazione del desktop è ferma. Riprendi quando sei pronto.", }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 92d94fef6e..ea8fc17d62 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -6818,7 +6818,6 @@ const messages: TranslationMap = { 'safety.resume': '자동화 재개', 'safety.haltedTitle': '자동화 중단됨', 'safety.haltedBody': '모든 데스크톱 자동화가 중지되었습니다. 준비가 되면 재개하세요.', - }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index faedd0867d..2b4a668b00 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -6995,7 +6995,6 @@ const messages: TranslationMap = { 'safety.resume': 'Wznów automatyzację', 'safety.haltedTitle': 'Automatyzacja wstrzymana', 'safety.haltedBody': 'Cała automatyzacja pulpitu jest zatrzymana. Wznów, gdy będziesz gotowy.', - }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 83c7bbd969..b5fd0f1fcd 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7009,7 +7009,6 @@ const messages: TranslationMap = { 'safety.resume': 'Retomar automação', 'safety.haltedTitle': 'Automação pausada', 'safety.haltedBody': 'Toda a automação do desktop está parada. Retome quando estiver pronto.', - }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 478b95a21d..1ee1f53c21 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -6969,8 +6969,8 @@ const messages: TranslationMap = { 'safety.emergencyStop': 'Аварийная остановка', 'safety.resume': 'Возобновить автоматизацию', 'safety.haltedTitle': 'Автоматизация приостановлена', - 'safety.haltedBody': 'Вся автоматизация рабочего стола остановлена. Возобновите, когда будете готовы.', - + 'safety.haltedBody': + 'Вся автоматизация рабочего стола остановлена. Возобновите, когда будете готовы.', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index c17f3f2292..e4fbc6f427 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6521,7 +6521,6 @@ const messages: TranslationMap = { 'safety.resume': '恢复自动化', 'safety.haltedTitle': '自动化已暂停', 'safety.haltedBody': '所有桌面自动化已停止。准备好后请恢复。', - }; export default messages; diff --git a/app/src/services/api/emergencyApi.test.ts b/app/src/services/api/emergencyApi.test.ts index be8728a1de..5e80fe8fd8 100644 --- a/app/src/services/api/emergencyApi.test.ts +++ b/app/src/services/api/emergencyApi.test.ts @@ -1,17 +1,20 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +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) })); -import { emergencyStop, emergencyResume, emergencyStatus } from './emergencyApi'; - beforeEach(() => call.mockReset()); describe('emergencyApi', () => { it('emergencyStop calls openhuman.emergency_stop with reason and unwraps envelope', async () => { call.mockResolvedValue({ result: { engaged: true, reason: 'user' }, logs: ['x'] }); const r = await emergencyStop('user'); - expect(call).toHaveBeenCalledWith({ method: 'openhuman.emergency_stop', params: { reason: 'user' } }); + expect(call).toHaveBeenCalledWith({ + method: 'openhuman.emergency_stop', + params: { reason: 'user' }, + }); expect(r.engaged).toBe(true); expect(r.reason).toBe('user'); }); diff --git a/app/src/services/api/emergencyApi.ts b/app/src/services/api/emergencyApi.ts index 986dc1e0f8..242f2f09c0 100644 --- a/app/src/services/api/emergencyApi.ts +++ b/app/src/services/api/emergencyApi.ts @@ -1,5 +1,5 @@ -import { callCoreRpc } from '../coreRpcClient'; import type { HaltState } from '../../store/safetySlice'; +import { callCoreRpc } from '../coreRpcClient'; /** Normalize the CLI envelope `{ result, logs }` and bare-value shapes. */ const unwrapValue = (raw: unknown): T => { @@ -10,7 +10,10 @@ const unwrapValue = (raw: unknown): T => { }; export async function emergencyStop(reason?: string): Promise { - const raw = await callCoreRpc({ method: 'openhuman.emergency_stop', params: reason ? { reason } : {} }); + const raw = await callCoreRpc({ + method: 'openhuman.emergency_stop', + params: reason ? { reason } : {}, + }); return unwrapValue(raw); } diff --git a/app/src/services/safety/hydrateEmergencyState.test.ts b/app/src/services/safety/hydrateEmergencyState.test.ts index e09f92cec1..4ad2b6b9c1 100644 --- a/app/src/services/safety/hydrateEmergencyState.test.ts +++ b/app/src/services/safety/hydrateEmergencyState.test.ts @@ -1,4 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { hydrateEmergencyState } from './hydrateEmergencyState'; // Mock emergencyApi before importing the module under test const emergencyStatusMock = vi.fn(); @@ -8,8 +10,6 @@ vi.mock('../api/emergencyApi', () => ({ emergencyStatus: () => emergencyStatusMo const hydrateHaltMock = vi.fn((x: unknown) => ({ type: 'safety/hydrateHalt', payload: x })); vi.mock('../../store/safetySlice', () => ({ hydrateHalt: (x: unknown) => hydrateHaltMock(x) })); -import { hydrateEmergencyState } from './hydrateEmergencyState'; - describe('hydrateEmergencyState', () => { const dispatch = vi.fn(); diff --git a/app/src/services/safety/hydrateEmergencyState.ts b/app/src/services/safety/hydrateEmergencyState.ts index bdeeea932a..3429746192 100644 --- a/app/src/services/safety/hydrateEmergencyState.ts +++ b/app/src/services/safety/hydrateEmergencyState.ts @@ -1,7 +1,7 @@ import type { Dispatch } from '@reduxjs/toolkit'; -import { emergencyStatus } from '../api/emergencyApi'; import { hydrateHalt } from '../../store/safetySlice'; +import { emergencyStatus } from '../api/emergencyApi'; /** * Fetches the authoritative halt state from the core and dispatches diff --git a/app/src/store/index.ts b/app/src/store/index.ts index 00d1b29f01..2939a1ca0a 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -34,10 +34,10 @@ 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'; -import safetyReducer from './safetySlice'; import userErrorsReducer from './userErrorsSlice'; import { userScopedStorage } from './userScopedStorage'; diff --git a/app/src/store/safetySlice.test.ts b/app/src/store/safetySlice.test.ts index 064b80c7ee..bcf652650e 100644 --- a/app/src/store/safetySlice.test.ts +++ b/app/src/store/safetySlice.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest'; -import reducer, { setHalt, clearHalt, hydrateHalt } from './safetySlice'; +import { describe, expect, it } from 'vitest'; + +import reducer, { clearHalt, hydrateHalt, setHalt } from './safetySlice'; describe('safetySlice', () => { it('starts not halted', () => { @@ -14,7 +15,10 @@ describe('safetySlice', () => { 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' })); + const s = reducer( + undefined, + hydrateHalt({ engaged: true, reason: 'boot', engaged_at_ms: 7, source: 'system' }) + ); expect(s.halted).toBe(true); expect(s.reason).toBe('boot'); expect(s.since).toBe(7); diff --git a/app/src/store/safetySlice.ts b/app/src/store/safetySlice.ts index 929a5bf75f..58f7f28f2b 100644 --- a/app/src/store/safetySlice.ts +++ b/app/src/store/safetySlice.ts @@ -21,7 +21,12 @@ const safetySlice = createSlice({ 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 }; + return { + halted: true, + reason: action.payload.reason, + source: action.payload.source, + since: action.payload.since, + }; }, clearHalt() { return { halted: false }; From 18e2800e6d321baee12f0c44764700e2474ffb1b Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Tue, 7 Jul 2026 02:06:33 +0530 Subject: [PATCH 23/33] =?UTF-8?q?fix(emergency):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20fail-closed=20socket,=20no=20false=20halt,=20atomic?= =?UTF-8?q?=20state,=20coverage=20(#4255)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - socketService: drop automation_halt payloads lacking a boolean engaged (a malformed broadcast can no longer silently clear an active halt). - EmergencyStopButton: do not mark halted locally when the stop RPC fails (no false sense of safety); log + leave button for retry. - state.rs: write the engaged flag inside the info mutex so snapshot never sees an inconsistent (flag, info) pair; is_engaged() stays lock-free. - ops.rs: run the blocking cascade-deny via spawn_blocking off the worker. - Extract emergency_halt_denial helper + unit test, and schemas handler/arm test — cover the changed lines the >=80% Rust diff-cover gate flagged. - Panic-safe RAII cleanup guards in the middleware, screen-intel, and e2e tests. - Add [emergency] debug logging (button/banner/api); es.ts informal tú; doc paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../safety/AutomationHaltedBanner.tsx | 4 +- .../safety/EmergencyStopButton.test.tsx | 12 +++-- .../components/safety/EmergencyStopButton.tsx | 14 ++++-- app/src/lib/i18n/es.ts | 2 +- .../__tests__/socketService.events.test.ts | 25 ++++++++++ app/src/services/api/emergencyApi.ts | 3 ++ app/src/services/socketService.ts | 10 +++- .../plans/2026-07-06-emergency-stop.md | 2 +- .../specs/2026-07-06-emergency-stop-design.md | 2 +- src/openhuman/emergency_stop/ops.rs | 11 ++++- src/openhuman/emergency_stop/schemas.rs | 39 +++++++++++++++ src/openhuman/emergency_stop/state.rs | 47 +++++++++++++----- src/openhuman/screen_intelligence/ops.rs | 10 ++-- src/openhuman/tinyagents/middleware.rs | 49 ++++++++++++++----- tests/json_rpc_e2e.rs | 16 ++++++ 15 files changed, 203 insertions(+), 43 deletions(-) diff --git a/app/src/components/safety/AutomationHaltedBanner.tsx b/app/src/components/safety/AutomationHaltedBanner.tsx index 5f625e2aec..61e74b7341 100644 --- a/app/src/components/safety/AutomationHaltedBanner.tsx +++ b/app/src/components/safety/AutomationHaltedBanner.tsx @@ -20,10 +20,12 @@ export function AutomationHaltedBanner() { const reason = useSelector(selectHaltReason); const onResume = useCallback(async () => { + console.debug('[emergency] resume requested (source=user)'); try { await emergencyResume(); + console.debug('[emergency] resume confirmed by core'); } catch (err) { - console.error('[emergency] resume failed', err); + console.error('[emergency] resume failed — clearing halt locally anyway', err); } finally { dispatch(clearHalt()); } diff --git a/app/src/components/safety/EmergencyStopButton.test.tsx b/app/src/components/safety/EmergencyStopButton.test.tsx index e66849cb6e..c4aae8b4ae 100644 --- a/app/src/components/safety/EmergencyStopButton.test.tsx +++ b/app/src/components/safety/EmergencyStopButton.test.tsx @@ -33,14 +33,16 @@ describe('EmergencyStopButton', () => { expect(safetyState.halted).toBe(true); }); - it('dispatches halt locally if emergencyStop throws', async () => { + it('does NOT mark halted when emergencyStop throws (no false halt)', async () => { stop.mockRejectedValueOnce(new Error('core unavailable')); const { store } = renderWithProviders(); fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); - await waitFor(() => { - const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; - expect(safetyState.halted).toBe(true); - }); + 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(); }); it('renders nothing while already halted (banner Resume takes over)', () => { diff --git a/app/src/components/safety/EmergencyStopButton.tsx b/app/src/components/safety/EmergencyStopButton.tsx index 2a984e9fba..371e19fdfc 100644 --- a/app/src/components/safety/EmergencyStopButton.tsx +++ b/app/src/components/safety/EmergencyStopButton.tsx @@ -20,13 +20,21 @@ export function EmergencyStopButton() { const halted = useSelector(selectHalted); const handleClick = useCallback(async () => { + 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) { - // Fail-visible: reflect intent locally even when the core is unreachable. - dispatch(setHalt({ source: 'user' })); - console.error('[emergency] stop failed', 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. Leave the button visible so the user can retry; + // a confirmed halt only surfaces from a successful response or the + // `automation_halt` broadcast. + console.error('[emergency] stop FAILED — core NOT halted, retry required', err); } }, [dispatch]); diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 588b7e536b..ec230552a6 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7033,7 +7033,7 @@ const messages: TranslationMap = { 'safety.resume': 'Reanudar automatización', 'safety.haltedTitle': 'Automatización detenida', 'safety.haltedBody': - 'Toda la automatización de escritorio está detenida. Reanude cuando esté listo.', + 'Toda la automatización de escritorio está detenida. Reanuda cuando estés listo.', }; export default messages; diff --git a/app/src/services/__tests__/socketService.events.test.ts b/app/src/services/__tests__/socketService.events.test.ts index 45e17aad58..af8c151f20 100644 --- a/app/src/services/__tests__/socketService.events.test.ts +++ b/app/src/services/__tests__/socketService.events.test.ts @@ -537,4 +537,29 @@ describe('socketService — automation_halt handler (#4255)', () => { 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. + expect(() => handlers['automation_halt']!({})).not.toThrow(); + expect(() => handlers['automation_halt']!({ reason: 'x' })).not.toThrow(); + expect(() => handlers['automation_halt']!({ engaged: 'true' })).not.toThrow(); + expect(storeMock.dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/app/src/services/api/emergencyApi.ts b/app/src/services/api/emergencyApi.ts index 242f2f09c0..224f485b6b 100644 --- a/app/src/services/api/emergencyApi.ts +++ b/app/src/services/api/emergencyApi.ts @@ -10,6 +10,7 @@ const unwrapValue = (raw: unknown): T => { }; export async function emergencyStop(reason?: string): Promise { + console.debug('[emergency] rpc → openhuman.emergency_stop', { reason: reason ?? 'none' }); const raw = await callCoreRpc({ method: 'openhuman.emergency_stop', params: reason ? { reason } : {}, @@ -18,11 +19,13 @@ export async function emergencyStop(reason?: string): Promise { } export async function emergencyResume(): Promise { + console.debug('[emergency] rpc → openhuman.emergency_resume'); const raw = await callCoreRpc({ method: 'openhuman.emergency_resume', params: {} }); return unwrapValue(raw); } export async function emergencyStatus(): Promise { + console.debug('[emergency] rpc → openhuman.emergency_status'); const raw = await callCoreRpc({ method: 'openhuman.emergency_status', params: {} }); return unwrapValue(raw); } diff --git a/app/src/services/socketService.ts b/app/src/services/socketService.ts index 1f28915e82..8de594ecb4 100644 --- a/app/src/services/socketService.ts +++ b/app/src/services/socketService.ts @@ -473,7 +473,15 @@ class SocketService { socketWarn('automation_halt dropped — invalid payload shape'); return; } - const engaged = typeof obj.engaged === 'boolean' ? obj.engaged : false; + // 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 obj.engaged !== 'boolean') { + socketWarn('automation_halt dropped — missing/invalid engaged flag'); + return; + } + const engaged = obj.engaged; const reason = typeof obj.reason === 'string' ? obj.reason : undefined; const source = typeof obj.source === 'string' ? obj.source : undefined; socketLog( diff --git a/docs/superpowers/plans/2026-07-06-emergency-stop.md b/docs/superpowers/plans/2026-07-06-emergency-stop.md index f076d74556..3b384f5c0e 100644 --- a/docs/superpowers/plans/2026-07-06-emergency-stop.md +++ b/docs/superpowers/plans/2026-07-06-emergency-stop.md @@ -18,7 +18,7 @@ - **Rust module shape** (AGENTS.md): `mod.rs` export-only; `types.rs` serde types; `state.rs` state; `ops.rs` logic returning `RpcOutcome`; `schemas.rs` controllers. New functionality → dedicated subdirectory; no new root-level `*.rs`. - **RPC naming:** `openhuman._` — here namespace `emergency`, functions `stop`/`resume`/`status`. - **Controller exposure:** register via `src/core/all.rs` registry, not branches in `cli.rs`/`jsonrpc.rs`. -- **i18n:** all UI text through `useT()`; add keys to `app/src/lib/i18n/locales/en.ts` **and** real translations in every locale file (`ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN`). CI enforces parity (`pnpm i18n:check`). +- **i18n:** all UI text through `useT()`; add keys to `app/src/lib/i18n/en.ts` **and** real translations in every sibling locale file (`app/src/lib/i18n/.ts` for `ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN`). CI enforces parity (`pnpm i18n:check`). - **Debug logging:** grep-friendly prefixes (`[emergency]`, `[rpc:emergency_*]`); log entry/exit, state transitions, errors; never log secrets/PII. - **Frontend:** no dynamic imports in `app/src`; use `invoke('core_rpc_relay', …)` via `coreRpcClient`; guard Tauri with `isTauri()`/try-catch. - **Rust checks:** `cargo check --manifest-path Cargo.toml` (add `GGML_NATIVE=OFF` on macOS Apple Silicon). Tests: `pnpm test:rust` or `bash scripts/test-rust-with-mock.sh --test `; targeted lib tests: `cargo test --manifest-path Cargo.toml `. diff --git a/docs/superpowers/specs/2026-07-06-emergency-stop-design.md b/docs/superpowers/specs/2026-07-06-emergency-stop-design.md index e3ba58b7c3..4dd331aefa 100644 --- a/docs/superpowers/specs/2026-07-06-emergency-stop-design.md +++ b/docs/superpowers/specs/2026-07-06-emergency-stop-design.md @@ -24,7 +24,7 @@ It is a **fail-closed kill switch**: while engaged, every automated real-world a - `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.rs` `ApprovalSurfaceSubscriber` — the pattern for RPC registration and bridging domain events to a web-channel socket event. +- `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 diff --git a/src/openhuman/emergency_stop/ops.rs b/src/openhuman/emergency_stop/ops.rs index 4630fa5cb3..c97aa43afb 100644 --- a/src/openhuman/emergency_stop/ops.rs +++ b/src/openhuman/emergency_stop/ops.rs @@ -35,8 +35,15 @@ pub async fn emergency_stop(reason: Option, source: &str) -> RpcOutcome< "[emergency] accessibility session stopped" ); - // Best-effort: cascade-deny every pending approval so parked tool calls fail closed. - let denied = cascade_deny_pending(); + // 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 { diff --git a/src/openhuman/emergency_stop/schemas.rs b/src/openhuman/emergency_stop/schemas.rs index 6493baa540..171a0fcd55 100644 --- a/src/openhuman/emergency_stop/schemas.rs +++ b/src/openhuman/emergency_stop/schemas.rs @@ -124,4 +124,43 @@ mod tests { assert_eq!(s.inputs[0].name, "reason"); assert!(!s.inputs[0].required); } + + #[test] + fn resume_status_and_unknown_schema_arms() { + assert_eq!(schemas("resume").function, "resume"); + assert!(schemas("resume").inputs.is_empty()); + assert_eq!(schemas("status").function, "status"); + assert!(schemas("status").inputs.is_empty()); + // The catch-all arm renders a placeholder rather than panicking. + assert_eq!(schemas("nope").function, "unknown"); + assert_eq!(schemas("nope").outputs[0].name, "error"); + } + + fn json_engaged(v: &Value) -> bool { + // stop/resume emit a diagnostic log → enveloped `{result, logs}`; + // status has no log → bare value. Normalize both. + let obj = v.get("result").unwrap_or(v); + obj.get("engaged") + .and_then(|e| e.as_bool()) + .unwrap_or(false) + } + + #[tokio::test] + async fn handlers_drive_stop_status_resume() { + let _g = crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); + let _reset = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; + + let mut params = Map::new(); + params.insert("reason".into(), Value::String("verify".into())); + let stopped = handle_stop(params).await.expect("handle_stop ok"); + assert!(json_engaged(&stopped)); + + let status = handle_status(Map::new()).await.expect("handle_status ok"); + assert!(json_engaged(&status)); + + let resumed = handle_resume(Map::new()).await.expect("handle_resume ok"); + assert!(!json_engaged(&resumed)); + } } diff --git a/src/openhuman/emergency_stop/state.rs b/src/openhuman/emergency_stop/state.rs index 8ad646f72f..91a910b3a1 100644 --- a/src/openhuman/emergency_stop/state.rs +++ b/src/openhuman/emergency_stop/state.rs @@ -50,31 +50,37 @@ impl EmergencyStop { } /// Engage the halt. Idempotent — re-engaging refreshes reason/source/time. + /// + /// The `engaged` flag is written **inside** the `info` lock so the + /// (flag, info) pair transitions atomically for any reader that takes the + /// lock (`snapshot`). The lock-free `is_engaged()` fast path used by the + /// enforcement chokepoints reads the flag directly and is eventually + /// consistent, which is all a fail-closed guard needs. pub fn engage(&self, reason: Option, source: &str, now_ms: u64) { - { - let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); - *guard = Some(HaltInfo { - reason, - engaged_at_ms: now_ms, - source: source.to_string(), - }); - } + 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. + /// Clear the halt. Idempotent. Flag + info are cleared under one lock so + /// a concurrent `snapshot` never observes an inconsistent pair. pub fn clear(&self) { - self.engaged.store(false, Ordering::SeqCst); let mut guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); *guard = None; + self.engaged.store(false, Ordering::SeqCst); } - /// Current snapshot for RPC/UI. + /// 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 { - if !self.is_engaged() { + let guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); + if !self.engaged.load(Ordering::SeqCst) { return HaltState::default(); } - let guard = self.info.lock().unwrap_or_else(|e| e.into_inner()); match guard.as_ref() { Some(info) => HaltState { engaged: true, @@ -98,6 +104,21 @@ impl EmergencyStop { #[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 { diff --git a/src/openhuman/screen_intelligence/ops.rs b/src/openhuman/screen_intelligence/ops.rs index 66ec73e29b..76ad746736 100644 --- a/src/openhuman/screen_intelligence/ops.rs +++ b/src/openhuman/screen_intelligence/ops.rs @@ -323,12 +323,15 @@ mod tests { #[tokio::test] async fn input_action_blocked_while_emergency_engaged() { - use crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD; + 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(), @@ -343,17 +346,17 @@ mod tests { assert!(!out.value.accepted); assert!(out.value.blocked); assert_eq!(out.value.reason.as_deref(), Some("emergency_stop")); - stop.clear(); } #[tokio::test] async fn panic_stop_passes_even_while_emergency_engaged() { - use crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD; + 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(), @@ -371,6 +374,5 @@ mod tests { if let Ok(outcome) = out { assert_ne!(outcome.value.reason.as_deref(), Some("emergency_stop")); } - stop.clear(); } } diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index 6b47f2b366..b637ecdcea 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -925,6 +925,26 @@ impl ApprovalSecurityMiddleware { } } +/// The fail-closed denial a halted external-effect tool call resolves to. +/// Returns `Some(result)` iff the emergency stop is engaged, otherwise `None`. +/// Extracted from `wrap_tool` so the deny path is unit-testable without +/// constructing a full `RunContext`/`ToolHandler` runtime. +fn emergency_halt_denial(call_id: String, name: String) -> Option { + if !crate::openhuman::emergency_stop::is_engaged_global() { + return None; + } + let reason = "Emergency stop is engaged — this action is blocked until you resume automation." + .to_string(); + Some(TaToolResult { + call_id, + name, + content: reason.clone(), + raw: None, + error: Some(reason), + elapsed_ms: 0, + }) +} + #[async_trait] impl ToolMiddleware<()> for ApprovalSecurityMiddleware { fn name(&self) -> &str { @@ -944,17 +964,9 @@ impl ToolMiddleware<()> for ApprovalSecurityMiddleware { 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 crate::openhuman::emergency_stop::is_engaged_global() { - let reason = "Emergency stop is engaged — this action is blocked until you resume automation.".to_string(); + 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(TaToolResult { - call_id: call.id, - name: call.name, - content: reason.clone(), - raw: None, - error: Some(reason), - elapsed_ms: 0, - })); + return Ok(MiddlewareToolOutcome::Result(denial)); } if let Some(gate) = ApprovalGate::try_global() { let summary = summarize_action(&call.name, &call.arguments); @@ -3936,13 +3948,28 @@ mod tests { 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()); - stop.clear(); + 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) ────────────────────────────── diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 7c192f0588..618f87aa98 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1198,6 +1198,22 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { #[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}"); From 44ab15306e3cd2d98b4d00e4ee0ba65a929b4abe Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Tue, 7 Jul 2026 03:01:04 +0530 Subject: [PATCH 24/33] fix(emergency): unblock CI + register bridge at boot + visible stop-failed error (#4255) - Remove cascade_deny gate-install test: ApprovalGate::init_global is a OnceLock and leaked a globally-installed gate that made unrelated tinyflows SSRF tests fail ('no origin label') in the shared coverage test binary. - Register register_automation_halt_subscriber() at core boot (jsonrpc.rs) next to the approval-surface bridge: start_channels is skipped on a web-chat-only desktop, so without this a halt from CLI/another client never reached the UI over the socket (Codex P2). - EmergencyStopButton: surface a visible, retryable error (safety.stopFailed, all locales) when the stop RPC fails, instead of only logging (CodeRabbit). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../safety/EmergencyStopButton.test.tsx | 4 +- .../components/safety/EmergencyStopButton.tsx | 50 +++++++---- app/src/lib/i18n/ar.ts | 1 + app/src/lib/i18n/bn.ts | 1 + app/src/lib/i18n/de.ts | 1 + app/src/lib/i18n/en.ts | 1 + app/src/lib/i18n/es.ts | 1 + app/src/lib/i18n/fr.ts | 1 + app/src/lib/i18n/hi.ts | 1 + app/src/lib/i18n/id.ts | 1 + app/src/lib/i18n/it.ts | 1 + app/src/lib/i18n/ko.ts | 1 + app/src/lib/i18n/pl.ts | 1 + app/src/lib/i18n/pt.ts | 1 + app/src/lib/i18n/ru.ts | 1 + app/src/lib/i18n/zh-CN.ts | 1 + src/core/jsonrpc.rs | 6 ++ src/openhuman/emergency_stop/ops.rs | 85 ------------------- 18 files changed, 56 insertions(+), 103 deletions(-) diff --git a/app/src/components/safety/EmergencyStopButton.test.tsx b/app/src/components/safety/EmergencyStopButton.test.tsx index c4aae8b4ae..12f72d8da1 100644 --- a/app/src/components/safety/EmergencyStopButton.test.tsx +++ b/app/src/components/safety/EmergencyStopButton.test.tsx @@ -33,7 +33,7 @@ describe('EmergencyStopButton', () => { expect(safetyState.halted).toBe(true); }); - it('does NOT mark halted when emergencyStop throws (no false halt)', async () => { + it('does NOT mark halted when emergencyStop throws, and shows a visible error', async () => { stop.mockRejectedValueOnce(new Error('core unavailable')); const { store } = renderWithProviders(); fireEvent.click(screen.getByRole('button', { name: /emergency stop/i })); @@ -43,6 +43,8 @@ describe('EmergencyStopButton', () => { 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)', () => { diff --git a/app/src/components/safety/EmergencyStopButton.tsx b/app/src/components/safety/EmergencyStopButton.tsx index 371e19fdfc..7b08c46aa4 100644 --- a/app/src/components/safety/EmergencyStopButton.tsx +++ b/app/src/components/safety/EmergencyStopButton.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useT } from '../../lib/i18n/I18nContext'; @@ -8,8 +8,11 @@ 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. If the RPC fails the halt is - * committed locally anyway so the user always sees a response to their action. + * 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. @@ -18,8 +21,10 @@ export function EmergencyStopButton() { const { t } = useT(); const dispatch = useDispatch(); const halted = useSelector(selectHalted); + const [failed, setFailed] = useState(false); const handleClick = useCallback(async () => { + setFailed(false); console.debug('[emergency] stop requested (source=user)'); try { const state = await emergencyStop(); @@ -31,9 +36,10 @@ export function EmergencyStopButton() { } 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. Leave the button visible so the user can retry; - // a confirmed halt only surfaces from a successful response or the - // `automation_halt` broadcast. + // 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]); @@ -42,16 +48,26 @@ export function EmergencyStopButton() { if (halted) return null; return ( - +
+ {failed && ( + + {t('safety.stopFailed')} + + )} + +
); } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 33fb3e228a..9708c0defd 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -6739,6 +6739,7 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'إيقاف الطوارئ', + 'safety.stopFailed': 'تعذّر إيقاف الأتمتة — أعد المحاولة.', 'safety.resume': 'استئناف الأتمتة', 'safety.haltedTitle': 'الأتمتة متوقفة', 'safety.haltedBody': 'تم إيقاف جميع أتمتة سطح المكتب. استأنف عندما تكون مستعدًا.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index c2871e16c7..1ca50065ab 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -6892,6 +6892,7 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'জরুরি বন্ধ', + 'safety.stopFailed': 'অটোমেশন থামানো যায়নি — আবার চেষ্টা করুন।', 'safety.resume': 'অটোমেশন পুনরায় শুরু করুন', 'safety.haltedTitle': 'অটোমেশন বন্ধ', 'safety.haltedBody': 'সমস্ত ডেস্কটপ অটোমেশন বন্ধ করা হয়েছে। প্রস্তুত হলে পুনরায় শুরু করুন।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 0a032df213..7ba98fe652 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7082,6 +7082,7 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Notabschaltung', + 'safety.stopFailed': 'Automatisierung konnte nicht gestoppt werden – bitte erneut versuchen.', 'safety.resume': 'Automatisierung fortsetzen', 'safety.haltedTitle': 'Automatisierung angehalten', 'safety.haltedBody': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index ce84426191..9e2f9cd487 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7188,6 +7188,7 @@ const en: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Emergency stop', + 'safety.stopFailed': 'Could not stop automation — try again.', 'safety.resume': 'Resume automation', 'safety.haltedTitle': 'Automation halted', 'safety.haltedBody': 'All desktop automation is stopped. Resume when you are ready.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index ec230552a6..a5c6f46895 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7030,6 +7030,7 @@ const messages: TranslationMap = { // 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.haltedTitle': 'Automatización detenida', 'safety.haltedBody': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index d83a7815e8..b95822e63d 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7053,6 +7053,7 @@ const messages: TranslationMap = { // 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.haltedTitle': 'Automatisation suspendue', 'safety.haltedBody': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index bee648dbeb..2d92eb0266 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -6891,6 +6891,7 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'आपातकालीन रोक', + 'safety.stopFailed': 'स्वचालन रोका नहीं जा सका — पुनः प्रयास करें।', 'safety.resume': 'स्वचालन पुनः प्रारंभ करें', 'safety.haltedTitle': 'स्वचालन रोका गया', 'safety.haltedBody': 'सभी डेस्कटॉप स्वचालन रोक दिया गया है। तैयार होने पर पुनः प्रारंभ करें।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index cc4d6807a1..c44820bfd7 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -6917,6 +6917,7 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Hentikan darurat', + 'safety.stopFailed': 'Tidak dapat menghentikan otomasi — coba lagi.', 'safety.resume': 'Lanjutkan otomasi', 'safety.haltedTitle': 'Otomasi dihentikan', 'safety.haltedBody': 'Semua otomasi desktop dihentikan. Lanjutkan ketika Anda siap.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index ee8d12b175..5e239e708d 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7017,6 +7017,7 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Arresto di emergenza', + 'safety.stopFailed': "Impossibile fermare l'automazione — riprova.", 'safety.resume': "Riprendi l'automazione", 'safety.haltedTitle': 'Automazione sospesa', 'safety.haltedBody': "Tutta l'automazione del desktop è ferma. Riprendi quando sei pronto.", diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index ea8fc17d62..8c90997c1b 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -6815,6 +6815,7 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': '긴급 정지', + 'safety.stopFailed': '자동화를 중지할 수 없습니다 — 다시 시도하세요.', 'safety.resume': '자동화 재개', 'safety.haltedTitle': '자동화 중단됨', 'safety.haltedBody': '모든 데스크톱 자동화가 중지되었습니다. 준비가 되면 재개하세요.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 2b4a668b00..b68cd1b5fd 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -6992,6 +6992,7 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Awaryjne zatrzymanie', + 'safety.stopFailed': 'Nie udało się zatrzymać automatyzacji — spróbuj ponownie.', 'safety.resume': 'Wznów automatyzację', 'safety.haltedTitle': 'Automatyzacja wstrzymana', 'safety.haltedBody': 'Cała automatyzacja pulpitu jest zatrzymana. Wznów, gdy będziesz gotowy.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index b5fd0f1fcd..6f28e3821c 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7006,6 +7006,7 @@ const messages: TranslationMap = { // 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.haltedTitle': 'Automação pausada', 'safety.haltedBody': 'Toda a automação do desktop está parada. Retome quando estiver pronto.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 1ee1f53c21..db3fa9fed3 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -6967,6 +6967,7 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Аварийная остановка', + 'safety.stopFailed': 'Не удалось остановить автоматизацию — попробуйте ещё раз.', 'safety.resume': 'Возобновить автоматизацию', 'safety.haltedTitle': 'Автоматизация приостановлена', 'safety.haltedBody': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index e4fbc6f427..a351ea28fd 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6518,6 +6518,7 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': '紧急停止', + 'safety.stopFailed': '无法停止自动化,请重试。', 'safety.resume': '恢复自动化', 'safety.haltedTitle': '自动化已暂停', 'safety.haltedBody': '所有桌面自动化已停止。准备好后请恢复。', diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 61fbad7366..c31c408232 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2657,6 +2657,12 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) { // unguarded standalone/CLI/Docker core would park a plan review that never // reaches the UI and dies at the gate TTL. Idempotent (Once-guarded). crate::openhuman::channels::providers::web::register_approval_surface_subscriber(); + // Bridge emergency-stop halt/resume → the `automation_halt` broadcast on the + // same always-run boot path. `start_channels` (which also registers this) + // is skipped on a web-chat-only desktop with no listening integrations, so + // without this a halt/resume initiated from the CLI or another client would + // never reach the UI. Idempotent (Once-guarded). (#4255) + crate::openhuman::channels::providers::web::register_automation_halt_subscriber(); if decision.install_gate { // Per-launch correlation token for the approval gate. This is diff --git a/src/openhuman/emergency_stop/ops.rs b/src/openhuman/emergency_stop/ops.rs index c97aa43afb..e8c42d3bb0 100644 --- a/src/openhuman/emergency_stop/ops.rs +++ b/src/openhuman/emergency_stop/ops.rs @@ -151,89 +151,4 @@ mod tests { assert_eq!(out.value.reason.as_deref(), Some("b")); let _ = emergency_resume("user").await; } - - /// `cascade_deny_pending` loop body executes when an `ApprovalGate` is - /// installed and pending rows exist. - /// - /// The previous ops tests run without a global `ApprovalGate`, so - /// `cascade_deny_pending()` returns 0 early and its loop body (list_pending - /// → decide(Deny) per row) is never exercised. This test installs a real - /// gate backed by a temporary workspace and inserts a pending approval row - /// directly via the store, then calls `emergency_stop` and asserts the row - /// was denied by the cascade. - /// - /// **Isolation note**: `ApprovalGate::init_global` is `OnceLock`-guarded; - /// once set it persists for the process lifetime. After this test the gate - /// lives on with its TempDir workspace deleted — subsequent `cascade_deny` - /// calls hit an empty/recreated DB (SQLite `create_dir_all` + open creates a - /// fresh file) and return 0, which is safe because those callers never assert - /// on the deny count. - #[tokio::test] - async fn cascade_deny_pending_loop_denies_all_pending_when_gate_installed() { - // Serialize against every test that touches the process-global - // EmergencyStop (mirrors the pattern in the sibling tests above). - let _g = EMERGENCY_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - - // Install the process-global ApprovalGate with a test-local workspace. - // No other unit test calls ApprovalGate::init_global, so this is the - // first install. The gate persists for the process lifetime; see the - // isolation note above. - let _dir = tempfile::TempDir::new().unwrap(); - let workspace_dir = _dir.path().to_path_buf(); - let session_id = format!("session-{}", uuid::Uuid::new_v4()); - let gate = crate::openhuman::approval::ApprovalGate::init_global( - crate::openhuman::config::Config { - workspace_dir: workspace_dir.clone(), - ..crate::openhuman::config::Config::default() - }, - &session_id, - ); - - // Insert a pending approval row directly via the store (bypassing the - // intercept/park async flow, which would block until a decision arrives - // or the TTL elapses). The row has a future expiry so it is not lazily - // expired by list_pending before cascade_deny runs. - let pending = crate::openhuman::approval::PendingApproval::new( - "req-cascade-test", - "cascade_test_tool", - "cascade deny smoke test", - serde_json::json!({}), - Some(chrono::Utc::now() + chrono::Duration::minutes(10)), - ); - crate::openhuman::approval::store::insert_pending( - &crate::openhuman::config::Config { - workspace_dir: workspace_dir.clone(), - ..crate::openhuman::config::Config::default() - }, - &pending, - &session_id, - ) - .unwrap(); - - // Sanity: row is visible before the stop. - assert_eq!( - gate.list_pending().unwrap().len(), - 1, - "test setup: pending row must be visible before emergency_stop" - ); - - // Engage the kill switch — cascade_deny_pending runs inside and - // should decide(Deny) every pending row. - let out = emergency_stop(Some("cascade test".into()), "user").await; - assert!( - out.value.engaged, - "emergency_stop must engage the halt flag" - ); - - // The loop body exercised: the pending row is now decided (denied). - assert!( - gate.list_pending().unwrap().is_empty(), - "cascade_deny_pending must deny all pending rows when an ApprovalGate is installed" - ); - - // Clear the global switch for subsequent tests. - let _ = emergency_resume("user").await; - } } From 4f38787d8974df20d946a6bcfd73549662218d9e Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Tue, 7 Jul 2026 03:17:52 +0530 Subject: [PATCH 25/33] style: prettier-format ChatRuntimeProvider.test.tsx (main-introduced, unblocks merge check) (#4255) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/providers/__tests__/ChatRuntimeProvider.test.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx index 2570fcfa28..319494d985 100644 --- a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx +++ b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx @@ -1019,14 +1019,13 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria await waitFor(() => expect(threadApi.appendMessage).toHaveBeenCalledWith( 't-interim', - expect.objectContaining({ - content: 'Let me check your calendar first.', - sender: 'agent', - }) + expect.objectContaining({ content: 'Let me check your calendar first.', sender: 'agent' }) ) ); // …and cleared from the live preview so it isn't shown twice. - expect(store.getState().chatRuntime.streamingAssistantByThread['t-interim']?.content).toBe(''); + expect(store.getState().chatRuntime.streamingAssistantByThread['t-interim']?.content).toBe( + '' + ); }); it('dedupes a re-delivered interim event by round', async () => { From 6525e346c2784e7c6e57e2e749729572e0b35453 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Tue, 7 Jul 2026 13:21:31 +0530 Subject: [PATCH 26/33] fix(emergency): use typed store/hooks + defensive selectors so App-shell tests pass (#4255) App.webviewOverlay.test.tsx mocks the app's ../store/hooks, but the safety components imported useDispatch/useSelector directly from react-redux, so once App.tsx mounts them (post-merge) they hit the unmocked context and threw 'no Provider'. Switch both to useAppDispatch/useAppSelector (mocked by the shell tests) and make selectHalted/selectHaltReason tolerate a mock state without the safety slice. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/components/safety/AutomationHaltedBanner.tsx | 8 ++++---- app/src/components/safety/EmergencyStopButton.tsx | 6 +++--- app/src/store/safetySlice.ts | 7 +++++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/components/safety/AutomationHaltedBanner.tsx b/app/src/components/safety/AutomationHaltedBanner.tsx index 61e74b7341..af90a7c69f 100644 --- a/app/src/components/safety/AutomationHaltedBanner.tsx +++ b/app/src/components/safety/AutomationHaltedBanner.tsx @@ -1,8 +1,8 @@ import { useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; 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'; /** @@ -15,9 +15,9 @@ import { clearHalt, selectHalted, selectHaltReason } from '../../store/safetySli */ export function AutomationHaltedBanner() { const { t } = useT(); - const dispatch = useDispatch(); - const halted = useSelector(selectHalted); - const reason = useSelector(selectHaltReason); + const dispatch = useAppDispatch(); + const halted = useAppSelector(selectHalted); + const reason = useAppSelector(selectHaltReason); const onResume = useCallback(async () => { console.debug('[emergency] resume requested (source=user)'); diff --git a/app/src/components/safety/EmergencyStopButton.tsx b/app/src/components/safety/EmergencyStopButton.tsx index 7b08c46aa4..254b38ac70 100644 --- a/app/src/components/safety/EmergencyStopButton.tsx +++ b/app/src/components/safety/EmergencyStopButton.tsx @@ -1,8 +1,8 @@ import { useCallback, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { useT } from '../../lib/i18n/I18nContext'; import { emergencyStop } from '../../services/api/emergencyApi'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { selectHalted, setHalt } from '../../store/safetySlice'; /** @@ -19,8 +19,8 @@ import { selectHalted, setHalt } from '../../store/safetySlice'; */ export function EmergencyStopButton() { const { t } = useT(); - const dispatch = useDispatch(); - const halted = useSelector(selectHalted); + const dispatch = useAppDispatch(); + const halted = useAppSelector(selectHalted); const [failed, setFailed] = useState(false); const handleClick = useCallback(async () => { diff --git a/app/src/store/safetySlice.ts b/app/src/store/safetySlice.ts index 58f7f28f2b..29f8d437dd 100644 --- a/app/src/store/safetySlice.ts +++ b/app/src/store/safetySlice.ts @@ -41,6 +41,9 @@ const safetySlice = createSlice({ }); export const { setHalt, clearHalt, hydrateHalt } = safetySlice.actions; -export const selectHalted = (state: { safety: SafetyState }) => state.safety.halted; -export const selectHaltReason = (state: { safety: SafetyState }) => state.safety.reason; +// 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; From a69f2ca948d0154bc5109671c4d8d13fd27dc3c2 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Tue, 7 Jul 2026 19:29:27 +0530 Subject: [PATCH 27/33] fix: repair 2 pre-existing main test breakages exposed by full-suite coverage (#4255) The new emergency_stop module adds a line to src/openhuman/mod.rs, which widens the changed-files coverage lane to the whole openhuman suite and runs two latent main failures normal (narrow-filter) PRs never exercise together: - flows dry_run_flags_tool_call_error_when_on_error_is_route: main's stricter graph validator now rejects on_error=route with no error-port edge; add a noop recovery node + error edge so the fixture is valid and the dry-run can still flag the unresolved-arg failure (the test's actual assertion). - cron ...offline_trips_halt_guard: run_agent_job captured only the top-level anyhow message, dropping the transport cause; render the full chain ({:#}) so the loopback-unreachable classifier sees 'tcp connect error: Connection refused (os error N)' (and observability gets the full chain, as intended). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/openhuman/cron/scheduler.rs | 6 +++++- src/openhuman/flows/builder_tools_tests.rs | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 3e00229cdf..8b320ecb0d 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -958,7 +958,11 @@ async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String, Option< // and provider URLs are appropriate; it must NOT reach the // user-visible notification body. let user_message = classify_agent_anyhow_for_user(&e); - (false, user_message.to_string(), Some(e.to_string())) + // Preserve the FULL anyhow chain (`{:#}`), not just the top-level + // message: the loopback-unreachable classifier and the observability + // pipeline key on the transport cause (`… tcp connect error: Connection + // refused (os error N)`), which a bare `to_string()` drops. + (false, user_message.to_string(), Some(format!("{e:#}"))) } } } diff --git a/src/openhuman/flows/builder_tools_tests.rs b/src/openhuman/flows/builder_tools_tests.rs index 02a4a68a0d..09d3373405 100644 --- a/src/openhuman/flows/builder_tools_tests.rs +++ b/src/openhuman/flows/builder_tools_tests.rs @@ -669,9 +669,19 @@ async fn dry_run_flags_tool_call_error_when_on_error_is_route() { { "id": "t", "kind": "trigger", "name": "Manual" }, { "id": "post", "kind": "tool_call", "name": "Send email", "config": { "slug": "GMAIL_SEND_EMAIL", "on_error": "route", - "args": { "to": "=item.email", "body": "hello" } } } + "args": { "to": "=item.email", "body": "hello" } } }, + // `on_error: "route"` requires an `error`-port edge — the graph + // validator (tinyflows) rejects a routing node with no recovery + // branch. A noop recovery node keeps the graph valid so the dry-run + // can still flag `post`'s unresolved-arg failure (the point of this + // test) instead of failing graph validation first. + { "id": "recover", "kind": "tool_call", "name": "Recover", + "config": { "slug": "oh:noop", "args": {} } } ], - "edges": [ { "from_node": "t", "to_node": "post" } ] + "edges": [ + { "from_node": "t", "to_node": "post" }, + { "from_node": "post", "from_port": "error", "to_node": "recover" } + ] }); // `to` misses (trigger input has no `email`) — a real run would fail the From 59169cf41f17a859d0b6d7e8509cea4f4512f638 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Thu, 9 Jul 2026 13:16:50 +0530 Subject: [PATCH 28/33] fix(emergency): address open review comments (blocker + majors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the still-open blocker + major comments on PR #4600 after the `18e2800e6`/`44ab15306` rounds. Verified against current code: - Already-fixed items skipped (`register_automation_halt_subscriber` on core boot, `state.rs` atomic-pair transitions, `EmergencyStopButton` failed-state banner, `json_rpc_e2e` `ResumeOnDrop` guard). ## Blockers ### `socketService.ts` — read halt payload from the `WebChannelEvent` `args` envelope `emit_web_channel_event` in `src/core/socketio.rs` serialises the whole `WebChannelEvent`, so `automation_halt` arrives as `{event, client_id, args: {engaged, reason, source}}` — the same contract as `approval_request`. The previous handler read `engaged`/`reason`/`source` from the top level, so every real broadcast (cross-client, CLI, cron) missed the fail-closed guard and silently `return`ed. Prefer the `args` envelope, fall back to the top level so direct-emit test payloads still work. Test updates: primary `engaged=true`/`engaged=false` cases now feed the real envelope shape so the tests no longer hide the bug. Adds two fail-closed cases covering `{args: {}}` and `{args: {engaged: 'true'}}` so a malformed real broadcast can never bypass the guard either. ### `cron/scheduler.rs` — refuse scheduled jobs while halted `execute_job_with_retry` now short-circuits with `(false, "blocked by emergency stop: …")` at the outermost dispatch. The tinyagents middleware already refuses external-effect tool calls inside `JobType::Agent`, but `JobType::Shell` (spawns `sh -lc`) and `JobType::Flow` (publishes a flow-trigger event) never go through the middleware. Without this guard, a due or Run Now shell/flow job could still perform external actions while automation was halted. Fail-closed at the outermost dispatch applies to every job type and every retry attempt, and never spawns the underlying process. New test `execute_job_with_retry_refuses_shell_job_while_halted` engages the process-global switch (under the shared `EMERGENCY_TEST_GUARD` + `ClearEmergencyOnDrop` RAII) and asserts the refusal path. ## Majors ### `AutomationHaltedBanner.tsx` — preserve halt state when resume fails The previous `finally { dispatch(clearHalt()) }` cleared the halt locally even when `emergency_resume` failed (timeout, auth, core unavailable), which re-exposed the Stop button while every external-effect action remained blocked — a false "safe to run" signal. Now `clearHalt` only fires on a confirmed resume; failures preserve the halted flag and surface a `role="status"` retry indicator ("Could not resume — …") so the user can retry, and the `automation_halt` socket broadcast still clears state if the resume succeeds server-side after an in-flight RPC failure. Adds `safety.resumeFailed` copy to all 14 locale files (en + ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN). Updates the banner test to assert the new behavior (halt preserved, retry indicator visible). ### `docs/superpowers/plans/…-emergency-stop.md` + `…-design.md` — reconcile audit-trail scope The design spec's Task 7 said halted tool calls should be recorded in the approval audit trail as `Aborted`, but the middleware's halt short-circuit returns before `ApprovalGate::intercept_audited` and there is no gate API today to write an aborted row without going through `intercept_audited`. Rather than expand this slice with new gate surface, the plan and design now explicitly acknowledge that audit-trail-for-halted refusals is a follow-up. The refusal itself is still surfaced via a `tracing::warn!`, the `AutomationHalted` domain event, and the `automation_halt` socket broadcast — the audit row is the only missing signal. ## Not touched `src/openhuman/cron/scheduler.rs:963` (`e.to_string()` → `format!("{e:#}")` change bundled from `a69f2ca9`) — reviewer question answered inline; change is intentional (the fix is load-bearing for the loopback-unreachable + session-expired classifiers, and reverting would take the PR red again under the full-suite coverage lane). No code change; kept as-is. --- .../safety/AutomationHaltedBanner.test.tsx | 23 +++++++-- .../safety/AutomationHaltedBanner.tsx | 46 +++++++++++------ app/src/lib/i18n/ar.ts | 1 + app/src/lib/i18n/bn.ts | 1 + app/src/lib/i18n/de.ts | 2 + app/src/lib/i18n/en.ts | 1 + app/src/lib/i18n/es.ts | 2 + app/src/lib/i18n/fr.ts | 2 + app/src/lib/i18n/hi.ts | 1 + app/src/lib/i18n/id.ts | 1 + app/src/lib/i18n/it.ts | 1 + app/src/lib/i18n/ko.ts | 2 + app/src/lib/i18n/pl.ts | 2 + app/src/lib/i18n/pt.ts | 1 + app/src/lib/i18n/ru.ts | 2 + app/src/lib/i18n/zh-CN.ts | 1 + .../__tests__/socketService.events.test.ts | 50 +++++++++++++++++-- app/src/services/socketService.ts | 15 ++++-- .../plans/2026-07-06-emergency-stop.md | 2 + .../specs/2026-07-06-emergency-stop-design.md | 4 +- src/openhuman/cron/scheduler.rs | 21 ++++++++ src/openhuman/cron/scheduler_tests.rs | 37 ++++++++++++++ 22 files changed, 189 insertions(+), 29 deletions(-) diff --git a/app/src/components/safety/AutomationHaltedBanner.test.tsx b/app/src/components/safety/AutomationHaltedBanner.test.tsx index f9ed7ef359..8aeab58971 100644 --- a/app/src/components/safety/AutomationHaltedBanner.test.tsx +++ b/app/src/components/safety/AutomationHaltedBanner.test.tsx @@ -54,16 +54,29 @@ describe('AutomationHaltedBanner', () => { expect(safetyState.halted).toBe(false); }); - it('clears halt even if emergencyResume throws', async () => { + it('preserves halt and surfaces a retry message when emergencyResume fails', async () => { + // Fail-closed: on RPC failure the core is still halted, so the UI must + // NOT silently clear the halt. Clearing locally would re-expose the Stop + // button while every external-effect action remained blocked, giving a + // false "resumed" signal (#4255 codex P2). resume.mockRejectedValueOnce(new Error('core error')); const { store } = renderWithProviders(, { preloadedState: { safety: { halted: true } }, }); fireEvent.click(screen.getByRole('button', { name: /resume/i })); - await waitFor(() => { - const safetyState = (store.getState() as { safety: { halted: boolean } }).safety; - expect(safetyState.halted).toBe(false); - }); + 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 }) ?? + screen.getByText(/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('dispatches halt and then renders banner after setHalt dispatch', async () => { diff --git a/app/src/components/safety/AutomationHaltedBanner.tsx b/app/src/components/safety/AutomationHaltedBanner.tsx index af90a7c69f..9fa40c38e4 100644 --- a/app/src/components/safety/AutomationHaltedBanner.tsx +++ b/app/src/components/safety/AutomationHaltedBanner.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { emergencyResume } from '../../services/api/emergencyApi'; @@ -9,25 +9,33 @@ import { clearHalt, selectHalted, selectHaltReason } from '../../store/safetySli * 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 `finally` block in `onResume` ensures the UI clears the halt locally even - * if the core resume RPC fails, so the user is never stuck in a halted state - * they cannot escape from without a restart. + * 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'); - } catch (err) { - console.error('[emergency] resume failed — clearing halt locally anyway', err); - } finally { + // 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]); @@ -44,13 +52,23 @@ export function AutomationHaltedBanner() { {reason ?? t('safety.haltedBody')}
- +
+ {resumeFailed && ( + + {t('safety.resumeFailed')} + + )} + +
); } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 5db3788815..f68c93cb12 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -6888,6 +6888,7 @@ const messages: TranslationMap = { 'safety.emergencyStop': 'إيقاف الطوارئ', 'safety.stopFailed': 'تعذّر إيقاف الأتمتة — أعد المحاولة.', 'safety.resume': 'استئناف الأتمتة', + 'safety.resumeFailed': 'تعذّر الاستئناف — لا تزال الأتمتة متوقفة. أعد المحاولة.', 'safety.haltedTitle': 'الأتمتة متوقفة', 'safety.haltedBody': 'تم إيقاف جميع أتمتة سطح المكتب. استأنف عندما تكون مستعدًا.', }; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 667cbbaa33..d312855b66 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7043,6 +7043,7 @@ const messages: TranslationMap = { 'safety.emergencyStop': 'জরুরি বন্ধ', 'safety.stopFailed': 'অটোমেশন থামানো যায়নি — আবার চেষ্টা করুন।', 'safety.resume': 'অটোমেশন পুনরায় শুরু করুন', + 'safety.resumeFailed': 'পুনরায় শুরু করা যায়নি — অটোমেশন এখনও বন্ধ। আবার চেষ্টা করুন।', 'safety.haltedTitle': 'অটোমেশন বন্ধ', 'safety.haltedBody': 'সমস্ত ডেস্কটপ অটোমেশন বন্ধ করা হয়েছে। প্রস্তুত হলে পুনরায় শুরু করুন।', }; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index ff67f3d1a5..9025a5c2da 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7240,6 +7240,8 @@ const messages: TranslationMap = { '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.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 955598d26e..eb532fd0fc 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7348,6 +7348,7 @@ const en: TranslationMap = { '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.', }; diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 77d8f61efb..fd6af33714 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7185,6 +7185,8 @@ const messages: TranslationMap = { '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.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 001f0c2cd9..4ac65b8023 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7208,6 +7208,8 @@ const messages: TranslationMap = { '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.", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index e02e5f7193..0551a9020a 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7041,6 +7041,7 @@ const messages: TranslationMap = { 'safety.emergencyStop': 'आपातकालीन रोक', 'safety.stopFailed': 'स्वचालन रोका नहीं जा सका — पुनः प्रयास करें।', 'safety.resume': 'स्वचालन पुनः प्रारंभ करें', + 'safety.resumeFailed': 'पुनः प्रारंभ नहीं हो सका — स्वचालन अभी भी रुका हुआ है। पुनः प्रयास करें।', 'safety.haltedTitle': 'स्वचालन रोका गया', 'safety.haltedBody': 'सभी डेस्कटॉप स्वचालन रोक दिया गया है। तैयार होने पर पुनः प्रारंभ करें।', }; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 80517a5d31..56c866d759 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7070,6 +7070,7 @@ const messages: TranslationMap = { '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.', }; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index ebaee22c17..b1da8818d8 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7172,6 +7172,7 @@ const messages: TranslationMap = { '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.", }; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index cf1c1f2c4d..ad42f1ad65 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -6964,6 +6964,8 @@ const messages: TranslationMap = { 'safety.emergencyStop': '긴급 정지', 'safety.stopFailed': '자동화를 중지할 수 없습니다 — 다시 시도하세요.', 'safety.resume': '자동화 재개', + 'safety.resumeFailed': + '재개하지 못했습니다 — 자동화가 여전히 중단된 상태입니다. 다시 시도하세요.', 'safety.haltedTitle': '자동화 중단됨', 'safety.haltedBody': '모든 데스크톱 자동화가 중지되었습니다. 준비가 되면 재개하세요.', }; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index ec58573f78..1c36f31ebe 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7147,6 +7147,8 @@ const messages: TranslationMap = { '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.', }; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 3299b74295..98b43f42a7 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7161,6 +7161,7 @@ const messages: TranslationMap = { '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.', }; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index dffa17991d..2adbd32f5f 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7121,6 +7121,8 @@ const messages: TranslationMap = { 'safety.emergencyStop': 'Аварийная остановка', 'safety.stopFailed': 'Не удалось остановить автоматизацию — попробуйте ещё раз.', 'safety.resume': 'Возобновить автоматизацию', + 'safety.resumeFailed': + 'Не удалось возобновить — автоматизация всё ещё приостановлена. Повторите попытку.', 'safety.haltedTitle': 'Автоматизация приостановлена', 'safety.haltedBody': 'Вся автоматизация рабочего стола остановлена. Возобновите, когда будете готовы.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 40bad6b268..8e1743c367 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6665,6 +6665,7 @@ const messages: TranslationMap = { 'safety.emergencyStop': '紧急停止', 'safety.stopFailed': '无法停止自动化,请重试。', 'safety.resume': '恢复自动化', + 'safety.resumeFailed': '无法恢复——自动化仍处于暂停状态。请重试。', 'safety.haltedTitle': '自动化已暂停', 'safety.haltedBody': '所有桌面自动化已停止。准备好后请恢复。', }; diff --git a/app/src/services/__tests__/socketService.events.test.ts b/app/src/services/__tests__/socketService.events.test.ts index af8c151f20..9202dd559b 100644 --- a/app/src/services/__tests__/socketService.events.test.ts +++ b/app/src/services/__tests__/socketService.events.test.ts @@ -467,7 +467,7 @@ describe('socketService — automation_halt handler (#4255)', () => { vi.restoreAllMocks(); }); - it('dispatches setHalt when automation_halt arrives with engaged=true', async () => { + 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'); @@ -485,14 +485,22 @@ describe('socketService — automation_halt handler (#4255)', () => { await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); - handlers['automation_halt']!({ engaged: true, reason: 'cli', source: 'cli' }); + // 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', async () => { + 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'); @@ -508,13 +516,41 @@ describe('socketService — automation_halt handler (#4255)', () => { await pollUntil(() => expect(handlers['automation_halt']).toBeDefined()); - handlers['automation_halt']!({ engaged: false }); + 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) })); @@ -557,9 +593,15 @@ describe('socketService — automation_halt handler (#4255)', () => { // Ambiguous payloads (missing/non-boolean `engaged`) must NOT be treated as // `engaged=false` — that would silently clear an active halt on a kill switch. + // Both the top-level shape and the `WebChannelEvent` `args` envelope shape + // are covered so a real malformed broadcast can never bypass the guard. expect(() => handlers['automation_halt']!({})).not.toThrow(); expect(() => handlers['automation_halt']!({ reason: 'x' })).not.toThrow(); expect(() => handlers['automation_halt']!({ engaged: 'true' })).not.toThrow(); + expect(() => handlers['automation_halt']!({ args: {} })).not.toThrow(); + expect(() => + handlers['automation_halt']!({ args: { engaged: 'true', reason: 'x' } }) + ).not.toThrow(); expect(storeMock.dispatch).not.toHaveBeenCalled(); }); }); diff --git a/app/src/services/socketService.ts b/app/src/services/socketService.ts index 8de594ecb4..f82ec9090f 100644 --- a/app/src/services/socketService.ts +++ b/app/src/services/socketService.ts @@ -473,17 +473,24 @@ class SocketService { socketWarn('automation_halt dropped — invalid payload shape'); return; } + // Halt fields ride under `args` in the `WebChannelEvent` envelope + // (same contract as `approval_request`; see `event_bus.rs` builder and + // `emit_web_channel_event` in `src/core/socketio.rs`, which does + // `serde_json::to_value(event)` on the whole envelope). Fall back to + // the top level so a direct-emit test payload keeps working. + const payload = + obj.args && typeof obj.args === 'object' ? (obj.args as Record) : obj; // Fail closed: a kill-switch event must carry an explicit boolean // `engaged`. An ambiguous payload (missing/non-boolean flag, e.g. `{}` or // `{reason:'x'}`) is dropped rather than treated as `false`, so a // malformed broadcast can never silently clear an active halt. - if (typeof obj.engaged !== 'boolean') { + if (typeof payload.engaged !== 'boolean') { socketWarn('automation_halt dropped — missing/invalid engaged flag'); return; } - const engaged = obj.engaged; - const reason = typeof obj.reason === 'string' ? obj.reason : undefined; - const source = typeof obj.source === 'string' ? obj.source : undefined; + 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, diff --git a/docs/superpowers/plans/2026-07-06-emergency-stop.md b/docs/superpowers/plans/2026-07-06-emergency-stop.md index 3b384f5c0e..ce629ba8d8 100644 --- a/docs/superpowers/plans/2026-07-06-emergency-stop.md +++ b/docs/superpowers/plans/2026-07-06-emergency-stop.md @@ -742,6 +742,8 @@ git commit -m "feat(emergency): RPC controllers (stop/resume/status) + boot inst } ``` +> **Audit-trail note (deferred):** the halt short-circuit returns immediately, so a refused call is NOT recorded through `ApprovalGate::intercept_audited` (which is what writes the "aborted" row for a denied external-effect call). This is a conscious scope choice for this slice — writing an `aborted` audit row from the middleware needs a new gate API (there is no such surface today), and the halted refusal is already surfaced via the `tracing::warn!` above plus the `AutomationHalted` domain event / `automation_halt` socket broadcast. Recording halted refusals in the approval audit trail is tracked as a follow-up; adjust either this step or the design spec's "audit trail" requirement once that follow-up lands. + - [ ] **Step 2: Write a unit test.** In the `#[cfg(test)]` module of `middleware.rs` (or a sibling `middleware_tests.rs` if one exists — match the file's convention), add a test that engages the global switch and asserts a halted external-effect call short-circuits. If constructing a full `RunContext`/`ToolHandler` is heavy, instead add a focused test in `emergency_stop` that exercises `is_engaged_global()` transitions and document the middleware behavior via an integration assertion in Task 10's E2E. Minimum viable unit test (pure guard behavior): ```rust diff --git a/docs/superpowers/specs/2026-07-06-emergency-stop-design.md b/docs/superpowers/specs/2026-07-06-emergency-stop-design.md index 4dd331aefa..45b3f60939 100644 --- a/docs/superpowers/specs/2026-07-06-emergency-stop-design.md +++ b/docs/superpowers/specs/2026-07-06-emergency-stop-design.md @@ -44,7 +44,7 @@ Follows the canonical module shape (`mod.rs` export-only; `types.rs`; `state.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 and record it in the audit trail (`record_execution` Aborted). This stops the agent loop from taking further real-world actions. +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. @@ -69,7 +69,7 @@ User clicks Emergency Stop → all clients: safetySlice.setHalt → button shows halted state + banner Agent tries another tool while halted - → middleware.wrap_tool sees is_engaged() → deny (audited Aborted) → agent cannot act + → 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' } diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 6563b7ad46..1d31f7f637 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -472,6 +472,27 @@ async fn execute_job_with_retry( security: &SecurityPolicy, job: &CronJob, ) -> (bool, String) { + // Emergency stop: refuse every scheduled job while the kill switch is + // engaged. The tinyagents middleware already fails-closed on external-effect + // tools inside `JobType::Agent`, but `JobType::Shell` spawns `sh -lc` and + // `JobType::Flow` publishes a flow-trigger event — neither goes through the + // middleware, so without this check a due or Run Now shell/flow job could + // still perform external actions while automation is halted. Fail-closed at + // the outermost dispatch is the safest place: it applies to every job type + // and to every retry attempt, and never spawns the underlying process. See + // #4255. + if crate::openhuman::emergency_stop::is_engaged_global() { + log::warn!( + "[cron] action=refused_while_halted job_id={} job_type={:?} — emergency stop engaged", + job.id.as_str(), + job.job_type + ); + return ( + false, + "blocked by emergency stop: automation is halted — resume to run this job".to_string(), + ); + } + let mut last_output = String::new(); let mut last_agent_error: Option = None; let retries = config.reliability.scheduler_retries; diff --git a/src/openhuman/cron/scheduler_tests.rs b/src/openhuman/cron/scheduler_tests.rs index 1ffbcbd89d..573b6e5045 100644 --- a/src/openhuman/cron/scheduler_tests.rs +++ b/src/openhuman/cron/scheduler_tests.rs @@ -439,6 +439,43 @@ async fn execute_job_with_retry_exhausts_attempts() { assert!(output.contains("always_missing_for_retry_test")); } +/// Emergency stop must refuse every scheduled shell/flow/agent job before it +/// launches, so a `sh -lc` or flow-trigger event can never fire while the kill +/// switch is engaged. Covers the codex-review gap for cron paths that don't +/// route through the tinyagents middleware (#4255). +#[cfg(not(windows))] +#[tokio::test] +async fn execute_job_with_retry_refuses_shell_job_while_halted() { + let _test_guard = crate::openhuman::emergency_stop::state::EMERGENCY_TEST_GUARD + .lock() + .unwrap_or_else(|e| e.into_inner()); + let stop = crate::openhuman::emergency_stop::EmergencyStop::init_global(); + stop.clear(); // start clean regardless of parallel-suite state + let _resume_on_drop = crate::openhuman::emergency_stop::state::ClearEmergencyOnDrop; + + let tmp = TempDir::new().unwrap(); + let mut config = test_config(&tmp).await; + config.reliability.scheduler_retries = 3; + config.reliability.provider_backoff_ms = 1; + let security = SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + &config.workspace_dir, + ); + let job = test_job("/bin/echo should-never-run"); + + // Engage AFTER building the job/config to isolate the halt check. + stop.engage(Some("test".into()), "test", 0); + + let (success, output) = execute_job_with_retry(&config, &security, &job).await; + + assert!(!success, "halted scheduler must not report success"); + assert!( + output.starts_with("blocked by emergency stop:"), + "output must be the emergency-stop refusal, got: {output}" + ); +} + // TAURI-RUST-N — backend 401 ("Invalid token") leaks from a cron-fired agent // job through `last_agent_error` and the existing classifier in // `core::observability::is_session_expired_message` matches it (the From 0398e2a81bc0746c753af8f5906fb4040a9c18fd Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 14 Jul 2026 19:58:13 +0530 Subject: [PATCH 29/33] fix(safety): reset stale resumeFailed across halt cycles + reason fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutomationHaltedBanner is mounted permanently, so resumeFailed leaked across halt cycles (failed resume → external socket clear → fresh halt showed a stale 'could not resume' indicator). Reset it whenever the halt lifts. Also use a truthiness fallback for reason so an empty-string reason still shows haltedBody. Add a cross-cycle regression test and drop a dead getByRole ?? fallback in the existing test (getByRole throws, so the branch never ran). Addresses CodeRabbit review. --- .../safety/AutomationHaltedBanner.test.tsx | 33 ++++++++++++++++--- .../safety/AutomationHaltedBanner.tsx | 13 ++++++-- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/app/src/components/safety/AutomationHaltedBanner.test.tsx b/app/src/components/safety/AutomationHaltedBanner.test.tsx index 8aeab58971..f9b2d2034d 100644 --- a/app/src/components/safety/AutomationHaltedBanner.test.tsx +++ b/app/src/components/safety/AutomationHaltedBanner.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { setHalt } from '../../store/safetySlice'; +import { clearHalt, setHalt } from '../../store/safetySlice'; import { renderWithProviders } from '../../test/test-utils'; import { AutomationHaltedBanner } from './AutomationHaltedBanner'; @@ -70,15 +70,38 @@ describe('AutomationHaltedBanner', () => { expect(safetyState.halted).toBe(true); // Visible retry indicator appears. await waitFor(() => - expect( - screen.getByRole('status', { name: /could not resume/i }) ?? - screen.getByText(/could not resume/i) - ).toBeDefined() + expect(screen.getByRole('status', { name: /could not resume/i })).toBeDefined() ); // Banner is still there so the user retains a Resume button to try again. expect(screen.getByRole('alert')).toBeDefined(); }); + it('clears the stale retry indicator on a new halt cycle', async () => { + // Guards the cross-cycle leak: the banner is mounted permanently, so a failed + // resume in one cycle must not surface a stale "could not resume" indicator on + // a later, unrelated halt. Drive: fail a resume → clear the halt via the + // external socket path (not the successful-RPC branch) → start a fresh halt. + resume.mockRejectedValueOnce(new Error('core error')); + const { store } = renderWithProviders(, { + preloadedState: { safety: { halted: true } }, + }); + fireEvent.click(screen.getByRole('button', { name: /resume/i })); + await waitFor(() => + expect(screen.getByRole('status', { name: /could not resume/i })).toBeDefined() + ); + // Halt lifts via the socket-driven clear (bypasses the successful resume path). + act(() => { + store.dispatch(clearHalt()); + }); + // A brand-new halt cycle begins. + act(() => { + store.dispatch(setHalt({ reason: 'second cycle', source: 'test' })); + }); + await waitFor(() => expect(screen.getByRole('alert')).toBeDefined()); + // The retry indicator from the previous cycle must not carry over. + expect(screen.queryByRole('status', { name: /could not resume/i })).toBeNull(); + }); + it('dispatches halt and then renders banner after setHalt dispatch', async () => { const { store } = renderWithProviders(); // Initially not halted diff --git a/app/src/components/safety/AutomationHaltedBanner.tsx b/app/src/components/safety/AutomationHaltedBanner.tsx index 9fa40c38e4..e5bdf5f6f2 100644 --- a/app/src/components/safety/AutomationHaltedBanner.tsx +++ b/app/src/components/safety/AutomationHaltedBanner.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { emergencyResume } from '../../services/api/emergencyApi'; @@ -39,6 +39,15 @@ export function AutomationHaltedBanner() { } }, [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; return ( @@ -49,7 +58,7 @@ export function AutomationHaltedBanner() {
{t('safety.haltedTitle')} - {reason ?? t('safety.haltedBody')} + {reason || t('safety.haltedBody')}
From 621b3cd5f33f2692c7d46de3ca7ed4df3aa1dd27 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 14 Jul 2026 20:48:02 +0530 Subject: [PATCH 30/33] fix(safety): give the resume-failed status region an accessible name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutomationHaltedBanner.test.tsx queries the retry indicator via getByRole('status', { name: /could not resume/i }), but the ARIA `status` role is name-from-author — its accessible name comes from aria-label/ aria-labelledby, not text content — so a `` with only text has no accessible name and the query fails (Frontend Checks red). Add aria-label={t('safety.resumeFailed')} so the live region is properly named for assistive tech and the test resolves it. --- app/src/components/safety/AutomationHaltedBanner.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/components/safety/AutomationHaltedBanner.tsx b/app/src/components/safety/AutomationHaltedBanner.tsx index e5bdf5f6f2..d760fcea73 100644 --- a/app/src/components/safety/AutomationHaltedBanner.tsx +++ b/app/src/components/safety/AutomationHaltedBanner.tsx @@ -65,6 +65,7 @@ export function AutomationHaltedBanner() { {resumeFailed && ( {t('safety.resumeFailed')} From bfd0c59b07cf97088b8031faa1afeedb179265c6 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 14 Jul 2026 22:56:23 +0530 Subject: [PATCH 31/33] test(emergency-stop): panic-safe ClearEmergencyOnDrop in ops tests Review (#4600): the ops.rs unit tests engaged the process-global kill switch and cleaned up via a manual emergency_resume() at the end. An assertion panic between engage and resume would leak the engaged switch into later tests in the same binary. Add the RAII ClearEmergencyOnDrop guard (already used in schemas.rs) right after the EMERGENCY_TEST_GUARD lock so the switch is cleared on drop, including on panic unwind. Mirrors the guard M3gA-Mind/CodeRabbit asked for on the sibling middleware/screen_intelligence tests. --- src/openhuman/emergency_stop/ops.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/openhuman/emergency_stop/ops.rs b/src/openhuman/emergency_stop/ops.rs index e8c42d3bb0..b1b3bc67eb 100644 --- a/src/openhuman/emergency_stop/ops.rs +++ b/src/openhuman/emergency_stop/ops.rs @@ -120,6 +120,11 @@ mod tests { 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; @@ -134,6 +139,9 @@ mod tests { 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); @@ -145,6 +153,9 @@ mod tests { 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); From e5bcf46bc1860001477823fdc5c47e24b798467c Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 16 Jul 2026 14:47:43 +0530 Subject: [PATCH 32/33] fix(i18n): remove em dashes from safety.* translations The locale coverage test (app/src/lib/i18n/__tests__/coverage.test.ts) forbids U+2014 in every locale value, including en. The emergency-stop safety.stopFailed / safety.resumeFailed strings used em dashes across 12 locale files; rephrase each into natural two-sentence copy with locale-appropriate punctuation. No key or meaning changes. --- app/src/lib/i18n/ar.ts | 4 ++-- app/src/lib/i18n/bn.ts | 4 ++-- app/src/lib/i18n/en.ts | 4 ++-- app/src/lib/i18n/fr.ts | 4 ++-- app/src/lib/i18n/hi.ts | 4 ++-- app/src/lib/i18n/id.ts | 4 ++-- app/src/lib/i18n/it.ts | 4 ++-- app/src/lib/i18n/ko.ts | 5 ++--- app/src/lib/i18n/pl.ts | 5 ++--- app/src/lib/i18n/pt.ts | 4 ++-- app/src/lib/i18n/ru.ts | 4 ++-- app/src/lib/i18n/zh-CN.ts | 2 +- 12 files changed, 23 insertions(+), 25 deletions(-) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index f316f4a9f9..52703f1dc2 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7133,9 +7133,9 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'إيقاف الطوارئ', - 'safety.stopFailed': 'تعذّر إيقاف الأتمتة — أعد المحاولة.', + 'safety.stopFailed': 'تعذّر إيقاف الأتمتة. أعد المحاولة.', 'safety.resume': 'استئناف الأتمتة', - 'safety.resumeFailed': 'تعذّر الاستئناف — لا تزال الأتمتة متوقفة. أعد المحاولة.', + 'safety.resumeFailed': 'تعذّر الاستئناف. لا تزال الأتمتة متوقفة. أعد المحاولة.', 'safety.haltedTitle': 'الأتمتة متوقفة', 'safety.haltedBody': 'تم إيقاف جميع أتمتة سطح المكتب. استأنف عندما تكون مستعدًا.', // Privacy status pill + per-action egress disclosure (#4437 / S3) diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index de074de126..22ac86f6d9 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7301,9 +7301,9 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'জরুরি বন্ধ', - 'safety.stopFailed': 'অটোমেশন থামানো যায়নি — আবার চেষ্টা করুন।', + 'safety.stopFailed': 'অটোমেশন থামানো যায়নি। আবার চেষ্টা করুন।', 'safety.resume': 'অটোমেশন পুনরায় শুরু করুন', - 'safety.resumeFailed': 'পুনরায় শুরু করা যায়নি — অটোমেশন এখনও বন্ধ। আবার চেষ্টা করুন।', + 'safety.resumeFailed': 'পুনরায় শুরু করা যায়নি। অটোমেশন এখনও বন্ধ। আবার চেষ্টা করুন।', 'safety.haltedTitle': 'অটোমেশন বন্ধ', 'safety.haltedBody': 'সমস্ত ডেস্কটপ অটোমেশন বন্ধ করা হয়েছে। প্রস্তুত হলে পুনরায় শুরু করুন।', // Privacy status pill + per-action egress disclosure (#4437 / S3) diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index af8b59c407..73ab4fa04a 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7601,9 +7601,9 @@ const en: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Emergency stop', - 'safety.stopFailed': 'Could not stop automation — try again.', + 'safety.stopFailed': 'Could not stop automation. Try again.', 'safety.resume': 'Resume automation', - 'safety.resumeFailed': 'Could not resume — automation is still halted. Try again.', + '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.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index be1d3932c8..e7555e5681 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7488,10 +7488,10 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': "Arrêt d'urgence", - 'safety.stopFailed': "Impossible d'arrêter l'automatisation — réessayez.", + '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.", + "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.", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 5ee2aa0b32..88b8c8b624 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7297,9 +7297,9 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'आपातकालीन रोक', - 'safety.stopFailed': 'स्वचालन रोका नहीं जा सका — पुनः प्रयास करें।', + 'safety.stopFailed': 'स्वचालन रोका नहीं जा सका। पुनः प्रयास करें।', 'safety.resume': 'स्वचालन पुनः प्रारंभ करें', - 'safety.resumeFailed': 'पुनः प्रारंभ नहीं हो सका — स्वचालन अभी भी रुका हुआ है। पुनः प्रयास करें।', + 'safety.resumeFailed': 'पुनः प्रारंभ नहीं हो सका। स्वचालन अभी भी रुका हुआ है। पुनः प्रयास करें।', 'safety.haltedTitle': 'स्वचालन रोका गया', 'safety.haltedBody': 'सभी डेस्कटॉप स्वचालन रोक दिया गया है। तैयार होने पर पुनः प्रारंभ करें।', // Privacy status pill + per-action egress disclosure (#4437 / S3) diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 991ee8a979..fefb2dbc58 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7335,9 +7335,9 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Hentikan darurat', - 'safety.stopFailed': 'Tidak dapat menghentikan otomasi — coba lagi.', + 'safety.stopFailed': 'Tidak dapat menghentikan otomasi. Coba lagi.', 'safety.resume': 'Lanjutkan otomasi', - 'safety.resumeFailed': 'Tidak dapat melanjutkan — otomasi masih dihentikan. Coba lagi.', + '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) diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index b55f37dc5d..8c48eefb30 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7442,9 +7442,9 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Arresto di emergenza', - 'safety.stopFailed': "Impossibile fermare l'automazione — riprova.", + 'safety.stopFailed': "Impossibile fermare l'automazione. Riprova.", 'safety.resume': "Riprendi l'automazione", - 'safety.resumeFailed': "Impossibile riprendere — l'automazione è ancora sospesa. Riprova.", + '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) diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6c307326fb..71b5452a7b 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7212,10 +7212,9 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': '긴급 정지', - 'safety.stopFailed': '자동화를 중지할 수 없습니다 — 다시 시도하세요.', + 'safety.stopFailed': '자동화를 중지할 수 없습니다. 다시 시도하세요.', 'safety.resume': '자동화 재개', - 'safety.resumeFailed': - '재개하지 못했습니다 — 자동화가 여전히 중단된 상태입니다. 다시 시도하세요.', + 'safety.resumeFailed': '재개하지 못했습니다. 자동화가 여전히 중단된 상태입니다. 다시 시도하세요.', 'safety.haltedTitle': '자동화 중단됨', 'safety.haltedBody': '모든 데스크톱 자동화가 중지되었습니다. 준비가 되면 재개하세요.', // Privacy status pill + per-action egress disclosure (#4437 / S3) diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index f26bdced4a..cfe0e77a5b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7411,10 +7411,9 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Awaryjne zatrzymanie', - 'safety.stopFailed': 'Nie udało się zatrzymać automatyzacji — spróbuj ponownie.', + '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.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) diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index e0407ac408..6c199906a6 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7422,9 +7422,9 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Parada de emergência', - 'safety.stopFailed': 'Não foi possível parar a automação — tente novamente.', + '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.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) diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 20fc7d778e..c4cf037bc8 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7382,10 +7382,10 @@ const messages: TranslationMap = { // Emergency stop (#4255) 'safety.emergencyStop': 'Аварийная остановка', - 'safety.stopFailed': 'Не удалось остановить автоматизацию — попробуйте ещё раз.', + 'safety.stopFailed': 'Не удалось остановить автоматизацию. Попробуйте ещё раз.', 'safety.resume': 'Возобновить автоматизацию', 'safety.resumeFailed': - 'Не удалось возобновить — автоматизация всё ещё приостановлена. Повторите попытку.', + 'Не удалось возобновить. Автоматизация всё ещё приостановлена. Повторите попытку.', 'safety.haltedTitle': 'Автоматизация приостановлена', 'safety.haltedBody': 'Вся автоматизация рабочего стола остановлена. Возобновите, когда будете готовы.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index c8f8545f6d..727630da1b 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6903,7 +6903,7 @@ const messages: TranslationMap = { 'safety.emergencyStop': '紧急停止', 'safety.stopFailed': '无法停止自动化,请重试。', 'safety.resume': '恢复自动化', - 'safety.resumeFailed': '无法恢复——自动化仍处于暂停状态。请重试。', + 'safety.resumeFailed': '无法恢复,自动化仍处于暂停状态。请重试。', 'safety.haltedTitle': '自动化已暂停', 'safety.haltedBody': '所有桌面自动化已停止。准备好后请恢复。', // Privacy status pill + per-action egress disclosure (#4437 / S3) From e340a8be82a9086c1ffaa73f2e6c0c461a9941ef Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 16 Jul 2026 14:53:21 +0530 Subject: [PATCH 33/33] fix(safety): re-check kill switch on cron retries + keep halt banner above webviews cron: the emergency-stop guard ran once before the retry loop, so a user who engaged Emergency Stop during a backoff sleep could still have the next retry execute a Shell/Flow/Agent job. Re-check is_engaged_global() at the top of each retry attempt and return the same fail-closed denial (Codex P1, #4255). UI: AutomationHaltedBanner rendered in normal flow, so the provider WebviewHost overlay (absolute inset-0 z-30) covered the halt banner and its Resume button whenever a provider account was active. Make the banner sticky top-0 z-40 so it stays visible and reachable above the webview, below the settings modal (z-50), per the app stacking convention (Codex P2). --- .../safety/AutomationHaltedBanner.tsx | 7 ++++++- src/openhuman/cron/scheduler.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/src/components/safety/AutomationHaltedBanner.tsx b/app/src/components/safety/AutomationHaltedBanner.tsx index d760fcea73..ded9a7a25b 100644 --- a/app/src/components/safety/AutomationHaltedBanner.tsx +++ b/app/src/components/safety/AutomationHaltedBanner.tsx @@ -50,11 +50,16 @@ export function AutomationHaltedBanner() { 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 (
+ 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)]">
{t('safety.haltedTitle')} diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 39eb52960b..1c869beb21 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -515,6 +515,23 @@ async fn execute_job_with_retry( let mut local_unreachable = false; for attempt in 0..=retries { + // Re-check the kill switch before each RETRY (attempt 0 is already + // covered by the pre-loop guard above): a user who engages Emergency + // Stop during the backoff sleep must not have the next attempt execute. + // Same fail-closed denial as the pre-loop guard (#4255). + if attempt > 0 && crate::openhuman::emergency_stop::is_engaged_global() { + log::warn!( + "[cron] action=refused_retry_while_halted job_id={} job_type={:?} attempt={} — emergency stop engaged", + job.id.as_str(), + job.job_type, + attempt + ); + return ( + false, + "blocked by emergency stop: automation is halted — resume to run this job" + .to_string(), + ); + } let (success, output, agent_error) = match job.job_type { JobType::Shell => { let (success, output) = run_job_command(config, security, job).await;