diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a9846e8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# ── Stage 1: build ────────────────────────────────────────────────────────── +FROM rust:1.82-slim AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY . . + +# Build the release binary in one layer so CI cache is maximally effective. +RUN cargo build --release --bin sentinel + +# ── Stage 2: runtime ───────────────────────────────────────────────────────── +FROM debian:bookworm-slim AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + openssh-client \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/sentinel /usr/local/bin/sentinel + +# Operators override these at runtime — never bake keys into the image. +ENV ANTHROPIC_API_KEY="" \ + OPENAI_API_KEY="" \ + RUST_LOG="info" + +ENTRYPOINT ["sentinel"] +CMD ["--help"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c1ea329 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + sentinel: + build: . + image: sentinel:latest + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:?ANTHROPIC_API_KEY must be set} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - RUST_LOG=${RUST_LOG:-info} + # A PTY is required for the TUI and for the run command's stdin approval prompt. + stdin_open: true + tty: true + # Default: launch the TUI pointed at localhost. + # Override at run-time: + # docker compose run sentinel run "check disk usage" --host myserver + command: tui --host localhost diff --git a/sentinel-tui/src/agent_bridge.rs b/sentinel-tui/src/agent_bridge.rs new file mode 100644 index 0000000..46b78b8 --- /dev/null +++ b/sentinel-tui/src/agent_bridge.rs @@ -0,0 +1,353 @@ +//! Bridge between the TUI event loop and the [`ReasoningLoop`]. +//! +//! [`run_agent_session`] drives the full investigate → plan → approve → act +//! lifecycle in a background tokio task, emitting [`SessionUpdate`]s into the +//! TUI's mpsc channel and surfacing a plan-gate [`ApprovalRequest`] for +//! operator sign-off before execution begins. + +use std::sync::Arc; + +use anyhow::Result; +use chrono::Utc; +use tokio::sync::{mpsc, oneshot, Mutex}; +use tracing::{error, info}; +use uuid::Uuid; + +use sentinel_agent_llm::{ + AnthropicBackend, CapabilityRegistry, LlmBackend, OpenAiBackend, ReasoningConfig, + ReasoningLoop, +}; +use sentinel_audit::AuditLog; +use sentinel_capabilities::all_capabilities; +use sentinel_core::{ApprovalDecision, SessionPhase}; +use sentinel_exec::RealCommandExecutor; +use sentinel_policy::default_policy; + +use crate::app::{ + ApprovalOutcome, ApprovalRequest, LogEntry, LogLevel, Plan, PlanStep, SessionUpdate, + StepStatus, +}; + +// ── AgentConfig ─────────────────────────────────────────────────────────────── + +/// Configuration for a single agent session, passed to [`run_agent_session`]. +/// +/// Built in the TUI event loop when the operator submits a goal and then given +/// to a background tokio task. +#[derive(Debug, Clone)] +pub struct AgentConfig { + /// The operator's natural-language goal. + pub goal: String, + /// Target host for capability execution. + pub host: String, + /// If `true`, generate a plan but skip execution. + pub dry_run: bool, + /// LLM backend to use ("anthropic" | "openai"). + pub backend_name: String, + /// Anthropic API key (required when `backend_name == "anthropic"`). + pub anthropic_api_key: Option, + /// OpenAI API key (required when `backend_name == "openai"`). + pub openai_api_key: Option, + /// Model identifier forwarded to the backend. + pub model: String, +} + +// ── Public entry point ──────────────────────────────────────────────────────── + +/// Drive a full agent session in the background, emitting [`SessionUpdate`]s. +/// +/// Designed to be spawned with [`tokio::spawn`]. All errors are forwarded as +/// `SessionUpdate::Error` so the TUI can surface them without panicking. +pub async fn run_agent_session( + config: AgentConfig, + update_tx: mpsc::Sender, + approval_tx: mpsc::Sender, +) { + if let Err(e) = run_inner(config, &update_tx, approval_tx).await { + let _ = update_tx + .send(SessionUpdate::Error(format!("Agent error: {e}"))) + .await; + } +} + +// ── Session driver ──────────────────────────────────────────────────────────── + +async fn run_inner( + config: AgentConfig, + update_tx: &mpsc::Sender, + approval_tx: mpsc::Sender, +) -> Result<()> { + let session_id = Uuid::new_v4(); + + // ── 1. Build the LLM backend ────────────────────────────────────────────── + let backend: Box = match config.backend_name.as_str() { + "anthropic" => { + let key = config + .anthropic_api_key + .ok_or_else(|| anyhow::anyhow!("ANTHROPIC_API_KEY required for anthropic backend"))?; + Box::new(AnthropicBackend::new(key, config.model.clone())) + } + "openai" => { + let key = config + .openai_api_key + .ok_or_else(|| anyhow::anyhow!("OPENAI_API_KEY required for openai backend"))?; + Box::new(OpenAiBackend::new(key, config.model.clone())) + } + other => return Err(anyhow::anyhow!("unknown backend '{other}'")), + }; + + // ── 2. Assemble capabilities, registry, policy, and audit log ───────────── + let executor = Arc::new(RealCommandExecutor); + let caps = all_capabilities(executor); + + let mut registry = CapabilityRegistry::new(); + for cap in &caps { + registry.register(cap.manifest().clone()); + } + let registry = Arc::new(registry); + + let policy = Arc::new(default_policy()); + let audit_path = std::path::PathBuf::from(format!("sentinel-audit-{session_id}.jsonl")); + let audit = Arc::new(Mutex::new(AuditLog::new(session_id, Some(audit_path)))); + + let agent = ReasoningLoop::new( + backend, + registry, + policy, + Arc::clone(&audit), + ReasoningConfig::default(), + ) + .with_capabilities(caps); + + // ── 3. Investigate ──────────────────────────────────────────────────────── + emit( + update_tx, + SessionUpdate::PhaseChanged(SessionPhase::Investigating), + ) + .await; + log_entry( + update_tx, + LogLevel::Info, + format!("Investigating goal: {}", config.goal), + ) + .await; + + let observations = agent + .investigate(session_id, &config.goal, &config.host) + .await + .map_err(|e| anyhow::anyhow!("investigate phase: {e}"))?; + + log_entry( + update_tx, + LogLevel::Info, + format!( + "Investigation complete — {} observation(s) collected.", + observations.len() + ), + ) + .await; + + // ── 4. Plan ─────────────────────────────────────────────────────────────── + emit( + update_tx, + SessionUpdate::PhaseChanged(SessionPhase::Planning), + ) + .await; + + let mut core_plan = agent + .plan(session_id, &config.goal, &observations) + .await + .map_err(|e| anyhow::anyhow!("planning phase: {e}"))?; + + info!( + plan_id = %core_plan.id, + steps = core_plan.steps.len(), + overall_risk = ?core_plan.overall_risk, + "plan ready — sending to TUI" + ); + + let app_plan = core_plan_to_app(&core_plan); + emit(update_tx, SessionUpdate::PlanProposed(app_plan)).await; + + // ── 5. Dry-run short-circuit ────────────────────────────────────────────── + if config.dry_run { + log_entry( + update_tx, + LogLevel::Info, + "Dry-run mode — plan generated but NOT executed.".to_string(), + ) + .await; + emit(update_tx, SessionUpdate::SessionCompleted).await; + return Ok(()); + } + + // ── 6. Operator approval gate ───────────────────────────────────────────── + // + // Surface a single plan-gate ApprovalRequest so the TUI's y/n modal handles + // it uniformly. The gate step represents "approve the full execution plan". + let (responder, answer_rx) = oneshot::channel::(); + + let gate_step = PlanStep::new( + format!( + "Execute plan ({} step(s), {:?} risk)", + core_plan.steps.len(), + core_plan.overall_risk + ), + "sentinel.plan.execute", + serde_json::json!({ + "goal": config.goal, + "steps": core_plan.steps.len(), + "overall_risk": format!("{:?}", core_plan.overall_risk), + }), + core_plan.overall_risk, + ); + + approval_tx + .send(ApprovalRequest { + step: gate_step, + responder, + }) + .await + .map_err(|_| anyhow::anyhow!("approval channel closed before plan-gate could be sent"))?; + + let outcome = answer_rx + .await + .map_err(|_| anyhow::anyhow!("approval responder dropped without reply"))?; + + match outcome { + ApprovalOutcome::Approve => { + emit(update_tx, SessionUpdate::PlanApproved).await; + } + ApprovalOutcome::Abort => { + emit( + update_tx, + SessionUpdate::PlanRejected { + reason: "Aborted by operator.".to_string(), + }, + ) + .await; + return Ok(()); + } + } + + // ── 7. Execute ──────────────────────────────────────────────────────────── + emit( + update_tx, + SessionUpdate::PhaseChanged(SessionPhase::Executing), + ) + .await; + log_entry( + update_tx, + LogLevel::Info, + format!( + "Executing {} step(s) on {}…", + core_plan.steps.len(), + config.host + ), + ) + .await; + + let summary = agent + .execute_plan( + session_id, + &config.host, + &mut core_plan, + ApprovalDecision::FullApproval, + ) + .await + .map_err(|e| anyhow::anyhow!("execute_plan: {e}"))?; + + // Emit per-step completion updates from the finalised plan state. + for step in &core_plan.steps { + let status = core_status_to_app(&step.status); + emit( + update_tx, + SessionUpdate::StepCompleted { + step_id: step.id, + status, + }, + ) + .await; + } + + log_entry( + update_tx, + LogLevel::Info, + format!( + "Execution complete — {} succeeded, {} failed, {} rolled back in {}ms.", + summary.steps_completed, + summary.steps_failed, + summary.steps_rolled_back, + summary.total_duration_ms, + ), + ) + .await; + + emit(update_tx, SessionUpdate::SessionCompleted).await; + Ok(()) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Send a [`SessionUpdate`], ignoring send errors (TUI may have exited). +async fn emit(tx: &mpsc::Sender, update: SessionUpdate) { + if tx.send(update).await.is_err() { + error!("TUI update channel closed — dropping update"); + } +} + +/// Emit a log line as a [`SessionUpdate::LogAppended`]. +async fn log_entry(tx: &mpsc::Sender, level: LogLevel, message: String) { + emit( + tx, + SessionUpdate::LogAppended(LogEntry { + timestamp: Utc::now(), + level, + message, + }), + ) + .await; +} + +/// Convert a [`sentinel_core::Plan`] into the TUI's [`Plan`] representation. +/// +/// Step UUIDs are preserved so that subsequent [`SessionUpdate::StepCompleted`] +/// messages can be matched back to the displayed plan. +fn core_plan_to_app(plan: &sentinel_core::Plan) -> Plan { + let steps = plan + .steps + .iter() + .map(|s| PlanStep { + id: s.id, + description: s.description.clone(), + capability_id: s.capability_id.clone(), + args: s.args.clone(), + risk_tier: s.risk_tier, + estimated_duration_ms: None, + status: StepStatus::Pending, + }) + .collect(); + + Plan { + id: plan.id, + goal: plan.goal.clone(), + steps, + overall_risk: plan.overall_risk, + created_at: plan.created_at, + } +} + +/// Map a [`sentinel_core::StepStatus`] to the TUI's [`StepStatus`]. +fn core_status_to_app(status: &sentinel_core::StepStatus) -> StepStatus { + match status { + sentinel_core::StepStatus::Pending + | sentinel_core::StepStatus::Approved + | sentinel_core::StepStatus::Executing => StepStatus::Running, + sentinel_core::StepStatus::Completed => StepStatus::Succeeded, + sentinel_core::StepStatus::Failed => StepStatus::Failed { + reason: "execution error".to_string(), + }, + sentinel_core::StepStatus::RolledBack => StepStatus::RolledBack, + sentinel_core::StepStatus::Skipped => StepStatus::Skipped, + } +} diff --git a/sentinel-tui/src/app.rs b/sentinel-tui/src/app.rs index 5b038bc..1fe99e3 100644 --- a/sentinel-tui/src/app.rs +++ b/sentinel-tui/src/app.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use sentinel_core::{RiskTier, SessionPhase}; -// ── Local plan / session types ──────────────────────────────────────────────── +// ── Local plan / session types ──────────────────────────────────────────────── // These mirror what the agent-llm crate will eventually expose. They are // defined here so the TUI can compile independently while that crate is a // placeholder. @@ -99,14 +99,14 @@ pub enum ApprovalDecision { Reject { reason: String }, } -// ── Interactive per-step approval ───────────────────────────────────────────── +// ── Interactive per-step approval ───────────────────────────────────────────── /// The operator's answer to a blocking per-step approval prompt. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ApprovalOutcome { - /// Approve this step — the agent may proceed. + /// Approve this step — the agent may proceed. Approve, - /// Abort — the agent must not run this step (and should stop the plan). + /// Abort — the agent must not run this step (and should stop the plan). Abort, } @@ -127,7 +127,7 @@ pub struct ApprovalRequest { /// High-level interaction state of the TUI. #[derive(Debug)] pub enum AppState { - /// Normal browsing/editing — tabs and inputs are active. + /// Normal browsing/editing — tabs and inputs are active. Normal, /// A modal is blocking input, awaiting approval of the contained step. ApprovingPlan(PlanStep), @@ -197,7 +197,7 @@ impl Session { } } -// ── SessionUpdate ───────────────────────────────────────────────────────────── +// ── SessionUpdate ───────────────────────────────────────────────────────────── /// An update pushed from the background agent task into the TUI event loop. #[derive(Debug, Clone)] @@ -213,7 +213,7 @@ pub enum SessionUpdate { Error(String), } -// ── Plan view ───────────────────────────────────────────────────────────────── +// ── Plan view ───────────────────────────────────────────────────────────────── /// Per-step display state in the Plan tab. #[derive(Debug, Clone)] @@ -285,7 +285,7 @@ impl PlanView { } } -// ── Tab enum ────────────────────────────────────────────────────────────────── +// ── Tab enum ────────────────────────────────────────────────────────────────── /// Top-level navigation tabs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -332,7 +332,7 @@ impl Tab { } } -// ── App ─────────────────────────────────────────────────────────────────────── +// ── App ─────────────────────────────────────────────────────────────────────── /// Top-level TUI application state. pub struct App { @@ -352,12 +352,27 @@ pub struct App { pub status_message: Option, /// Cursor position within the goal input field. pub input_cursor: usize, - /// Current interaction state — drives modal/blocking input handling. + /// Current interaction state — drives modal/blocking input handling. pub state: AppState, + + // ── New fields for live agent integration ───────────────────────────────── + + /// Goal that was just submitted; `run_app()` drains this each tick to + /// spawn the background agent task. + pub pending_goal: Option, + /// Target host passed to the agent for capability execution. + pub host: String, + /// If `true`, sessions run in plan-only (dry-run) mode. + pub dry_run: bool, + + // ── Private channels ────────────────────────────────────────────────────── + /// Channel responder for the in-flight approval modal, if any. approval_responder: Option>, /// Channel on which the agent emits approval requests for the TUI to poll. approval_rx: Option>, + /// Channel on which the background agent emits [`SessionUpdate`]s. + session_update_rx: Option>, } impl Default for App { @@ -378,12 +393,44 @@ impl App { status_message: None, input_cursor: 0, state: AppState::Normal, + // Live agent integration + pending_goal: None, + host: "localhost".to_string(), + dry_run: false, + // Private channels approval_responder: None, approval_rx: None, + session_update_rx: None, + } + } + + // ── Session update channel ──────────────────────────────────────────────── + + /// Attach the channel on which the background agent emits [`SessionUpdate`]s. + pub fn set_session_update_channel(&mut self, rx: mpsc::Receiver) { + self.session_update_rx = Some(rx); + } + + /// Drain all pending [`SessionUpdate`]s from the background agent. + /// + /// Call this on every tick before rendering so the UI reflects the latest + /// agent state. + pub fn poll_session_updates(&mut self) { + let Some(rx) = self.session_update_rx.as_mut() else { + return; + }; + // Collect updates to avoid borrowing `self` while calling + // `apply_session_update` (which needs `&mut self`). + let mut updates = Vec::new(); + while let Ok(update) = rx.try_recv() { + updates.push(update); + } + for update in updates { + self.apply_session_update(update); } } - // ── Interactive approval ────────────────────────────────────────────────── + // ── Interactive approval ────────────────────────────────────────────────── /// Attach the channel on which the agent will send approval requests. pub fn set_approval_channel(&mut self, rx: mpsc::Receiver) { @@ -463,7 +510,7 @@ impl App { self.state = AppState::Normal; } - // ── Navigation ──────────────────────────────────────────────────────────── + // ── Navigation ──────────────────────────────────────────────────────────── pub fn next_tab(&mut self) { self.current_tab = self.current_tab.next(); @@ -473,7 +520,7 @@ impl App { self.current_tab = self.current_tab.prev(); } - // ── Log scrolling ───────────────────────────────────────────────────────── + // ── Log scrolling ───────────────────────────────────────────────────────── pub fn scroll_log_down(&mut self) { self.log_scroll = self.log_scroll.saturating_add(1); @@ -483,7 +530,7 @@ impl App { self.log_scroll = self.log_scroll.saturating_sub(1); } - // ── Plan approval ───────────────────────────────────────────────────────── + // ── Plan approval ───────────────────────────────────────────────────────── /// Approve every step in the current plan at once. pub fn approve_all(&mut self) { @@ -514,7 +561,7 @@ impl App { self.status_message = Some(format!("Plan rejected: {}", reason)); } - // ── Goal / session helpers ──────────────────────────────────────────────── + // ── Goal / session helpers ──────────────────────────────────────────────── pub fn set_status(&mut self, msg: impl Into) { self.status_message = Some(msg.into()); @@ -524,18 +571,23 @@ impl App { self.status_message = None; } - /// Start a new session with `goal_input`. + /// Start a new session from `goal_input`. + /// + /// Sets [`pending_goal`](Self::pending_goal) so that the `run_app()` event + /// loop can pick it up on the next tick and spawn the background agent task. pub fn start_session(&mut self, host: String, dry_run: bool) { let goal = self.goal_input.clone(); if goal.is_empty() { self.status_message = Some("Please enter a goal first.".into()); return; } + // Signal run_app() to spawn the agent task on the next tick. + self.pending_goal = Some(goal.clone()); let mut session = Session::new(goal.clone(), host, dry_run); - session.log(LogLevel::Info, format!("Session started — goal: {}", goal)); + session.log(LogLevel::Info, format!("Session started — goal: {}", goal)); self.session = Some(session); self.current_tab = Tab::Investigation; - self.status_message = Some("Session started.".into()); + self.status_message = Some("Session started. Connecting to agent…".into()); } /// Apply a `SessionUpdate` received from the background agent. @@ -554,7 +606,7 @@ impl App { SessionUpdate::PlanProposed(plan) => { self.plan_view.load_plan(&plan); if let Some(s) = &mut self.session { - s.log(LogLevel::Info, "Plan proposed — review required."); + s.log(LogLevel::Info, "Plan proposed — review required."); s.current_plan = Some(plan); } self.current_tab = Tab::Plan; @@ -563,7 +615,7 @@ impl App { } SessionUpdate::PlanApproved => { if let Some(s) = &mut self.session { - s.log(LogLevel::Info, "Plan approved — beginning execution."); + s.log(LogLevel::Info, "Plan approved — beginning execution."); } self.current_tab = Tab::Execution; } @@ -620,7 +672,7 @@ impl App { } } - // ── Plan view delegation ────────────────────────────────────────────────── + // ── Plan view delegation ────────────────────────────────────────────────── pub fn plan_scroll_down(&mut self) { self.plan_view.move_down(); @@ -631,12 +683,12 @@ impl App { } } -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; - // ── Tab navigation ──────────────────────────────────────────────────────── + // ── Tab navigation ──────────────────────────────────────────────────────── #[test] fn new_app_starts_on_goal_tab() { @@ -646,6 +698,14 @@ mod tests { assert!(app.goal_input.is_empty()); } + #[test] + fn new_app_has_default_host_and_no_pending_goal() { + let app = App::new(); + assert_eq!(app.host, "localhost"); + assert!(!app.dry_run); + assert!(app.pending_goal.is_none()); + } + #[test] fn tab_next_wraps_around() { let mut app = App::new(); @@ -682,7 +742,7 @@ mod tests { assert_eq!(titles.len(), unique.len()); } - // ── Log scroll ──────────────────────────────────────────────────────────── + // ── Log scroll ──────────────────────────────────────────────────────────── #[test] fn scroll_down_and_up() { @@ -700,7 +760,7 @@ mod tests { assert_eq!(app.log_scroll, 0); } - // ── Plan approval ───────────────────────────────────────────────────────── + // ── Plan approval ───────────────────────────────────────────────────────── fn make_plan() -> Plan { Plan::new( @@ -752,7 +812,7 @@ mod tests { )); } - // ── Session ─────────────────────────────────────────────────────────────── + // ── Session ─────────────────────────────────────────────────────────────── #[test] fn start_session_without_goal_shows_message() { @@ -760,10 +820,11 @@ mod tests { app.start_session("localhost".into(), false); assert!(app.session.is_none()); assert!(app.status_message.is_some()); + assert!(app.pending_goal.is_none()); } #[test] - fn start_session_with_goal_creates_session() { + fn start_session_with_goal_creates_session_and_sets_pending_goal() { let mut app = App::new(); app.goal_input = "fix disk full".into(); app.start_session("web01".into(), false); @@ -773,6 +834,8 @@ mod tests { assert_eq!(s.host, "web01"); assert!(!s.dry_run); assert_eq!(app.current_tab, Tab::Investigation); + // pending_goal must be set so run_app() spawns the agent task. + assert_eq!(app.pending_goal.as_deref(), Some("fix disk full")); } #[test] @@ -796,7 +859,24 @@ mod tests { ); } - // ── Status message ──────────────────────────────────────────────────────── + // ── poll_session_updates ────────────────────────────────────────────────── + + #[tokio::test] + async fn poll_session_updates_applies_updates() { + let mut app = App::new(); + app.session = Some(Session::new("goal", "h", false)); + + let (tx, rx) = mpsc::channel::(16); + app.set_session_update_channel(rx); + + tx.send(SessionUpdate::SessionCompleted).await.unwrap(); + + app.poll_session_updates(); + + assert_eq!(app.current_tab, Tab::Audit); + } + + // ── Status message ──────────────────────────────────────────────────────── #[test] fn set_and_clear_status() { @@ -807,7 +887,7 @@ mod tests { assert!(app.status_message.is_none()); } - // ── PlanView ────────────────────────────────────────────────────────────── + // ── PlanView ────────────────────────────────────────────────────────────── #[test] fn plan_view_move_down_clamps_at_end() { @@ -830,7 +910,7 @@ mod tests { assert!(!pv.steps[0].expanded); } - // ── Interactive approval ────────────────────────────────────────────────── + // ── Interactive approval ────────────────────────────────────────────────── fn approval_step() -> PlanStep { PlanStep::new( diff --git a/sentinel-tui/src/event_handler.rs b/sentinel-tui/src/event_handler.rs index 78b5b35..7804a23 100644 --- a/sentinel-tui/src/event_handler.rs +++ b/sentinel-tui/src/event_handler.rs @@ -33,7 +33,7 @@ pub async fn handle_events( match event { AppEvent::Key(key) => handle_key(app, key), AppEvent::Tick => { - // Periodic tick — nothing to do beyond the approval poll above. + // Periodic tick — nothing to do beyond the approval poll above. } AppEvent::SessionUpdate(update) => { app.apply_session_update(update); @@ -76,7 +76,7 @@ fn handle_key(app: &mut App, key: KeyEvent) { } match key.code { - // ── Global quit ─────────────────────────────────────────────────── + // ── Global quit ─────────────────────────────────────────────────── KeyCode::Char('q') | KeyCode::Esc => { // On the Goal tab, Esc clears the input; on other tabs it quits. if app.current_tab == Tab::Goal && key.code == KeyCode::Esc { @@ -87,11 +87,11 @@ fn handle_key(app: &mut App, key: KeyEvent) { } } - // ── Tab navigation ──────────────────────────────────────────────── + // ── Tab navigation ──────────────────────────────────────────────── KeyCode::Tab => app.next_tab(), KeyCode::BackTab => app.prev_tab(), - // ── Scrolling ───────────────────────────────────────────────────── + // ── Scrolling ───────────────────────────────────────────────────── KeyCode::Down | KeyCode::Char('j') => { match app.current_tab { Tab::Plan => app.plan_scroll_down(), @@ -105,7 +105,7 @@ fn handle_key(app: &mut App, key: KeyEvent) { } } - // ── Plan approval actions ───────────────────────────────────────── + // ── Plan approval actions ───────────────────────────────────────── KeyCode::Char('a') => { if app.current_tab == Tab::Plan { app.approve_all(); @@ -124,13 +124,14 @@ fn handle_key(app: &mut App, key: KeyEvent) { } } - // ── Goal input (only on Goal tab) ───────────────────────────────── + // ── Goal input (only on Goal tab) ───────────────────────────────── KeyCode::Enter => { if app.current_tab == Tab::Goal { - // Start with a default host; in a real session the host would - // come from the CLI arguments already stored in app state. - let host = "localhost".to_string(); - app.start_session(host, false); + // Use the host and dry_run stored in app state (set from CLI args + // in run_tui) rather than hardcoded defaults. + let host = app.host.clone(); + let dry_run = app.dry_run; + app.start_session(host, dry_run); } } KeyCode::Char(c) => { @@ -182,7 +183,7 @@ mod tests { } } - // ── Quit ────────────────────────────────────────────────────────────────── + // ── Quit ────────────────────────────────────────────────────────────────── #[test] fn q_sets_should_quit_on_non_goal_tab() { @@ -202,7 +203,7 @@ mod tests { assert!(app.should_quit); } - // ── Tab navigation ──────────────────────────────────────────────────────── + // ── Tab navigation ──────────────────────────────────────────────────────── #[test] fn tab_key_advances_tab() { @@ -220,7 +221,7 @@ mod tests { assert_eq!(app.current_tab, Tab::Investigation); } - // ── Goal input ──────────────────────────────────────────────────────────── + // ── Goal input ──────────────────────────────────────────────────────────── #[test] fn char_keys_append_to_goal_on_goal_tab() { @@ -247,9 +248,21 @@ mod tests { handle_key(&mut app, key(KeyCode::Enter)); assert!(app.session.is_some()); assert_eq!(app.current_tab, Tab::Investigation); + // pending_goal should be set for the agent task spawn + assert_eq!(app.pending_goal.as_deref(), Some("restart sshd")); } - // ── Plan approval ───────────────────────────────────────────────────────── + #[test] + fn enter_uses_app_host_not_hardcoded_localhost() { + let mut app = App::new(); + app.host = "prod-web-01".to_string(); + app.goal_input = "check logs".into(); + handle_key(&mut app, key(KeyCode::Enter)); + let session = app.session.as_ref().unwrap(); + assert_eq!(session.host, "prod-web-01"); + } + + // ── Plan approval ───────────────────────────────────────────────────────── #[test] fn a_key_approves_all_on_plan_tab() { @@ -283,7 +296,7 @@ mod tests { )); } - // ── Scroll ──────────────────────────────────────────────────────────────── + // ── Scroll ──────────────────────────────────────────────────────────────── #[test] fn j_key_scrolls_down_on_investigation_tab() { @@ -302,7 +315,7 @@ mod tests { assert_eq!(app.log_scroll, 2); } - // ── Interactive approval modal ──────────────────────────────────────────── + // ── Interactive approval modal ──────────────────────────────────────────── fn approving_app() -> (App, tokio::sync::oneshot::Receiver) { use crate::app::PlanStep; @@ -353,7 +366,7 @@ mod tests { assert!(app.is_approving()); } - // ── Async handle_events ─────────────────────────────────────────────────── + // ── Async handle_events ─────────────────────────────────────────────────── #[tokio::test] async fn handle_tick_is_noop() { diff --git a/sentinel-tui/src/lib.rs b/sentinel-tui/src/lib.rs index 56cca4e..2405327 100644 --- a/sentinel-tui/src/lib.rs +++ b/sentinel-tui/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agent_bridge; pub mod app; pub mod event_handler; pub mod ui; diff --git a/sentinel-tui/src/main.rs b/sentinel-tui/src/main.rs index d5b835a..8ce676c 100644 --- a/sentinel-tui/src/main.rs +++ b/sentinel-tui/src/main.rs @@ -10,6 +10,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::prelude::*; +use tokio::sync::{mpsc, Mutex}; use tracing_subscriber::{fmt, EnvFilter}; use sentinel_agent_llm::{ @@ -21,10 +22,10 @@ use sentinel_core::{ApprovalDecision, CapabilityResult, ExecutionContext}; use sentinel_exec::RealCommandExecutor; use sentinel_fleet::{execute_on_fleet, FleetConfig}; use sentinel_policy::{default_policy, RuleCondition}; -use tokio::sync::Mutex; use uuid::Uuid; use sentinel_tui::{ + agent_bridge::{run_agent_session, AgentConfig}, app::App, event_handler::{handle_events, AppEvent}, ui, @@ -49,7 +50,7 @@ struct Cli { backend: String, /// Model identifier - #[arg(long, default_value = "claude-opus-4-7", global = true)] + #[arg(long, default_value = "claude-opus-4-8", global = true)] model: String, /// Log level (trace, debug, info, warn, error) @@ -102,6 +103,9 @@ enum Commands { /// Target host #[arg(long, default_value = "localhost")] host: String, + /// Run in dry-run mode (plan only, no execution) + #[arg(long)] + dry_run: bool, }, } @@ -115,8 +119,19 @@ async fn main() -> Result<()> { match cli.command.unwrap_or(Commands::Tui { host: "localhost".into(), + dry_run: false, }) { - Commands::Tui { host } => run_tui(host).await?, + Commands::Tui { host, dry_run } => { + run_tui( + host, + dry_run, + cli.backend.clone(), + cli.anthropic_api_key.clone(), + cli.openai_api_key.clone(), + cli.model.clone(), + ) + .await? + } Commands::Capabilities => list_capabilities(), Commands::Policy => show_policy(), Commands::VerifyAudit { path } => verify_audit(&path)?, @@ -149,9 +164,16 @@ async fn main() -> Result<()> { Ok(()) } -// ── TUI entry point ─────────────────────────────────────────────────────────── +// ── TUI entry point ─────────────────────────────────────────────────────────── -async fn run_tui(host: String) -> Result<()> { +async fn run_tui( + host: String, + dry_run: bool, + backend_name: String, + anthropic_api_key: Option, + openai_api_key: Option, + model: String, +) -> Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; @@ -159,12 +181,21 @@ async fn run_tui(host: String) -> Result<()> { let mut terminal = Terminal::new(backend)?; let mut app = App::new(); + app.host = host.clone(); + app.dry_run = dry_run; app.set_status(format!( - "Welcome to Sentinel! Target host: {}. Enter a goal and press Enter.", - host + "Welcome to Sentinel TUI! Host: {host}. Enter a goal and press Enter.", )); - let result = run_app(&mut terminal, &mut app).await; + let result = run_app( + &mut terminal, + &mut app, + backend_name, + anthropic_api_key, + openai_api_key, + model, + ) + .await; disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; @@ -173,13 +204,51 @@ async fn run_tui(host: String) -> Result<()> { result } +/// Main TUI event loop. +/// +/// On every tick it: +/// 1. Drains any [`SessionUpdate`]s from the running agent. +/// 2. Polls for pending approval requests. +/// 3. Spawns an agent task if the operator just submitted a new goal. +/// 4. Re-renders the terminal. +/// 5. Handles keyboard input. async fn run_app( terminal: &mut Terminal>, app: &mut App, + backend_name: String, + anthropic_api_key: Option, + openai_api_key: Option, + model: String, ) -> Result<()> { loop { + // ── Drain live agent updates ────────────────────────────────────── + app.poll_session_updates(); + app.poll_approval(); + + // ── Spawn agent task when a new goal arrives ────────────────────── + if let Some(goal) = app.pending_goal.take() { + let (update_tx, update_rx) = mpsc::channel(128); + let (approval_tx, approval_rx) = mpsc::channel(4); + app.set_session_update_channel(update_rx); + app.set_approval_channel(approval_rx); + + let config = AgentConfig { + goal, + host: app.host.clone(), + dry_run: app.dry_run, + backend_name: backend_name.clone(), + anthropic_api_key: anthropic_api_key.clone(), + openai_api_key: openai_api_key.clone(), + model: model.clone(), + }; + + tokio::spawn(run_agent_session(config, update_tx, approval_tx)); + } + + // ── Render ──────────────────────────────────────────────────────── terminal.draw(|f| ui::draw(f, app))?; + // ── Input ───────────────────────────────────────────────────────── if event::poll(Duration::from_millis(50))? { if let Event::Key(key) = event::read()? { handle_events(app, AppEvent::Key(key)).await?; @@ -195,10 +264,10 @@ async fn run_app( Ok(()) } -// ── Subcommand handlers ─────────────────────────────────────────────────────── +// ── Subcommand handlers ─────────────────────────────────────────────────────── -/// Wire the full agent stack — LLM backend, executor, capabilities, registry, -/// policy, audit log — and drive an investigate → plan → approve → act session. +/// Wire the full agent stack — LLM backend, executor, capabilities, registry, +/// policy, audit log — and drive an investigate → plan → approve → act session. #[allow(clippy::too_many_arguments)] async fn run_agent( goal: String, @@ -266,12 +335,12 @@ async fn run_agent( println!(); // Investigate. - println!("── Investigating ──"); + println!("── Investigating ──"); let observations = agent.investigate(session_id, &goal, &host).await?; println!("Collected {} observation(s).", observations.len()); // Plan. - println!("\n── Planning ──"); + println!("\n── Planning ──"); let mut plan = agent.plan(session_id, &goal, &observations).await?; println!("Rationale : {}", plan.rationale); println!("Overall risk : {:?}", plan.overall_risk); @@ -294,7 +363,7 @@ async fn run_agent( // Approve. let approval = if auto_approve { - println!("\nAuto-approve enabled — executing plan."); + println!("\nAuto-approve enabled — executing plan."); ApprovalDecision::FullApproval } else { use std::io::Write as _; @@ -318,7 +387,7 @@ async fn run_agent( } // Act. - println!("\n── Executing ──"); + println!("\n── Executing ──"); let summary = agent .execute_plan(session_id, &host, &mut plan, approval) .await?; @@ -372,18 +441,18 @@ async fn run_fleet( match &results[hostname] { CapabilityResult::Success { output } => { ok += 1; - println!("✔ {hostname}: success"); + println!("✔ {hostname}: success"); if let Ok(pretty) = serde_json::to_string(output) { println!(" {pretty}"); } } CapabilityResult::Failure { error, .. } => { failed += 1; - println!("x {hostname}: FAILED — {error}"); + println!("x {hostname}: FAILED — {error}"); } CapabilityResult::DryRun { predicted_effect } => { ok += 1; - println!("• {hostname}: dry-run"); + println!("• {hostname}: dry-run"); if let Ok(pretty) = serde_json::to_string(predicted_effect) { println!(" {pretty}"); } @@ -418,7 +487,7 @@ fn show_policy() { let rules = evaluator.rules(); println!( - "Default Sentinel policy (deny-by-default) — {} rule(s):", + "Default Sentinel policy (deny-by-default) — {} rule(s):", rules.len() ); println!("{:-<78}", ""); @@ -501,12 +570,12 @@ fn verify_audit(path: &std::path::Path) -> Result<()> { if result.valid { println!( - "Audit log VALID — {} event(s) verified.", + "Audit log VALID — {} event(s) verified.", result.events_checked ); } else { eprintln!( - "Audit log INVALID — chain broken at sequence {}.", + "Audit log INVALID — chain broken at sequence {}.", result.first_broken_at.unwrap_or(0) ); if let Some(err) = &result.error {