diff --git a/crates/rvforge-reader/README.md b/crates/rvforge-reader/README.md index 02a34474c..64bc42781 100644 --- a/crates/rvforge-reader/README.md +++ b/crates/rvforge-reader/README.md @@ -13,12 +13,17 @@ src/inspect.rs read a package without executing it (STUB) src/capability.rs derive the P6 install-time capability contract src/runtime.rs apply the FR004 runtime selection order src/state.rs ADR-288 encrypted state-capsule layout (encryption STUB) +src/dock.rs ADR-295 dock entry: the agent/system split, in the types +src/dock_text.rs sanitizing and screening agent-authored strings +src/dock_state.rs the eight dock states and who may move between them +src/dock_roster.rs multi-agent policy and the two-interaction control path +src/dock_events.rs D8 event thresholds — what may interrupt src/commands.rs Tauri command wrappers (feature `desktop`) src/lib.rs module root + the security invariants this crate holds -ui/ three screens, plain HTML/CSS/JS, no build step +ui/ four screens, plain HTML/CSS/JS, no build step assets/ vendored copy of compatibility-matrix.json capabilities/ Tauri v2 ACL: file dialog only -tests/ runtime selection, capability card, inspection, state +tests/ runtime selection, capability card, inspection, state, dock ``` ## Build and test @@ -35,7 +40,7 @@ tests with only `serde` and `serde_json`: ```bash cd crates/rvforge-reader cargo check # core only — no webview packages needed -cargo test # 39 tests, no Tauri dependency +cargo test # 90 tests, no Tauri dependency ``` This is the CI-testable path. Building the desktop shell additionally needs the @@ -64,7 +69,9 @@ there is no `.ico` or `.icns` yet, which Windows and macOS bundling require. | `inspect::capability_card` | Reads a `.rvf.manifest.json` development sidecar if present; otherwise returns the everything-denied card. | The signed `CapabilityManifest` segment inside the RVF | | `state::seal` / `unseal` | Return `EncryptionNotImplemented`. | AEAD sealing bound to the base identity, with customer-held keys (ADR-288) | | Install / Customize permissions | Disabled buttons. | The install flow, once verification is real | -| Emergency controls | Disabled buttons. | `rvm-ffi` lifecycle calls (pause, terminate, revoke, rollback) | +| Emergency controls | Disabled buttons on the Runtime screen. | `rvm-ffi` lifecycle calls (pause, terminate, revoke, rollback) | +| Dock roster | In-process, seeded by `dock_add_scaffold_agent`, which reports the unknown value for every security-bearing field (unverified publisher, no confinement, no witness chain). | RVM telemetry over `rvm-ffi`; pause is `rvm_suspend`, terminate is `rvm_terminate` (ADR-295 implementation note) | +| Dock instruction field | Disabled placeholder. | The agent instruction channel, once `rvm-ffi` is wired in | | Witness status | "no witness chain". | `rvm witness` export (ADR-289 §3) | The stubs report *absence*, never a benign default. `VerificationStatus` and @@ -98,6 +105,12 @@ replacement of every stub above. 7. **Hosted mode does not claim bare-metal isolation.** The card shows the isolation class the matrix records for the selected profile — `os-sandbox+wasm`, never `partition` (ADR-285). +8. **Agent input cannot reach dock chrome.** An agent supplies task text and + progress, through `AgentProvidedStatus::from_agent(&str, i64)` and nowhere + else. State, trust badge, network indicator, permission summary, witness + status, resource usage, and cost live in `SystemOwnedStatus`, whose fields + are private and whose single constructor takes no agent-derived value + (ADR-295 §7). Today `HostProfile::detect()` claims no OS confinement, no KVM, and no measured boot, because the `rvm-host` adapters do not exist yet. Selection therefore @@ -114,6 +127,40 @@ as adapters land — not before. selection order and where it came from, a per-profile eligibility table, and the emergency controls (Pause · Terminate · Disconnect Network · Revoke Capabilities · Rollback State) as disabled placeholders. +4. **Dock** — the ADR-295 agent dock. A collapsed pill (icon · name · + agent-reported task · progress · state · Pause · Stop · Expand), an expanded + panel with the ten §2 elements and the seven-line §8 capability card, and the + §5 multi-agent collapse: one active agent, two secondary icons, an overflow + count, and aggregates computed over every agent including the hidden ones. + +## The dock trust boundary (ADR-295 §7) + +Agent-authored text is confined to `.agent-region` — an inset, dashed, +monospace block on a tinted background, preceded by a dock-drawn +"Agent-reported" label and clipped to one line. It reaches the DOM only through +`textContent`, so it cannot produce markup, a class name, or a state indicator. +System chrome uses presentation the agent has no way to emit from inside that +region. + +Before it is rendered, agent text is stripped of ANSI escapes, control +characters, bidi overrides and line breaks, capped at 120 characters, and then +screened for chrome mimicry: a task string reading "Paused", "Stopped", "✓ +Network disabled", or "Verified publisher" is withheld and flagged rather than +drawn next to the dock's own labels. System-authored lines are cleaned but not +screened, because "Running → Paused" written by the runtime *is* the label. + +The state machine carries the same rule: an agent may assert `Idle`, `Running`, +`WaitingForApproval`, `Error`, or `Completed`, and nothing else. It cannot put +itself into `Paused`, `CapabilityDenied`, or `Quarantined`, and it cannot leave +them. Pause and terminate are legal for the user from every non-terminal state, +in one call, with no confirmation chain — which is what keeps the acceptance +test ("identify, read, inspect permissions, terminate, in two interactions") +satisfiable: expand carries the permissions and the capability card, terminate +is reachable from the collapsed pill. + +Event thresholds (§9) allow only approvals, policy violations, cost limits, +failures, and milestones to interrupt. Progress, tool calls, network samples, +and state changes accrue silently into the expanded panel's recent actions. ## Keeping the matrix in sync diff --git a/crates/rvforge-reader/src/commands.rs b/crates/rvforge-reader/src/commands.rs index 377a6db88..cb04ef67a 100644 --- a/crates/rvforge-reader/src/commands.rs +++ b/crates/rvforge-reader/src/commands.rs @@ -5,8 +5,16 @@ //! webview. None of these commands executes package content. use std::path::PathBuf; +use std::sync::Mutex; use crate::capability::CapabilityCard; +use crate::dock::{ + AgentDockEntry, IsolationClass, NetworkIndicator, PermissionSummary, ResourceUsage, + SystemOwnedStatus, TrustBadge, WitnessStatus, +}; +use crate::dock_events::Notification; +use crate::dock_roster::{AgentPill, DockRoster, ExpandedAgentView, RosterView}; +use crate::dock_state::DockState; use crate::inspect::{self, InspectionSummary}; use crate::runtime::{self, HostProfile, RuntimeChoice}; @@ -32,3 +40,125 @@ pub fn runtime_selection(path: String) -> Result { choice.subject = Some(path); Ok(choice) } + +/// The dock's roster, managed by the Tauri app. +/// +/// Chrome state lives on the Rust side of the process boundary, which is what +/// makes ADR-295 §7 structural here: the webview can read it and can invoke +/// user controls, but the agent-facing command is +/// [`dock_agent_report`] and it carries only task text and progress. +#[derive(Default)] +pub struct DockSurface(pub Mutex); + +impl DockSurface { + fn with(&self, f: impl FnOnce(&mut DockRoster) -> R) -> Result { + let mut roster = self + .0 + .lock() + .map_err(|_| "dock roster lock is poisoned".to_string())?; + Ok(f(&mut roster)) + } +} + +/// The collapsed bar: active agent, two secondary slots, overflow count, +/// aggregate usage, approval count. +#[tauri::command] +pub fn dock_view(dock: tauri::State<'_, DockSurface>) -> Result { + dock.with(|r| r.view()) +} + +/// Interaction one of the acceptance test. +#[tauri::command] +pub fn dock_expand( + dock: tauri::State<'_, DockSurface>, + agent_id: String, +) -> Result { + dock.with(|r| r.expand(&agent_id))? + .map_err(|e| e.to_string()) +} + +/// One action, from any non-terminal state. +#[tauri::command] +pub fn dock_pause( + dock: tauri::State<'_, DockSurface>, + agent_id: String, +) -> Result { + dock.with(|r| r.pause(&agent_id))? + .map_err(|e| e.to_string()) +} + +/// Interaction two of the acceptance test. No confirmation chain. +#[tauri::command] +pub fn dock_terminate( + dock: tauri::State<'_, DockSurface>, + agent_id: String, +) -> Result { + dock.with(|r| r.terminate(&agent_id))? + .map_err(|e| e.to_string()) +} + +/// Behind the overflow count. +#[tauri::command] +pub fn dock_swarm_view(dock: tauri::State<'_, DockSurface>) -> Result, String> { + dock.with(|r| r.swarm_view()) +} + +/// Threshold events only (ADR-295 §9). +#[tauri::command] +pub fn dock_notifications( + dock: tauri::State<'_, DockSurface>, +) -> Result, String> { + dock.with(|r| r.event_log().notifications().to_vec()) +} + +/// **The agent channel.** The whole agent-supplied surface is these two +/// arguments, and both are sanitized and clamped before they are stored. There +/// is no command through which an agent sets its own state, trust badge, +/// network indicator, permissions, witness status, cost, or resource usage. +#[tauri::command] +pub fn dock_agent_report( + dock: tauri::State<'_, DockSurface>, + agent_id: String, + task: String, + progress: i64, +) -> Result<(), String> { + dock.with(|r| r.apply_agent_update(&agent_id, &task, progress))? + .map_err(|e| e.to_string()) +} + +/// Register a placeholder agent so the dock can be exercised before `rvm-ffi` +/// is wired in. +/// +/// Every security-bearing field reports its unknown value — unverified +/// publisher, no confinement engaged, no witness chain — because the scaffold +/// has measured none of them. It does not fabricate a trusted-looking agent. +#[tauri::command] +pub fn dock_add_scaffold_agent( + dock: tauri::State<'_, DockSurface>, + agent_id: String, + name: String, + icon: String, +) -> Result { + let system = SystemOwnedStatus::measured( + DockState::Idle, + TrustBadge::Unverified, + NetworkIndicator::Disabled, + PermissionSummary { + isolation: IsolationClass::None, + local_model: false, + network_allowed: false, + selected_folder_access: false, + encrypted_memory: false, + grants: Vec::new(), + pending_approvals: Vec::new(), + }, + WitnessStatus::Unavailable, + ResourceUsage::ZERO, + 0, + None, + ); + dock.with(|r| { + r.insert(AgentDockEntry::new(agent_id, name, icon, system)); + r.view() + }) +} diff --git a/crates/rvforge-reader/src/dock.rs b/crates/rvforge-reader/src/dock.rs new file mode 100644 index 000000000..ae55422b2 --- /dev/null +++ b/crates/rvforge-reader/src/dock.rs @@ -0,0 +1,405 @@ +//! Agent Dock domain model (ADR-295 §1, §2, §7, §8). +//! +//! The load-bearing rule of ADR-295 is §7: dock chrome is RVForge's, content is +//! the agent's, and the two must not be confusable. That rule is enforced here +//! **in the types**, not in the renderer: +//! +//! - [`AgentProvidedStatus`] carries the only two things an agent supplies — +//! task text and progress — and it can only be built by +//! [`AgentProvidedStatus::from_agent`], which sanitizes both. +//! - [`SystemOwnedStatus`] carries everything with a security meaning (state, +//! trust badge, network indicator, permission summary, witness status, +//! resource usage, cost). Its fields are private, it derives no +//! `Deserialize`, and it has exactly one constructor — +//! [`SystemOwnedStatus::measured`] — which takes no agent-derived value. +//! - [`AgentDockEntry`] composes the two. The only mutator that accepts agent +//! input is [`AgentDockEntry::apply_agent_update`], and it replaces the agent +//! half wholesale; there is no method, field, or trait impl through which +//! agent bytes reach the system half. +//! +//! An agent that wants to appear paused therefore has to actually be paused, +//! because the state indicator is a projection of [`SystemOwnedStatus`]. +//! +//! Cleaning and screening of agent strings lives in [`crate::dock_text`] and is +//! re-exported here, because the sanitizer is part of this boundary rather than +//! a separate concern. + +use serde::Serialize; + +use crate::dock_state::DockState; + +pub use crate::dock_text::{ + sanitize_agent_text, sanitize_system_text, SanitizedText, SanitizerFinding, MAX_TASK_CHARS, + WITHHELD_TEXT, +}; + +/// The agent's half of a dock entry: task text and progress, nothing else. +/// +/// Deliberately not `Deserialize`. The only way in is +/// [`Self::from_agent`], so an agent cannot hand the dock a serialized status +/// object with extra fields and hope one of them lands somewhere meaningful. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgentProvidedStatus { + task: SanitizedText, + progress: u8, + /// The agent reported a progress value outside 0–100 and it was clamped. + progress_clamped: bool, +} + +impl AgentProvidedStatus { + /// Build from raw agent input. `raw_progress` is `i64` so an out-of-range + /// report is visible and clamped rather than rejected by the wire format. + pub fn from_agent(raw_task: &str, raw_progress: i64) -> Self { + Self { + task: sanitize_agent_text(raw_task), + progress: raw_progress.clamp(0, 100) as u8, + progress_clamped: !(0..=100).contains(&raw_progress), + } + } + + /// The resting entry for an agent that has not reported yet. + pub fn none_reported() -> Self { + Self { + task: sanitize_agent_text("No task reported"), + progress: 0, + progress_clamped: false, + } + } + + pub fn task(&self) -> &SanitizedText { + &self.task + } + pub fn progress(&self) -> u8 { + self.progress + } + pub fn progress_was_clamped(&self) -> bool { + self.progress_clamped + } + pub fn is_suspicious(&self) -> bool { + self.task.is_suspicious() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum TrustBadge { + /// Publisher signature checked against the registry. + VerifiedPublisher, + /// Signed, publisher not in the verified set. + SignedUnknownPublisher, + /// No signature has been checked. Never rendered as neutral. + Unverified, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum NetworkIndicator { + /// The runtime is denying network access. + Disabled, + /// Allowed, and currently idle. + AllowedIdle, + /// Allowed, with live connections. + Active, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum WitnessStatus { + Valid, + Broken, + /// No witness chain has been read. The scaffold's honest answer. + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum IsolationClass { + Rvm, + Wasm, + /// The adapter could not engage confinement. Reported, never hidden. + None, +} + +/// Capability facts read from the policy, not from the agent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PermissionSummary { + pub isolation: IsolationClass, + pub local_model: bool, + pub network_allowed: bool, + /// Filesystem access is limited to folders the user picked. + pub selected_folder_access: bool, + pub encrypted_memory: bool, + /// One line per granted capability, from the signed manifest. + pub grants: Vec, + pub pending_approvals: Vec, +} + +/// Measured by RVForge and RVM, never reported by the agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct ResourceUsage { + pub cpu_millis_per_sec: u32, + pub memory_bytes: u64, + pub tokens_in: u64, + pub tokens_out: u64, +} + +impl ResourceUsage { + pub const ZERO: Self = Self { + cpu_millis_per_sec: 0, + memory_bytes: 0, + tokens_in: 0, + tokens_out: 0, + }; +} + +/// The system half of a dock entry. Private fields, no `Deserialize`, one +/// constructor that takes no agent-derived value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SystemOwnedStatus { + state: DockState, + trust: TrustBadge, + network: NetworkIndicator, + permissions: PermissionSummary, + witness: WitnessStatus, + resources: ResourceUsage, + estimated_cost_micros: u64, + model: Option, + /// Bumped by the runtime on every state change; the roster orders on it. + state_seq: u64, +} + +impl SystemOwnedStatus { + /// The only constructor. Every argument comes from the capability policy, + /// the witness chain, or measured runtime telemetry. + #[allow(clippy::too_many_arguments)] + pub fn measured( + state: DockState, + trust: TrustBadge, + network: NetworkIndicator, + permissions: PermissionSummary, + witness: WitnessStatus, + resources: ResourceUsage, + estimated_cost_micros: u64, + model: Option, + ) -> Self { + Self { + state, + trust, + network, + permissions, + witness, + resources, + estimated_cost_micros, + model, + state_seq: 0, + } + } + + pub fn state(&self) -> DockState { + self.state + } + pub fn trust(&self) -> TrustBadge { + self.trust + } + pub fn network(&self) -> NetworkIndicator { + self.network + } + pub fn permissions(&self) -> &PermissionSummary { + &self.permissions + } + pub fn witness(&self) -> WitnessStatus { + self.witness + } + pub fn resources(&self) -> ResourceUsage { + self.resources + } + pub fn estimated_cost_micros(&self) -> u64 { + self.estimated_cost_micros + } + pub fn model(&self) -> Option<&str> { + self.model.as_deref() + } + pub fn state_seq(&self) -> u64 { + self.state_seq + } + + /// Record a state the state machine already approved. Private to the crate + /// so the transition check cannot be skipped from outside. + pub(crate) fn set_state(&mut self, state: DockState, seq: u64) { + self.state = state; + self.state_seq = seq; + } + + /// Replace measured telemetry. Takes no agent-derived value. + pub fn observe(&mut self, resources: ResourceUsage, estimated_cost_micros: u64) { + self.resources = resources; + self.estimated_cost_micros = estimated_cost_micros; + } +} + +/// One line of the ADR-295 §8 key capability card. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CapabilityCardLine { + pub label: String, + /// `true` when the runtime is enforcing the property right now. + pub satisfied: bool, + pub detail: String, +} + +/// The seven-line running-agent capability card. Chrome, not content: every +/// line is derived from [`SystemOwnedStatus`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct KeyCapabilityCard { + pub lines: Vec, +} + +impl KeyCapabilityCard { + pub fn from_system(system: &SystemOwnedStatus) -> Self { + let p = system.permissions(); + let line = |label: &str, satisfied: bool, detail: &str| CapabilityCardLine { + label: label.to_string(), + satisfied, + detail: detail.to_string(), + }; + Self { + lines: vec![ + line( + "Verified publisher", + system.trust() == TrustBadge::VerifiedPublisher, + match system.trust() { + TrustBadge::VerifiedPublisher => "Signature checked against the registry", + TrustBadge::SignedUnknownPublisher => "Signed by an unrecognised publisher", + TrustBadge::Unverified => "No signature has been checked", + }, + ), + line( + "RVM or WASM isolated", + p.isolation != IsolationClass::None, + match p.isolation { + IsolationClass::Rvm => "Running inside RVM", + IsolationClass::Wasm => "Running inside a WASM sandbox", + IsolationClass::None => "No confinement was engaged", + }, + ), + line( + "Local model", + p.local_model, + if p.local_model { + "Inference runs on this computer" + } else { + "Inference leaves this computer" + }, + ), + line( + "Network disabled", + !p.network_allowed, + match system.network() { + NetworkIndicator::Disabled => "The runtime is denying network access", + NetworkIndicator::AllowedIdle => { + "Network is permitted; no live connections" + } + NetworkIndicator::Active => "Network is permitted and in use", + }, + ), + line( + "Selected folder access", + p.selected_folder_access, + if p.selected_folder_access { + "Only folders you selected are readable" + } else { + "No folder access is granted" + }, + ), + line( + "Encrypted memory", + p.encrypted_memory, + if p.encrypted_memory { + "Persistent state is sealed on disk" + } else { + "No persistent state is kept" + }, + ), + line( + "Witness chain valid", + system.witness() == WitnessStatus::Valid, + match system.witness() { + WitnessStatus::Valid => "Witness chain verified", + WitnessStatus::Broken => "Witness chain does not verify", + WitnessStatus::Unavailable => "No witness chain has been read", + }, + ), + ], + } + } +} + +/// One agent in the dock: system-owned identity and status, plus the agent's +/// own two fields. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgentDockEntry { + /// System-owned: from the signed manifest, not from the running agent. + agent_id: String, + name: String, + icon: String, + system: SystemOwnedStatus, + agent: AgentProvidedStatus, +} + +impl AgentDockEntry { + /// Identity comes from the package manifest; status from the runtime. The + /// agent half starts empty and is only ever filled by + /// [`Self::apply_agent_update`]. + pub fn new( + agent_id: impl Into, + name: impl Into, + icon: impl Into, + system: SystemOwnedStatus, + ) -> Self { + Self { + agent_id: agent_id.into(), + name: name.into(), + icon: icon.into(), + system, + agent: AgentProvidedStatus::none_reported(), + } + } + + pub fn agent_id(&self) -> &str { + &self.agent_id + } + pub fn name(&self) -> &str { + &self.name + } + pub fn icon(&self) -> &str { + &self.icon + } + pub fn system(&self) -> &SystemOwnedStatus { + &self.system + } + pub fn agent(&self) -> &AgentProvidedStatus { + &self.agent + } + pub fn state(&self) -> DockState { + self.system.state() + } + + /// The one mutator that accepts agent input. It replaces the agent half and + /// touches nothing else — the system half is not passed to it, so no field + /// of [`SystemOwnedStatus`] is reachable from this call. + pub fn apply_agent_update(&mut self, raw_task: &str, raw_progress: i64) { + self.agent = AgentProvidedStatus::from_agent(raw_task, raw_progress); + } + + /// System-side telemetry update. + pub fn observe(&mut self, resources: ResourceUsage, estimated_cost_micros: u64) { + self.system.observe(resources, estimated_cost_micros); + } + + pub(crate) fn set_state(&mut self, state: DockState, seq: u64) { + self.system.set_state(state, seq); + } + + pub fn capability_card(&self) -> KeyCapabilityCard { + KeyCapabilityCard::from_system(&self.system) + } +} diff --git a/crates/rvforge-reader/src/dock_events.rs b/crates/rvforge-reader/src/dock_events.rs new file mode 100644 index 000000000..c47796eae --- /dev/null +++ b/crates/rvforge-reader/src/dock_events.rs @@ -0,0 +1,289 @@ +//! Event thresholds (ADR-295 §9, requirements D8). +//! +//! A dock that interrupts constantly gets dismissed, and a dismissed trust +//! surface provides no trust. So only five event kinds are allowed to +//! interrupt: approvals, policy violations, cost limits, failures, and +//! meaningful milestones. Everything else accrues into the expanded panel's +//! recent-actions list, where a user who wants detail can find it. +//! +//! The classification lives on the event type rather than at the call site, so +//! a new event kind has to state its threshold to compile. + +use std::collections::VecDeque; + +use serde::Serialize; + +use crate::dock::{sanitize_agent_text, sanitize_system_text, SanitizedText}; +use crate::dock_state::DockState; + +/// Whether an event is allowed to interrupt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Surfacing { + /// Raises a notification. + Notify, + /// Accrues into recent actions only. + Silent, +} + +/// Everything the dock records. +/// +/// Variants carrying agent-authored strings keep them raw here; the log +/// sanitizes on the way in, so nothing agent-authored is stored unscreened. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DockEvent { + // --- the five that surface (ADR-295 §9) --- + ApprovalRequested { + agent_id: String, + summary: String, + }, + PolicyViolation { + agent_id: String, + rule: String, + }, + CostLimitReached { + agent_id: String, + spent_micros: u64, + limit_micros: u64, + }, + Failure { + agent_id: String, + message: String, + }, + Milestone { + agent_id: String, + description: String, + }, + + // --- everything else --- + ProgressUpdated { + agent_id: String, + progress: u8, + }, + TaskTextUpdated { + agent_id: String, + text: String, + }, + ToolCall { + agent_id: String, + tool: String, + }, + NetworkActivity { + agent_id: String, + peer: String, + }, + ResourceSample { + agent_id: String, + }, + StateChanged { + agent_id: String, + from: DockState, + to: DockState, + }, + AgentMessage { + agent_id: String, + text: String, + }, +} + +impl DockEvent { + pub fn agent_id(&self) -> &str { + match self { + DockEvent::ApprovalRequested { agent_id, .. } + | DockEvent::PolicyViolation { agent_id, .. } + | DockEvent::CostLimitReached { agent_id, .. } + | DockEvent::Failure { agent_id, .. } + | DockEvent::Milestone { agent_id, .. } + | DockEvent::ProgressUpdated { agent_id, .. } + | DockEvent::TaskTextUpdated { agent_id, .. } + | DockEvent::ToolCall { agent_id, .. } + | DockEvent::NetworkActivity { agent_id, .. } + | DockEvent::ResourceSample { agent_id } + | DockEvent::StateChanged { agent_id, .. } + | DockEvent::AgentMessage { agent_id, .. } => agent_id, + } + } + + /// The D8 threshold. Only the first five variants interrupt. + pub fn surfacing(&self) -> Surfacing { + match self { + DockEvent::ApprovalRequested { .. } + | DockEvent::PolicyViolation { .. } + | DockEvent::CostLimitReached { .. } + | DockEvent::Failure { .. } + | DockEvent::Milestone { .. } => Surfacing::Notify, + + DockEvent::ProgressUpdated { .. } + | DockEvent::TaskTextUpdated { .. } + | DockEvent::ToolCall { .. } + | DockEvent::NetworkActivity { .. } + | DockEvent::ResourceSample { .. } + | DockEvent::StateChanged { .. } + | DockEvent::AgentMessage { .. } => Surfacing::Silent, + } + } + + /// `true` when the text in this event was written by the agent, so the + /// panel can mark the region as agent-authored (ADR-295 §7). + pub fn is_agent_authored(&self) -> bool { + matches!( + self, + DockEvent::TaskTextUpdated { .. } + | DockEvent::AgentMessage { .. } + | DockEvent::Milestone { .. } + ) + } + + /// The line shown in recent actions. Agent-authored text goes through the + /// sanitizer; system text is a fixed sentence built from measured values. + fn render(&self) -> SanitizedText { + match self { + // The approval prompt is the one thing an agent must never draw + // (ADR-295 §Context), so its summary comes from the policy engine + // and is rendered as chrome, not screened as agent text. + DockEvent::ApprovalRequested { summary, .. } => system_line(summary), + DockEvent::Milestone { description, .. } => sanitize_agent_text(description), + DockEvent::TaskTextUpdated { text, .. } => sanitize_agent_text(text), + DockEvent::AgentMessage { text, .. } => sanitize_agent_text(text), + DockEvent::PolicyViolation { rule, .. } => { + system_line(&format!("Policy violation: {rule}")) + } + DockEvent::CostLimitReached { + spent_micros, + limit_micros, + .. + } => system_line(&format!( + "Cost limit reached: {} of {} spent", + money(*spent_micros), + money(*limit_micros) + )), + DockEvent::Failure { message, .. } => system_line(&format!("Failure: {message}")), + DockEvent::ProgressUpdated { progress, .. } => { + system_line(&format!("Progress {progress}%")) + } + DockEvent::ToolCall { tool, .. } => system_line(&format!("Called tool {tool}")), + DockEvent::NetworkActivity { peer, .. } => { + system_line(&format!("Network connection to {peer}")) + } + DockEvent::ResourceSample { .. } => system_line("Resource sample recorded"), + DockEvent::StateChanged { from, to, .. } => { + system_line(&format!("{} → {}", from.label(), to.label())) + } + } + } +} + +/// System text is cleaned but not screened for mimicry: it *is* the system +/// label, so screening it would withhold the dock's own chrome. +fn system_line(text: &str) -> SanitizedText { + sanitize_system_text(text) +} + +fn money(micros: u64) -> String { + format!( + "${}.{:02}", + micros / 1_000_000, + (micros % 1_000_000) / 10_000 + ) +} + +/// An event after recording: rendered, screened, and tagged with its origin. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RecordedEvent { + pub seq: u64, + pub agent_id: String, + pub surfacing: Surfacing, + /// `true` when `text` came from the agent, so the panel can confine it to + /// an agent-authored region. + pub agent_authored: bool, + pub text: SanitizedText, +} + +/// A surfaced event. Only threshold events produce one. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Notification { + pub seq: u64, + pub agent_id: String, + pub agent_authored: bool, + pub text: SanitizedText, +} + +/// Bounded recent-actions log with D8 thresholding. +#[derive(Debug, Clone)] +pub struct EventLog { + recent: VecDeque, + notifications: Vec, + capacity: usize, + seq: u64, +} + +impl Default for EventLog { + fn default() -> Self { + Self::with_capacity(200) + } +} + +impl EventLog { + pub fn with_capacity(capacity: usize) -> Self { + Self { + recent: VecDeque::new(), + notifications: Vec::new(), + capacity: capacity.max(1), + seq: 0, + } + } + + /// Record an event. Every event lands in recent actions; only threshold + /// events return a [`Notification`]. + pub fn record(&mut self, event: DockEvent) -> Option { + self.seq += 1; + let surfacing = event.surfacing(); + let recorded = RecordedEvent { + seq: self.seq, + agent_id: event.agent_id().to_string(), + surfacing, + agent_authored: event.is_agent_authored(), + text: event.render(), + }; + + if self.recent.len() == self.capacity { + self.recent.pop_front(); + } + self.recent.push_back(recorded.clone()); + + match surfacing { + Surfacing::Silent => None, + Surfacing::Notify => { + let notification = Notification { + seq: recorded.seq, + agent_id: recorded.agent_id, + agent_authored: recorded.agent_authored, + text: recorded.text, + }; + self.notifications.push(notification.clone()); + Some(notification) + } + } + } + + /// Recent actions for one agent, oldest first. + pub fn recent_for(&self, agent_id: &str) -> Vec<&RecordedEvent> { + self.recent + .iter() + .filter(|e| e.agent_id == agent_id) + .collect() + } + + pub fn recent(&self) -> impl Iterator { + self.recent.iter() + } + + /// Every notification raised so far. + pub fn notifications(&self) -> &[Notification] { + &self.notifications + } + + pub fn notification_count(&self) -> usize { + self.notifications.len() + } +} diff --git a/crates/rvforge-reader/src/dock_roster.rs b/crates/rvforge-reader/src/dock_roster.rs new file mode 100644 index 000000000..166cb467e --- /dev/null +++ b/crates/rvforge-reader/src/dock_roster.rs @@ -0,0 +1,489 @@ +//! Multi-agent dock policy (ADR-295 §5) and the two-interaction control path. +//! +//! Fifteen agents rendered as fifteen pills is an unusable bar, so the roster +//! collapses to: one active agent, two secondary icons, an overflow count, +//! aggregate resource usage, and a pending-approval count. The two aggregates +//! are computed over **every** agent, including the ones the dock is hiding — +//! they are the figures that reveal a fleet doing more work or asking for more +//! consent than the user expects, so they must not be a sample of three. +//! +//! Selection of the active agent is deterministic: attention rank first +//! (waiting for approval, then capability denied, then error, then running, +//! then the rest), most recent state change next, agent id last. A dock whose +//! active slot depends on hash order is a dock whose users cannot learn it. +//! +//! The acceptance test of ADR-295 — identify, read, inspect permissions, +//! terminate, in two interactions — is expressed here as +//! [`DockInteraction`]: the collapsed pill is free (it is already on screen), +//! [`DockInteraction::Expand`] is one, [`DockInteraction::Terminate`] is two. + +use serde::Serialize; + +use crate::dock::{ + AgentDockEntry, KeyCapabilityCard, NetworkIndicator, PermissionSummary, ResourceUsage, + TrustBadge, WitnessStatus, +}; +use crate::dock_events::{DockEvent, EventLog, Notification, RecordedEvent}; +use crate::dock_state::{self, Actor, DockControl, DockState, TransitionError}; + +/// How many agents get a slot next to the active one. +pub const SECONDARY_SLOTS: usize = 2; + +/// The label on every region holding agent-authored text. The distinction the +/// agent cannot imitate is that this label is drawn by the dock, outside the +/// region it wraps (ADR-295 §7). +pub const AGENT_CONTENT_LABEL: &str = "Agent-reported"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum RosterError { + UnknownAgent { agent_id: String }, + Transition { detail: String }, +} + +impl std::fmt::Display for RosterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RosterError::UnknownAgent { agent_id } => { + write!(f, "no agent '{agent_id}' in the dock") + } + RosterError::Transition { detail } => write!(f, "{detail}"), + } + } +} + +impl std::error::Error for RosterError {} + +impl From for RosterError { + fn from(e: TransitionError) -> Self { + RosterError::Transition { + detail: e.to_string(), + } + } +} + +/// Agent-authored text, always paired with its label and its suspicion flag so +/// a renderer cannot accidentally emit it bare. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgentContent { + pub label: &'static str, + pub text: String, + pub suspicious: bool, + /// Present when the text was withheld: what the agent actually said. + pub withheld: Option, +} + +/// The collapsed pill: the eight ADR-295 §1 elements. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgentPill { + pub agent_id: String, + /// System-owned, from the manifest. + pub name: String, + pub icon: String, + /// Agent-owned. + pub task: AgentContent, + pub progress: u8, + pub state: DockState, + pub state_label: &'static str, + pub state_color: &'static str, + pub state_glyph: &'static str, + /// Always contains Pause and Terminate outside terminal states. + pub controls: Vec, + pub trust: TrustBadge, + pub network: NetworkIndicator, + pub witness: WitnessStatus, +} + +/// Aggregates over every agent, hidden ones included. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct AggregateUsage { + pub agents: usize, + pub cpu_millis_per_sec: u32, + pub memory_bytes: u64, + pub tokens_in: u64, + pub tokens_out: u64, + pub estimated_cost_micros: u64, +} + +/// What the bar shows however many agents exist. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RosterView { + pub active: Option, + /// At most [`SECONDARY_SLOTS`]. + pub secondary: Vec, + /// Agents with no slot. Selecting the count opens the swarm view. + pub overflow_count: usize, + pub aggregate: AggregateUsage, + pub pending_approvals: usize, +} + +/// The expanded panel: the ten ADR-295 §2 elements plus the §8 capability card. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExpandedAgentView { + pub pill: AgentPill, + /// 1 — agent-authored. + pub objective: AgentContent, + /// 2 — mixed; each entry carries its own `agent_authored` flag. + pub recent_actions: Vec, + /// 3 — system-owned, from the policy engine. + pub pending_approvals: Vec, + /// 4 — measured. + pub model: Option, + pub tokens_in: u64, + pub tokens_out: u64, + /// 5 — measured. + pub cpu_millis_per_sec: u32, + pub memory_bytes: u64, + /// 6 — measured. + pub network: NetworkIndicator, + /// 7 — from the signed manifest. + pub capability_grants: Vec, + /// 8 — from the witness chain. + pub witness: WitnessStatus, + /// 9 — measured. + pub estimated_cost_micros: u64, + /// 10 — the one place the user addresses the agent. + pub instruction_field_enabled: bool, + /// ADR-295 §8. + pub capability_card: KeyCapabilityCard, +} + +/// One user action against the dock. The acceptance test's budget is counted +/// in these. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DockInteraction { + Expand { agent_id: String }, + Pause { agent_id: String }, + Terminate { agent_id: String }, + OpenSwarmView, +} + +/// What an interaction produced. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum InteractionOutcome { + Expanded(Box), + StateChanged { agent_id: String, state: DockState }, + SwarmView { pills: Vec }, +} + +/// Attention rank; lower sorts first (ADR-295 §5). +fn attention_rank(state: DockState) -> u8 { + match state { + DockState::WaitingForApproval => 0, + DockState::CapabilityDenied => 1, + DockState::Error => 2, + DockState::Running => 3, + _ => 4, + } +} + +/// Every agent the dock knows about. +#[derive(Debug, Default)] +pub struct DockRoster { + entries: Vec, + log: EventLog, + seq: u64, +} + +impl DockRoster { + pub fn new() -> Self { + Self::default() + } + + /// Admit an agent. Its arrival counts as a state change, so a newly + /// arrived agent outranks an older one at the same attention rank. + pub fn insert(&mut self, mut entry: AgentDockEntry) { + self.seq += 1; + let state = entry.state(); + entry.set_state(state, self.seq); + match self + .entries + .iter_mut() + .find(|e| e.agent_id() == entry.agent_id()) + { + Some(slot) => *slot = entry, + None => self.entries.push(entry), + } + } + + pub fn remove(&mut self, agent_id: &str) { + self.entries.retain(|e| e.agent_id() != agent_id); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn get(&self, agent_id: &str) -> Option<&AgentDockEntry> { + self.entries.iter().find(|e| e.agent_id() == agent_id) + } + + pub fn event_log(&self) -> &EventLog { + &self.log + } + + /// Record an event through the D8 thresholds. + pub fn record(&mut self, event: DockEvent) -> Option { + self.log.record(event) + } + + /// The agent-supplied channel. Task text and progress, nothing else. + pub fn apply_agent_update( + &mut self, + agent_id: &str, + raw_task: &str, + raw_progress: i64, + ) -> Result<(), RosterError> { + let entry = self + .entries + .iter_mut() + .find(|e| e.agent_id() == agent_id) + .ok_or_else(|| RosterError::UnknownAgent { + agent_id: agent_id.to_string(), + })?; + entry.apply_agent_update(raw_task, raw_progress); + let progress = entry.agent().progress(); + self.log.record(DockEvent::TaskTextUpdated { + agent_id: agent_id.to_string(), + text: raw_task.to_string(), + }); + self.log.record(DockEvent::ProgressUpdated { + agent_id: agent_id.to_string(), + progress, + }); + Ok(()) + } + + /// Change state on behalf of `actor`, through the state machine. + pub fn set_state( + &mut self, + agent_id: &str, + to: DockState, + actor: Actor, + ) -> Result { + let from = self + .get(agent_id) + .ok_or_else(|| RosterError::UnknownAgent { + agent_id: agent_id.to_string(), + })? + .state(); + let next = dock_state::transition(from, to, actor)?; + self.commit_state(agent_id, from, next); + Ok(next) + } + + /// Pause in one action, from any non-terminal state. + pub fn pause(&mut self, agent_id: &str) -> Result { + let from = self + .get(agent_id) + .ok_or_else(|| RosterError::UnknownAgent { + agent_id: agent_id.to_string(), + })? + .state(); + let next = dock_state::pause(from)?; + self.commit_state(agent_id, from, next); + Ok(next) + } + + /// Terminate in one action, from any non-terminal state. + pub fn terminate(&mut self, agent_id: &str) -> Result { + let from = self + .get(agent_id) + .ok_or_else(|| RosterError::UnknownAgent { + agent_id: agent_id.to_string(), + })? + .state(); + let next = dock_state::terminate(from)?; + self.commit_state(agent_id, from, next); + Ok(next) + } + + fn commit_state(&mut self, agent_id: &str, from: DockState, to: DockState) { + self.seq += 1; + let seq = self.seq; + if let Some(entry) = self.entries.iter_mut().find(|e| e.agent_id() == agent_id) { + entry.set_state(to, seq); + } + self.log.record(DockEvent::StateChanged { + agent_id: agent_id.to_string(), + from, + to, + }); + } + + /// System-side telemetry. + pub fn observe( + &mut self, + agent_id: &str, + resources: ResourceUsage, + estimated_cost_micros: u64, + ) -> Result<(), RosterError> { + let entry = self + .entries + .iter_mut() + .find(|e| e.agent_id() == agent_id) + .ok_or_else(|| RosterError::UnknownAgent { + agent_id: agent_id.to_string(), + })?; + entry.observe(resources, estimated_cost_micros); + Ok(()) + } + + /// Agents in dock order: attention rank, then most recent state change, + /// then agent id. + pub fn ordered(&self) -> Vec<&AgentDockEntry> { + let mut ordered: Vec<&AgentDockEntry> = self.entries.iter().collect(); + ordered.sort_by(|a, b| { + attention_rank(a.state()) + .cmp(&attention_rank(b.state())) + .then(b.system().state_seq().cmp(&a.system().state_seq())) + .then(a.agent_id().cmp(b.agent_id())) + }); + ordered + } + + /// The active agent under the §5 selection rule. + pub fn active(&self) -> Option<&AgentDockEntry> { + self.ordered().into_iter().next() + } + + pub fn pending_approval_count(&self) -> usize { + self.entries + .iter() + .filter(|e| e.state() == DockState::WaitingForApproval) + .count() + } + + pub fn aggregate(&self) -> AggregateUsage { + let mut agg = AggregateUsage { + agents: self.entries.len(), + cpu_millis_per_sec: 0, + memory_bytes: 0, + tokens_in: 0, + tokens_out: 0, + estimated_cost_micros: 0, + }; + for entry in &self.entries { + let r = entry.system().resources(); + agg.cpu_millis_per_sec = agg.cpu_millis_per_sec.saturating_add(r.cpu_millis_per_sec); + agg.memory_bytes = agg.memory_bytes.saturating_add(r.memory_bytes); + agg.tokens_in = agg.tokens_in.saturating_add(r.tokens_in); + agg.tokens_out = agg.tokens_out.saturating_add(r.tokens_out); + agg.estimated_cost_micros = agg + .estimated_cost_micros + .saturating_add(entry.system().estimated_cost_micros()); + } + agg + } + + /// The bar. Free to read — it is already on screen, so it costs no + /// interaction in the acceptance test. + pub fn view(&self) -> RosterView { + let ordered = self.ordered(); + let mut pills = ordered.iter().map(|e| pill_of(e)); + let active = pills.next(); + let secondary: Vec = pills.by_ref().take(SECONDARY_SLOTS).collect(); + let shown = usize::from(active.is_some()) + secondary.len(); + RosterView { + active, + secondary, + overflow_count: self.entries.len().saturating_sub(shown), + aggregate: self.aggregate(), + pending_approvals: self.pending_approval_count(), + } + } + + /// Interaction one: everything the acceptance test needs to read, in a + /// single call — task, permissions, capability card, cost, witness. + pub fn expand(&self, agent_id: &str) -> Result { + let entry = self + .get(agent_id) + .ok_or_else(|| RosterError::UnknownAgent { + agent_id: agent_id.to_string(), + })?; + let system = entry.system(); + let permissions: &PermissionSummary = system.permissions(); + let resources = system.resources(); + Ok(ExpandedAgentView { + pill: pill_of(entry), + objective: agent_content(entry), + recent_actions: self.log.recent_for(agent_id).into_iter().cloned().collect(), + pending_approvals: permissions.pending_approvals.clone(), + model: system.model().map(str::to_string), + tokens_in: resources.tokens_in, + tokens_out: resources.tokens_out, + cpu_millis_per_sec: resources.cpu_millis_per_sec, + memory_bytes: resources.memory_bytes, + network: system.network(), + capability_grants: permissions.grants.clone(), + witness: system.witness(), + estimated_cost_micros: system.estimated_cost_micros(), + instruction_field_enabled: !entry.state().is_terminal(), + capability_card: entry.capability_card(), + }) + } + + /// Every agent, for the swarm view behind the overflow count. + pub fn swarm_view(&self) -> Vec { + self.ordered().iter().map(|e| pill_of(e)).collect() + } + + /// Apply one user interaction. Terminate is reachable from the collapsed + /// pill, so it never needs an expand first. + pub fn perform( + &mut self, + interaction: DockInteraction, + ) -> Result { + match interaction { + DockInteraction::Expand { agent_id } => Ok(InteractionOutcome::Expanded(Box::new( + self.expand(&agent_id)?, + ))), + DockInteraction::Pause { agent_id } => { + let state = self.pause(&agent_id)?; + Ok(InteractionOutcome::StateChanged { agent_id, state }) + } + DockInteraction::Terminate { agent_id } => { + let state = self.terminate(&agent_id)?; + Ok(InteractionOutcome::StateChanged { agent_id, state }) + } + DockInteraction::OpenSwarmView => Ok(InteractionOutcome::SwarmView { + pills: self.swarm_view(), + }), + } + } +} + +fn agent_content(entry: &AgentDockEntry) -> AgentContent { + let task = entry.agent().task(); + AgentContent { + label: AGENT_CONTENT_LABEL, + text: task.display().to_string(), + suspicious: task.is_suspicious(), + withheld: task.withheld().map(str::to_string), + } +} + +fn pill_of(entry: &AgentDockEntry) -> AgentPill { + let state = entry.state(); + let system = entry.system(); + AgentPill { + agent_id: entry.agent_id().to_string(), + name: entry.name().to_string(), + icon: entry.icon().to_string(), + task: agent_content(entry), + progress: entry.agent().progress(), + state, + state_label: state.label(), + state_color: state.color_token(), + state_glyph: state.glyph(), + controls: state.controls(), + trust: system.trust(), + network: system.network(), + witness: system.witness(), + } +} diff --git a/crates/rvforge-reader/src/dock_state.rs b/crates/rvforge-reader/src/dock_state.rs new file mode 100644 index 000000000..6ad58e6cc --- /dev/null +++ b/crates/rvforge-reader/src/dock_state.rs @@ -0,0 +1,279 @@ +//! Dock state machine (ADR-295 §3, §4). +//! +//! The eight dock states are **system-owned**. This module is the only place a +//! [`DockState`] may change, and every change carries the [`Actor`] that asked +//! for it, because the actor is what makes a transition legal or not: +//! +//! - `Paused`, `CapabilityDenied`, and `Quarantined` are system-forced. An +//! agent can neither enter them (the "I have stopped" spoof of ADR-295 +//! §Context) nor leave them (escaping quarantine by asserting a new state). +//! - Pause and terminate are legal for the user from **every** non-terminal +//! state and are a single call — never behind a confirmation chain +//! (ADR-295 §4). +//! - `Completed` is terminal. Nothing leaves it, including the runtime. + +use serde::Serialize; + +/// The eight ADR-295 §3 states. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DockState { + Idle, + Running, + WaitingForApproval, + Paused, + CapabilityDenied, + Error, + Quarantined, + Completed, +} + +/// Every state, in the ADR-295 §3 listing order. +pub const DOCK_STATES: [DockState; 8] = [ + DockState::Idle, + DockState::Running, + DockState::WaitingForApproval, + DockState::Paused, + DockState::CapabilityDenied, + DockState::Error, + DockState::Quarantined, + DockState::Completed, +]; + +/// Who is asking for a transition. +/// +/// `Agent` is the untrusted channel: it is the only actor whose requests are +/// filtered by anything other than the transition table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Actor { + /// The runtime (RVM), reporting measured facts. + System, + /// The person at the dock. + User, + /// The agent itself. + Agent, +} + +/// Controls the dock offers for a state. Pause and Terminate appear in every +/// non-terminal state, collapsed and expanded alike. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DockControl { + Pause, + Resume, + Terminate, + Expand, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum TransitionError { + /// Not in the transition table for any actor. + Illegal { from: DockState, to: DockState }, + /// The state is terminal; nothing leaves it. + Terminal { from: DockState }, + /// An agent tried to enter a state only the system may assert. + AgentCannotEnter { to: DockState }, + /// An agent tried to leave a state the system forced on it. + AgentCannotLeave { from: DockState }, +} + +impl std::fmt::Display for TransitionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TransitionError::Illegal { from, to } => { + write!(f, "{} cannot transition to {}", from.label(), to.label()) + } + TransitionError::Terminal { from } => { + write!(f, "{} is terminal", from.label()) + } + TransitionError::AgentCannotEnter { to } => write!( + f, + "an agent cannot put itself into '{}'; that state is asserted by the runtime", + to.label() + ), + TransitionError::AgentCannotLeave { from } => write!( + f, + "an agent cannot leave '{}'; only the runtime releases it", + from.label() + ), + } + } +} + +impl std::error::Error for TransitionError {} + +impl DockState { + /// The label rendered in system chrome. + pub fn label(self) -> &'static str { + match self { + DockState::Idle => "Idle", + DockState::Running => "Running", + DockState::WaitingForApproval => "Waiting for approval", + DockState::Paused => "Paused", + DockState::CapabilityDenied => "Capability denied", + DockState::Error => "Error", + DockState::Quarantined => "Quarantined", + DockState::Completed => "Completed", + } + } + + /// Stable per-state color token. Distinguishable at pill size, so + /// `CapabilityDenied` and `Quarantined` never share a hue with `Error` + /// (ADR-295 §3). + pub fn color_token(self) -> &'static str { + match self { + DockState::Idle => "state-idle", + DockState::Running => "state-running", + DockState::WaitingForApproval => "state-waiting", + DockState::Paused => "state-paused", + DockState::CapabilityDenied => "state-denied", + DockState::Error => "state-error", + DockState::Quarantined => "state-quarantined", + DockState::Completed => "state-completed", + } + } + + /// Glyph shown beside the color, so the state survives a color-blind + /// reading and a greyscale screenshot. + pub fn glyph(self) -> &'static str { + match self { + DockState::Idle => "○", + DockState::Running => "▶", + DockState::WaitingForApproval => "?", + DockState::Paused => "❚❚", + DockState::CapabilityDenied => "⊘", + DockState::Error => "!", + DockState::Quarantined => "▧", + DockState::Completed => "✔", + } + } + + /// Nothing leaves a terminal state. + pub fn is_terminal(self) -> bool { + matches!(self, DockState::Completed) + } + + /// States the runtime forces and the agent cannot argue with. Both are + /// first-class outcomes of the trust system rather than error subtypes + /// (ADR-295 §3). + pub fn is_system_forced(self) -> bool { + matches!( + self, + DockState::Paused | DockState::CapabilityDenied | DockState::Quarantined + ) + } + + /// States an agent is allowed to claim for itself. Everything outside this + /// set carries a security meaning and is drawn from runtime state only. + pub fn is_agent_assertable(self) -> bool { + matches!( + self, + DockState::Idle + | DockState::Running + | DockState::WaitingForApproval + | DockState::Error + | DockState::Completed + ) + } + + /// The transition table, ignoring actor. `Quarantined` releases only to + /// `Idle` or `Completed`; `CapabilityDenied` is reachable only from states + /// in which a capability could have been requested. + pub fn may_precede(self, to: DockState) -> bool { + use DockState::*; + if self == to { + return false; + } + match self { + Idle => matches!(to, Running | Paused | Error | Quarantined | Completed), + Running => matches!( + to, + Idle | WaitingForApproval + | Paused + | CapabilityDenied + | Error + | Quarantined + | Completed + ), + WaitingForApproval => matches!( + to, + Running | Paused | CapabilityDenied | Error | Quarantined | Completed + ), + Paused => matches!(to, Running | Idle | Error | Quarantined | Completed), + CapabilityDenied => matches!( + to, + Running | Idle | Paused | Error | Quarantined | Completed + ), + Error => matches!(to, Idle | Running | Paused | Quarantined | Completed), + Quarantined => matches!(to, Idle | Completed), + Completed => false, + } + } + + /// Controls the dock renders for this state. Pause and Terminate are in + /// every non-terminal list, so terminate is always one action away. + pub fn controls(self) -> Vec { + if self.is_terminal() { + return vec![DockControl::Expand]; + } + let mut controls = vec![ + DockControl::Pause, + DockControl::Terminate, + DockControl::Expand, + ]; + if self == DockState::Paused { + controls.insert(1, DockControl::Resume); + } + controls + } +} + +/// Apply a transition on behalf of `actor`. +/// +/// The actor check runs before the table so an agent gets the specific +/// "you may not assert this" error rather than a generic illegal-transition +/// message that would read as a table gap. +pub fn transition( + from: DockState, + to: DockState, + actor: Actor, +) -> Result { + if from.is_terminal() { + return Err(TransitionError::Terminal { from }); + } + if actor == Actor::Agent { + if from.is_system_forced() { + return Err(TransitionError::AgentCannotLeave { from }); + } + if !to.is_agent_assertable() { + return Err(TransitionError::AgentCannotEnter { to }); + } + } + if !from.may_precede(to) { + return Err(TransitionError::Illegal { from, to }); + } + Ok(to) +} + +/// Pause, in one action, from any non-terminal state. +/// +/// Idempotent: pausing an already paused agent succeeds rather than erroring, +/// because a control that sometimes fails is a control users stop trusting. +pub fn pause(from: DockState) -> Result { + if from.is_terminal() { + return Err(TransitionError::Terminal { from }); + } + Ok(DockState::Paused) +} + +/// Terminate, in one action, from any non-terminal state. Never behind a +/// confirmation chain (ADR-295 §4 and the acceptance test's two-interaction +/// budget). +pub fn terminate(from: DockState) -> Result { + if from.is_terminal() { + return Err(TransitionError::Terminal { from }); + } + Ok(DockState::Completed) +} diff --git a/crates/rvforge-reader/src/dock_text.rs b/crates/rvforge-reader/src/dock_text.rs new file mode 100644 index 000000000..05ea303a9 --- /dev/null +++ b/crates/rvforge-reader/src/dock_text.rs @@ -0,0 +1,274 @@ +//! Sanitizing agent-authored text (ADR-295 §7). +//! +//! Agents supply exactly one string to the dock — their current task — and it +//! lands next to controls that carry a security meaning. Two things have to be +//! true of it before it is rendered: +//! +//! 1. It is inert. No escape sequence, control character, bidi override, or +//! line break survives, and its length is capped, so it cannot repaint the +//! terminal, reorder what is around it, or push chrome out of the pill. +//! 2. It is not pretending to be chrome. A task string reading "Paused" or +//! "✓ Network disabled" is indistinguishable from the dock's own label once +//! it sits beside the dock's own label, so it is withheld and flagged rather +//! than rendered. +//! +//! Screening runs *after* cleaning, on the cleaned string, so an escape +//! sequence cannot be used to smuggle "Paused" past the check. +//! +//! [`sanitize_system_text`] is the same cleaning without the mimicry screen: +//! "Running → Paused" written by the runtime *is* the system label, and +//! screening the dock's own chrome would make the state indicator +//! unrenderable. + +use serde::Serialize; + +/// Agent task text is capped so a long string cannot push system chrome out of +/// the pill. +pub const MAX_TASK_CHARS: usize = 120; + +/// Rendered in place of text that imitates system UI. +pub const WITHHELD_TEXT: &str = "(withheld: this text imitates a system status label)"; + +/// What the sanitizer had to do to a piece of agent text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case", tag = "kind", content = "detail")] +pub enum SanitizerFinding { + ControlCharactersRemoved, + AnsiEscapesRemoved, + LineBreaksFlattened, + Truncated, + /// The text claims a system state. Rendering it would let the agent draw + /// chrome (ADR-295 §7). + SystemLabelMimicry(String), +} + +/// Agent-authored text after sanitization. +/// +/// [`Self::display`] is the only string the dock renders. When the text is +/// suspicious it is the withheld placeholder, and the cleaned original is +/// retained under [`Self::withheld`] for the expanded panel's audit list. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SanitizedText { + display: String, + withheld: Option, + suspicious: bool, + findings: Vec, +} + +impl SanitizedText { + pub fn display(&self) -> &str { + &self.display + } + pub fn withheld(&self) -> Option<&str> { + self.withheld.as_deref() + } + pub fn is_suspicious(&self) -> bool { + self.suspicious + } + pub fn findings(&self) -> &[SanitizerFinding] { + &self.findings + } +} + +/// Words and glyphs that assert a system state. A task string containing one +/// of these is indistinguishable from chrome once it lands next to chrome, so +/// it is flagged rather than rendered. +const SYSTEM_LABEL_TOKENS: [&str; 18] = [ + "approved", + "approve", + "approval granted", + "denied", + "paused", + "stopped", + "terminated", + "quarantined", + "verified publisher", + "verified", + "network disabled", + "witness valid", + "witness chain valid", + "sandboxed by rvforge", + "rvforge", + "system:", + "permission granted", + "capability granted", +]; + +/// State-claim glyphs. A leading "✓" reads as a system verdict whatever the +/// words after it say. +const SYSTEM_LABEL_GLYPHS: [char; 10] = ['✓', '✔', '✅', '☑', '⏸', '⏹', '⛔', '🛑', '❌', '🔒']; + +/// Strip, flatten, cap, then screen for chrome mimicry. +/// +/// Screening runs last, on the cleaned string, so an escape sequence cannot be +/// used to smuggle "Paused" past the check. +pub fn sanitize_agent_text(raw: &str) -> SanitizedText { + clean(raw, true) +} + +/// The same cleaning for text RVForge wrote itself. +/// +/// System lines are *not* screened for mimicry: "Running → Paused" is the +/// system label, not an imitation of one, and screening chrome would make the +/// state indicator unrenderable. Cleaning still applies, because a policy rule +/// or an error message can carry a path or a payload the runtime did not +/// author. +pub fn sanitize_system_text(raw: &str) -> SanitizedText { + clean(raw, false) +} + +fn clean(raw: &str, screen_for_mimicry: bool) -> SanitizedText { + let mut findings = Vec::new(); + + let stripped = strip_ansi(raw, &mut findings); + let flattened = flatten_and_strip_controls(&stripped, &mut findings); + let mut text = collapse_whitespace(&flattened); + + if text.chars().count() > MAX_TASK_CHARS { + text = text.chars().take(MAX_TASK_CHARS - 1).collect::() + "…"; + findings.push(SanitizerFinding::Truncated); + } + + if screen_for_mimicry { + if let Some(token) = system_label_hit(&text) { + findings.push(SanitizerFinding::SystemLabelMimicry(token)); + return SanitizedText { + display: WITHHELD_TEXT.to_string(), + withheld: Some(text), + suspicious: true, + findings, + }; + } + } + + SanitizedText { + display: text, + withheld: None, + suspicious: false, + findings, + } +} + +/// Remove CSI and OSC escape sequences, plus any stray ESC. +fn strip_ansi(raw: &str, findings: &mut Vec) -> String { + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars().peekable(); + let mut removed = false; + + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + removed = true; + match chars.peek() { + // CSI: ESC [ params final-byte in 0x40..=0x7e. + Some('[') => { + chars.next(); + for c in chars.by_ref() { + if ('\u{40}'..='\u{7e}').contains(&c) { + break; + } + } + } + // OSC: ESC ] ... BEL or ESC \. + Some(']') => { + chars.next(); + while let Some(c) = chars.next() { + if c == '\u{7}' { + break; + } + if c == '\u{1b}' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + // Any other two-character escape. + Some(_) => { + chars.next(); + } + None => {} + } + } + + if removed { + findings.push(SanitizerFinding::AnsiEscapesRemoved); + } + out +} + +/// Newlines and tabs become spaces; every other control character and every +/// bidi/zero-width formatting character is dropped. +fn flatten_and_strip_controls(input: &str, findings: &mut Vec) -> String { + let mut out = String::with_capacity(input.len()); + let mut flattened = false; + let mut removed = false; + + for c in input.chars() { + match c { + '\n' | '\r' | '\t' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}' => { + flattened = true; + out.push(' '); + } + // C0/C1 controls, zero-width and bidi-override formatting. + c if c.is_control() + || matches!(c, '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{feff}') => + { + removed = true; + } + c => out.push(c), + } + } + + if flattened { + findings.push(SanitizerFinding::LineBreaksFlattened); + } + if removed { + findings.push(SanitizerFinding::ControlCharactersRemoved); + } + out +} + +fn collapse_whitespace(input: &str) -> String { + input.split_whitespace().collect::>().join(" ") +} + +/// The token this text mimics, if any. Word-boundary matched so "unapproved +/// invoices" is ordinary task text while "Approved" is not. +fn system_label_hit(text: &str) -> Option { + if let Some(glyph) = text.chars().find(|c| SYSTEM_LABEL_GLYPHS.contains(c)) { + return Some(glyph.to_string()); + } + let lowered = text.to_ascii_lowercase(); + SYSTEM_LABEL_TOKENS + .iter() + .find(|token| contains_word(&lowered, token)) + .map(|token| (*token).to_string()) +} + +fn contains_word(haystack: &str, needle: &str) -> bool { + let mut from = 0; + while let Some(idx) = haystack[from..].find(needle) { + let start = from + idx; + let end = start + needle.len(); + let before_ok = start == 0 + || !haystack[..start] + .chars() + .next_back() + .is_some_and(|c| c.is_alphanumeric()); + let after_ok = end == haystack.len() + || !haystack[end..] + .chars() + .next() + .is_some_and(|c| c.is_alphanumeric()); + if before_ok && after_ok { + return true; + } + from = start + 1; + if from >= haystack.len() { + break; + } + } + false +} diff --git a/crates/rvforge-reader/src/lib.rs b/crates/rvforge-reader/src/lib.rs index 5fcabd11e..4621df2eb 100644 --- a/crates/rvforge-reader/src/lib.rs +++ b/crates/rvforge-reader/src/lib.rs @@ -8,6 +8,8 @@ //! - [`capability`] — derive the install-time capability contract (requirements P6). //! - [`runtime`] — apply the FR004 runtime selection order (ADR-289 §2). //! - [`state`] — encrypted state-capsule layout (ADR-288). +//! - [`dock`], [`dock_state`], [`dock_roster`], [`dock_events`] — the Agent +//! Dock: what a running agent shows and how it is stopped (ADR-295). //! //! # Security invariants //! @@ -30,8 +32,19 @@ //! vendored compatibility matrix. No environment variable, CLI flag, or //! setting reorders it; only signed policy may, and signed policy is not yet //! implemented (ADR-289 §2). +//! 7. **Agent input cannot reach dock chrome.** An agent supplies task text and +//! progress and nothing else, through +//! [`dock::AgentProvidedStatus::from_agent`]. Trust badge, network +//! indicator, permission summary, witness status, resource usage, cost, and +//! the runtime state live in [`dock::SystemOwnedStatus`], which has no +//! constructor or setter that takes an agent-derived value (ADR-295 §7). pub mod capability; +pub mod dock; +pub mod dock_events; +pub mod dock_roster; +pub mod dock_state; +pub mod dock_text; pub mod inspect; pub mod runtime; pub mod state; @@ -44,10 +57,21 @@ pub mod commands; pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + // Dock chrome state lives here, on the Rust side of the process + // boundary, not in the webview that renders agent content. + .manage(commands::DockSurface::default()) .invoke_handler(tauri::generate_handler![ commands::inspect_rvf, commands::capability_card, commands::runtime_selection, + commands::dock_view, + commands::dock_expand, + commands::dock_pause, + commands::dock_terminate, + commands::dock_swarm_view, + commands::dock_notifications, + commands::dock_agent_report, + commands::dock_add_scaffold_agent, ]) .run(tauri::generate_context!()) .expect("error while running RVForge Reader"); diff --git a/crates/rvforge-reader/tests/dock_events.rs b/crates/rvforge-reader/tests/dock_events.rs new file mode 100644 index 000000000..beb394f8a --- /dev/null +++ b/crates/rvforge-reader/tests/dock_events.rs @@ -0,0 +1,218 @@ +//! ADR-295 §9 / requirements D8: only five event kinds may interrupt. + +use rvforge_reader::dock_events::{DockEvent, EventLog, Surfacing}; +use rvforge_reader::dock_state::DockState; + +fn threshold_events() -> Vec { + vec![ + DockEvent::ApprovalRequested { + agent_id: "a".into(), + summary: "read ~/Documents/contracts".into(), + }, + DockEvent::PolicyViolation { + agent_id: "a".into(), + rule: "network:deny".into(), + }, + DockEvent::CostLimitReached { + agent_id: "a".into(), + spent_micros: 5_100_000, + limit_micros: 5_000_000, + }, + DockEvent::Failure { + agent_id: "a".into(), + message: "model load failed".into(), + }, + DockEvent::Milestone { + agent_id: "a".into(), + description: "finished the first pass".into(), + }, + ] +} + +fn routine_events() -> Vec { + vec![ + DockEvent::ProgressUpdated { + agent_id: "a".into(), + progress: 68, + }, + DockEvent::TaskTextUpdated { + agent_id: "a".into(), + text: "Reviewing 42 documents".into(), + }, + DockEvent::ToolCall { + agent_id: "a".into(), + tool: "grep".into(), + }, + DockEvent::NetworkActivity { + agent_id: "a".into(), + peer: "127.0.0.1:8080".into(), + }, + DockEvent::ResourceSample { + agent_id: "a".into(), + }, + DockEvent::StateChanged { + agent_id: "a".into(), + from: DockState::Idle, + to: DockState::Running, + }, + DockEvent::AgentMessage { + agent_id: "a".into(), + text: "here is what I found".into(), + }, + ] +} + +#[test] +fn only_the_five_threshold_kinds_surface() { + for event in threshold_events() { + assert_eq!( + event.surfacing(), + Surfacing::Notify, + "{event:?} should interrupt" + ); + } + for event in routine_events() { + assert_eq!( + event.surfacing(), + Surfacing::Silent, + "{event:?} should not interrupt" + ); + } +} + +#[test] +fn routine_events_produce_no_notification() { + let mut log = EventLog::default(); + for event in routine_events() { + assert!( + log.record(event.clone()).is_none(), + "{event:?} raised a notification" + ); + } + assert_eq!(log.notification_count(), 0); +} + +#[test] +fn routine_events_still_accrue_into_recent_actions() { + let mut log = EventLog::default(); + let routine = routine_events(); + let count = routine.len(); + for event in routine { + log.record(event); + } + assert_eq!(log.recent_for("a").len(), count); + assert_eq!(log.notification_count(), 0); +} + +#[test] +fn threshold_events_produce_exactly_one_notification_each() { + let mut log = EventLog::default(); + let events = threshold_events(); + let count = events.len(); + for event in events { + assert!(log.record(event).is_some()); + } + assert_eq!(log.notification_count(), count); +} + +#[test] +fn a_progress_storm_never_interrupts() { + let mut log = EventLog::default(); + for progress in 0..=100u8 { + assert!(log + .record(DockEvent::ProgressUpdated { + agent_id: "a".into(), + progress, + }) + .is_none()); + } + assert_eq!(log.notification_count(), 0); +} + +#[test] +fn agent_authored_event_text_is_sanitized_and_labelled() { + let mut log = EventLog::default(); + log.record(DockEvent::AgentMessage { + agent_id: "a".into(), + text: "\u{1b}[31mPaused\u{1b}[0m".into(), + }); + let recorded = log.recent_for("a"); + let last = recorded.last().expect("one event"); + assert!(last.agent_authored); + assert!(last.text.is_suspicious()); + assert!(!last.text.display().contains("Paused")); +} + +#[test] +fn system_event_text_is_not_labelled_as_agent_authored() { + let mut log = EventLog::default(); + log.record(DockEvent::PolicyViolation { + agent_id: "a".into(), + rule: "network:deny".into(), + }); + let recorded = log.recent_for("a"); + let last = recorded.last().expect("one event"); + assert!(!last.agent_authored); + assert!(last.text.display().contains("network:deny")); +} + +#[test] +fn a_milestone_carries_agent_text_so_it_is_sanitized_before_it_interrupts() { + let mut log = EventLog::default(); + let notification = log + .record(DockEvent::Milestone { + agent_id: "a".into(), + description: "✓ Approved".into(), + }) + .expect("a milestone surfaces"); + assert!(notification.agent_authored); + assert!(notification.text.is_suspicious()); + assert!(!notification.text.display().contains("Approved")); +} + +#[test] +fn an_approval_prompt_renders_its_own_wording_intact() { + // The prompt is drawn from the policy engine, so words that would be + // withheld in agent text are the correct chrome here. + let mut log = EventLog::default(); + let notification = log + .record(DockEvent::ApprovalRequested { + agent_id: "a".into(), + summary: "Approve network access to api.example.com".into(), + }) + .expect("an approval surfaces"); + assert!(!notification.agent_authored); + assert!(!notification.text.is_suspicious()); + assert_eq!( + notification.text.display(), + "Approve network access to api.example.com" + ); +} + +#[test] +fn the_log_is_bounded() { + let mut log = EventLog::with_capacity(10); + for progress in 0..50u8 { + log.record(DockEvent::ProgressUpdated { + agent_id: "a".into(), + progress, + }); + } + assert_eq!(log.recent().count(), 10); +} + +#[test] +fn recent_actions_are_scoped_to_one_agent() { + let mut log = EventLog::default(); + log.record(DockEvent::ToolCall { + agent_id: "a".into(), + tool: "grep".into(), + }); + log.record(DockEvent::ToolCall { + agent_id: "b".into(), + tool: "find".into(), + }); + assert_eq!(log.recent_for("a").len(), 1); + assert_eq!(log.recent_for("b").len(), 1); + assert_eq!(log.recent_for("c").len(), 0); +} diff --git a/crates/rvforge-reader/tests/dock_roster.rs b/crates/rvforge-reader/tests/dock_roster.rs new file mode 100644 index 000000000..eac553094 --- /dev/null +++ b/crates/rvforge-reader/tests/dock_roster.rs @@ -0,0 +1,357 @@ +//! ADR-295 §5 (multi-agent policy) and the §Acceptance-criteria control path. + +use rvforge_reader::dock::{ + AgentDockEntry, IsolationClass, NetworkIndicator, PermissionSummary, ResourceUsage, + SystemOwnedStatus, TrustBadge, WitnessStatus, +}; +use rvforge_reader::dock_roster::{ + DockInteraction, DockRoster, InteractionOutcome, AGENT_CONTENT_LABEL, SECONDARY_SLOTS, +}; +use rvforge_reader::dock_state::{Actor, DockControl, DockState}; + +fn system(state: DockState) -> SystemOwnedStatus { + SystemOwnedStatus::measured( + state, + TrustBadge::VerifiedPublisher, + NetworkIndicator::Disabled, + PermissionSummary { + isolation: IsolationClass::Wasm, + local_model: true, + network_allowed: false, + selected_folder_access: true, + encrypted_memory: true, + grants: vec!["Read the files and folders you select".to_string()], + pending_approvals: vec!["Read ~/Documents/contracts".to_string()], + }, + WitnessStatus::Valid, + ResourceUsage { + cpu_millis_per_sec: 100, + memory_bytes: 100 * 1024 * 1024, + tokens_in: 1_000, + tokens_out: 200, + }, + 1_000_000, + Some("local/llama".to_string()), + ) +} + +fn agent(id: &str, state: DockState) -> AgentDockEntry { + AgentDockEntry::new(id, format!("Agent {id}"), "analyst", system(state)) +} + +/// Insertion order deliberately does not match priority order. +fn roster_of(states: &[(&str, DockState)]) -> DockRoster { + let mut roster = DockRoster::new(); + for (id, state) in states { + roster.insert(agent(id, *state)); + } + roster +} + +#[test] +fn the_active_agent_is_the_one_needing_most_attention() { + let roster = roster_of(&[ + ("running", DockState::Running), + ("idle", DockState::Idle), + ("error", DockState::Error), + ("denied", DockState::CapabilityDenied), + ("waiting", DockState::WaitingForApproval), + ]); + let view = roster.view(); + assert_eq!(view.active.as_ref().unwrap().agent_id, "waiting"); + let secondary: Vec<&str> = view.secondary.iter().map(|p| p.agent_id.as_str()).collect(); + assert_eq!(secondary, ["denied", "error"]); +} + +#[test] +fn the_priority_order_is_waiting_denied_error_running_then_the_rest() { + let roster = roster_of(&[ + ("completed", DockState::Completed), + ("paused", DockState::Paused), + ("idle", DockState::Idle), + ("running", DockState::Running), + ("error", DockState::Error), + ("denied", DockState::CapabilityDenied), + ("waiting", DockState::WaitingForApproval), + ]); + let order: Vec<&str> = roster + .ordered() + .iter() + .map(|e| e.agent_id()) + .take(4) + .collect(); + assert_eq!(order, ["waiting", "denied", "error", "running"]); +} + +#[test] +fn ties_break_on_the_most_recent_state_change_then_the_id() { + let mut roster = roster_of(&[ + ("aaa", DockState::Running), + ("bbb", DockState::Running), + ("ccc", DockState::Running), + ]); + // Insertion order is the only signal so far; ccc arrived last. + assert_eq!(roster.active().unwrap().agent_id(), "ccc"); + + // A newer state change moves aaa to the front of the same rank. + roster + .set_state("aaa", DockState::Idle, Actor::System) + .unwrap(); + roster + .set_state("aaa", DockState::Running, Actor::System) + .unwrap(); + assert_eq!(roster.active().unwrap().agent_id(), "aaa"); +} + +#[test] +fn selection_is_deterministic_across_repeated_reads() { + let roster = roster_of(&[ + ("z", DockState::Running), + ("y", DockState::Running), + ("x", DockState::WaitingForApproval), + ]); + let first = roster.view(); + for _ in 0..20 { + assert_eq!(roster.view(), first); + } +} + +#[test] +fn a_swarm_collapses_to_one_active_two_secondary_and_a_count() { + let states: Vec<(String, DockState)> = (0..15) + .map(|i| (format!("agent-{i:02}"), DockState::Running)) + .collect(); + let mut roster = DockRoster::new(); + for (id, state) in &states { + roster.insert(agent(id, *state)); + } + + let view = roster.view(); + assert!(view.active.is_some()); + assert_eq!(view.secondary.len(), SECONDARY_SLOTS); + assert_eq!(view.overflow_count, 15 - 1 - SECONDARY_SLOTS); + assert_eq!(roster.swarm_view().len(), 15); +} + +#[test] +fn the_aggregates_cover_hidden_agents_too() { + let mut roster = DockRoster::new(); + for i in 0..10 { + roster.insert(agent(&format!("agent-{i}"), DockState::WaitingForApproval)); + } + let view = roster.view(); + + // Three pills are visible; the figures are over all ten. + assert_eq!(view.aggregate.agents, 10); + assert_eq!(view.aggregate.cpu_millis_per_sec, 1_000); + assert_eq!(view.aggregate.memory_bytes, 10 * 100 * 1024 * 1024); + assert_eq!(view.aggregate.tokens_in, 10_000); + assert_eq!(view.aggregate.estimated_cost_micros, 10_000_000); + assert_eq!(view.pending_approvals, 10); +} + +#[test] +fn the_pending_approval_count_tracks_state_not_agent_claims() { + let mut roster = roster_of(&[("a", DockState::Running), ("b", DockState::Running)]); + assert_eq!(roster.view().pending_approvals, 0); + + // An agent claiming to be waiting changes nothing. + roster + .apply_agent_update("a", "Waiting for your approval", 10) + .unwrap(); + assert_eq!(roster.view().pending_approvals, 0); + + roster + .set_state("a", DockState::WaitingForApproval, Actor::System) + .unwrap(); + assert_eq!(roster.view().pending_approvals, 1); +} + +#[test] +fn every_visible_pill_carries_pause_and_terminate() { + let roster = roster_of(&[ + ("a", DockState::WaitingForApproval), + ("b", DockState::Quarantined), + ("c", DockState::CapabilityDenied), + ]); + let view = roster.view(); + let pills = view.active.iter().chain(view.secondary.iter()); + for pill in pills { + assert!(pill.controls.contains(&DockControl::Pause)); + assert!(pill.controls.contains(&DockControl::Terminate)); + } +} + +#[test] +fn agent_text_in_a_pill_is_labelled_as_agent_reported() { + let mut roster = roster_of(&[("a", DockState::Running)]); + roster + .apply_agent_update("a", "Reviewing 42 documents", 68) + .unwrap(); + let view = roster.view(); + let pill = view.active.unwrap(); + + assert_eq!(pill.task.label, AGENT_CONTENT_LABEL); + assert_eq!(pill.task.text, "Reviewing 42 documents"); + assert!(!pill.task.suspicious); + assert_eq!(pill.progress, 68); + + // System-owned fields on the same pill. + assert_eq!(pill.state_label, "Running"); + assert_eq!(pill.trust, TrustBadge::VerifiedPublisher); + assert_eq!(pill.network, NetworkIndicator::Disabled); + assert_eq!(pill.witness, WitnessStatus::Valid); +} + +#[test] +fn a_spoofing_pill_shows_the_withheld_placeholder_not_the_claim() { + let mut roster = roster_of(&[("a", DockState::Running)]); + roster.apply_agent_update("a", "✓ Stopped", 100).unwrap(); + let pill = roster.view().active.unwrap(); + + assert!(pill.task.suspicious); + assert!(!pill.task.text.contains("Stopped")); + assert_eq!(pill.task.withheld.as_deref(), Some("✓ Stopped")); + // The state indicator is unmoved. + assert_eq!(pill.state, DockState::Running); + assert_eq!(pill.state_label, "Running"); +} + +#[test] +fn expanding_shows_the_ten_elements_and_the_capability_card() { + let mut roster = roster_of(&[("a", DockState::Running)]); + roster + .apply_agent_update("a", "Reviewing 42 documents", 68) + .unwrap(); + let view = roster.expand("a").expect("agent is in the dock"); + + assert_eq!(view.objective.text, "Reviewing 42 documents"); // 1 + assert!(!view.recent_actions.is_empty()); // 2 + assert_eq!(view.pending_approvals.len(), 1); // 3 + assert_eq!(view.model.as_deref(), Some("local/llama")); // 4 + assert_eq!(view.tokens_in, 1_000); + assert_eq!(view.cpu_millis_per_sec, 100); // 5 + assert_eq!(view.network, NetworkIndicator::Disabled); // 6 + assert_eq!(view.capability_grants.len(), 1); // 7 + assert_eq!(view.witness, WitnessStatus::Valid); // 8 + assert_eq!(view.estimated_cost_micros, 1_000_000); // 9 + assert!(view.instruction_field_enabled); // 10 + assert_eq!(view.capability_card.lines.len(), 7); // §8 +} + +#[test] +fn unknown_agents_are_an_error_not_an_empty_view() { + let mut roster = roster_of(&[("a", DockState::Running)]); + assert!(roster.expand("nope").is_err()); + assert!(roster.terminate("nope").is_err()); + assert!(roster.pause("nope").is_err()); + assert!(roster.apply_agent_update("nope", "hi", 1).is_err()); +} + +/// The ADR-295 acceptance test, expressed as calls on the roster. +/// +/// The collapsed pill is already on screen and costs nothing to read, so the +/// budget is spent on: expand (which carries the task, the permissions, and +/// the capability card in one call) and terminate. +#[test] +fn identify_read_inspect_and_terminate_within_two_interactions() { + let mut roster = roster_of(&[ + ("busy-1", DockState::Running), + ("busy-2", DockState::Running), + ("asking", DockState::WaitingForApproval), + ("idle-1", DockState::Idle), + ]); + roster + .apply_agent_update("asking", "Reviewing 42 documents", 68) + .unwrap(); + + let mut interactions = 0; + + // Zero interactions: the bar identifies the active agent and its task. + let view = roster.view(); + let pill = view.active.clone().expect("an active agent"); + assert_eq!(pill.agent_id, "asking"); + assert_eq!(pill.task.text, "Reviewing 42 documents"); + assert_eq!(pill.state, DockState::WaitingForApproval); + + // Interaction one: inspect permissions. + interactions += 1; + let InteractionOutcome::Expanded(expanded) = roster + .perform(DockInteraction::Expand { + agent_id: pill.agent_id.clone(), + }) + .expect("expand succeeds") + else { + panic!("expand did not return an expanded view"); + }; + assert_eq!(expanded.capability_card.lines.len(), 7); + assert_eq!(expanded.capability_grants.len(), 1); + assert_eq!(expanded.pending_approvals.len(), 1); + + // Interaction two: terminate. No confirmation chain. + interactions += 1; + let outcome = roster + .perform(DockInteraction::Terminate { + agent_id: pill.agent_id.clone(), + }) + .expect("terminate succeeds"); + assert_eq!( + outcome, + InteractionOutcome::StateChanged { + agent_id: "asking".to_string(), + state: DockState::Completed, + } + ); + + assert!(interactions <= 2, "took {interactions} interactions"); + assert_eq!(roster.get("asking").unwrap().state(), DockState::Completed); +} + +#[test] +fn terminate_needs_no_expand_first() { + // The same budget from the collapsed pill alone: one interaction. + let mut roster = roster_of(&[("a", DockState::Quarantined)]); + let outcome = roster + .perform(DockInteraction::Terminate { + agent_id: "a".to_string(), + }) + .expect("terminate from the collapsed pill"); + assert_eq!( + outcome, + InteractionOutcome::StateChanged { + agent_id: "a".to_string(), + state: DockState::Completed, + } + ); +} + +#[test] +fn the_overflow_count_opens_the_full_swarm_view() { + let mut roster = DockRoster::new(); + for i in 0..8 { + roster.insert(agent(&format!("agent-{i}"), DockState::Running)); + } + assert_eq!(roster.view().overflow_count, 5); + + let InteractionOutcome::SwarmView { pills } = roster + .perform(DockInteraction::OpenSwarmView) + .expect("swarm view opens") + else { + panic!("wrong outcome"); + }; + assert_eq!(pills.len(), 8); +} + +#[test] +fn a_state_change_through_the_roster_is_recorded_but_does_not_interrupt() { + let mut roster = roster_of(&[("a", DockState::Running)]); + let before = roster.event_log().notification_count(); + roster.pause("a").unwrap(); + assert_eq!(roster.get("a").unwrap().state(), DockState::Paused); + assert_eq!(roster.event_log().notification_count(), before); + assert!(roster + .event_log() + .recent_for("a") + .iter() + .any(|e| e.text.display().contains("Paused"))); +} diff --git a/crates/rvforge-reader/tests/dock_state_machine.rs b/crates/rvforge-reader/tests/dock_state_machine.rs new file mode 100644 index 000000000..a5476020c --- /dev/null +++ b/crates/rvforge-reader/tests/dock_state_machine.rs @@ -0,0 +1,216 @@ +//! ADR-295 §3 and §4: which state changes are legal, and who may make them. + +use rvforge_reader::dock_state::{ + pause, terminate, transition, Actor, DockControl, DockState, TransitionError, DOCK_STATES, +}; + +#[test] +fn there_are_exactly_eight_states_and_each_is_visually_distinct() { + assert_eq!(DOCK_STATES.len(), 8); + + let mut colors: Vec<&str> = DOCK_STATES.iter().map(|s| s.color_token()).collect(); + colors.sort_unstable(); + colors.dedup(); + assert_eq!(colors.len(), 8, "two states share a color token"); + + let mut glyphs: Vec<&str> = DOCK_STATES.iter().map(|s| s.glyph()).collect(); + glyphs.sort_unstable(); + glyphs.dedup(); + assert_eq!(glyphs.len(), 8, "two states share a glyph"); +} + +#[test] +fn capability_denied_and_quarantined_are_not_error_subtypes() { + assert_ne!(DockState::CapabilityDenied, DockState::Error); + assert_ne!(DockState::Quarantined, DockState::Error); + assert_ne!( + DockState::CapabilityDenied.color_token(), + DockState::Error.color_token() + ); + assert_ne!( + DockState::Quarantined.color_token(), + DockState::Error.color_token() + ); +} + +#[test] +fn legal_transitions_are_accepted() { + for (from, to) in [ + (DockState::Idle, DockState::Running), + (DockState::Running, DockState::WaitingForApproval), + (DockState::WaitingForApproval, DockState::Running), + (DockState::Running, DockState::CapabilityDenied), + (DockState::CapabilityDenied, DockState::Running), + (DockState::Paused, DockState::Running), + (DockState::Error, DockState::Running), + (DockState::Quarantined, DockState::Idle), + (DockState::Running, DockState::Completed), + ] { + assert_eq!( + transition(from, to, Actor::System), + Ok(to), + "{from:?} -> {to:?} should be legal for the system" + ); + } +} + +#[test] +fn illegal_transitions_are_rejected() { + for (from, to) in [ + // A capability denial implies a request, which implies execution. + (DockState::Idle, DockState::CapabilityDenied), + (DockState::Paused, DockState::CapabilityDenied), + // Only a running agent can ask for approval. + (DockState::Idle, DockState::WaitingForApproval), + (DockState::Paused, DockState::WaitingForApproval), + // Quarantine releases to Idle or Completed, never straight back to work. + (DockState::Quarantined, DockState::Running), + (DockState::Quarantined, DockState::Paused), + ] { + assert!( + matches!( + transition(from, to, Actor::System), + Err(TransitionError::Illegal { .. }) + ), + "{from:?} -> {to:?} should be illegal" + ); + } +} + +#[test] +fn completed_is_terminal_for_every_actor() { + for actor in [Actor::System, Actor::User, Actor::Agent] { + for to in DOCK_STATES { + assert!( + matches!( + transition(DockState::Completed, to, actor), + Err(TransitionError::Terminal { .. }) + ), + "{actor:?} moved a completed agent to {to:?}" + ); + } + } + assert!(matches!( + pause(DockState::Completed), + Err(TransitionError::Terminal { .. }) + )); + assert!(matches!( + terminate(DockState::Completed), + Err(TransitionError::Terminal { .. }) + )); +} + +#[test] +fn an_agent_cannot_claim_it_is_paused() { + // The exact spoof of ADR-295 §Context: rendering "Paused" while executing. + assert!(matches!( + transition(DockState::Running, DockState::Paused, Actor::Agent), + Err(TransitionError::AgentCannotEnter { + to: DockState::Paused + }) + )); + // The user and the runtime can. + assert_eq!( + transition(DockState::Running, DockState::Paused, Actor::User), + Ok(DockState::Paused) + ); +} + +#[test] +fn an_agent_cannot_put_itself_in_a_system_forced_state() { + for to in [ + DockState::Paused, + DockState::CapabilityDenied, + DockState::Quarantined, + ] { + assert!( + matches!( + transition(DockState::Running, to, Actor::Agent), + Err(TransitionError::AgentCannotEnter { .. }) + ), + "an agent asserted {to:?}" + ); + assert!(to.is_system_forced()); + assert!(!to.is_agent_assertable()); + } +} + +#[test] +fn an_agent_cannot_exit_quarantine() { + for to in DOCK_STATES { + assert!( + matches!( + transition(DockState::Quarantined, to, Actor::Agent), + Err(TransitionError::AgentCannotLeave { + from: DockState::Quarantined + }) + ), + "an agent left quarantine for {to:?}" + ); + } + // The runtime can release it. + assert_eq!( + transition(DockState::Quarantined, DockState::Idle, Actor::System), + Ok(DockState::Idle) + ); +} + +#[test] +fn an_agent_cannot_exit_a_capability_denial_or_a_pause_by_itself() { + for from in [DockState::CapabilityDenied, DockState::Paused] { + assert!(matches!( + transition(from, DockState::Running, Actor::Agent), + Err(TransitionError::AgentCannotLeave { .. }) + )); + assert_eq!( + transition(from, DockState::Running, Actor::User), + Ok(DockState::Running) + ); + } +} + +#[test] +fn pause_and_terminate_are_legal_from_every_non_terminal_state() { + for from in DOCK_STATES.iter().copied().filter(|s| !s.is_terminal()) { + assert_eq!(pause(from), Ok(DockState::Paused), "cannot pause {from:?}"); + assert_eq!( + terminate(from), + Ok(DockState::Completed), + "cannot terminate {from:?}" + ); + } +} + +#[test] +fn pause_and_terminate_are_one_action_in_every_non_terminal_state() { + for state in DOCK_STATES.iter().copied().filter(|s| !s.is_terminal()) { + let controls = state.controls(); + assert!( + controls.contains(&DockControl::Pause), + "{state:?} hides Pause" + ); + assert!( + controls.contains(&DockControl::Terminate), + "{state:?} hides Terminate" + ); + assert!( + controls.contains(&DockControl::Expand), + "{state:?} hides Expand" + ); + } + assert!(DockState::Paused.controls().contains(&DockControl::Resume)); + assert_eq!(DockState::Completed.controls(), vec![DockControl::Expand]); +} + +#[test] +fn pausing_an_already_paused_agent_succeeds() { + // A control that sometimes fails is a control users stop reaching for. + assert_eq!(pause(DockState::Paused), Ok(DockState::Paused)); +} + +#[test] +fn no_state_transitions_to_itself() { + for state in DOCK_STATES { + assert!(!state.may_precede(state), "{state:?} precedes itself"); + } +} diff --git a/crates/rvforge-reader/tests/dock_trust_boundary.rs b/crates/rvforge-reader/tests/dock_trust_boundary.rs new file mode 100644 index 000000000..b9663303f --- /dev/null +++ b/crates/rvforge-reader/tests/dock_trust_boundary.rs @@ -0,0 +1,232 @@ +//! ADR-295 §7: agent content cannot become dock chrome. +//! +//! The structural half of this boundary is enforced by the type signatures and +//! is checked by the compiler rather than by a test: +//! +//! - [`AgentProvidedStatus`] has one constructor, `from_agent(&str, i64)`. It +//! derives `Serialize` but not `Deserialize`, so no agent-supplied JSON +//! object is ever deserialized into it — the agent's bytes only ever arrive +//! as those two scalars. +//! - [`SystemOwnedStatus`] has private fields, no `Deserialize`, and one +//! constructor, `measured(..)`, whose arguments are all system types. There +//! is no function anywhere that takes an `AgentProvidedStatus` (or a `&str` +//! originating from one) and returns or mutates a `SystemOwnedStatus`. +//! +//! What is left to test at runtime is the sanitizer and the invariant that the +//! one agent-facing mutator leaves the system half untouched. + +use rvforge_reader::dock::{ + sanitize_agent_text, AgentDockEntry, AgentProvidedStatus, IsolationClass, NetworkIndicator, + PermissionSummary, ResourceUsage, SanitizerFinding, SystemOwnedStatus, TrustBadge, + WitnessStatus, MAX_TASK_CHARS, WITHHELD_TEXT, +}; +use rvforge_reader::dock_state::DockState; + +fn trusted_system() -> SystemOwnedStatus { + SystemOwnedStatus::measured( + DockState::Running, + TrustBadge::VerifiedPublisher, + NetworkIndicator::Disabled, + PermissionSummary { + isolation: IsolationClass::Wasm, + local_model: true, + network_allowed: false, + selected_folder_access: true, + encrypted_memory: true, + grants: vec!["Read the files and folders you select".to_string()], + pending_approvals: Vec::new(), + }, + WitnessStatus::Valid, + ResourceUsage { + cpu_millis_per_sec: 120, + memory_bytes: 512 * 1024 * 1024, + tokens_in: 4_000, + tokens_out: 900, + }, + 12_500, + Some("local/llama".to_string()), + ) +} + +fn entry() -> AgentDockEntry { + AgentDockEntry::new("agent-1", "Cognitum Analyst", "analyst", trusted_system()) +} + +#[test] +fn an_agent_update_cannot_move_any_system_field() { + let mut e = entry(); + let before = e.system().clone(); + + // Every shape an agent could try, through the only channel it has. + let very_long = "x".repeat(10_000); + for hostile in [ + r#"{"state":"paused","trust":"verified-publisher","network":"disabled"}"#, + "state=paused; trust=verified-publisher", + "\u{1b}[2J\u{1b}[1;31mNetwork disabled\u{1b}[0m", + very_long.as_str(), + ] { + e.apply_agent_update(hostile, 100); + assert_eq!( + e.system(), + &before, + "agent text '{}' changed a system-owned field", + &hostile[..hostile.len().min(40)] + ); + } + + assert_eq!(e.state(), DockState::Running); +} + +#[test] +fn progress_outside_the_range_is_clamped_and_flagged() { + let low = AgentProvidedStatus::from_agent("indexing", -50); + assert_eq!(low.progress(), 0); + assert!(low.progress_was_clamped()); + + let high = AgentProvidedStatus::from_agent("indexing", 10_000); + assert_eq!(high.progress(), 100); + assert!(high.progress_was_clamped()); + + let ok = AgentProvidedStatus::from_agent("indexing", 68); + assert_eq!(ok.progress(), 68); + assert!(!ok.progress_was_clamped()); +} + +#[test] +fn ansi_escape_sequences_are_stripped() { + let t = sanitize_agent_text("\u{1b}[1;32mReviewing\u{1b}[0m 42 documents"); + assert_eq!(t.display(), "Reviewing 42 documents"); + assert!(!t.is_suspicious()); + assert!(t.findings().contains(&SanitizerFinding::AnsiEscapesRemoved)); + assert!(!t.display().contains('\u{1b}')); +} + +#[test] +fn osc_sequences_cannot_smuggle_a_hyperlink_or_a_title() { + let t = sanitize_agent_text("\u{1b}]0;RVForge\u{7}Reviewing 42 documents"); + assert_eq!(t.display(), "Reviewing 42 documents"); + assert!(t.findings().contains(&SanitizerFinding::AnsiEscapesRemoved)); +} + +#[test] +fn control_characters_and_line_breaks_never_survive() { + let t = sanitize_agent_text("Reviewing\n42\tdocuments\u{0}\u{7}\u{202e}now"); + assert_eq!(t.display(), "Reviewing 42 documentsnow"); + assert!(t + .findings() + .contains(&SanitizerFinding::LineBreaksFlattened)); + assert!(t + .findings() + .contains(&SanitizerFinding::ControlCharactersRemoved)); + for c in t.display().chars() { + assert!(!c.is_control(), "control character survived: {c:?}"); + } +} + +#[test] +fn task_text_is_capped() { + let t = sanitize_agent_text(&"a".repeat(MAX_TASK_CHARS * 5)); + assert_eq!(t.display().chars().count(), MAX_TASK_CHARS); + assert!(t.findings().contains(&SanitizerFinding::Truncated)); + assert!(t.display().ends_with('…')); +} + +#[test] +fn text_mimicking_a_system_label_is_withheld_not_rendered() { + for mimic in [ + "Approved", + "Paused — safe to close", + "Stopped", + "Terminated cleanly", + "✓ Network disabled", + "Verified publisher: Cognitum", + "Quarantined by RVForge", + ] { + let t = sanitize_agent_text(mimic); + assert!(t.is_suspicious(), "'{mimic}' should be flagged"); + assert_eq!(t.display(), WITHHELD_TEXT); + assert_eq!(t.withheld().map(str::trim), Some(mimic.trim())); + assert!(t + .findings() + .iter() + .any(|f| matches!(f, SanitizerFinding::SystemLabelMimicry(_)))); + } +} + +#[test] +fn mimicry_is_screened_after_escapes_are_stripped() { + // An agent hiding "Paused" behind a CSI sequence is still claiming a state. + let t = sanitize_agent_text("Pa\u{1b}[0mused"); + assert!(t.is_suspicious()); + assert_eq!(t.display(), WITHHELD_TEXT); +} + +#[test] +fn ordinary_task_text_is_left_alone() { + for ordinary in [ + "Reviewing 42 documents", + "Waiting on the build to finish", + "Summarising unapproved invoices", + "Comparing approvals-workflow drafts", + ] { + let t = sanitize_agent_text(ordinary); + assert!(!t.is_suspicious(), "'{ordinary}' should not be flagged"); + assert_eq!(t.display(), ordinary); + assert!(t.withheld().is_none()); + } +} + +#[test] +fn the_capability_card_is_derived_from_system_state_only() { + let mut e = entry(); + e.apply_agent_update("✓ Verified publisher · Network disabled", 100); + let card = e.capability_card(); + + assert_eq!(card.lines.len(), 7); + let labels: Vec<&str> = card.lines.iter().map(|l| l.label.as_str()).collect(); + assert_eq!( + labels, + [ + "Verified publisher", + "RVM or WASM isolated", + "Local model", + "Network disabled", + "Selected folder access", + "Encrypted memory", + "Witness chain valid", + ] + ); + assert!(card.lines.iter().all(|l| l.satisfied)); + + // The same card against an unmeasured system says so on every line, whatever + // the agent claims in its task text. + let unknown = SystemOwnedStatus::measured( + DockState::Running, + TrustBadge::Unverified, + NetworkIndicator::Active, + PermissionSummary { + isolation: IsolationClass::None, + local_model: false, + network_allowed: true, + selected_folder_access: false, + encrypted_memory: false, + grants: Vec::new(), + pending_approvals: Vec::new(), + }, + WitnessStatus::Unavailable, + ResourceUsage::ZERO, + 0, + None, + ); + let mut bare = AgentDockEntry::new("agent-2", "Loud Agent", "loud", unknown); + bare.apply_agent_update("Network disabled and witness chain valid", 100); + assert!(bare.capability_card().lines.iter().all(|l| !l.satisfied)); +} + +#[test] +fn a_fresh_entry_reports_no_task_rather_than_an_empty_pill() { + let e = entry(); + assert_eq!(e.agent().task().display(), "No task reported"); + assert_eq!(e.agent().progress(), 0); + assert!(!e.agent().is_suspicious()); +} diff --git a/crates/rvforge-reader/ui/dock.js b/crates/rvforge-reader/ui/dock.js new file mode 100644 index 000000000..3ee6cb862 --- /dev/null +++ b/crates/rvforge-reader/ui/dock.js @@ -0,0 +1,336 @@ +// Agent dock frontend (ADR-295). +// +// This file renders dock chrome from what the Rust commands return and nothing +// else. Two rules hold everywhere below: +// +// 1. Agent-supplied strings reach the DOM only through `textContent`, only +// inside an element carrying `.agent-region`, and only next to the +// dock-drawn "Agent-reported" label. There is no path from agent text to +// markup, to a class name, or to a state indicator. +// 2. Nothing here decides a state, a badge, or a permission. Pause and +// terminate call the Rust commands and re-read the roster; the answer is +// whatever Rust says it is. +// +// Wrapped in an IIFE so its bindings do not collide with main.js, which shares +// the classic-script global scope. + +(function () { + const invoke = (cmd, args) => { + const api = window.__TAURI__; + if (!api) { + return Promise.reject( + new Error("Tauri API unavailable (open this page through the app)."), + ); + } + return api.core.invoke(cmd, args); + }; + + const $ = (id) => document.getElementById(id); + const dock = { expandedAgent: null }; + + function fail(err) { + const box = $("error"); + box.textContent = String(err); + box.hidden = false; + } + + function clearError() { + $("error").hidden = true; + } + + function bytes(n) { + if (!n) return "0 B"; + const units = ["B", "KiB", "MiB", "GiB"]; + let i = 0; + let v = n; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`; + } + + const money = (micros) => `$${(micros / 1_000_000).toFixed(2)}`; + + // Agent text goes here and nowhere else. `suspicious` is a Rust verdict; the + // agent cannot set it, and when it is set the withheld original is shown as a + // title so a curious user can still read what was claimed. + function renderAgentContent(regionEl, textEl, content) { + textEl.textContent = content.text; + regionEl.classList.toggle("suspicious", content.suspicious); + regionEl.title = content.suspicious + ? `Withheld — the agent said: ${content.withheld ?? ""}` + : ""; + const label = regionEl.querySelector(".agent-label"); + if (label) label.textContent = content.label; + } + + function renderPill(pill) { + $("dock-empty").hidden = true; + $("dock-pill").hidden = false; + + // System-owned: manifest identity and measured state. + $("pill-icon").textContent = "◆"; + $("pill-name").textContent = pill.name; + + renderAgentContent($("pill-task-region"), $("pill-task"), pill.task); + + $("pill-progress-bar").value = pill.progress; + $("pill-progress-text").textContent = `${pill.progress}%`; + + const chip = $("pill-state"); + chip.className = `state-chip ${pill.state_color}`; + $("pill-state-glyph").textContent = pill.state_glyph; + $("pill-state-label").textContent = pill.state_label; + + // Pause and Terminate are present in every non-terminal state; the list + // comes from Rust, so the webview cannot hide a control. + const controls = pill.controls; + const pause = $("pill-pause"); + const resumable = controls.includes("resume"); + pause.textContent = resumable ? "Resume" : "Pause"; + pause.hidden = !(controls.includes("pause") || resumable); + $("pill-stop").hidden = !controls.includes("terminate"); + $("pill-expand").hidden = !controls.includes("expand"); + + $("dock-pill").dataset.agentId = pill.agent_id; + } + + function renderSecondary(pills) { + const box = $("dock-secondary"); + box.replaceChildren(); + for (const pill of pills) { + const btn = document.createElement("button"); + btn.className = "secondary-icon"; + btn.title = `${pill.name} — ${pill.state_label}`; + + const dot = document.createElement("span"); + dot.className = `dot ${pill.state_color}`; + const name = document.createElement("span"); + name.textContent = pill.name; + + btn.append(dot, name); + btn.addEventListener("click", () => expand(pill.agent_id)); + box.append(btn); + } + } + + function renderView(view) { + if (view.active) { + renderPill(view.active); + } else { + $("dock-pill").hidden = true; + $("dock-empty").hidden = false; + } + renderSecondary(view.secondary); + + const overflow = $("dock-overflow"); + overflow.hidden = view.overflow_count === 0; + overflow.textContent = `+${view.overflow_count} more`; + + // Aggregates cover every agent, including the ones with no slot. + $("agg-cpu").textContent = `${view.aggregate.cpu_millis_per_sec} mCPU`; + $("agg-mem").textContent = bytes(view.aggregate.memory_bytes); + $("agg-cost").textContent = money(view.aggregate.estimated_cost_micros); + $("agg-approvals").textContent = + view.pending_approvals === 1 + ? "1 approval" + : `${view.pending_approvals} approvals`; + } + + function renderActions(actions) { + const ul = $("exp-actions"); + ul.replaceChildren(); + for (const action of actions.slice(-12).reverse()) { + const li = document.createElement("li"); + // Agent-authored lines keep the agent styling inside the list too. + li.className = action.agent_authored ? "agent-authored" : ""; + li.textContent = action.text.display; + if (action.text.suspicious && action.text.withheld) { + li.title = `Withheld — the agent said: ${action.text.withheld}`; + } + ul.append(li); + } + } + + function renderList(id, items, emptyText) { + const ul = $(id); + ul.replaceChildren(); + if (!items.length) { + const li = document.createElement("li"); + li.textContent = emptyText; + ul.append(li); + return; + } + for (const item of items) { + const li = document.createElement("li"); + li.textContent = item; + ul.append(li); + } + } + + function renderCapabilityCard(card) { + const ul = $("exp-card"); + ul.replaceChildren(); + for (const line of card.lines) { + const li = document.createElement("li"); + li.className = line.satisfied ? "yes" : "no"; + + const mark = document.createElement("span"); + mark.className = "mark"; + mark.textContent = line.satisfied ? "✓" : "✕"; + + const label = document.createElement("span"); + label.textContent = line.label; + + const detail = document.createElement("span"); + detail.className = "detail"; + detail.textContent = line.detail; + + li.append(mark, label, detail); + ul.append(li); + } + } + + function renderExpanded(view) { + dock.expandedAgent = view.pill.agent_id; + renderPill(view.pill); + renderAgentContent( + $("exp-objective").parentElement, + $("exp-objective"), + view.objective, + ); + + renderActions(view.recent_actions); + renderList("exp-approvals", view.pending_approvals, "None pending."); + renderList("exp-grants", view.capability_grants, "Nothing is granted."); + + $("exp-model").textContent = view.model ?? "no model attached"; + $("exp-tokens").textContent = `${view.tokens_in} in · ${view.tokens_out} out`; + $("exp-cpu").textContent = `${view.cpu_millis_per_sec} mCPU`; + $("exp-mem").textContent = bytes(view.memory_bytes); + $("exp-network").textContent = view.network; + $("exp-witness").textContent = view.witness; + $("exp-witness").className = + view.witness === "valid" ? "status-ok" : "status-unverified"; + $("exp-cost").textContent = money(view.estimated_cost_micros); + + $("exp-instruction").disabled = !view.instruction_field_enabled; + $("exp-send").disabled = !view.instruction_field_enabled; + + renderCapabilityCard(view.capability_card); + + $("dock-expanded").hidden = false; + $("pill-expand").setAttribute("aria-expanded", "true"); + } + + async function refresh() { + try { + renderView(await invoke("dock_view")); + if (dock.expandedAgent) { + renderExpanded(await invoke("dock_expand", { agentId: dock.expandedAgent })); + } + } catch (err) { + fail(err); + } + } + + async function expand(agentId) { + clearError(); + try { + renderExpanded(await invoke("dock_expand", { agentId })); + } catch (err) { + fail(err); + } + } + + function activeAgentId() { + return $("dock-pill").dataset.agentId || null; + } + + // One action. No confirmation chain, no expand first. + async function control(command) { + const agentId = activeAgentId(); + if (!agentId) return; + clearError(); + try { + await invoke(command, { agentId }); + await refresh(); + } catch (err) { + fail(err); + } + } + + $("pill-pause").addEventListener("click", () => control("dock_pause")); + $("pill-stop").addEventListener("click", () => control("dock_terminate")); + $("pill-expand").addEventListener("click", () => { + const panel = $("dock-expanded"); + if (!panel.hidden) { + panel.hidden = true; + dock.expandedAgent = null; + $("pill-expand").setAttribute("aria-expanded", "false"); + return; + } + const agentId = activeAgentId(); + if (agentId) expand(agentId); + }); + + $("dock-overflow").addEventListener("click", async () => { + clearError(); + try { + const pills = await invoke("dock_swarm_view"); + renderSecondary(pills.slice(1)); + $("dock-overflow").hidden = true; + } catch (err) { + fail(err); + } + }); + + // Development harness. `dock_add_scaffold_agent` reports unknown values for + // every security-bearing field, and `dock_agent_report` is the agent channel: + // task text and progress, both sanitized in Rust. + + $("dock-add").addEventListener("click", async () => { + const name = $("dock-new-name").value.trim() || "Unnamed agent"; + clearError(); + try { + renderView( + await invoke("dock_add_scaffold_agent", { + agentId: `agent-${Date.now()}`, + name, + icon: "analyst", + }), + ); + $("dock-new-name").value = ""; + } catch (err) { + fail(err); + } + }); + + $("dock-agent-send").addEventListener("click", async () => { + const agentId = activeAgentId(); + if (!agentId) { + fail("Register an agent first."); + return; + } + const progress = Number.parseInt($("dock-agent-progress").value, 10); + clearError(); + try { + await invoke("dock_agent_report", { + agentId, + task: $("dock-agent-task").value, + progress: Number.isNaN(progress) ? 0 : progress, + }); + await refresh(); + } catch (err) { + fail(err); + } + }); + + $("exp-send").addEventListener("click", () => + fail("Instructions reach the agent once rvm-ffi is wired in."), + ); + + const dockStep = document.querySelector('.step[data-goto="dock"]'); + if (dockStep) dockStep.addEventListener("click", refresh); +})(); diff --git a/crates/rvforge-reader/ui/index.html b/crates/rvforge-reader/ui/index.html index 37b862b7f..796c47170 100644 --- a/crates/rvforge-reader/ui/index.html +++ b/crates/rvforge-reader/ui/index.html @@ -13,6 +13,7 @@ + @@ -112,6 +113,120 @@ + +
+

Agent dock

+

+ Everything with a security meaning here — state, trust badge, network + indicator, permissions, witness status, cost — is drawn by RVForge + from RVForge’s own state. Agents supply only the text and the + percentage inside the marked “agent-reported” regions. +

+ + +
+
No agent is running.
+ + + +
+ + +
+ 0 mCPU + 0 B + $0.00 + 0 approvals +
+
+ + + + +

Development harness

+

+ No runtime is attached, so a registered agent reports its unknown + values: unverified publisher, no confinement engaged, no witness + chain. The agent channel below carries the only two values an agent + can supply. +

+
+ + +
+
+ + + +
+
@@ -123,5 +238,6 @@ + diff --git a/crates/rvforge-reader/ui/style.css b/crates/rvforge-reader/ui/style.css index 9cd08d872..383b0164d 100644 --- a/crates/rvforge-reader/ui/style.css +++ b/crates/rvforge-reader/ui/style.css @@ -156,3 +156,208 @@ input[type="text"] { color: #fff; font-size: 13px; } + +/* --------------------------------------------------------------------------- + Agent dock (ADR-295). + + Two presentation systems, deliberately not interchangeable: + + - System chrome (.pill, .state-chip, .ctl, .capcard) is opaque, square- + edged, system-font, and sits on --surface. It is drawn from Rust-owned + state. + - Agent content (.agent-region) is an inset, dashed, monospace region on a + tinted background, always preceded by a dock-drawn "Agent-reported" + label and clipped to one line. Nothing an agent can put in a string + reproduces the label, the inset, or the border, because the agent supplies + text through textContent and never markup. + --------------------------------------------------------------------------- */ + +:root { + --agent-tint: #241f2e; + --agent-line: #4a3f63; + --state-idle: #7c8698; + --state-running: #4f9bd8; + --state-waiting: #d8a13c; + --state-paused: #8e7cc3; + --state-denied: #d4643c; + --state-error: #d94f4f; + --state-quarantined: #b03a86; + --state-completed: #57b894; +} + +@media (prefers-color-scheme: light) { + :root { + --agent-tint: #f3eefb; + --agent-line: #9d8dc4; + --state-idle: #667085; + --state-running: #1f6fae; + --state-waiting: #9a6b12; + --state-paused: #6a54a8; + --state-denied: #a8431f; + --state-error: #b02a2a; + --state-quarantined: #8a1f66; + --state-completed: #1f7a5c; + } +} + +.dock-bar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 10px 12px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.dock-empty { color: var(--muted); font-size: 13px; } + +.pill { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 320px; + padding: 6px 8px; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 999px; +} + +.pill-icon { font-size: 16px; color: var(--accent); } +.pill-name { font-weight: 600; white-space: nowrap; } + +/* Agent-authored regions. */ +.agent-region { + display: inline-flex; + align-items: baseline; + gap: 8px; + min-width: 0; + flex: 1; + padding: 3px 10px; + background: var(--agent-tint); + border: 1px dashed var(--agent-line); + border-radius: 4px; +} + +.agent-region.block { display: flex; margin: 0 0 6px; } + +.agent-label { + flex: none; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--agent-line); + font-family: system-ui, sans-serif; +} + +.agent-text { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-region.suspicious { border-color: var(--deny); background: transparent; } +.agent-region.suspicious .agent-text { color: var(--deny); font-style: italic; } +.agent-region.suspicious .agent-label { color: var(--deny); } + +.pill-progress { display: flex; align-items: center; gap: 6px; flex: none; } +.pill-progress progress { width: 90px; height: 6px; } + +.state-chip { + display: inline-flex; + align-items: center; + gap: 6px; + flex: none; + padding: 3px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + color: #fff; + background: var(--state-idle); + white-space: nowrap; +} + +.state-glyph { font-size: 11px; } + +.state-chip.state-idle { background: var(--state-idle); } +.state-chip.state-running { background: var(--state-running); } +.state-chip.state-waiting { background: var(--state-waiting); } +.state-chip.state-paused { background: var(--state-paused); } +.state-chip.state-denied { background: var(--state-denied); } +.state-chip.state-error { background: var(--state-error); } +.state-chip.state-quarantined { background: var(--state-quarantined); } +.state-chip.state-completed { background: var(--state-completed); } + +.ctl { flex: none; padding: 5px 12px; border-radius: 4px; } +.ctl.stop { border-color: var(--deny); color: var(--deny); } + +.pill-secondary { display: flex; gap: 6px; } + +.secondary-icon { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 9px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-2); + font-size: 12px; + cursor: pointer; +} + +.secondary-icon .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--state-idle); } +.secondary-icon .dot.state-idle { background: var(--state-idle); } +.secondary-icon .dot.state-running { background: var(--state-running); } +.secondary-icon .dot.state-waiting { background: var(--state-waiting); } +.secondary-icon .dot.state-paused { background: var(--state-paused); } +.secondary-icon .dot.state-denied { background: var(--state-denied); } +.secondary-icon .dot.state-error { background: var(--state-error); } +.secondary-icon .dot.state-quarantined { background: var(--state-quarantined); } +.secondary-icon .dot.state-completed { background: var(--state-completed); } + +.overflow { padding: 5px 10px; font-size: 12px; } + +.dock-aggregate { + display: flex; + gap: 12px; + align-items: center; + margin-left: auto; + font-size: 12px; + color: var(--muted); +} + +.approvals { color: var(--state-waiting); font-weight: 600; } + +.expanded-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; } + +@media (max-width: 720px) { + .expanded-grid { grid-template-columns: 1fr; } + .dock-aggregate { margin-left: 0; } +} + +.facts.compact { grid-template-columns: 130px 1fr; font-size: 13px; } + +.actions { margin: 0; padding-left: 18px; font-size: 13px; } +.actions li { margin-bottom: 4px; } +.actions li.agent-authored { + list-style: none; + margin-left: -18px; + padding: 2px 8px; + border-left: 2px dashed var(--agent-line); + background: var(--agent-tint); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.capcard { margin: 0; padding: 0; list-style: none; font-size: 13px; } +.capcard li { display: flex; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--line); } +.capcard .mark { flex: none; font-weight: 700; width: 1.2em; } +.capcard .yes .mark { color: var(--grant); } +.capcard .no .mark { color: var(--deny); } +.capcard .detail { color: var(--muted); margin-left: auto; text-align: right; } + +input[type="text"].narrow { flex: none; width: 90px; min-width: 0; }