diff --git a/crates/rvforge-reader/src/commands.rs b/crates/rvforge-reader/src/commands.rs index 23e36cd12..4568b2779 100644 --- a/crates/rvforge-reader/src/commands.rs +++ b/crates/rvforge-reader/src/commands.rs @@ -15,8 +15,12 @@ use crate::dock::{ use crate::dock_events::Notification; use crate::dock_roster::{AgentPill, DockRoster, ExpandedAgentView, RosterView}; use crate::dock_state::DockState; -use crate::inspect::{self, InspectionSummary, VerificationOutcome}; +use crate::dock_bridge; +use crate::inspect::{self, InspectionSummary, VerificationOutcome, VerificationStatus}; +use crate::install::{InstallOffer, InstallRecord, InstallRequest, StateContract}; +use crate::library::{Library, LibraryEntry, LibraryState, StateTransfer, UninstallOutcome, UninstallPolicy}; use crate::runtime::{self, HostProfile, RuntimeChoice}; +use crate::update::{self, ReleaseDescriptor, UpdateApproval, UpdatePlan}; use crate::witness_view::{self, WitnessChainView}; /// Read identity, segments, and declared capabilities. Checks nothing, executes @@ -65,6 +69,224 @@ pub fn witness_chain(path: Option) -> Result { } } +// --------------------------------------------------------------------------- +// Install, library, update (requirements P6, P7, P9). +// +// Each of these is a thin wrapper too. The refusals — unverified package, +// undeclared grant, illegal library transition, lineage mismatch, unapproved +// expansion, unsafe rollback — are decided in `install`, `library`, and +// `update`, so the webview cannot reach a decision by calling a different +// command. +// --------------------------------------------------------------------------- + +/// Verify a package and derive the contract the user will be asked to accept. +/// Writes a verification receipt; installs nothing. +#[tauri::command] +pub fn install_offer(library: tauri::State<'_, Library>, path: String) -> InstallOffer { + library.offer(&PathBuf::from(path)) +} + +/// Install a verified package with the classes the user accepted. +/// +/// `accepted_classes` must be a subset of what +/// [`install_offer`] reported as grantable; anything else is refused. +#[tauri::command] +pub fn install_agent( + library: tauri::State<'_, Library>, + path: String, + name: String, + accepted_classes: Vec, + state_schema_version: Option, + rollback_unsafe: Option, +) -> Result { + let mut request = InstallRequest::new(PathBuf::from(path), name, accepted_classes); + request.state_contract = StateContract { + state_schema_version: state_schema_version.unwrap_or(1), + rollback_unsafe: rollback_unsafe.unwrap_or(false), + }; + library.install(&request).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn library_list(library: tauri::State<'_, Library>) -> Result, String> { + library.list().map_err(|e| e.to_string()) +} + +/// Open an agent. Re-verifies the installed artifact first; a package that no +/// longer verifies is quarantined instead of opened. +#[tauri::command] +pub fn library_open( + library: tauri::State<'_, Library>, + install_id: String, +) -> Result { + library.open(&install_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn library_pause( + library: tauri::State<'_, Library>, + install_id: String, +) -> Result { + library.pause(&install_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn library_terminate( + library: tauri::State<'_, Library>, + install_id: String, +) -> Result { + library.terminate(&install_id).map_err(|e| e.to_string()) +} + +/// Discard every delta and checkpoint. The base artifact is untouched. +#[tauri::command] +pub fn library_reset( + library: tauri::State<'_, Library>, + install_id: String, +) -> Result { + library.reset(&install_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn library_verify( + library: tauri::State<'_, Library>, + install_id: String, +) -> Result { + library.verify(&install_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn library_archive( + library: tauri::State<'_, Library>, + install_id: String, +) -> Result { + library.archive(&install_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn library_restore( + library: tauri::State<'_, Library>, + install_id: String, +) -> Result { + library.restore(&install_id).map_err(|e| e.to_string()) +} + +/// Copy the sealed state capsule out. The bytes stay encrypted. +#[tauri::command] +pub fn library_export_state( + library: tauri::State<'_, Library>, + install_id: String, + dest: String, +) -> Result { + library + .export_state(&install_id, &PathBuf::from(dest)) + .map_err(|e| e.to_string()) +} + +/// Copy a sealed capsule in, refusing state recorded against another lineage. +#[tauri::command] +pub fn library_import_state( + library: tauri::State<'_, Library>, + install_id: String, + source: String, +) -> Result { + library + .import_state(&install_id, &PathBuf::from(source)) + .map_err(|e| e.to_string()) +} + +/// Remove an agent. `remove_state` defaults to `false`, and asking for it +/// without an `export_to` is the explicit discard. +#[tauri::command] +pub fn library_uninstall( + library: tauri::State<'_, Library>, + install_id: String, + remove_state: Option, + export_to: Option, +) -> Result { + let policy = UninstallPolicy { + remove_state: remove_state.unwrap_or(false), + export_state_to: export_to.map(PathBuf::from), + }; + library + .uninstall(&install_id, &policy) + .map_err(|e| e.to_string()) +} + +/// The P9 permission difference for a candidate release. Decides nothing and +/// installs nothing. +#[tauri::command] +pub fn update_plan( + library: tauri::State<'_, Library>, + install_id: String, + path: String, + version_label: String, + predecessor_identity: String, + state_schema_version: Option, + rollback_unsafe: Option, +) -> Result { + let release = release_of( + path, + version_label, + predecessor_identity, + state_schema_version, + rollback_unsafe, + ); + update::plan(&library, &install_id, &release).map_err(|e| e.to_string()) +} + +/// Apply an update. `approved_classes` must name every expanding class the plan +/// listed, or the update is refused. +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub fn update_apply( + library: tauri::State<'_, Library>, + install_id: String, + path: String, + version_label: String, + predecessor_identity: String, + approved_classes: Vec, + state_schema_version: Option, + rollback_unsafe: Option, +) -> Result { + let release = release_of( + path, + version_label, + predecessor_identity, + state_schema_version, + rollback_unsafe, + ); + let plan = update::plan(&library, &install_id, &release).map_err(|e| e.to_string())?; + let approval = UpdateApproval { approved_classes }; + update::apply(&library, &release, &plan, &approval).map_err(|e| e.to_string()) +} + +/// Return to the retained predecessor, unless this release declared rollback +/// unsafe before it was installed. +#[tauri::command] +pub fn update_rollback( + library: tauri::State<'_, Library>, + install_id: String, +) -> Result { + update::rollback(&library, &install_id).map_err(|e| e.to_string()) +} + +fn release_of( + path: String, + version_label: String, + predecessor_identity: String, + state_schema_version: Option, + rollback_unsafe: Option, +) -> ReleaseDescriptor { + let mut release = + ReleaseDescriptor::new(PathBuf::from(path), version_label, predecessor_identity); + release.state_contract = StateContract { + state_schema_version: state_schema_version.unwrap_or(1), + rollback_unsafe: rollback_unsafe.unwrap_or(false), + }; + release +} + /// The dock's roster, managed by the Tauri app. /// /// Chrome state lives on the Rust side of the process boundary, which is what @@ -91,6 +313,26 @@ pub fn dock_view(dock: tauri::State<'_, DockSurface>) -> Result, + library: tauri::State<'_, Library>, +) -> Result { + let rebuilt = dock_bridge::roster(&library).map_err(|e| e.to_string())?; + dock.with(|r| { + *r = rebuilt; + r.view() + }) +} + /// Interaction one of the acceptance test. #[tauri::command] pub fn dock_expand( diff --git a/crates/rvforge-reader/src/dock_bridge.rs b/crates/rvforge-reader/src/dock_bridge.rs new file mode 100644 index 000000000..17db605fe --- /dev/null +++ b/crates/rvforge-reader/src/dock_bridge.rs @@ -0,0 +1,138 @@ +//! The dock's roster, built from the library rather than from sample data. +//! +//! This is the seam where ADR-295 §7 has to hold against real data: everything +//! the dock renders about an installed agent comes from the install record, the +//! library state, and the verified witness chain — three sources the agent does +//! not write to. The agent's own channel remains +//! [`DockRoster::apply_agent_update`], and nothing in this module passes an +//! agent-supplied value into [`SystemOwnedStatus::measured`]. +//! +//! Two things are reported as unknown rather than invented, because nothing in +//! the reader measures them yet: [`ResourceUsage`] is zero and the model is +//! `None`. A dock that displayed plausible-looking CPU figures for an agent +//! nobody is metering would be worse than one that displays zero. + +use crate::dock::{ + AgentDockEntry, IsolationClass, NetworkIndicator, PermissionSummary, ResourceUsage, + SystemOwnedStatus, TrustBadge, WitnessStatus, +}; +use crate::dock_roster::DockRoster; +use crate::dock_state::DockState; +use crate::inspect::SignatureStatus; +use crate::install::InstallRecord; +use crate::library::{Library, LibraryEntry, LibraryError, LibraryState}; +use crate::witness_view; + +/// The icon the dock draws for an installed agent. System chrome: it is not +/// read from the package, so a publisher cannot pick a glyph that imitates a +/// dock control. +pub const INSTALLED_AGENT_ICON: &str = "◆"; + +/// Build a roster reflecting every installed agent. +/// +/// # Errors +/// +/// Any [`LibraryError`] from listing the library. +pub fn roster(library: &Library) -> Result { + let witness = lifecycle_witness(library); + let mut roster = DockRoster::new(); + for entry in library.list()? { + roster.insert(dock_entry(&entry, witness)); + } + Ok(roster) +} + +/// The witness status the dock shows for this library: the verdict over the +/// lifecycle chain, recomputed by the viewer rather than reported by the file. +pub fn lifecycle_witness(library: &Library) -> WitnessStatus { + witness_view::chain_at(library.layout().lifecycle_log().path()) + .map(|view| view.dock_status) + .unwrap_or(WitnessStatus::Unavailable) +} + +/// One library row as a dock entry. +pub fn dock_entry(entry: &LibraryEntry, witness: WitnessStatus) -> AgentDockEntry { + let system = SystemOwnedStatus::measured( + dock_state(entry.state), + trust_badge(entry.record.signature), + network_indicator(&entry.record), + permissions(&entry.record), + witness, + // Nothing meters an installed agent yet, so nothing is claimed. + ResourceUsage::ZERO, + 0, + None, + ); + AgentDockEntry::new( + entry.record.install_id.clone(), + entry.record.agent_name.clone(), + INSTALLED_AGENT_ICON, + system, + ) +} + +/// Library state to dock state. +/// +/// `Updates` maps to `Idle`: an update being available says nothing about +/// whether the agent is running, and the dock's job is to show what it is +/// doing. `Archived` maps to `Completed`, the dock's terminal state. +pub fn dock_state(state: LibraryState) -> DockState { + match state { + LibraryState::Installed | LibraryState::Updates => DockState::Idle, + LibraryState::Running => DockState::Running, + LibraryState::Paused => DockState::Paused, + LibraryState::Quarantined => DockState::Quarantined, + LibraryState::Archived => DockState::Completed, + } +} + +/// The trust badge for what was actually checked at install time. A signature +/// nobody could check is `Unverified`, never neutral. +pub fn trust_badge(signature: SignatureStatus) -> TrustBadge { + match signature { + SignatureStatus::Verified => TrustBadge::VerifiedPublisher, + SignatureStatus::NotChecked => TrustBadge::SignedUnknownPublisher, + SignatureStatus::Unverified | SignatureStatus::Unsigned | SignatureStatus::Invalid => { + TrustBadge::Unverified + } + } +} + +/// Network is `AllowedIdle` at most: the reader observes no connections, so it +/// never reports `Active`. +fn network_indicator(record: &InstallRecord) -> NetworkIndicator { + if granted(record, "network") { + NetworkIndicator::AllowedIdle + } else { + NetworkIndicator::Disabled + } +} + +fn permissions(record: &InstallRecord) -> PermissionSummary { + PermissionSummary { + isolation: isolation_class(record), + local_model: granted(record, "model"), + network_allowed: granted(record, "network"), + selected_folder_access: granted(record, "filesystem"), + encrypted_memory: granted(record, "persistent-state"), + grants: record.granted_text.clone(), + // Approval prompts come from a running policy engine, which is not + // wired here. An empty list is the honest answer, not a claim that + // nothing was ever asked. + pending_approvals: Vec::new(), + } +} + +/// The isolation the selected runtime profile claims. An unrecognised or +/// missing profile is `None` — reported, never assumed. +fn isolation_class(record: &InstallRecord) -> IsolationClass { + match record.runtime_profile.as_deref() { + Some("rvm-native") => IsolationClass::Rvm, + Some("wasm") | Some("os-isolation+wasm") | Some("linux-microvm") => IsolationClass::Wasm, + _ => IsolationClass::None, + } +} + +fn granted(record: &InstallRecord, class: &str) -> bool { + record.granted_classes.iter().any(|c| c == class) +} diff --git a/crates/rvforge-reader/src/inspect.rs b/crates/rvforge-reader/src/inspect.rs index f8da5d68e..7acc65496 100644 --- a/crates/rvforge-reader/src/inspect.rs +++ b/crates/rvforge-reader/src/inspect.rs @@ -218,7 +218,16 @@ pub fn capability_card(path: &Path) -> CapabilityCard { /// [`capability_card`], with the receipt log named explicitly. pub fn capability_card_with_receipts(path: &Path, log: &ReceiptLog) -> CapabilityCard { - let outcome = verify_with_receipts(path, log); + card_for(&verify_with_receipts(path, log), path) +} + +/// The card for an outcome whose verification has already run. +/// +/// Exists so a caller that has just verified a package — the install flow — +/// derives the card the user was shown without verifying the same bytes twice +/// and writing a second receipt for one user action. The refusal rule is +/// unchanged and is applied here, not by the caller. +pub fn card_for(outcome: &VerificationOutcome, path: &Path) -> CapabilityCard { if outcome.summary.verification != VerificationStatus::Verified { let reason = outcome .summary diff --git a/crates/rvforge-reader/src/install/flow.rs b/crates/rvforge-reader/src/install/flow.rs new file mode 100644 index 000000000..5c9a8d25e --- /dev/null +++ b/crates/rvforge-reader/src/install/flow.rs @@ -0,0 +1,301 @@ +//! The install flow itself: offer the contract, then materialize the package. +//! +//! [`offer`] verifies and derives the P6 card; [`install`] repeats the same +//! refusals and, when they all pass, copies the verified bytes in and witnesses +//! the result. The refusals live here rather than in the command layer so a +//! caller cannot reach a decision by invoking a different entry point. + +use std::path::Path; + +use serde::Serialize; + +use crate::capability::{CapabilityCard, CardStatus}; +use crate::inspect::{self, SignatureStatus, VerificationStatus}; +use crate::runtime::{self, HostProfile, SelectionStatus}; +use crate::state::StateCapsule; + +use super::{io, InstallError, InstallLayout, InstallRecord, InstallRequest, INSTALL_RECORD_SCHEMA}; + +/// What the user is asked to consent to, before anything is written. +/// +/// [`Self::card`] is the P6 contract and [`Self::grantable_classes`] is exactly +/// what [`install`] will accept, so a UI cannot present one set and submit +/// another without the install refusing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct InstallOffer { + pub path: String, + pub verified: bool, + pub base_identity: Option, + pub signature: SignatureStatus, + pub card: CapabilityCard, + pub grantable_classes: Vec, + pub runtime_profile: Option, + pub isolation_claim: Option, + /// Why installation is not offered, when it is not. + pub refusal: Option, +} + +/// Verify a package and derive the contract the user will be asked to accept. +/// +/// This runs the same verification [`install`] runs and writes the same +/// receipt, so the card shown here is derived from bytes that passed, not from +/// a hopeful pre-read. +pub fn offer(layout: &InstallLayout, rvf_path: &Path) -> InstallOffer { + offer_for_host(layout, rvf_path, &HostProfile::detect()) +} + +/// [`offer`], against an explicit host profile. +pub fn offer_for_host(layout: &InstallLayout, rvf_path: &Path, host: &HostProfile) -> InstallOffer { + let outcome = inspect::verify_with_receipts(rvf_path, &layout.receipt_log()); + let card = inspect::card_for(&outcome, rvf_path); + let verified = outcome.summary.verification == VerificationStatus::Verified; + + let choice = runtime::select_for_host(host).ok(); + let unsupported = choice + .as_ref() + .map(|c| c.status == SelectionStatus::Unsupported) + .unwrap_or(true); + + let refusal = if !verified { + Some(format!( + "This package did not verify, so it cannot be installed: {}", + outcome + .summary + .notes + .last() + .cloned() + .unwrap_or_else(|| "verification failed".to_string()) + )) + } else if card.status != CardStatus::Derived { + Some("No capability contract could be derived for this package.".to_string()) + } else if unsupported { + Some("No runtime on this host can run this package.".to_string()) + } else { + None + }; + + InstallOffer { + path: rvf_path.display().to_string(), + verified, + base_identity: outcome.summary.identity.clone().map(content_address), + signature: outcome.summary.signature, + grantable_classes: card.requests.iter().map(|r| r.class.clone()).collect(), + runtime_profile: choice.as_ref().and_then(|c| c.profile.clone()), + isolation_claim: choice.as_ref().and_then(|c| c.isolation_claim.clone()), + card, + refusal, + } +} + +/// Install a verified package, materializing it and witnessing the result. +/// +/// # Errors +/// +/// Every [`InstallError`]. A refusal writes no install directory; the refusals +/// that are decisions rather than I/O failures are witnessed in the lifecycle +/// log before they are returned. +pub fn install(layout: &InstallLayout, request: &InstallRequest) -> Result { + install_for_host(layout, request, &HostProfile::detect()) +} + +/// [`install`], against an explicit host profile. +pub fn install_for_host( + layout: &InstallLayout, + request: &InstallRequest, + host: &HostProfile, +) -> Result { + let offer = offer_for_host(layout, &request.rvf_path, host); + let log = layout.lifecycle_log(); + let subject = |id: &str| -> String { id.to_string() }; + + // Refusals first, each witnessed against the id the install would have had. + if !offer.verified { + let reason = offer + .refusal + .clone() + .unwrap_or_else(|| "verification failed".to_string()); + let _ = log.append( + &request.rvf_path.display().to_string(), + "install", + "refused", + &reason, + ); + return Err(InstallError::NotVerified { reason }); + } + if offer.card.status != CardStatus::Derived { + return Err(InstallError::NoContract { + reason: offer.card.notes.join(" "), + }); + } + let base_identity = offer + .base_identity + .clone() + .ok_or_else(|| InstallError::NotVerified { + reason: "the container reported no identity".to_string(), + })?; + + let install_id = request + .install_id + .clone() + .unwrap_or_else(|| derive_install_id(&base_identity)); + + for class in &request.accepted_classes { + let class = class.trim().to_ascii_lowercase(); + if !offer.grantable_classes.contains(&class) { + let _ = log.append( + &subject(&install_id), + "install", + "refused", + &format!("'{class}' was accepted but the container does not declare it"), + ); + return Err(InstallError::GrantNotDeclared { class }); + } + } + + if offer.runtime_profile.is_none() { + let order = runtime::select_for_host(host) + .map(|c| c.order) + .unwrap_or_default(); + let _ = log.append( + &subject(&install_id), + "install", + "refused", + "no compatible runtime on this host", + ); + return Err(InstallError::UnsupportedRuntime { order }); + } + + if request.install_id.is_none() && layout.record_path(&install_id).is_file() { + return Err(InstallError::AlreadyInstalled { install_id }); + } + + // The capsule constructor is the ADR-288 §2 check: it refuses a state root + // inside the install root, so this runs before anything is written. + let install_dir = layout.install_dir(&install_id); + let capsule = StateCapsule::new(&install_dir, layout.state_root(), &base_identity)?; + + materialize(&request.rvf_path, &install_dir, &capsule)?; + + let granted_text = offer + .card + .requests + .iter() + .filter(|r| { + request + .accepted_classes + .iter() + .any(|c| c.trim().eq_ignore_ascii_case(&r.class)) + }) + .map(|r| r.text.clone()) + .collect(); + + let mut record = InstallRecord { + schema: INSTALL_RECORD_SCHEMA.to_string(), + install_id: install_id.clone(), + agent_name: request.agent_name.clone(), + base_identity: base_identity.clone(), + source_path: request.rvf_path.display().to_string(), + installed_at_unix: now_unix(), + install_dir: install_dir.display().to_string(), + state_root: layout.state_root().display().to_string(), + granted_classes: request + .accepted_classes + .iter() + .map(|c| c.trim().to_ascii_lowercase()) + .collect(), + granted_text, + state_contract: request.state_contract.clone(), + predecessor_identity: request.predecessor_identity.clone(), + runtime_profile: offer.runtime_profile.clone(), + isolation_claim: offer.isolation_claim.clone(), + signature: offer.signature, + receipt_id: None, + }; + + let details = format!( + "installed {base_identity} as '{install_id}' granting [{}]; runtime {}; rollback {}", + record.granted_classes.join(", "), + record.runtime_profile.as_deref().unwrap_or("none"), + if record.state_contract.rollback_unsafe { + "declared unsafe" + } else { + "available" + } + ); + record.receipt_id = log + .append(&subject(&install_id), "install", "accepted", &details) + .ok() + .map(|r| r.receipt_id); + + layout.write_record(&record)?; + Ok(record) +} + +/// Copy the verified bytes in, make them read-only, and create the capsule dir. +fn materialize( + source: &Path, + install_dir: &Path, + capsule: &StateCapsule, +) -> Result<(), InstallError> { + std::fs::create_dir_all(install_dir).map_err(|e| io(install_dir, &e))?; + let dest = install_dir.join("base.rvf"); + + // Remove first: copying onto a 0444 file fails, and an update rebinding an + // install id replaces the base artifact rather than editing it. + if dest.exists() { + set_writable(&dest); + std::fs::remove_file(&dest).map_err(|e| io(&dest, &e))?; + } + std::fs::copy(source, &dest).map_err(|e| io(&dest, &e))?; + set_read_only(&dest); + + let capsule_dir = capsule.capsule_dir(); + std::fs::create_dir_all(&capsule_dir).map_err(|e| io(&capsule_dir, &e))?; + Ok(()) +} + +/// Best effort: an installed artifact that stayed writable is still verified +/// on every open, so a failure to set the bit is not a reason to refuse. +fn set_read_only(path: &Path) { + if let Ok(meta) = std::fs::metadata(path) { + let mut perms = meta.permissions(); + perms.set_readonly(true); + let _ = std::fs::set_permissions(path, perms); + } +} + +fn set_writable(path: &Path) { + if let Ok(meta) = std::fs::metadata(path) { + let mut perms = meta.permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + let _ = std::fs::set_permissions(path, perms); + } +} + +/// `rvf-forge-core` reports a bare hex digest; ADR-288 identities are content +/// addresses. Prefixing here keeps the reader's on-disk records in one shape. +fn content_address(hex: String) -> String { + if hex.starts_with("sha256:") { + hex + } else { + format!("sha256:{hex}") + } +} + +/// A stable, filesystem-safe id for a first install. +/// +/// Derived from the base identity so two different packages never collide, and +/// short enough to be a readable directory name. Updates keep the id they were +/// installed under, so it identifies the *agent*, not the release. +pub fn derive_install_id(base_identity: &str) -> String { + let hex = base_identity.strip_prefix("sha256:").unwrap_or(base_identity); + format!("agent-{}", &hex[..hex.len().min(16)]) +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} diff --git a/crates/rvforge-reader/src/install/layout.rs b/crates/rvforge-reader/src/install/layout.rs new file mode 100644 index 000000000..c3a0d56ae --- /dev/null +++ b/crates/rvforge-reader/src/install/layout.rs @@ -0,0 +1,125 @@ +//! Where installations and their state live on disk. +//! +//! One layout per reader: an install root holding one directory per installed +//! agent, and a state root holding the capsules and both receipt logs. They are +//! separate roots because ADR-288 §2 requires state to be deletable without +//! touching the immutable base artifact, and [`crate::state::StateCapsule`] +//! refuses a state root nested inside an install root. + +use std::path::{Path, PathBuf}; + +use crate::receipts::ReceiptLog; +use crate::witness_receipt::WitnessLog; + +use super::{io, InstallError, InstallRecord}; + +/// Where installations and their state live. One layout per reader. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallLayout { + install_root: PathBuf, + state_root: PathBuf, +} + +impl InstallLayout { + pub fn new(install_root: impl Into, state_root: impl Into) -> Self { + Self { + install_root: install_root.into(), + state_root: state_root.into(), + } + } + + /// The per-user layout: installs beside the state root, never inside it. + pub fn default_roots() -> Self { + let state_root = crate::state::default_state_root(); + let install_root = state_root + .parent() + .map(|p| p.join("installed")) + .unwrap_or_else(|| PathBuf::from("installed")); + Self::new(install_root, state_root) + } + + pub fn install_root(&self) -> &Path { + &self.install_root + } + + pub fn state_root(&self) -> &Path { + &self.state_root + } + + pub fn install_dir(&self, install_id: &str) -> PathBuf { + self.install_root.join(install_id) + } + + pub fn record_path(&self, install_id: &str) -> PathBuf { + self.install_dir(install_id).join("install.json") + } + + /// The verification receipt log. + pub fn receipt_log(&self) -> ReceiptLog { + ReceiptLog::at(self.state_root.join("receipts.jsonl")) + } + + /// The chained lifecycle witness log. + pub fn lifecycle_log(&self) -> WitnessLog { + WitnessLog::at(self.state_root.join("lifecycle.jsonl")) + } + + /// Read one installation record. + /// + /// # Errors + /// + /// [`InstallError::Io`] if the record is missing or unreadable. + pub fn read_record(&self, install_id: &str) -> Result { + let path = self.record_path(install_id); + let text = std::fs::read_to_string(&path).map_err(|e| io(&path, &e))?; + serde_json::from_str(&text).map_err(|e| InstallError::Io { + path: path.display().to_string(), + message: format!("not a readable install record: {e}"), + }) + } + + /// Every install id present under the install root, sorted. + /// + /// # Errors + /// + /// [`InstallError::Io`] if the install root exists but cannot be listed. A + /// root that does not exist yet lists as empty. + pub fn install_ids(&self) -> Result, InstallError> { + let entries = match std::fs::read_dir(&self.install_root) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(io(&self.install_root, &e)), + }; + let mut ids: Vec = entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().join("install.json").is_file()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + ids.sort(); + Ok(ids) + } + + /// Write a record, replacing any previous one. Used by the update flow, + /// which rebinds an install id to a new base identity. + /// + /// # Errors + /// + /// [`InstallError::Io`] for any filesystem or serialization failure. + pub(crate) fn write_record(&self, record: &InstallRecord) -> Result<(), InstallError> { + let path = self.record_path(&record.install_id); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| io(parent, &e))?; + } + let json = serde_json::to_string_pretty(record).map_err(|e| InstallError::Io { + path: path.display().to_string(), + message: e.to_string(), + })?; + std::fs::write(&path, json).map_err(|e| io(&path, &e)) + } +} + +impl Default for InstallLayout { + fn default() -> Self { + Self::default_roots() + } +} diff --git a/crates/rvforge-reader/src/install/mod.rs b/crates/rvforge-reader/src/install/mod.rs new file mode 100644 index 000000000..f44394875 --- /dev/null +++ b/crates/rvforge-reader/src/install/mod.rs @@ -0,0 +1,231 @@ +//! Installation (requirements P6, ADR-288 §1–§2, ADR-294 §Consequences). +//! +//! Installing is the moment a package stops being a file the user is looking at +//! and becomes something the machine is prepared to run, so three refusals are +//! structural here rather than advisory: +//! +//! 1. **Verification precedes installation.** [`install`] verifies the bytes it +//! is about to copy and refuses anything short of +//! [`VerificationStatus::Verified`]. The refusal is witnessed twice — once by +//! the verification receipt, once by a lifecycle receipt — because a package +//! that was refused at install time is exactly the event an audit is looking +//! for. +//! 2. **A grant the container did not declare is not installable.** The user +//! accepts a set of capability classes, and every accepted class must appear +//! in the card derived from the verified container. Accepting more than was +//! declared is [`InstallError::GrantNotDeclared`], not a silent narrowing: +//! if the two disagree, the user was shown one contract and asked to consent +//! to another. +//! 3. **The base artifact is immutable and state lives elsewhere.** The +//! verified bytes are copied to `/base.rvf` and made read-only; +//! the state capsule directory is under a state root that +//! [`StateCapsule::new`] rejects if it is inside the install root, so +//! deleting state can never touch the signed artifact (ADR-288 §2). +//! +//! Nothing here executes package content. The installed `base.rvf` is a copy of +//! bytes, and no code path in this crate maps, links, or interprets it. +//! +//! ```text +//! // +//! install.json immutable install record — what was consented to +//! library.json mutable library state (owned by [`crate::library`]) +//! base.rvf the verified container, read-only +//! / +//! receipts.jsonl verification receipts +//! lifecycle.jsonl chained lifecycle witness receipts +//! / state capsule (ADR-288) +//! ``` + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::inspect::SignatureStatus; +use crate::state::{StateCapsule, StateError}; + +mod flow; +mod layout; + +pub use flow::{derive_install_id, install, install_for_host, offer, offer_for_host, InstallOffer}; +pub use layout::InstallLayout; + +/// Schema identifier carried by every [`InstallRecord`]. +pub const INSTALL_RECORD_SCHEMA: &str = "rvforge.reader.install/1"; + +/// What an installation declares about its state before it happens. +/// +/// P9 requires that "rollback is unsafe" be declared *before* installation, so +/// it is part of the request rather than something discovered at rollback time. +/// A release that reserves the right to make rollback unsafe later would make +/// the guarantee meaningless. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StateContract { + /// The `stateSchemaVersion` this release reads and writes (ADR-288 §3). + pub state_schema_version: u32, + /// This release's state migration makes returning to the predecessor + /// unsafe. Declared here, enforced by [`crate::update::rollback`]. + pub rollback_unsafe: bool, +} + +impl Default for StateContract { + fn default() -> Self { + Self { + state_schema_version: 1, + rollback_unsafe: false, + } + } +} + +/// One installation request: what to install, where, and what the user accepted. +#[derive(Debug, Clone)] +pub struct InstallRequest { + pub rvf_path: PathBuf, + /// A display name for the library and the dock. System-owned: it comes from + /// the installer, never from the running agent. + pub agent_name: String, + /// The capability classes the user accepted from the card. Every one must + /// have been declared by the verified container. + pub accepted_classes: Vec, + pub state_contract: StateContract, + /// Set by [`crate::update`] to keep an agent's identity stable across a new + /// base RVF. `None` derives a fresh id from the base identity. + pub install_id: Option, + /// The base identity this release supersedes, forming the ADR-288 §7 + /// lineage chain. `None` for a first install. + pub predecessor_identity: Option, +} + +impl InstallRequest { + /// A first install accepting exactly the classes the card granted. + pub fn new( + rvf_path: impl Into, + agent_name: impl Into, + accepted_classes: Vec, + ) -> Self { + Self { + rvf_path: rvf_path.into(), + agent_name: agent_name.into(), + accepted_classes, + state_contract: StateContract::default(), + install_id: None, + predecessor_identity: None, + } + } +} + +/// The immutable record of one installation. Written once and never edited; +/// mutable library state lives in `library.json` beside it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallRecord { + /// Always [`INSTALL_RECORD_SCHEMA`]. + pub schema: String, + pub install_id: String, + pub agent_name: String, + /// SHA-256 of the installed container, as `sha256:`. + pub base_identity: String, + /// Where the package was installed from. Provenance, not a live reference. + pub source_path: String, + pub installed_at_unix: u64, + pub install_dir: String, + pub state_root: String, + /// The classes the user accepted, which are a subset of the declared ones. + pub granted_classes: Vec, + /// The exact sentences the user was shown when they accepted. + pub granted_text: Vec, + pub state_contract: StateContract, + pub predecessor_identity: Option, + /// The runtime the FR004 ladder chose on this host at install time. + pub runtime_profile: Option, + pub isolation_claim: Option, + /// What was known about the publisher's signature when it was installed. + pub signature: SignatureStatus, + /// The lifecycle receipt this installation was recorded as. + pub receipt_id: Option, +} + +impl InstallRecord { + pub fn install_dir(&self) -> PathBuf { + PathBuf::from(&self.install_dir) + } + + /// The immutable installed artifact. + pub fn base_artifact(&self) -> PathBuf { + self.install_dir().join("base.rvf") + } + + /// The capsule this install's state belongs to. + /// + /// # Errors + /// + /// [`StateError`] if the recorded roots no longer satisfy the ADR-288 §2 + /// separation, or the base identity is not a content address. + pub fn capsule(&self) -> Result { + StateCapsule::new(&self.install_dir, &self.state_root, &self.base_identity) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum InstallError { + /// The package did not verify. Nothing was written. + NotVerified { reason: String }, + /// Verification passed but no capability card could be derived, so there is + /// no contract to consent to. + NoContract { reason: String }, + /// An accepted class was not declared by the verified container. + GrantNotDeclared { class: String }, + /// No runtime on this host can run the package (ADR-289 §2 is terminal). + UnsupportedRuntime { order: Vec }, + /// This install id already exists. Updating goes through + /// [`crate::update`], which preserves lineage. + AlreadyInstalled { install_id: String }, + Layout { detail: String }, + Io { path: String, message: String }, +} + +impl std::fmt::Display for InstallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InstallError::NotVerified { reason } => { + write!(f, "refusing to install an unverified package: {reason}") + } + InstallError::NoContract { reason } => { + write!(f, "no capability contract to accept: {reason}") + } + InstallError::GrantNotDeclared { class } => write!( + f, + "'{class}' was accepted but the verified container does not declare it" + ), + InstallError::UnsupportedRuntime { order } => write!( + f, + "no compatible runtime on this host; the selection order is {}", + order.join(" → ") + ), + InstallError::AlreadyInstalled { install_id } => { + write!(f, "'{install_id}' is already installed") + } + InstallError::Layout { detail } => write!(f, "{detail}"), + InstallError::Io { path, message } => write!(f, "{path}: {message}"), + } + } +} + +impl std::error::Error for InstallError {} + +impl From for InstallError { + fn from(e: StateError) -> Self { + InstallError::Layout { + detail: e.to_string(), + } + } +} + +fn io(path: &Path, e: &std::io::Error) -> InstallError { + InstallError::Io { + path: path.display().to_string(), + message: e.to_string(), + } +} + diff --git a/crates/rvforge-reader/src/lib.rs b/crates/rvforge-reader/src/lib.rs index 31f643c4e..25a312045 100644 --- a/crates/rvforge-reader/src/lib.rs +++ b/crates/rvforge-reader/src/lib.rs @@ -10,8 +10,15 @@ //! - [`capability`] — derive the install-time capability contract (requirements P6). //! - [`runtime`] — apply the FR004 runtime selection order (ADR-289 §2). //! - [`state`] — encrypted state-capsule layout and sealing (ADR-288). +//! - [`install`] — verify, present the contract, and materialize an +//! installation (requirements P6). +//! - [`library`] — the installed-agent registry and its operations +//! (requirements P7). +//! - [`update`] — the semantic permission diff, the approval gate, and rollback +//! (requirements P9, ADR-288 §7). //! - [`dock`], [`dock_state`], [`dock_roster`], [`dock_events`] — the Agent //! Dock: what a running agent shows and how it is stopped (ADR-295). +//! - [`dock_bridge`] — the dock roster, built from the library. //! - [`witness_receipt`], [`witness_view`] — the witness viewer: recompute each //! receipt's content address, check `prevReceipt` continuity per subject, and //! report where a chain stops verifying (requirements P15.11, P8). @@ -41,7 +48,17 @@ //! 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 +//! 7. **Installation is gated on verification, and grants cannot exceed the +//! declaration.** [`install::install`] refuses an unverified package and +//! refuses an accepted class the verified container did not declare. Every +//! lifecycle decision — install, update, rollback, uninstall, state export +//! and import — appends a chained witness receipt, so a refusal is a record +//! rather than an absence. +//! 8. **Removal does not silently destroy user state.** [`library::UninstallPolicy`] +//! keeps state by default; deleting it requires asking, and the +//! export-before-remove path aborts the whole uninstall if the export fails +//! (ADR-294). +//! 9. **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 @@ -50,14 +67,18 @@ pub mod capability; pub mod dock; +pub mod dock_bridge; pub mod dock_events; pub mod dock_roster; pub mod dock_state; pub mod dock_text; pub mod inspect; +pub mod install; +pub mod library; pub mod receipts; pub mod runtime; pub mod state; +pub mod update; pub mod witness_receipt; pub mod witness_view; @@ -72,11 +93,31 @@ pub fn run() { // 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()) + // The library is the reader's own state, on the Rust side of the + // process boundary for the same reason the dock roster is. + .manage(library::Library::default()) .invoke_handler(tauri::generate_handler![ commands::inspect_rvf, commands::verify_rvf, commands::capability_card, commands::runtime_selection, + commands::install_offer, + commands::install_agent, + commands::library_list, + commands::library_open, + commands::library_pause, + commands::library_terminate, + commands::library_reset, + commands::library_verify, + commands::library_archive, + commands::library_restore, + commands::library_export_state, + commands::library_import_state, + commands::library_uninstall, + commands::update_plan, + commands::update_apply, + commands::update_rollback, + commands::dock_sync, commands::dock_view, commands::dock_expand, commands::dock_pause, diff --git a/crates/rvforge-reader/src/library/fsops.rs b/crates/rvforge-reader/src/library/fsops.rs new file mode 100644 index 000000000..79b88144c --- /dev/null +++ b/crates/rvforge-reader/src/library/fsops.rs @@ -0,0 +1,74 @@ +//! Filesystem helpers for the capsule directory. +//! +//! Only sealed capsule files are touched: a directory listing that swept up +//! anything else would let an unrelated file be counted as state, exported as +//! state, or deleted as state. + +use std::path::{Path, PathBuf}; + +use super::{io, LibraryError, StateTransfer}; + +/// Sealed capsule files, ignoring anything else in the directory. +pub(super) fn sealed_files(dir: &Path) -> Result, LibraryError> { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(io(dir, &e)), + }; + let mut files: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file()) + .collect(); + files.sort(); + Ok(files) +} + +pub(super) fn dir_bytes(dir: &Path) -> Result { + let mut total = 0; + for path in sealed_files(dir)? { + total += std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + } + Ok(total) +} + +pub(super) fn clear_dir(dir: &Path) -> Result { + let files = sealed_files(dir)?; + for path in &files { + std::fs::remove_file(path).map_err(|e| io(path, &e))?; + } + Ok(files.len()) +} + +pub(super) fn copy_dir(from: &Path, to: &Path, base_identity: &str) -> Result { + std::fs::create_dir_all(to).map_err(|e| io(to, &e))?; + let mut bytes = 0; + let files = sealed_files(from)?; + for path in &files { + let name = path.file_name().unwrap_or_default(); + bytes += std::fs::copy(path, to.join(name)).map_err(|e| io(path, &e))?; + } + Ok(StateTransfer { + base_identity: base_identity.to_string(), + files: files.len(), + bytes, + path: to.display().to_string(), + }) +} + +/// The installed artifact is read-only, so removing its directory needs the +/// bit cleared first on platforms where read-only blocks unlink. +pub(super) fn unlock_dir(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if let Ok(meta) = std::fs::metadata(&path) { + let mut perms = meta.permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + let _ = std::fs::set_permissions(&path, perms); + } + } +} diff --git a/crates/rvforge-reader/src/library/mod.rs b/crates/rvforge-reader/src/library/mod.rs new file mode 100644 index 000000000..f67e8c436 --- /dev/null +++ b/crates/rvforge-reader/src/library/mod.rs @@ -0,0 +1,235 @@ +//! The installed-agent library (requirements P7, ADR-294 §Consequences). +//! +//! The library is the list of things this machine has agreed to run, and the +//! operations that change that agreement. Three rules shape it: +//! +//! - **Verification precedes every load.** [`Library::open`] re-verifies the +//! installed `base.rvf` before it will move an agent to +//! [`LibraryState::Running`]. A package that stops verifying — because the +//! file was altered after installation — is moved to +//! [`LibraryState::Quarantined`] rather than opened, and the refusal is +//! witnessed. +//! - **State transitions are checked, not assumed.** [`LibraryState::may_move_to`] +//! is the whole table. An archived agent cannot start running and a +//! quarantined one cannot start running; both must be brought back to +//! [`LibraryState::Installed`] first, which is a deliberate act. +//! - **Removal never silently destroys user state.** ADR-294 requires that +//! revocation and removal preserve export and forensic access. +//! [`UninstallPolicy`] therefore defaults to keeping state, and the only way +//! to delete it is to ask for that specifically — optionally through +//! [`UninstallPolicy::export_state_to`], which exports first and aborts the +//! whole uninstall if the export fails. +//! +//! Every operation appends a chained lifecycle receipt, so the library's +//! history is auditable through the same witness viewer as everything else. +//! +//! `Organization Managed`, the seventh P7 state, is not implemented: it is a +//! property of an organizational policy source the reader has no channel to. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::install::{InstallError, InstallRecord}; +use crate::state::StateError; + +mod fsops; +mod policy; +mod registry; + +pub use policy::{StateTransfer, UninstallOutcome, UninstallPolicy}; +pub use registry::Library; + +/// Schema identifier carried by every [`LibraryEntryState`]. +pub const LIBRARY_STATE_SCHEMA: &str = "rvforge.reader.library/1"; + +/// The P7 library states the reader implements. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LibraryState { + Installed, + Running, + Paused, + /// P7's "Updates": a newer release is available for this agent. + Updates, + /// Execution is blocked by policy. State is preserved (ADR-294). + Quarantined, + /// Kept, not runnable, not deleted. + Archived, +} + +impl LibraryState { + pub fn label(self) -> &'static str { + match self { + LibraryState::Installed => "Installed", + LibraryState::Running => "Running", + LibraryState::Paused => "Paused", + LibraryState::Updates => "Update available", + LibraryState::Quarantined => "Quarantined", + LibraryState::Archived => "Archived", + } + } + + /// Whether an agent in this state may be executing. + pub fn is_active(self) -> bool { + matches!(self, LibraryState::Running | LibraryState::Paused) + } + + /// The transition table. + /// + /// `Quarantined` and `Archived` release only to `Installed`: coming back + /// from either is a decision, and a decision that lands directly in + /// `Running` would hide it. + pub fn may_move_to(self, to: LibraryState) -> bool { + use LibraryState::*; + if self == to { + return true; + } + match self { + Installed => matches!(to, Running | Updates | Quarantined | Archived), + Running => matches!(to, Paused | Installed | Updates | Quarantined), + Paused => matches!(to, Running | Installed | Updates | Quarantined), + Updates => matches!(to, Running | Installed | Quarantined | Archived), + Quarantined => matches!(to, Installed | Archived), + Archived => matches!(to, Installed), + } + } +} + +/// The mutable half of an installation, written beside the immutable record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryEntryState { + pub schema: String, + pub state: LibraryState, + pub changed_at_unix: u64, + /// Why the entry is where it is, when that needs saying. + pub note: Option, +} + +impl LibraryEntryState { + fn at(state: LibraryState, note: Option) -> Self { + Self { + schema: LIBRARY_STATE_SCHEMA.to_string(), + state, + changed_at_unix: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + note, + } + } +} + +/// One row of the library. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LibraryEntry { + pub record: InstallRecord, + pub state: LibraryState, + pub state_label: &'static str, + pub note: Option, + /// Bytes of sealed state currently held for this agent. + pub state_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum LibraryError { + NotInstalled { + install_id: String, + }, + /// The move is not in the transition table. + IllegalTransition { + install_id: String, + from: LibraryState, + to: LibraryState, + }, + /// The installed artifact no longer verifies. The entry was quarantined. + VerificationFailed { + install_id: String, + detail: String, + }, + /// State recorded against a different base identity was offered for import. + LineageRejected { + detail: String, + }, + /// Uninstalling would have destroyed state that was not explicitly given up. + StateWouldBeLost { + install_id: String, + }, + /// An agent that may be executing cannot be removed under it. + StillActive { + install_id: String, + state: LibraryState, + }, + Install { + detail: String, + }, + Io { + path: String, + message: String, + }, +} + +impl std::fmt::Display for LibraryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LibraryError::NotInstalled { install_id } => { + write!(f, "no installed agent '{install_id}'") + } + LibraryError::IllegalTransition { + install_id, + from, + to, + } => write!( + f, + "'{install_id}' cannot move from {} to {}", + from.label(), + to.label() + ), + LibraryError::VerificationFailed { install_id, detail } => write!( + f, + "'{install_id}' no longer verifies and was quarantined: {detail}" + ), + LibraryError::LineageRejected { detail } => write!(f, "{detail}"), + LibraryError::StateWouldBeLost { install_id } => write!( + f, + "uninstalling '{install_id}' would delete its state; export it or ask for \ + removal explicitly" + ), + LibraryError::StillActive { install_id, state } => write!( + f, + "'{install_id}' is {}; terminate it before uninstalling", + state.label() + ), + LibraryError::Install { detail } => write!(f, "{detail}"), + LibraryError::Io { path, message } => write!(f, "{path}: {message}"), + } + } +} + +impl std::error::Error for LibraryError {} + +impl From for LibraryError { + fn from(e: InstallError) -> Self { + LibraryError::Install { + detail: e.to_string(), + } + } +} + +impl From for LibraryError { + fn from(e: StateError) -> Self { + LibraryError::LineageRejected { + detail: e.to_string(), + } + } +} + +fn io(path: &Path, e: &std::io::Error) -> LibraryError { + LibraryError::Io { + path: path.display().to_string(), + message: e.to_string(), + } +} + diff --git a/crates/rvforge-reader/src/library/policy.rs b/crates/rvforge-reader/src/library/policy.rs new file mode 100644 index 000000000..985488cf0 --- /dev/null +++ b/crates/rvforge-reader/src/library/policy.rs @@ -0,0 +1,60 @@ +//! What an uninstall may remove, and what it reports having removed. +//! +//! ADR-294's rule is that revocation and removal preserve export and forensic +//! access, so the default here keeps state and deleting it has to be asked for +//! by name. + +use std::path::PathBuf; + +use serde::Serialize; + +/// What an uninstall is allowed to remove. +/// +/// The default removes the runtime and keeps the state, which is the ADR-294 +/// rule: revocation and removal preserve export and forensic access unless the +/// user asked for the state to go too. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct UninstallPolicy { + /// Delete the state capsule. Only ever true because someone asked for it. + pub remove_state: bool, + /// Copy the sealed state here first. An export that fails aborts the + /// uninstall, so state is never lost to a half-completed removal. + pub export_state_to: Option, +} + +impl UninstallPolicy { + /// Remove the runtime, keep the state. + pub fn keep_state() -> Self { + Self::default() + } + + /// Export the state, then remove both. + pub fn export_then_remove(dest: impl Into) -> Self { + Self { + remove_state: true, + export_state_to: Some(dest.into()), + } + } +} + +/// What an uninstall actually did. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UninstallOutcome { + pub install_id: String, + pub runtime_removed: bool, + pub state_removed: bool, + pub exported_to: Option, + pub exported_files: usize, + /// Where state was left, when it was left. + pub state_retained_at: Option, +} + +/// A copied state capsule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct StateTransfer { + pub base_identity: String, + pub files: usize, + pub bytes: u64, + pub path: String, +} + diff --git a/crates/rvforge-reader/src/library/registry.rs b/crates/rvforge-reader/src/library/registry.rs new file mode 100644 index 000000000..07944ac85 --- /dev/null +++ b/crates/rvforge-reader/src/library/registry.rs @@ -0,0 +1,440 @@ +//! The registry itself: the operations P7 lists, over one [`InstallLayout`]. + +use std::path::{Path, PathBuf}; + +use crate::install::{self, InstallLayout, InstallRecord, InstallRequest}; +use crate::inspect::{self, VerificationStatus}; +use crate::state::recorded_identity; + +use super::fsops::{clear_dir, copy_dir, dir_bytes, sealed_files, unlock_dir}; +use super::{ + io, LibraryEntry, LibraryEntryState, LibraryError, LibraryState, StateTransfer, + UninstallOutcome, UninstallPolicy, +}; + +/// The installed-agent registry over one [`InstallLayout`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Library { + layout: InstallLayout, +} + +impl Library { + pub fn new(layout: InstallLayout) -> Self { + Self { layout } + } + + pub fn at(install_root: impl Into, state_root: impl Into) -> Self { + Self::new(InstallLayout::new(install_root, state_root)) + } + + pub fn layout(&self) -> &InstallLayout { + &self.layout + } + + /// Verify a package and derive the contract, without installing it. + pub fn offer(&self, rvf_path: &Path) -> install::InstallOffer { + install::offer(&self.layout, rvf_path) + } + + /// Install a package into this library. + /// + /// # Errors + /// + /// Every [`InstallError`], surfaced as [`LibraryError::Install`]. + pub fn install(&self, request: &InstallRequest) -> Result { + let record = install::install(&self.layout, request)?; + self.write_state( + &record.install_id, + &LibraryEntryState::at(LibraryState::Installed, None), + )?; + self.entry_for(record) + } + + /// Every installed agent, by install id. + /// + /// # Errors + /// + /// [`LibraryError::Io`] if the install root cannot be listed. An unreadable + /// individual record is skipped rather than failing the whole listing: one + /// corrupt entry must not hide the rest of the library. + pub fn list(&self) -> Result, LibraryError> { + let mut entries = Vec::new(); + for id in self.layout.install_ids()? { + if let Ok(record) = self.layout.read_record(&id) { + entries.push(self.entry_for(record)?); + } + } + Ok(entries) + } + + /// One installed agent. + /// + /// # Errors + /// + /// [`LibraryError::NotInstalled`] when there is no such record. + pub fn get(&self, install_id: &str) -> Result { + let record = self + .layout + .read_record(install_id) + .map_err(|_| LibraryError::NotInstalled { + install_id: install_id.to_string(), + })?; + self.entry_for(record) + } + + pub fn state_of(&self, install_id: &str) -> LibraryState { + self.read_state(install_id) + .map(|s| s.state) + .unwrap_or(LibraryState::Installed) + } + + /// Re-verify the installed artifact, witnessing the result. + /// + /// A failure quarantines the entry: an artifact that stopped verifying is + /// not something to keep offering as runnable. + /// + /// # Errors + /// + /// [`LibraryError::NotInstalled`], or [`LibraryError::VerificationFailed`] + /// when the installed bytes no longer pass. + pub fn verify(&self, install_id: &str) -> Result { + let entry = self.get(install_id)?; + let outcome = inspect::verify_with_receipts( + &entry.record.base_artifact(), + &self.layout.receipt_log(), + ); + if outcome.summary.verification == VerificationStatus::Verified { + self.witness(install_id, "verify", "passed", "installed artifact verified"); + return Ok(VerificationStatus::Verified); + } + let detail = outcome + .summary + .notes + .last() + .cloned() + .unwrap_or_else(|| "verification failed".to_string()); + self.force_state(install_id, LibraryState::Quarantined, Some(detail.clone()))?; + self.witness(install_id, "verify", "failed", &detail); + Err(LibraryError::VerificationFailed { + install_id: install_id.to_string(), + detail, + }) + } + + /// Open an agent: verify first, then move to [`LibraryState::Running`]. + /// + /// # Errors + /// + /// [`LibraryError::VerificationFailed`] if the artifact no longer verifies, + /// and [`LibraryError::IllegalTransition`] from a state that is not + /// runnable. + pub fn open(&self, install_id: &str) -> Result { + // The transition is checked before verification so an archived or + // quarantined agent is refused for the reason the user needs to hear, + // rather than passing verification and then being refused anyway. + self.require_transition(install_id, LibraryState::Running)?; + self.verify(install_id)?; + self.move_to(install_id, LibraryState::Running, None) + } + + pub fn pause(&self, install_id: &str) -> Result { + self.move_to(install_id, LibraryState::Paused, None) + } + + /// Stop an agent. It returns to [`LibraryState::Installed`], not to a + /// terminal state: the installation outlives the run. + pub fn terminate(&self, install_id: &str) -> Result { + self.move_to(install_id, LibraryState::Installed, None) + } + + pub fn quarantine(&self, install_id: &str, reason: &str) -> Result { + self.move_to( + install_id, + LibraryState::Quarantined, + Some(reason.to_string()), + ) + } + + pub fn archive(&self, install_id: &str) -> Result { + self.move_to(install_id, LibraryState::Archived, None) + } + + /// Bring a quarantined or archived agent back to installed. + pub fn restore(&self, install_id: &str) -> Result { + self.move_to(install_id, LibraryState::Installed, None) + } + + /// Flag that a newer release exists (set by [`crate::update::plan`]). + pub fn mark_update_available( + &self, + install_id: &str, + note: &str, + ) -> Result { + self.move_to(install_id, LibraryState::Updates, Some(note.to_string())) + } + + /// Discard every delta and checkpoint, returning the agent to the base + /// artifact's initial state (ADR-288 §5 `reset`). The base is untouched. + /// + /// # Errors + /// + /// [`LibraryError::Io`] if the capsule directory cannot be cleared. + pub fn reset(&self, install_id: &str) -> Result { + let entry = self.get(install_id)?; + let dir = entry.record.capsule()?.capsule_dir(); + let removed = clear_dir(&dir)?; + self.witness( + install_id, + "reset", + "completed", + &format!("discarded {removed} state record(s); base artifact untouched"), + ); + Ok(removed) + } + + /// Copy the sealed state capsule out. The bytes stay encrypted: an export + /// is a copy of the capsule, not a decryption of it. + /// + /// # Errors + /// + /// [`LibraryError::Io`] for any copy failure. + pub fn export_state( + &self, + install_id: &str, + dest: &Path, + ) -> Result { + let entry = self.get(install_id)?; + let capsule = entry.record.capsule()?; + let transfer = copy_dir(&capsule.capsule_dir(), dest, &entry.record.base_identity)?; + self.witness( + install_id, + "state-export", + "completed", + &format!( + "exported {} sealed record(s) ({} bytes) to {}", + transfer.files, transfer.bytes, transfer.path + ), + ); + Ok(transfer) + } + + /// Copy a sealed capsule in, refusing state from another lineage. + /// + /// Every sealed file is checked against the installed base identity before + /// anything is written, so a lineage rejection leaves the existing capsule + /// exactly as it was (ADR-288 §4). + /// + /// # Errors + /// + /// [`LibraryError::LineageRejected`] for foreign state, [`LibraryError::Io`] + /// for a copy failure. + pub fn import_state( + &self, + install_id: &str, + source: &Path, + ) -> Result { + let entry = self.get(install_id)?; + let capsule = entry.record.capsule()?; + + for path in sealed_files(source)? { + let bytes = std::fs::read(&path).map_err(|e| io(&path, &e))?; + let recorded = recorded_identity(&bytes)?; + capsule.check_lineage(&recorded)?; + } + + let transfer = copy_dir(source, &capsule.capsule_dir(), &entry.record.base_identity)?; + self.witness( + install_id, + "state-import", + "completed", + &format!( + "imported {} sealed record(s) from {}", + transfer.files, + source.display() + ), + ); + Ok(transfer) + } + + /// Remove an agent under `policy`. + /// + /// # Errors + /// + /// [`LibraryError::StillActive`] while it may be executing, + /// [`LibraryError::StateWouldBeLost`] if removal would destroy state that + /// was not explicitly given up, and [`LibraryError::Io`] for a failed + /// export — in which case nothing is removed. + pub fn uninstall( + &self, + install_id: &str, + policy: &UninstallPolicy, + ) -> Result { + let entry = self.get(install_id)?; + if entry.state.is_active() { + return Err(LibraryError::StillActive { + install_id: install_id.to_string(), + state: entry.state, + }); + } + + let capsule_dir = entry.record.capsule()?.capsule_dir(); + let has_state = !sealed_files(&capsule_dir)?.is_empty(); + + // Export first. A failure here aborts before anything is removed. + let mut exported = None; + if let Some(dest) = &policy.export_state_to { + exported = Some(self.export_state(install_id, dest)?); + } + if policy.remove_state && has_state && exported.is_none() && !policy.explicit_discard() { + return Err(LibraryError::StateWouldBeLost { + install_id: install_id.to_string(), + }); + } + + let install_dir = entry.record.install_dir(); + if install_dir.exists() { + unlock_dir(&install_dir); + std::fs::remove_dir_all(&install_dir).map_err(|e| io(&install_dir, &e))?; + } + if policy.remove_state { + let _ = std::fs::remove_dir_all(&capsule_dir); + } + + let outcome = UninstallOutcome { + install_id: install_id.to_string(), + runtime_removed: true, + state_removed: policy.remove_state, + exported_to: exported.as_ref().map(|t| t.path.clone()), + exported_files: exported.as_ref().map(|t| t.files).unwrap_or(0), + state_retained_at: (!policy.remove_state) + .then(|| capsule_dir.display().to_string()), + }; + self.witness( + install_id, + "uninstall", + "completed", + &format!( + "runtime removed; state {}", + match (&outcome.state_removed, &outcome.exported_to) { + (true, Some(to)) => format!("exported to {to} then deleted"), + (true, None) => "deleted at the user's request".to_string(), + (false, _) => format!("retained at {}", capsule_dir.display()), + } + ), + ); + Ok(outcome) + } + + /// Record a lifecycle event. Best effort: a receipt that could not be + /// written must not roll back an operation that already happened. + pub(crate) fn witness(&self, subject: &str, event: &str, outcome: &str, details: &str) { + let _ = self + .layout + .lifecycle_log() + .append(subject, event, outcome, details); + } + + /// Check a move without performing it, returning the current state. + fn require_transition( + &self, + install_id: &str, + to: LibraryState, + ) -> Result { + let from = self.get(install_id)?.state; + if !from.may_move_to(to) { + return Err(LibraryError::IllegalTransition { + install_id: install_id.to_string(), + from, + to, + }); + } + Ok(from) + } + + pub(crate) fn move_to( + &self, + install_id: &str, + to: LibraryState, + note: Option, + ) -> Result { + let from = self.require_transition(install_id, to)?; + self.write_state(install_id, &LibraryEntryState::at(to, note))?; + if from != to { + self.witness( + install_id, + "library-state", + to.label(), + &format!("{} → {}", from.label(), to.label()), + ); + } + Ok(to) + } + + /// Move regardless of the table. Only the verification failure path uses + /// it: quarantine is asserted by the system, and refusing to quarantine + /// because of a table gap would leave a failing artifact runnable. + fn force_state( + &self, + install_id: &str, + to: LibraryState, + note: Option, + ) -> Result<(), LibraryError> { + self.write_state(install_id, &LibraryEntryState::at(to, note)) + } + + fn state_path(&self, install_id: &str) -> PathBuf { + self.layout.install_dir(install_id).join("library.json") + } + + fn read_state(&self, install_id: &str) -> Result { + let path = self.state_path(install_id); + let text = std::fs::read_to_string(&path).map_err(|e| io(&path, &e))?; + serde_json::from_str(&text).map_err(|e| LibraryError::Io { + path: path.display().to_string(), + message: format!("not a readable library state: {e}"), + }) + } + + fn write_state( + &self, + install_id: &str, + state: &LibraryEntryState, + ) -> Result<(), LibraryError> { + let path = self.state_path(install_id); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| io(parent, &e))?; + } + let json = serde_json::to_string_pretty(state).map_err(|e| LibraryError::Io { + path: path.display().to_string(), + message: e.to_string(), + })?; + std::fs::write(&path, json).map_err(|e| io(&path, &e)) + } + + fn entry_for(&self, record: InstallRecord) -> Result { + let stored = self.read_state(&record.install_id).ok(); + let capsule_dir = record.capsule().map(|c| c.capsule_dir()).ok(); + let state_bytes = capsule_dir + .as_deref() + .map(dir_bytes) + .transpose()? + .unwrap_or(0); + let state = stored.as_ref().map(|s| s.state).unwrap_or(LibraryState::Installed); + Ok(LibraryEntry { + record, + state, + state_label: state.label(), + note: stored.and_then(|s| s.note), + state_bytes, + }) + } +} + +impl UninstallPolicy { + /// `remove_state` with no export destination is the explicit discard: the + /// caller named the destructive option and named no rescue. + fn explicit_discard(&self) -> bool { + self.remove_state && self.export_state_to.is_none() + } +} + diff --git a/crates/rvforge-reader/src/update/diff.rs b/crates/rvforge-reader/src/update/diff.rs new file mode 100644 index 000000000..38d78326a --- /dev/null +++ b/crates/rvforge-reader/src/update/diff.rs @@ -0,0 +1,177 @@ +//! The semantic permission difference (requirements P9). +//! +//! Computed from two verified capability cards, never from what a release says +//! about itself, and biased toward calling an ambiguous change an expansion: +//! the failure mode of guessing the other way is a contract that grows without +//! anyone approving it. + +use serde::{Deserialize, Serialize}; + +use crate::capability::{CapabilityCard, CardLine}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ChangeKind { + Added, + Removed, + ScopeChanged, +} + +/// One line of the P9 permission difference. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CapabilityChange { + pub class: String, + pub kind: ChangeKind, + pub from_scope: Option, + pub to_scope: Option, + /// The change gives the agent more than it had. Only expansions need + /// approval. + pub expansion: bool, + /// The exact sentence shown to the user. + pub text: String, +} + +/// A `stateSchemaVersion` move across an update. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct SchemaChange { + pub from: u32, + pub to: u32, +} + +/// What changes between two releases' contracts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PermissionDiff { + pub changes: Vec, + pub state_schema_change: Option, +} + +impl PermissionDiff { + /// The changes that give the agent more than it had. + pub fn expansions(&self) -> Vec<&CapabilityChange> { + self.changes.iter().filter(|c| c.expansion).collect() + } + + /// Classes the user must approve by name. + pub fn expanding_classes(&self) -> Vec { + self.expansions().iter().map(|c| c.class.clone()).collect() + } + + /// P9: users must approve any capability expansion. + pub fn requires_approval(&self) -> bool { + !self.expansions().is_empty() + } + + /// The P9 rendering, one line per change. + pub fn lines(&self) -> Vec { + let mut lines: Vec = self.changes.iter().map(|c| c.text.clone()).collect(); + if let Some(s) = self.state_schema_change { + lines.push(format!( + "Changes memory schema from {} to {}", + s.from, s.to + )); + } + if lines.is_empty() { + lines.push("No capability change; this release stays within the existing contract." + .to_string()); + } + lines + } +} + +/// What happens to accumulated state when the update is applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum StateDisposition { + /// The predecessor's capsule is kept and the new base starts empty. + /// Migration is not implemented here (see the module docs). + RetainedUnderPredecessor, +} + +/// Everything the user needs to decide, computed before anything is written. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UpdatePlan { + pub install_id: String, + pub version_label: String, + pub from_identity: String, + pub to_identity: String, + pub diff: PermissionDiff, + pub requires_approval: bool, + /// The classes an approval must name. + pub approval_classes: Vec, + /// Whether rolling back off the *new* release will be possible. + pub rollback_available_after: bool, + pub state_disposition: StateDisposition, + /// The full contract of the new release, so the P6 card can be re-shown. + pub new_card: CapabilityCard, +} + +/// The semantic difference between two capability cards. +/// +/// A scope that becomes unstated is a widening; a scope that becomes stated is +/// a narrowing. Two different stated scopes are treated as an expansion, +/// because the reader cannot prove the new one is contained in the old one and +/// guessing in the permissive direction is how contracts quietly grow. +pub fn diff_cards(before: &CapabilityCard, after: &CapabilityCard) -> PermissionDiff { + let mut changes = Vec::new(); + + for line in &after.requests { + match find(&before.requests, &line.class) { + None => changes.push(CapabilityChange { + class: line.class.clone(), + kind: ChangeKind::Added, + from_scope: None, + to_scope: line.scope.clone(), + expansion: true, + text: format!("Adds {}", line.text.trim_start_matches("This agent ")), + }), + Some(old) if old.scope != line.scope => { + let widened = line.scope.is_none() || old.scope.is_some(); + changes.push(CapabilityChange { + class: line.class.clone(), + kind: ChangeKind::ScopeChanged, + from_scope: old.scope.clone(), + to_scope: line.scope.clone(), + expansion: widened, + text: format!( + "Changes {} scope from {} to {}", + line.class, + scope_text(&old.scope), + scope_text(&line.scope) + ), + }); + } + Some(_) => {} + } + } + + for line in &before.requests { + if find(&after.requests, &line.class).is_none() { + changes.push(CapabilityChange { + class: line.class.clone(), + kind: ChangeKind::Removed, + from_scope: line.scope.clone(), + to_scope: None, + expansion: false, + text: format!("Removes {} access", line.class), + }); + } + } + + changes.sort_by(|a, b| a.class.cmp(&b.class)); + PermissionDiff { + changes, + state_schema_change: None, + } +} + +fn find<'a>(lines: &'a [CardLine], class: &str) -> Option<&'a CardLine> { + lines.iter().find(|l| l.class == class) +} + +fn scope_text(scope: &Option) -> String { + match scope { + Some(s) => format!("'{s}'"), + None => "unstated".to_string(), + } +} + diff --git a/crates/rvforge-reader/src/update/flow.rs b/crates/rvforge-reader/src/update/flow.rs new file mode 100644 index 000000000..90b21c237 --- /dev/null +++ b/crates/rvforge-reader/src/update/flow.rs @@ -0,0 +1,284 @@ +//! Planning, applying, and rolling back an update. + +use std::path::PathBuf; + +use crate::capability::{CapabilityCard, CardStatus}; +use crate::install::{self, InstallRecord, InstallRequest}; +use crate::library::{Library, LibraryState}; + +use super::diff::{diff_cards, SchemaChange, StateDisposition, UpdatePlan}; +use super::{io, ReleaseDescriptor, UpdateApproval, UpdateError}; + +/// Compute the plan for applying `release` to an installed agent. +/// +/// Verifies the new package, checks lineage, and diffs the contracts. Nothing +/// is written except the verification receipt and, when the update is real, the +/// library's [`LibraryState::Updates`] flag. +/// +/// # Errors +/// +/// [`UpdateError::NotVerified`], [`UpdateError::LineageMismatch`], +/// [`UpdateError::NotAnUpdate`], and any library error. +pub fn plan( + library: &Library, + install_id: &str, + release: &ReleaseDescriptor, +) -> Result { + let entry = library.get(install_id)?; + let installed = entry.record.base_identity.clone(); + + // Lineage first: an update that does not descend from what is installed is + // rejected before its contract is even shown, so a mismatched package can + // never reach the approval screen. + if release.predecessor_identity != installed { + library.witness( + install_id, + "update", + "rejected", + &format!( + "lineage mismatch: release supersedes {}, installed is {installed}", + release.predecessor_identity + ), + ); + return Err(UpdateError::LineageMismatch { + install_id: install_id.to_string(), + installed, + declared: release.predecessor_identity.clone(), + }); + } + + let offer = library.offer(&release.rvf_path); + if !offer.verified || offer.card.status != CardStatus::Derived { + let reason = offer + .refusal + .unwrap_or_else(|| "the release did not verify".to_string()); + library.witness(install_id, "update", "rejected", &reason); + return Err(UpdateError::NotVerified { reason }); + } + let to_identity = offer + .base_identity + .clone() + .ok_or_else(|| UpdateError::NotVerified { + reason: "the release reported no identity".to_string(), + })?; + if to_identity == installed { + return Err(UpdateError::NotAnUpdate { + identity: to_identity, + }); + } + + let before = installed_card(library, &entry.record); + let mut diff = diff_cards(&before, &offer.card); + if release.state_contract.state_schema_version != entry.record.state_contract.state_schema_version + { + diff.state_schema_change = Some(SchemaChange { + from: entry.record.state_contract.state_schema_version, + to: release.state_contract.state_schema_version, + }); + } + + let plan = UpdatePlan { + install_id: install_id.to_string(), + version_label: release.version_label.clone(), + from_identity: installed, + to_identity, + requires_approval: diff.requires_approval(), + approval_classes: diff.expanding_classes(), + rollback_available_after: !release.state_contract.rollback_unsafe, + state_disposition: StateDisposition::RetainedUnderPredecessor, + diff, + new_card: offer.card, + }; + + if entry.state != LibraryState::Updates { + let _ = library.mark_update_available( + install_id, + &format!("{} is available", plan.version_label), + ); + } + Ok(plan) +} + +/// The contract the installed release is running under. +/// +/// Reconstructed from the record rather than re-derived from the artifact: it +/// is what the user actually consented to, which is what an update is a +/// difference *from*. A class that was declared but not accepted is not part of +/// the current contract and so shows as an addition if the new release keeps it. +fn installed_card(library: &Library, record: &InstallRecord) -> CapabilityCard { + let _ = library; + let mut card = CapabilityCard::from_declared_classes(&record.granted_classes) + .unwrap_or_else(|e| CapabilityCard::manifest_unavailable(&e.to_string())); + // Restore the sentences the user was shown, so a diff renders the same + // prose the install screen did. + for (line, text) in card.requests.iter_mut().zip(record.granted_text.iter()) { + line.text = text.clone(); + } + card +} + +/// Apply an update the user has approved. +/// +/// # Errors +/// +/// [`UpdateError::ApprovalRequired`] when an expanding class is not named in +/// the approval, plus everything [`plan`] and [`install`](crate::install) +/// raise. A refusal leaves the installed release exactly as it was. +pub fn apply( + library: &Library, + release: &ReleaseDescriptor, + plan: &UpdatePlan, + approval: &UpdateApproval, +) -> Result { + let missing: Vec = plan + .approval_classes + .iter() + .filter(|c| !approval.approved_classes.iter().any(|a| a == *c)) + .cloned() + .collect(); + if !missing.is_empty() { + library.witness( + &plan.install_id, + "update", + "refused", + &format!("unapproved capability expansion: {}", missing.join(", ")), + ); + return Err(UpdateError::ApprovalRequired { classes: missing }); + } + + let entry = library.get(&plan.install_id)?; + let previous = snapshot_previous(&entry.record)?; + + // The accepted set is what the previous release held, plus every expansion + // the user just approved. A class that appears in the new card but was not + // granted before and not approved now stays denied. + let mut accepted: Vec = entry.record.granted_classes.clone(); + for class in &approval.approved_classes { + if !accepted.contains(class) { + accepted.push(class.clone()); + } + } + accepted.retain(|c| plan.new_card.requests.iter().any(|r| &r.class == c)); + + let request = InstallRequest { + rvf_path: release.rvf_path.clone(), + agent_name: entry.record.agent_name.clone(), + accepted_classes: accepted, + state_contract: release.state_contract.clone(), + install_id: Some(plan.install_id.clone()), + predecessor_identity: Some(plan.from_identity.clone()), + }; + + let record = install::install(library.layout(), &request)?; + library.witness( + &plan.install_id, + "update", + "applied", + &format!( + "{} → {} ({}); approved [{}]; predecessor retained at {}", + plan.from_identity, + plan.to_identity, + plan.version_label, + approval.approved_classes.join(", "), + previous.display() + ), + ); + let _ = library.move_to(&plan.install_id, LibraryState::Installed, None); + Ok(record) +} + +/// Return to the retained predecessor release. +/// +/// # Errors +/// +/// [`UpdateError::RollbackUnsafe`] when the installed release declared before +/// installation that rollback is unsafe, and [`UpdateError::NoPredecessor`] +/// when nothing was retained. +pub fn rollback(library: &Library, install_id: &str) -> Result { + let entry = library.get(install_id)?; + + if entry.record.state_contract.rollback_unsafe { + library.witness( + install_id, + "rollback", + "refused", + "the installed release declared its state migration makes rollback unsafe", + ); + return Err(UpdateError::RollbackUnsafe { + install_id: install_id.to_string(), + }); + } + + let dir = previous_dir(&entry.record); + let record_path = dir.join("install.json"); + let artifact = dir.join("base.rvf"); + if !record_path.is_file() || !artifact.is_file() { + return Err(UpdateError::NoPredecessor { + install_id: install_id.to_string(), + }); + } + + let text = std::fs::read_to_string(&record_path).map_err(|e| io(&record_path, &e))?; + let restored: InstallRecord = + serde_json::from_str(&text).map_err(|e| UpdateError::Io { + path: record_path.display().to_string(), + message: format!("not a readable install record: {e}"), + })?; + + // Reinstall from the retained artifact, which re-verifies it: a predecessor + // that no longer verifies must not be restored just because it is older. + let request = InstallRequest { + rvf_path: artifact.clone(), + agent_name: restored.agent_name.clone(), + accepted_classes: restored.granted_classes.clone(), + state_contract: restored.state_contract.clone(), + install_id: Some(install_id.to_string()), + predecessor_identity: restored.predecessor_identity.clone(), + }; + let record = install::install(library.layout(), &request)?; + + library.witness( + install_id, + "rollback", + "completed", + &format!( + "{} → {}", + entry.record.base_identity, record.base_identity + ), + ); + let _ = library.move_to(install_id, LibraryState::Installed, None); + Ok(record) +} + +fn previous_dir(record: &InstallRecord) -> PathBuf { + record.install_dir().join("previous") +} + +/// Keep the current release beside the install so rollback has something to +/// return to. One level deep: rolling back twice is not supported, and the +/// second attempt reports [`UpdateError::NoPredecessor`] rather than silently +/// restoring the wrong release. +fn snapshot_previous(record: &InstallRecord) -> Result { + let dir = previous_dir(record); + std::fs::create_dir_all(&dir).map_err(|e| io(&dir, &e))?; + + let artifact = dir.join("base.rvf"); + if artifact.exists() { + let mut perms = std::fs::metadata(&artifact) + .map_err(|e| io(&artifact, &e))? + .permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + let _ = std::fs::set_permissions(&artifact, perms); + std::fs::remove_file(&artifact).map_err(|e| io(&artifact, &e))?; + } + std::fs::copy(record.base_artifact(), &artifact).map_err(|e| io(&artifact, &e))?; + + let json = serde_json::to_string_pretty(record).map_err(|e| UpdateError::Io { + path: dir.display().to_string(), + message: e.to_string(), + })?; + let record_path = dir.join("install.json"); + std::fs::write(&record_path, json).map_err(|e| io(&record_path, &e))?; + Ok(dir) +} diff --git a/crates/rvforge-reader/src/update/mod.rs b/crates/rvforge-reader/src/update/mod.rs new file mode 100644 index 000000000..47d857e38 --- /dev/null +++ b/crates/rvforge-reader/src/update/mod.rs @@ -0,0 +1,181 @@ +//! Updates and the semantic permission difference (requirements P9, ADR-288 §7). +//! +//! An update is a **new base RVF**, never a mutation of the installed one, and +//! four rules govern applying it: +//! +//! - **Lineage is checked before anything else that costs the user a decision.** +//! The release declares the base identity it supersedes; if that is not the +//! identity currently installed under this id, the update is rejected +//! ([`UpdateError::LineageMismatch`]). This is what stops an unrelated +//! package being installed over an agent the user already trusts. +//! - **The difference is semantic, not textual.** [`diff_cards`] reports added +//! classes, removed classes, and scope changes, and it is computed from the +//! two verified capability cards rather than from anything the release says +//! about itself. +//! - **Any expansion needs explicit approval.** [`UpdatePlan::requires_approval`] +//! is true whenever a class is added or a scope widens, and [`apply`] refuses +//! unless the approval names every expanding class. An update that only +//! narrows or leaves the contract alone can be applied without one, which is +//! the P9 "code fixes within the existing contract" case. +//! - **Rollback stays available until it was declared unsafe.** The +//! [`StateContract::rollback_unsafe`] flag is part of the install request, so +//! it is declared *before* the release is installed. [`rollback`] refuses on +//! a release that declared it, rather than discovering the problem while +//! rolling back. +//! +//! What is **not** implemented: ADR-288 §5 `migrate`. State is keyed by base +//! identity, so an updated agent starts from the new base's empty capsule and +//! the predecessor's capsule is retained untouched. The plan reports this as +//! [`StateDisposition::RetainedUnderPredecessor`]; carrying state across the +//! boundary is `rvm-state` scope and the reader does not fake it. + +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::install::{InstallError, StateContract}; +use crate::library::LibraryError; + +mod diff; +mod flow; + +pub use diff::{ + diff_cards, CapabilityChange, ChangeKind, PermissionDiff, SchemaChange, StateDisposition, + UpdatePlan, +}; +pub use flow::{apply, plan, rollback}; + +/// One release offered as an update to an installed agent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseDescriptor { + pub rvf_path: PathBuf, + /// What the publisher calls it. Display only; nothing is decided from it. + pub version_label: String, + /// The base identity this release supersedes (ADR-288 §7). Checked against + /// what is actually installed. + pub predecessor_identity: String, + /// Declared before this release is installed, per P9. + pub state_contract: StateContract, +} + +impl ReleaseDescriptor { + pub fn new( + rvf_path: impl Into, + version_label: impl Into, + predecessor_identity: impl Into, + ) -> Self { + Self { + rvf_path: rvf_path.into(), + version_label: version_label.into(), + predecessor_identity: predecessor_identity.into(), + state_contract: StateContract::default(), + } + } +} + + +/// The user's answer to an [`UpdatePlan`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct UpdateApproval { + /// Every expanding class, named. An approval that omits one is not an + /// approval of it. + pub approved_classes: Vec, +} + +impl UpdateApproval { + /// Approve exactly what a plan asks for. + pub fn granting(plan: &UpdatePlan) -> Self { + Self { + approved_classes: plan.approval_classes.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum UpdateError { + /// The release does not supersede what is installed. + LineageMismatch { + install_id: String, + installed: String, + declared: String, + }, + /// The new release did not verify. Nothing was touched. + NotVerified { reason: String }, + /// The release is the same base identity that is already installed. + NotAnUpdate { identity: String }, + /// An expansion was not approved by name. + ApprovalRequired { classes: Vec }, + /// The installed release declared, before it was installed, that its state + /// migration makes rollback unsafe. + RollbackUnsafe { install_id: String }, + /// There is no retained predecessor to roll back to. + NoPredecessor { install_id: String }, + Library { detail: String }, + Install { detail: String }, + Io { path: String, message: String }, +} + +impl std::fmt::Display for UpdateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UpdateError::LineageMismatch { + install_id, + installed, + declared, + } => write!( + f, + "this release supersedes {declared}, but '{install_id}' has {installed} installed" + ), + UpdateError::NotVerified { reason } => { + write!(f, "refusing to update from an unverified package: {reason}") + } + UpdateError::NotAnUpdate { identity } => { + write!(f, "{identity} is already the installed release") + } + UpdateError::ApprovalRequired { classes } => write!( + f, + "this update expands {} and needs explicit approval", + classes.join(", ") + ), + UpdateError::RollbackUnsafe { install_id } => write!( + f, + "'{install_id}' declared before installation that its state migration makes \ + rollback unsafe" + ), + UpdateError::NoPredecessor { install_id } => { + write!(f, "'{install_id}' has no retained predecessor to roll back to") + } + UpdateError::Library { detail } | UpdateError::Install { detail } => { + write!(f, "{detail}") + } + UpdateError::Io { path, message } => write!(f, "{path}: {message}"), + } + } +} + +impl std::error::Error for UpdateError {} + +impl From for UpdateError { + fn from(e: LibraryError) -> Self { + UpdateError::Library { + detail: e.to_string(), + } + } +} + +impl From for UpdateError { + fn from(e: InstallError) -> Self { + UpdateError::Install { + detail: e.to_string(), + } + } +} + +fn io(path: &Path, e: &std::io::Error) -> UpdateError { + UpdateError::Io { + path: path.display().to_string(), + message: e.to_string(), + } +} + diff --git a/crates/rvforge-reader/src/witness_receipt.rs b/crates/rvforge-reader/src/witness_receipt.rs index 0e30a1d67..84fb7e699 100644 --- a/crates/rvforge-reader/src/witness_receipt.rs +++ b/crates/rvforge-reader/src/witness_receipt.rs @@ -9,6 +9,9 @@ //! [`crate::witness_view`] is where the chain is verified; this module only //! defines what a receipt is and how its address is derived. +use std::io::Write; +use std::path::{Path, PathBuf}; + use serde::{Deserialize, Serialize}; use rvf_forge_core::canonical::canonical_sha256_hex; @@ -81,6 +84,185 @@ pub fn recompute_id(receipt: &WitnessReceipt) -> Option { .map(|hex| format!("{DIGEST_PREFIX}{hex}")) } +impl WitnessReceipt { + /// Build a receipt that chains onto `prev_receipt`, computing its own + /// content address. + /// + /// The id is derived rather than supplied, so a caller cannot mint a + /// receipt whose declared address disagrees with its contents — that is + /// exactly the tampering [`crate::witness_view`] exists to catch, and it + /// would be pointless for the writer to be able to produce it by accident. + pub fn chained( + subject: impl Into, + event: impl Into, + outcome: impl Into, + actor: Actor, + details: impl Into, + prev_receipt: Option, + ) -> Self { + let mut receipt = Self { + schema_version: WITNESS_SCHEMA_VERSION, + object_type: WITNESS_RECEIPT_TYPE.to_string(), + receipt_id: String::new(), + subject: subject.into(), + event: event.into(), + outcome: outcome.into(), + actor, + evidence: Evidence { + details: details.into(), + }, + timestamp: now_rfc3339(), + prev_receipt, + signatures: Vec::new(), + }; + receipt.receipt_id = recompute_id(&receipt).unwrap_or_default(); + receipt + } +} + +/// The actor RVForge itself records for a lifecycle operation the user asked +/// for. Not a publisher and not the agent: the reader is what observed it. +pub fn reader_actor() -> Actor { + Actor { + kind: "reader".to_string(), + id: "io.ruv.rvforge.reader".to_string(), + } +} + +/// An append-only log of chained witness receipts, one JSON object per line. +/// +/// This is where the *lifecycle* is witnessed — install, update, rollback, +/// uninstall, state export — as opposed to [`crate::receipts::ReceiptLog`], +/// which records verification results in the reader's own local shape. Both +/// files are read by the same viewer, and receipts written here are chained per +/// subject, so a missing or reordered lifecycle event is detectable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WitnessLog { + path: PathBuf, +} + +impl WitnessLog { + pub fn at(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Every receipt in the log, oldest first. A line that does not parse is + /// skipped here; [`crate::witness_view`] is what reports it as a gap. + /// + /// # Errors + /// + /// Any I/O error from reading the file. A log that does not exist yet reads + /// as empty. + pub fn read_all(&self) -> std::io::Result> { + let text = match std::fs::read_to_string(&self.path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + Ok(text + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|r| r.object_type == WITNESS_RECEIPT_TYPE) + .collect()) + } + + /// The most recent receipt id recorded for `subject`, which becomes the + /// next receipt's `prevReceipt`. + /// + /// # Errors + /// + /// Any I/O error from reading the log. + pub fn head_for(&self, subject: &str) -> std::io::Result> { + Ok(self + .read_all()? + .into_iter() + .rfind(|r| r.subject == subject) + .map(|r| r.receipt_id)) + } + + /// Append one receipt for `subject`, chained onto that subject's head. + /// + /// # Errors + /// + /// Any I/O error from reading the existing chain, creating the parent + /// directory, or writing the line. The receipt is returned so the caller can + /// report the id it actually recorded. + pub fn append( + &self, + subject: &str, + event: &str, + outcome: &str, + details: &str, + ) -> std::io::Result { + let prev = self.head_for(subject)?; + let receipt = + WitnessReceipt::chained(subject, event, outcome, reader_actor(), details, prev); + + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let line = serde_json::to_string(&receipt) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + + let mut opts = std::fs::OpenOptions::new(); + opts.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut file = opts.open(&self.path)?; + writeln!(file, "{line}")?; + file.flush()?; + Ok(receipt) + } +} + +/// The current time as an RFC 3339 UTC timestamp. +/// +/// Written out rather than pulled from a date crate: the receipt schema wants +/// one specific string shape, this is the only place the reader produces one, +/// and the civil-date conversion is a closed-form calculation. +fn now_rfc3339() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + format_rfc3339(secs) +} + +/// Seconds since the Unix epoch, as RFC 3339 UTC. +pub fn format_rfc3339(secs: u64) -> String { + let days = (secs / 86_400) as i64; + let time_of_day = secs % 86_400; + let (year, month, day) = civil_from_days(days); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + time_of_day / 3600, + (time_of_day % 3600) / 60, + time_of_day % 60 + ) +} + +/// Days since 1970-01-01 to a civil date, by Howard Hinnant's `civil_from_days`. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m as u32, d as u32) +} + /// A content address shortened for display, prefix kept so it stays readable /// as a digest rather than as an opaque token. pub fn short_id(id: &str) -> String { diff --git a/crates/rvforge-reader/tests/common/mod.rs b/crates/rvforge-reader/tests/common/mod.rs new file mode 100644 index 000000000..feca92736 --- /dev/null +++ b/crates/rvforge-reader/tests/common/mod.rs @@ -0,0 +1,105 @@ +//! Shared fixtures for the install/library/update tests. +//! +//! Every test gets its own roots under the system temp directory, so no test +//! reads or writes the user's real install root, state root, or receipt log — +//! and two tests running concurrently cannot see each other's library. + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; + +use rvf_forge_core::inspect::HEADER_SIZE; +use rvf_forge_core::testkit::minimal_container; +use rvforge_reader::install::InstallLayout; +use rvforge_reader::library::Library; + +static COUNTER: AtomicU32 = AtomicU32::new(0); + +/// An isolated install root, state root, and scratch area. +pub struct Sandbox { + root: PathBuf, +} + +impl Sandbox { + pub fn new(name: &str) -> Self { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir() + .join("rvforge-reader-tests") + .join(format!("{name}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("sandbox root"); + Self { root } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn install_root(&self) -> PathBuf { + self.root.join("installed") + } + + pub fn state_root(&self) -> PathBuf { + self.root.join("state") + } + + pub fn layout(&self) -> InstallLayout { + InstallLayout::new(self.install_root(), self.state_root()) + } + + pub fn library(&self) -> Library { + Library::new(self.layout()) + } + + /// A directory inside the sandbox, created. + pub fn dir(&self, name: &str) -> PathBuf { + let path = self.root.join(name); + std::fs::create_dir_all(&path).expect("sandbox dir"); + path + } + + /// A well-formed container declaring `capabilities`, written as `.rvf`. + pub fn package(&self, name: &str, capabilities: &str) -> PathBuf { + self.write(name, &minimal_container(capabilities)) + } + + /// A container whose first segment's payload has been altered, so it walks + /// but fails its content-hash check. + pub fn tampered_package(&self, name: &str, capabilities: &str) -> PathBuf { + let mut bytes = minimal_container(capabilities); + bytes[HEADER_SIZE + 2] ^= 0xff; + self.write(name, &bytes) + } + + pub fn write(&self, name: &str, bytes: &[u8]) -> PathBuf { + let path = self.root.join(name); + std::fs::write(&path, bytes).expect("write fixture"); + path + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +/// Every lifecycle receipt recorded, as `(subject, event, outcome)`. +pub fn lifecycle_events(library: &Library) -> Vec<(String, String, String)> { + library + .layout() + .lifecycle_log() + .read_all() + .expect("lifecycle log") + .into_iter() + .map(|r| (r.subject, r.event, r.outcome)) + .collect() +} + +/// Whether the lifecycle log recorded `event` with `outcome` for any subject. +pub fn recorded(library: &Library, event: &str, outcome: &str) -> bool { + lifecycle_events(library) + .iter() + .any(|(_, e, o)| e == event && o == outcome) +} diff --git a/crates/rvforge-reader/tests/dock_library.rs b/crates/rvforge-reader/tests/dock_library.rs new file mode 100644 index 000000000..d7d50a2d4 --- /dev/null +++ b/crates/rvforge-reader/tests/dock_library.rs @@ -0,0 +1,266 @@ +//! The dock roster built from real installations, and the ADR-295 §7 boundary +//! holding against it: nothing an agent reports reaches dock chrome, and no +//! chrome field claims something the installation did not establish. + +mod common; + +use common::Sandbox; + +use rvforge_reader::dock::{IsolationClass, NetworkIndicator, TrustBadge, WitnessStatus}; +use rvforge_reader::dock_bridge; +use rvforge_reader::dock_state::DockState; +use rvforge_reader::install::InstallRequest; +use rvforge_reader::library::{Library, LibraryState, UninstallPolicy}; + +fn accept(classes: &[&str]) -> Vec { + classes.iter().map(|c| (*c).to_string()).collect() +} + +fn install(library: &Library, sandbox: &Sandbox, file: &str, name: &str, caps: &str) -> String { + library + .install(&InstallRequest::new( + sandbox.package(file, caps), + name, + accept(&caps.split(',').collect::>()), + )) + .expect("install") + .record + .install_id +} + +#[test] +fn the_roster_is_the_library_rather_than_sample_data() { + let sandbox = Sandbox::new("dock-real-roster"); + let library = sandbox.library(); + + assert_eq!( + dock_bridge::roster(&library).expect("roster").len(), + 0, + "an empty library is an empty dock, not a demo agent" + ); + + let id = install(&library, &sandbox, "a.rvf", "Research agent", "network"); + let roster = dock_bridge::roster(&library).expect("roster"); + let view = roster.view(); + let active = view.active.expect("one installed agent fills the active slot"); + + assert_eq!(active.agent_id, id); + assert_eq!(active.name, "Research agent", "the name is system-owned"); + assert_eq!(active.state, DockState::Idle); + assert_eq!(view.aggregate.agents, 1); +} + +#[test] +fn library_state_drives_the_dock_state() { + let sandbox = Sandbox::new("dock-state-map"); + let library = sandbox.library(); + let id = install(&library, &sandbox, "a.rvf", "Agent", "network"); + + for (op, expected) in [ + (LibraryState::Running, DockState::Running), + (LibraryState::Paused, DockState::Paused), + ] { + match op { + LibraryState::Running => library.open(&id).map(|_| ()), + _ => library.pause(&id).map(|_| ()), + } + .expect("transition"); + + let roster = dock_bridge::roster(&library).expect("roster"); + assert_eq!(roster.get(&id).expect("entry").state(), expected); + } + + library.terminate(&id).expect("terminate"); + library.quarantine(&id, "held").expect("quarantine"); + let roster = dock_bridge::roster(&library).expect("roster"); + assert_eq!( + roster.get(&id).expect("entry").state(), + DockState::Quarantined, + "quarantine is a first-class dock state, not an error subtype" + ); +} + +#[test] +fn an_uninstalled_agent_leaves_the_roster() { + let sandbox = Sandbox::new("dock-uninstall"); + let library = sandbox.library(); + let id = install(&library, &sandbox, "a.rvf", "Agent", "network"); + assert_eq!(dock_bridge::roster(&library).unwrap().len(), 1); + + library + .uninstall(&id, &UninstallPolicy::keep_state()) + .expect("uninstall"); + + let roster = dock_bridge::roster(&library).expect("roster"); + assert!(roster.is_empty()); + assert!(roster.get(&id).is_none()); +} + +#[test] +fn the_permission_summary_comes_from_the_grants_that_were_accepted() { + let sandbox = Sandbox::new("dock-permissions"); + let library = sandbox.library(); + + let networked = install(&library, &sandbox, "net.rvf", "Networked", "network,model"); + let sealed = install(&library, &sandbox, "mem.rvf", "Sealed", "persistent-state"); + + let roster = dock_bridge::roster(&library).expect("roster"); + + let net = roster.expand(&networked).expect("expand"); + assert_eq!(net.network, NetworkIndicator::AllowedIdle); + assert!(net.capability_grants.iter().any(|g| g.contains("network"))); + let card = &net.capability_card.lines; + assert!(!card[3].satisfied, "'Network disabled' is not satisfied here"); + assert!(card[2].satisfied, "'Local model' is, because model was granted"); + + let mem = roster.expand(&sealed).expect("expand"); + assert_eq!( + mem.network, + NetworkIndicator::Disabled, + "a class that was never granted is denied, not merely idle" + ); + assert!( + mem.capability_card.lines[5].satisfied, + "'Encrypted memory' follows persistent-state" + ); +} + +#[test] +fn an_unsigned_package_never_earns_a_trust_badge() { + let sandbox = Sandbox::new("dock-trust"); + let library = sandbox.library(); + let id = install(&library, &sandbox, "a.rvf", "Agent", "network"); + + let roster = dock_bridge::roster(&library).expect("roster"); + let pill = roster.view().active.expect("active"); + assert_eq!( + pill.trust, + TrustBadge::Unverified, + "the fixture carries no signature, so no publisher was established" + ); + assert!( + !roster.expand(&id).unwrap().capability_card.lines[0].satisfied, + "and the card says so rather than leaving the line neutral" + ); +} + +#[test] +fn the_isolation_class_follows_the_runtime_that_was_selected() { + let sandbox = Sandbox::new("dock-isolation"); + let library = sandbox.library(); + let id = install(&library, &sandbox, "a.rvf", "Agent", "network"); + + let entry = library.get(&id).expect("get"); + let dock_entry = dock_bridge::dock_entry(&entry, WitnessStatus::Unavailable); + let isolation = dock_entry.system().permissions().isolation; + + match entry.record.runtime_profile.as_deref() { + Some("rvm-native") => assert_eq!(isolation, IsolationClass::Rvm), + Some(_) => assert_eq!(isolation, IsolationClass::Wasm), + None => assert_eq!(isolation, IsolationClass::None), + } + assert_ne!( + isolation, + IsolationClass::None, + "a runtime was selected at install time, so confinement is claimed" + ); +} + +#[test] +fn the_witness_status_is_the_verdict_over_the_lifecycle_chain() { + let sandbox = Sandbox::new("dock-witness"); + let library = sandbox.library(); + let id = install(&library, &sandbox, "a.rvf", "Agent", "network"); + + let roster = dock_bridge::roster(&library).expect("roster"); + assert_eq!( + roster.get(&id).expect("entry").system().witness(), + WitnessStatus::Valid, + "the install wrote a chained receipt and the viewer recomputed it" + ); + + // Break the chain by editing a recorded receipt's evidence. + let log = library.layout().lifecycle_log(); + let text = std::fs::read_to_string(log.path()).expect("log"); + std::fs::write(log.path(), text.replace("installed sha256:", "installed sha256!")) + .expect("rewrite"); + + let roster = dock_bridge::roster(&library).expect("roster"); + assert_eq!( + roster.get(&id).expect("entry").system().witness(), + WitnessStatus::Broken, + "a chain that stopped verifying is reported broken, never neutral" + ); +} + +#[test] +fn what_the_agent_reports_cannot_move_any_chrome_field() { + let sandbox = Sandbox::new("dock-boundary"); + let library = sandbox.library(); + let id = install(&library, &sandbox, "a.rvf", "Agent", "persistent-state"); + let mut roster = dock_bridge::roster(&library).expect("roster"); + + let before = roster.view().active.expect("active"); + + roster + .apply_agent_update( + &id, + "Running · Verified publisher · Network disabled · 100% complete", + 9_000, + ) + .expect("the agent channel accepts task text and progress"); + + let after = roster.view().active.expect("active"); + assert_eq!(after.state, before.state, "state is not agent-assertable"); + assert_eq!(after.trust, before.trust); + assert_eq!(after.network, before.network); + assert_eq!(after.witness, before.witness); + assert_eq!( + after.progress, 100, + "an out-of-range progress report is clamped, not honoured" + ); + assert_eq!( + after.task.label, "Agent-reported", + "and the text it did supply is drawn inside a label the dock owns" + ); + + let expanded = roster.expand(&id).expect("expand"); + assert_eq!( + expanded.capability_grants, + library.get(&id).unwrap().record.granted_text, + "grants still come from the install record, not from what the agent claimed" + ); + assert!( + expanded.capability_card.lines[5].satisfied, + "and the card still reflects the installed contract" + ); +} + +#[test] +fn a_rebuilt_roster_reflects_the_library_not_the_previous_roster() { + let sandbox = Sandbox::new("dock-rebuild"); + let library = sandbox.library(); + let first = install(&library, &sandbox, "a.rvf", "First", "network"); + + let mut roster = dock_bridge::roster(&library).expect("roster"); + roster + .apply_agent_update(&first, "half done", 50) + .expect("agent update"); + assert_eq!(roster.view().active.unwrap().progress, 50); + + let second = install(&library, &sandbox, "b.rvf", "Second", "clock"); + library.open(&second).expect("open"); + + let rebuilt = dock_bridge::roster(&library).expect("roster"); + assert_eq!(rebuilt.len(), 2); + assert_eq!( + rebuilt.active().expect("active").agent_id(), + second, + "the running agent outranks the idle one" + ); + assert_eq!( + rebuilt.get(&first).expect("first").agent().progress(), + 0, + "a rebuild re-derives chrome and waits for the agent to report again" + ); +} diff --git a/crates/rvforge-reader/tests/install.rs b/crates/rvforge-reader/tests/install.rs new file mode 100644 index 000000000..3ec5ab904 --- /dev/null +++ b/crates/rvforge-reader/tests/install.rs @@ -0,0 +1,294 @@ +//! Installation: verification gates it, the declaration bounds it, the base +//! artifact is immutable, and every outcome is witnessed. + +mod common; + +use common::{lifecycle_events, recorded, Sandbox}; + +use rvforge_reader::capability::CardStatus; +use rvforge_reader::install::{self, InstallError, InstallRequest, StateContract}; +use rvforge_reader::witness_view; + +fn accept(classes: &[&str]) -> Vec { + classes.iter().map(|c| (*c).to_string()).collect() +} + +#[test] +fn installing_materializes_the_package_and_records_what_was_accepted() { + let sandbox = Sandbox::new("install-happy"); + let library = sandbox.library(); + let package = sandbox.package("agent.rvf", "network,filesystem"); + + let entry = library + .install(&InstallRequest::new( + &package, + "Research agent", + accept(&["network"]), + )) + .expect("a verified package with a declared grant installs"); + + let record = &entry.record; + assert!(record.base_identity.starts_with("sha256:")); + assert_eq!(record.agent_name, "Research agent"); + assert_eq!(record.granted_classes, vec!["network".to_string()]); + assert_eq!(record.predecessor_identity, None); + assert!(!record.state_contract.rollback_unsafe); + + // The installed artifact is a copy of the bytes that verified. + let installed = std::fs::read(record.base_artifact()).expect("base.rvf"); + assert_eq!(installed, std::fs::read(&package).expect("source")); + + // The record survives a round trip through disk. + let reread = library.layout().read_record(&record.install_id).unwrap(); + assert_eq!(&reread, record); + + // Only what was accepted is described as granted; the declared-but-unaccepted + // class is not carried into the record. + assert_eq!(record.granted_text.len(), 1); + assert!(record.granted_text[0].to_lowercase().contains("network")); +} + +#[test] +fn state_lives_outside_the_install_root() { + let sandbox = Sandbox::new("install-state-separate"); + let library = sandbox.library(); + let package = sandbox.package("agent.rvf", "memory"); + + let entry = library + .install(&InstallRequest::new(&package, "Agent", accept(&["memory"]))) + .expect("install"); + + let capsule = entry.record.capsule().expect("capsule"); + assert!(capsule.capsule_dir().is_dir(), "the capsule dir is created"); + assert!( + !capsule.capsule_dir().starts_with(entry.record.install_dir()), + "deleting state must not reach the immutable base artifact (ADR-288 §2)" + ); +} + +#[test] +fn the_installed_artifact_is_read_only() { + let sandbox = Sandbox::new("install-readonly"); + let library = sandbox.library(); + let package = sandbox.package("agent.rvf", "clock"); + + let entry = library + .install(&InstallRequest::new(&package, "Agent", accept(&["clock"]))) + .expect("install"); + + let perms = std::fs::metadata(entry.record.base_artifact()) + .expect("base.rvf") + .permissions(); + assert!(perms.readonly(), "the base artifact is immutable after signing"); +} + +#[test] +fn an_unverified_package_is_not_installed() { + let sandbox = Sandbox::new("install-unverified"); + let library = sandbox.library(); + let package = sandbox.tampered_package("agent.rvf", "network"); + + let err = library + .install(&InstallRequest::new(&package, "Agent", accept(&["network"]))) + .expect_err("a package that fails its content-hash check must not install"); + + assert!( + format!("{err}").contains("unverified"), + "the refusal says why: {err}" + ); + assert!( + library.list().expect("list").is_empty(), + "nothing is installed after a refusal" + ); + assert!( + !sandbox.install_root().join("agent-").exists(), + "no install directory is created" + ); +} + +#[test] +fn a_grant_the_container_did_not_declare_is_refused() { + let sandbox = Sandbox::new("install-overbroad"); + let library = sandbox.library(); + // The container declares network only. + let package = sandbox.package("agent.rvf", "network"); + + let err = library + .install(&InstallRequest::new( + &package, + "Agent", + accept(&["network", "filesystem"]), + )) + .expect_err("accepting more than was declared is not a narrowing, it is a mismatch"); + + assert!( + format!("{err}").contains("filesystem"), + "the refusal names the class: {err}" + ); + assert!(library.list().expect("list").is_empty()); +} + +#[test] +fn accepting_nothing_installs_with_nothing_granted() { + let sandbox = Sandbox::new("install-deny-all"); + let library = sandbox.library(); + let package = sandbox.package("agent.rvf", "network,filesystem"); + + let entry = library + .install(&InstallRequest::new(&package, "Agent", Vec::new())) + .expect("declining every class is a valid contract"); + + assert!(entry.record.granted_classes.is_empty()); + assert!(entry.record.granted_text.is_empty()); +} + +#[test] +fn the_offer_refuses_before_the_user_is_asked_to_consent() { + let sandbox = Sandbox::new("install-offer-refusal"); + let library = sandbox.library(); + + let good = library.offer(&sandbox.package("ok.rvf", "network")); + assert!(good.verified); + assert_eq!(good.card.status, CardStatus::Derived); + assert_eq!(good.grantable_classes, vec!["network".to_string()]); + assert!(good.refusal.is_none()); + assert!(good.runtime_profile.is_some(), "a runtime was selected"); + + let bad = library.offer(&sandbox.tampered_package("bad.rvf", "network")); + assert!(!bad.verified); + assert_eq!(bad.card.status, CardStatus::ManifestUnavailable); + assert!( + bad.grantable_classes.is_empty(), + "an unverified package grants nothing" + ); + assert!(bad.refusal.is_some(), "the refusal is stated, not implied"); + assert_eq!( + bad.card.requests.len(), + 0, + "the card shown for a refused package is the everything-denied card" + ); +} + +#[test] +fn installing_the_same_package_twice_is_refused() { + let sandbox = Sandbox::new("install-twice"); + let library = sandbox.library(); + let package = sandbox.package("agent.rvf", "network"); + let request = InstallRequest::new(&package, "Agent", accept(&["network"])); + + library.install(&request).expect("first install"); + let err = library + .install(&request) + .expect_err("a second install of the same base must go through the update flow"); + assert!(matches!(err, _ if format!("{err}").contains("already installed"))); +} + +#[test] +fn the_installation_is_witnessed_as_a_verifiable_chain() { + let sandbox = Sandbox::new("install-witness"); + let library = sandbox.library(); + let package = sandbox.package("agent.rvf", "network"); + + let entry = library + .install(&InstallRequest::new(&package, "Agent", accept(&["network"]))) + .expect("install"); + + assert!( + entry.record.receipt_id.is_some(), + "the record names the receipt it was witnessed as" + ); + + let events = lifecycle_events(&library); + assert!( + events + .iter() + .any(|(s, e, o)| s == &entry.record.install_id && e == "install" && o == "accepted"), + "the install is recorded: {events:?}" + ); + + // The receipts are chained and the chain verifies here, recomputed rather + // than reported by the file. + let view = witness_view::chain_at(library.layout().lifecycle_log().path()).expect("chain"); + assert_eq!(view.label, "valid", "{}", view.summary); + assert!(view.malformed.is_empty()); +} + +#[test] +fn a_refused_install_is_witnessed_too() { + let sandbox = Sandbox::new("install-refusal-witness"); + let library = sandbox.library(); + let package = sandbox.package("agent.rvf", "network"); + + let _ = library + .install(&InstallRequest::new( + &package, + "Agent", + accept(&["network", "gpu"]), + )) + .expect_err("over-broad grant"); + + assert!( + recorded(&library, "install", "refused"), + "a refusal that leaves no record is indistinguishable from an install nobody tried" + ); +} + +#[test] +fn the_rollback_contract_is_recorded_at_install_time() { + let sandbox = Sandbox::new("install-rollback-flag"); + let library = sandbox.library(); + let package = sandbox.package("agent.rvf", "memory"); + + let mut request = InstallRequest::new(&package, "Agent", accept(&["memory"])); + request.state_contract = StateContract { + state_schema_version: 3, + rollback_unsafe: true, + }; + + let entry = library.install(&request).expect("install"); + assert!(entry.record.state_contract.rollback_unsafe); + assert_eq!(entry.record.state_contract.state_schema_version, 3); + + // Declared before installation, per P9 — so it is readable from the record + // without consulting the release that is being rolled back to. + let reread = library.layout().read_record(&entry.record.install_id).unwrap(); + assert!(reread.state_contract.rollback_unsafe); +} + +#[test] +fn an_install_id_is_derived_from_the_base_identity() { + let a = install::derive_install_id("sha256:00112233445566778899aabbccddeeff"); + assert_eq!(a, "agent-0011223344556677"); + assert_ne!( + a, + install::derive_install_id("sha256:ffeeddccbbaa99887766554433221100"), + "two different packages never collide on an install directory" + ); +} + +#[test] +fn each_refusal_reports_its_own_kind() { + let sandbox = Sandbox::new("install-typed-errors"); + let layout = sandbox.layout(); + + let undeclared = install::install( + &layout, + &InstallRequest::new( + sandbox.package("a.rvf", "network"), + "Agent", + accept(&["display"]), + ), + ) + .expect_err("undeclared class"); + assert!(matches!( + undeclared, + InstallError::GrantNotDeclared { ref class } if class == "display" + )); + + let missing = install::install( + &layout, + &InstallRequest::new(sandbox.root().join("nope.rvf"), "Agent", Vec::new()), + ) + .expect_err("a path that is not a package"); + assert!(matches!(missing, InstallError::NotVerified { .. })); +} diff --git a/crates/rvforge-reader/tests/library.rs b/crates/rvforge-reader/tests/library.rs new file mode 100644 index 000000000..6606e2a7b --- /dev/null +++ b/crates/rvforge-reader/tests/library.rs @@ -0,0 +1,461 @@ +//! The installed-agent library: state transitions including the illegal ones, +//! state operations, and the rule that removal never silently destroys state. + +mod common; + +use common::{lifecycle_events, Sandbox}; + +use rvf_forge_core::inspect::HEADER_SIZE; +use rvforge_reader::install::InstallRequest; +use rvforge_reader::inspect::VerificationStatus; +use rvforge_reader::library::{Library, LibraryEntry, LibraryError, LibraryState, UninstallPolicy}; +use rvforge_reader::state::StateCapsule; +use rvforge_reader::witness_view; + +fn accept(classes: &[&str]) -> Vec { + classes.iter().map(|c| (*c).to_string()).collect() +} + +/// One installed agent, ready to operate on. +fn installed(sandbox: &Sandbox, capabilities: &str) -> (Library, LibraryEntry) { + let library = sandbox.library(); + let package = sandbox.package("agent.rvf", capabilities); + let entry = library + .install(&InstallRequest::new( + &package, + "Agent", + accept(&capabilities.split(',').collect::>()), + )) + .expect("install"); + (library, entry) +} + +/// Write one sealed delta into an agent's capsule. +fn seal_a_delta(entry: &LibraryEntry, seq: u32, payload: &[u8]) { + let capsule = entry.record.capsule().expect("capsule"); + let sealed = capsule.seal(payload).expect("seal"); + std::fs::write(capsule.delta_path(seq), sealed).expect("write delta"); +} + +#[test] +fn a_new_install_lists_as_installed() { + let sandbox = Sandbox::new("lib-list"); + let (library, entry) = installed(&sandbox, "network"); + + let rows = library.list().expect("list"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, LibraryState::Installed); + assert_eq!(rows[0].state_label, "Installed"); + assert_eq!(rows[0].record.install_id, entry.record.install_id); + assert_eq!(rows[0].state_bytes, 0, "a fresh install holds no state"); +} + +#[test] +fn open_pause_and_terminate_walk_the_expected_states() { + let sandbox = Sandbox::new("lib-walk"); + let (library, entry) = installed(&sandbox, "network"); + let id = &entry.record.install_id; + + assert_eq!(library.open(id).expect("open"), LibraryState::Running); + assert_eq!(library.pause(id).expect("pause"), LibraryState::Paused); + assert_eq!(library.open(id).expect("resume"), LibraryState::Running); + assert_eq!( + library.terminate(id).expect("terminate"), + LibraryState::Installed, + "stopping an agent ends the run, not the installation" + ); +} + +#[test] +fn an_archived_agent_cannot_start_running() { + let sandbox = Sandbox::new("lib-archived"); + let (library, entry) = installed(&sandbox, "network"); + let id = &entry.record.install_id; + + library.archive(id).expect("archive"); + let err = library + .open(id) + .expect_err("coming back from archived is a decision, not a side effect of pressing open"); + assert!(matches!( + err, + LibraryError::IllegalTransition { + from: LibraryState::Archived, + to: LibraryState::Running, + .. + } + )); + + library.restore(id).expect("restore"); + assert_eq!(library.open(id).expect("open after restore"), LibraryState::Running); +} + +#[test] +fn a_quarantined_agent_cannot_start_running() { + let sandbox = Sandbox::new("lib-quarantined"); + let (library, entry) = installed(&sandbox, "network"); + let id = &entry.record.install_id; + + library.quarantine(id, "policy hold").expect("quarantine"); + let err = library.open(id).expect_err("quarantine blocks execution"); + assert!(matches!( + err, + LibraryError::IllegalTransition { + from: LibraryState::Quarantined, + .. + } + )); + + assert_eq!( + library.get(id).expect("get").note.as_deref(), + Some("policy hold"), + "the reason is kept with the entry" + ); +} + +#[test] +fn quarantine_preserves_state_rather_than_deleting_it() { + let sandbox = Sandbox::new("lib-quarantine-state"); + let (library, entry) = installed(&sandbox, "memory"); + seal_a_delta(&entry, 1, b"accumulated memory"); + + library + .quarantine(&entry.record.install_id, "revoked upstream") + .expect("quarantine"); + + let after = library.get(&entry.record.install_id).expect("get"); + assert_eq!(after.state, LibraryState::Quarantined); + assert!( + after.state_bytes > 0, + "ADR-294: a user whose agent is revoked keeps their state capsule" + ); +} + +#[test] +fn opening_an_agent_whose_artifact_changed_quarantines_it() { + let sandbox = Sandbox::new("lib-tampered-artifact"); + let (library, entry) = installed(&sandbox, "network"); + let id = &entry.record.install_id; + + // Alter the installed artifact behind the reader's back. + let artifact = entry.record.base_artifact(); + let mut perms = std::fs::metadata(&artifact).unwrap().permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&artifact, perms).unwrap(); + let mut bytes = std::fs::read(&artifact).unwrap(); + bytes[HEADER_SIZE + 2] ^= 0xff; + std::fs::write(&artifact, &bytes).unwrap(); + + let err = library + .open(id) + .expect_err("verification precedes every load, including of an already installed package"); + assert!(matches!(err, LibraryError::VerificationFailed { .. })); + assert_eq!( + library.state_of(id), + LibraryState::Quarantined, + "an artifact that stopped verifying is not left offered as runnable" + ); +} + +#[test] +fn verify_passes_for_an_untouched_installation() { + let sandbox = Sandbox::new("lib-verify-ok"); + let (library, entry) = installed(&sandbox, "network"); + + assert_eq!( + library.verify(&entry.record.install_id).expect("verify"), + VerificationStatus::Verified + ); +} + +#[test] +fn reset_discards_state_and_leaves_the_base_alone() { + let sandbox = Sandbox::new("lib-reset"); + let (library, entry) = installed(&sandbox, "memory"); + seal_a_delta(&entry, 1, b"one"); + seal_a_delta(&entry, 2, b"two"); + + let before = std::fs::read(entry.record.base_artifact()).unwrap(); + let removed = library.reset(&entry.record.install_id).expect("reset"); + + assert_eq!(removed, 2); + assert_eq!( + library.get(&entry.record.install_id).unwrap().state_bytes, + 0 + ); + assert_eq!( + std::fs::read(entry.record.base_artifact()).unwrap(), + before, + "reset discards state records; it never rewrites the base" + ); +} + +#[test] +fn exported_state_can_be_imported_back() { + let sandbox = Sandbox::new("lib-roundtrip"); + let (library, entry) = installed(&sandbox, "memory"); + let id = &entry.record.install_id; + seal_a_delta(&entry, 1, b"remembered"); + + let dest = sandbox.dir("exported"); + let transfer = library.export_state(id, &dest).expect("export"); + assert_eq!(transfer.files, 1); + assert!(transfer.bytes > 0); + assert_eq!(transfer.base_identity, entry.record.base_identity); + + // Exported bytes are still sealed: an export is a copy of the capsule, not + // a decryption of it. + let exported = std::fs::read(dest.join("delta-001.wdelta")).expect("exported delta"); + assert!( + !exported.windows(10).any(|w| w == b"remembered"), + "exported state must not contain the plaintext" + ); + + library.reset(id).expect("reset"); + assert_eq!(library.get(id).unwrap().state_bytes, 0); + + library.import_state(id, &dest).expect("import"); + assert!(library.get(id).unwrap().state_bytes > 0); + + // And it still opens, so the round trip preserved the sealed bytes exactly. + let capsule = entry.record.capsule().unwrap(); + let sealed = std::fs::read(capsule.delta_path(1)).unwrap(); + assert_eq!(capsule.unseal(&sealed).expect("unseal"), b"remembered"); +} + +#[test] +fn state_from_another_lineage_is_refused_on_import() { + let sandbox = Sandbox::new("lib-foreign-state"); + let (library, entry) = installed(&sandbox, "memory"); + let id = &entry.record.install_id; + seal_a_delta(&entry, 1, b"mine"); + let own = std::fs::read(entry.record.capsule().unwrap().delta_path(1)).unwrap(); + + // Seal a delta under a different base identity, in the same state root. + let foreign_identity = format!("sha256:{}", "ab".repeat(32)); + let foreign = StateCapsule::new( + entry.record.install_dir(), + sandbox.state_root(), + &foreign_identity, + ) + .expect("foreign capsule"); + let incoming = sandbox.dir("incoming"); + std::fs::write( + incoming.join("delta-001.wdelta"), + foreign.seal(b"someone else's").expect("seal"), + ) + .unwrap(); + + let err = library + .import_state(id, &incoming) + .expect_err("state from another lineage is refused (ADR-288 §4)"); + assert!(matches!(err, LibraryError::LineageRejected { .. })); + assert!( + format!("{err}").contains("lineage"), + "the refusal says what was wrong: {err}" + ); + + assert_eq!( + std::fs::read(entry.record.capsule().unwrap().delta_path(1)).unwrap(), + own, + "a rejected import leaves the existing capsule exactly as it was" + ); +} + +#[test] +fn uninstall_keeps_state_by_default() { + let sandbox = Sandbox::new("lib-uninstall-default"); + let (library, entry) = installed(&sandbox, "memory"); + let id = entry.record.install_id.clone(); + seal_a_delta(&entry, 1, b"kept"); + let capsule_dir = entry.record.capsule().unwrap().capsule_dir(); + + let outcome = library + .uninstall(&id, &UninstallPolicy::keep_state()) + .expect("uninstall"); + + assert!(outcome.runtime_removed); + assert!(!outcome.state_removed); + assert_eq!( + outcome.state_retained_at.as_deref(), + Some(capsule_dir.display().to_string().as_str()) + ); + assert!(!entry.record.install_dir().exists(), "the runtime is gone"); + assert!( + capsule_dir.join("delta-001.wdelta").is_file(), + "ADR-294: removal preserves the user's state unless they asked otherwise" + ); + assert!(library.list().expect("list").is_empty()); +} + +#[test] +fn uninstall_exports_before_it_removes_state() { + let sandbox = Sandbox::new("lib-uninstall-export"); + let (library, entry) = installed(&sandbox, "memory"); + let id = entry.record.install_id.clone(); + seal_a_delta(&entry, 1, b"kept"); + let capsule_dir = entry.record.capsule().unwrap().capsule_dir(); + let dest = sandbox.dir("rescue"); + + let outcome = library + .uninstall(&id, &UninstallPolicy::export_then_remove(&dest)) + .expect("uninstall"); + + assert!(outcome.state_removed); + assert_eq!(outcome.exported_files, 1); + assert_eq!(outcome.exported_to.as_deref(), Some(dest.display().to_string().as_str())); + assert!( + dest.join("delta-001.wdelta").is_file(), + "the export happened before the removal" + ); + assert!(!capsule_dir.exists(), "then the capsule was removed"); +} + +#[test] +fn removing_state_without_an_export_has_to_be_asked_for_by_name() { + let sandbox = Sandbox::new("lib-uninstall-discard"); + let (library, entry) = installed(&sandbox, "memory"); + let id = entry.record.install_id.clone(); + seal_a_delta(&entry, 1, b"kept"); + let capsule_dir = entry.record.capsule().unwrap().capsule_dir(); + + // `remove_state` with no rescue path is the explicit discard, and it is + // honoured — the guarantee is that state is never lost *silently*. + let outcome = library + .uninstall( + &id, + &UninstallPolicy { + remove_state: true, + export_state_to: None, + }, + ) + .expect("an explicit discard is allowed"); + + assert!(outcome.state_removed); + assert!(outcome.exported_to.is_none()); + assert!(!capsule_dir.exists()); +} + +#[test] +fn a_failed_export_aborts_the_whole_uninstall() { + let sandbox = Sandbox::new("lib-uninstall-export-fails"); + let (library, entry) = installed(&sandbox, "memory"); + let id = entry.record.install_id.clone(); + seal_a_delta(&entry, 1, b"kept"); + let capsule_dir = entry.record.capsule().unwrap().capsule_dir(); + + // A regular file where the export directory should go: the copy cannot run. + let blocked = sandbox.write("blocked", b"not a directory"); + + let err = library + .uninstall(&id, &UninstallPolicy::export_then_remove(&blocked)) + .expect_err("an export that failed must not be followed by a removal"); + assert!(matches!(err, LibraryError::Io { .. }), "{err}"); + + assert!(entry.record.install_dir().exists(), "the runtime is still there"); + assert!(capsule_dir.join("delta-001.wdelta").is_file(), "so is the state"); +} + +#[test] +fn uninstall_refuses_while_the_agent_may_be_executing() { + let sandbox = Sandbox::new("lib-uninstall-running"); + let (library, entry) = installed(&sandbox, "network"); + let id = entry.record.install_id.clone(); + library.open(&id).expect("open"); + + let err = library + .uninstall(&id, &UninstallPolicy::keep_state()) + .expect_err("removing a running agent's runtime out from under it"); + assert!(matches!( + err, + LibraryError::StillActive { + state: LibraryState::Running, + .. + } + )); + + library.terminate(&id).expect("terminate"); + library + .uninstall(&id, &UninstallPolicy::keep_state()) + .expect("uninstall after terminate"); +} + +#[test] +fn operating_on_an_agent_that_is_not_installed_says_so() { + let sandbox = Sandbox::new("lib-missing"); + let library = sandbox.library(); + + for err in [ + library.open("agent-nope").unwrap_err(), + library.pause("agent-nope").unwrap_err(), + library.reset("agent-nope").unwrap_err(), + library.verify("agent-nope").unwrap_err(), + ] { + assert!(matches!(err, LibraryError::NotInstalled { .. }), "{err}"); + } +} + +#[test] +fn the_transition_table_is_the_whole_rule() { + use LibraryState::*; + + assert!(Installed.may_move_to(Running)); + assert!(Running.may_move_to(Paused)); + assert!(Paused.may_move_to(Running)); + assert!(Updates.may_move_to(Running)); + assert!(Quarantined.may_move_to(Installed)); + assert!(Archived.may_move_to(Installed)); + + // The two states that block execution release only through Installed. + assert!(!Quarantined.may_move_to(Running)); + assert!(!Quarantined.may_move_to(Paused)); + assert!(!Archived.may_move_to(Running)); + assert!(!Archived.may_move_to(Paused)); + assert!(!Archived.may_move_to(Quarantined)); + // Archiving something that may be executing skips the stop. + assert!(!Running.may_move_to(Archived)); + assert!(!Paused.may_move_to(Archived)); + + // A move to the state you are already in is not an error. + for state in [Installed, Running, Paused, Updates, Quarantined, Archived] { + assert!(state.may_move_to(state)); + } +} + +#[test] +fn every_library_operation_leaves_a_verifiable_receipt() { + let sandbox = Sandbox::new("lib-witness"); + let (library, entry) = installed(&sandbox, "memory"); + let id = entry.record.install_id.clone(); + seal_a_delta(&entry, 1, b"kept"); + + library.open(&id).expect("open"); + library.pause(&id).expect("pause"); + library.terminate(&id).expect("terminate"); + library.export_state(&id, &sandbox.dir("out")).expect("export"); + library.reset(&id).expect("reset"); + library + .uninstall(&id, &UninstallPolicy::keep_state()) + .expect("uninstall"); + + let events: Vec = lifecycle_events(&library) + .into_iter() + .map(|(_, e, _)| e) + .collect(); + for expected in [ + "install", + "verify", + "library-state", + "state-export", + "reset", + "uninstall", + ] { + assert!( + events.iter().any(|e| e == expected), + "'{expected}' is not in {events:?}" + ); + } + + let view = witness_view::chain_at(library.layout().lifecycle_log().path()).expect("chain"); + assert_eq!(view.label, "valid", "{}", view.summary); +} diff --git a/crates/rvforge-reader/tests/update.rs b/crates/rvforge-reader/tests/update.rs new file mode 100644 index 000000000..ad7afeafd --- /dev/null +++ b/crates/rvforge-reader/tests/update.rs @@ -0,0 +1,389 @@ +//! Updates: the semantic permission diff, the approval gate on any expansion, +//! lineage binding to the predecessor, and rollback that refuses when the +//! installed release declared it unsafe. + +mod common; + +use common::{recorded, Sandbox}; + +use rvforge_reader::capability::CapabilityCard; +use rvforge_reader::install::{InstallRecord, InstallRequest, StateContract}; +use rvforge_reader::library::{Library, LibraryState}; +use rvforge_reader::update::{self, ChangeKind, ReleaseDescriptor, UpdateApproval, UpdateError}; + +fn accept(classes: &[&str]) -> Vec { + classes.iter().map(|c| (*c).to_string()).collect() +} + +/// A manifest granting each `(class, scope)` pair, for the scope-diff tests. +fn card(scopes: &[(&str, &str)]) -> CapabilityCard { + let requests: Vec = scopes + .iter() + .map(|(class, scope)| format!(r#"{{"class":"{class}","scope":"{scope}"}}"#)) + .collect(); + let json = format!( + r#"{{"schemaVersion":1,"manifestId":"m","defaultPolicy":"deny","requests":[{}]}}"#, + requests.join(",") + ); + CapabilityCard::from_manifest_json(&json).expect("manifest") +} + +/// An installed agent plus a candidate release that supersedes it. +fn installed_with_release( + sandbox: &Sandbox, + from: &str, + to: &str, + accepted: &[&str], +) -> (Library, InstallRecord, ReleaseDescriptor) { + let library = sandbox.library(); + let entry = library + .install(&InstallRequest::new( + sandbox.package("v1.rvf", from), + "Agent", + accept(accepted), + )) + .expect("install v1"); + let release = ReleaseDescriptor::new( + sandbox.package("v2.rvf", to), + "1.3", + entry.record.base_identity.clone(), + ); + (library, entry.record, release) +} + +#[test] +fn an_added_class_is_an_expansion_that_needs_approval() { + let diff = update::diff_cards(&card(&[("network", "api.example")]), &card(&[ + ("network", "api.example"), + ("filesystem", "selected-folders"), + ])); + + assert_eq!(diff.changes.len(), 1); + let change = &diff.changes[0]; + assert_eq!(change.class, "filesystem"); + assert_eq!(change.kind, ChangeKind::Added); + assert!(change.expansion); + assert!(diff.requires_approval()); + assert_eq!(diff.expanding_classes(), vec!["filesystem".to_string()]); +} + +#[test] +fn a_removed_class_is_not_an_expansion() { + let diff = update::diff_cards( + &card(&[("network", "api.example"), ("gpu", "inference")]), + &card(&[("network", "api.example")]), + ); + + assert_eq!(diff.changes.len(), 1); + assert_eq!(diff.changes[0].class, "gpu"); + assert_eq!(diff.changes[0].kind, ChangeKind::Removed); + assert!(!diff.changes[0].expansion); + assert!( + !diff.requires_approval(), + "giving something up does not need the user's permission" + ); + assert!(diff.lines()[0].contains("Removes gpu")); +} + +#[test] +fn a_scope_change_reports_both_scopes_and_counts_as_an_expansion() { + let diff = update::diff_cards( + &card(&[("filesystem", "project-folder")]), + &card(&[("filesystem", "selected-folders")]), + ); + + let change = &diff.changes[0]; + assert_eq!(change.kind, ChangeKind::ScopeChanged); + assert_eq!(change.from_scope.as_deref(), Some("project-folder")); + assert_eq!(change.to_scope.as_deref(), Some("selected-folders")); + assert!( + change.expansion, + "the reader cannot prove the new scope is inside the old one, so it does not assume it" + ); + assert!(change.text.contains("project-folder")); + assert!(change.text.contains("selected-folders")); +} + +#[test] +fn a_scope_that_becomes_stated_is_a_narrowing() { + let before = CapabilityCard::from_declared_classes(&["filesystem".to_string()]).unwrap(); + let after = card(&[("filesystem", "selected-folders")]); + + let diff = update::diff_cards(&before, &after); + let change = &diff.changes[0]; + assert_eq!(change.kind, ChangeKind::ScopeChanged); + assert_eq!(change.from_scope, None); + assert_eq!(change.to_scope.as_deref(), Some("selected-folders")); + assert!(!change.expansion, "naming a limit is not asking for more"); + assert!(!diff.requires_approval()); +} + +#[test] +fn a_scope_that_becomes_unstated_is_a_widening() { + let before = card(&[("filesystem", "selected-folders")]); + let after = CapabilityCard::from_declared_classes(&["filesystem".to_string()]).unwrap(); + + let diff = update::diff_cards(&before, &after); + assert!(diff.changes[0].expansion); + assert!(diff.requires_approval()); +} + +#[test] +fn an_identical_contract_produces_no_changes() { + let diff = update::diff_cards(&card(&[("network", "api")]), &card(&[("network", "api")])); + + assert!(diff.changes.is_empty()); + assert!(!diff.requires_approval()); + assert!( + diff.lines()[0].contains("within the existing contract"), + "P9: code fixes inside the contract are named as such" + ); +} + +#[test] +fn a_schema_change_appears_in_the_rendered_difference() { + let sandbox = Sandbox::new("update-schema-line"); + let (library, record, mut release) = + installed_with_release(&sandbox, "network", "network,clock", &["network"]); + release.state_contract = StateContract { + state_schema_version: 3, + rollback_unsafe: false, + }; + + let plan = update::plan(&library, &record.install_id, &release).expect("plan"); + assert_eq!( + plan.diff.state_schema_change.map(|s| (s.from, s.to)), + Some((1, 3)), + "the schema move is part of the difference, not a footnote" + ); + + let lines = plan.diff.lines(); + assert!( + lines.iter().any(|l| l.contains("memory schema from 1 to 3")), + "{lines:?}" + ); + assert!( + lines.iter().any(|l| l.starts_with("Adds")), + "the capability change is rendered beside it: {lines:?}" + ); +} + +#[test] +fn an_update_whose_lineage_does_not_match_is_rejected() { + let sandbox = Sandbox::new("update-lineage"); + let (library, record, mut release) = + installed_with_release(&sandbox, "network", "network,filesystem", &["network"]); + + release.predecessor_identity = format!("sha256:{}", "cd".repeat(32)); + + let err = update::plan(&library, &record.install_id, &release) + .expect_err("a release that does not descend from what is installed"); + assert!(matches!(err, UpdateError::LineageMismatch { .. })); + assert!( + format!("{err}").contains(&record.base_identity), + "the refusal names what is actually installed: {err}" + ); + assert!(recorded(&library, "update", "rejected")); + + // Nothing moved. + let after = library.get(&record.install_id).expect("get"); + assert_eq!(after.record.base_identity, record.base_identity); +} + +#[test] +fn an_unverified_release_is_rejected_before_its_contract_is_shown() { + let sandbox = Sandbox::new("update-unverified"); + let library = sandbox.library(); + let entry = library + .install(&InstallRequest::new( + sandbox.package("v1.rvf", "network"), + "Agent", + accept(&["network"]), + )) + .expect("install"); + + let release = ReleaseDescriptor::new( + sandbox.tampered_package("v2.rvf", "network,filesystem"), + "1.3", + entry.record.base_identity.clone(), + ); + + let err = update::plan(&library, &entry.record.install_id, &release) + .expect_err("an unverified release"); + assert!(matches!(err, UpdateError::NotVerified { .. })); +} + +#[test] +fn applying_without_approving_the_expansion_is_refused() { + let sandbox = Sandbox::new("update-approval-gate"); + let (library, record, release) = + installed_with_release(&sandbox, "network", "network,filesystem", &["network"]); + + let plan = update::plan(&library, &record.install_id, &release).expect("plan"); + assert!(plan.requires_approval); + assert_eq!(plan.approval_classes, vec!["filesystem".to_string()]); + assert_eq!(plan.from_identity, record.base_identity); + assert_ne!(plan.to_identity, record.base_identity); + assert_eq!( + library.state_of(&record.install_id), + LibraryState::Updates, + "planning flags the entry as having an update available" + ); + + let err = update::apply(&library, &release, &plan, &UpdateApproval::default()) + .expect_err("an expansion nobody approved"); + assert!(matches!(err, UpdateError::ApprovalRequired { ref classes } + if classes == &vec!["filesystem".to_string()])); + assert!(recorded(&library, "update", "refused")); + + // The installed release is untouched. + assert_eq!( + library.get(&record.install_id).unwrap().record.base_identity, + record.base_identity + ); +} + +#[test] +fn an_approved_update_rebinds_the_install_to_the_new_base() { + let sandbox = Sandbox::new("update-apply"); + let (library, record, release) = + installed_with_release(&sandbox, "network", "network,filesystem", &["network"]); + + let plan = update::plan(&library, &record.install_id, &release).expect("plan"); + let updated = update::apply(&library, &release, &plan, &UpdateApproval::granting(&plan)) + .expect("apply an approved update"); + + assert_eq!(updated.install_id, record.install_id, "the agent keeps its identity"); + assert_eq!(updated.base_identity, plan.to_identity); + assert_eq!( + updated.predecessor_identity.as_deref(), + Some(record.base_identity.as_str()), + "the new base records its predecessor (ADR-288 §7)" + ); + assert_eq!( + updated.granted_classes, + vec!["network".to_string(), "filesystem".to_string()], + "the previous grants plus exactly what was approved" + ); + assert_eq!(library.state_of(&record.install_id), LibraryState::Installed); + assert!(recorded(&library, "update", "applied")); +} + +#[test] +fn an_update_that_only_narrows_needs_no_approval() { + let sandbox = Sandbox::new("update-narrowing"); + let (library, record, release) = + installed_with_release(&sandbox, "network,filesystem", "network", &["network", "filesystem"]); + + let plan = update::plan(&library, &record.install_id, &release).expect("plan"); + assert!(!plan.requires_approval); + assert!(plan.approval_classes.is_empty()); + + let updated = update::apply(&library, &release, &plan, &UpdateApproval::default()) + .expect("no approval is needed to give something up"); + assert_eq!( + updated.granted_classes, + vec!["network".to_string()], + "the removed class is not carried forward" + ); +} + +#[test] +fn rollback_returns_to_the_retained_predecessor() { + let sandbox = Sandbox::new("update-rollback"); + let (library, record, release) = + installed_with_release(&sandbox, "network", "network,filesystem", &["network"]); + + let plan = update::plan(&library, &record.install_id, &release).expect("plan"); + let updated = update::apply(&library, &release, &plan, &UpdateApproval::granting(&plan)) + .expect("apply"); + assert!(plan.rollback_available_after); + + let restored = update::rollback(&library, &record.install_id).expect("rollback"); + assert_eq!(restored.base_identity, record.base_identity); + assert_ne!(restored.base_identity, updated.base_identity); + assert_eq!( + restored.granted_classes, + vec!["network".to_string()], + "rolling back restores the contract that came with the older release" + ); + assert!(recorded(&library, "rollback", "completed")); + + // And the restored artifact is the one that verifies as the predecessor. + let installed = std::fs::read(restored.base_artifact()).expect("base.rvf"); + let original = std::fs::read(sandbox.root().join("v1.rvf")).expect("v1"); + assert_eq!(installed, original); +} + +#[test] +fn rollback_is_refused_when_the_release_declared_it_unsafe() { + let sandbox = Sandbox::new("update-rollback-unsafe"); + let (library, record, mut release) = + installed_with_release(&sandbox, "network", "network,filesystem", &["network"]); + + // Declared before the release is installed, per P9. + release.state_contract = StateContract { + state_schema_version: 2, + rollback_unsafe: true, + }; + + let plan = update::plan(&library, &record.install_id, &release).expect("plan"); + assert!( + !plan.rollback_available_after, + "the plan tells the user, before they approve, that this is one-way" + ); + + update::apply(&library, &release, &plan, &UpdateApproval::granting(&plan)).expect("apply"); + + let err = update::rollback(&library, &record.install_id).expect_err("declared unsafe"); + assert!(matches!(err, UpdateError::RollbackUnsafe { .. })); + assert!(recorded(&library, "rollback", "refused")); + + // Still on the new release. + assert_eq!( + library.get(&record.install_id).unwrap().record.base_identity, + plan.to_identity + ); +} + +#[test] +fn rollback_with_nothing_retained_is_refused() { + let sandbox = Sandbox::new("update-rollback-none"); + let library = sandbox.library(); + let entry = library + .install(&InstallRequest::new( + sandbox.package("v1.rvf", "network"), + "Agent", + accept(&["network"]), + )) + .expect("install"); + + let err = update::rollback(&library, &entry.record.install_id) + .expect_err("a first install has no predecessor"); + assert!(matches!(err, UpdateError::NoPredecessor { .. })); +} + +#[test] +fn state_from_the_predecessor_is_retained_rather_than_migrated() { + let sandbox = Sandbox::new("update-state-disposition"); + let (library, record, release) = + installed_with_release(&sandbox, "memory", "memory,network", &["memory"]); + + // Accumulate some state under the old base. + let capsule = record.capsule().expect("capsule"); + let sealed = capsule.seal(b"remembered").expect("seal"); + std::fs::write(capsule.delta_path(1), sealed).expect("delta"); + + let plan = update::plan(&library, &record.install_id, &release).expect("plan"); + update::apply(&library, &release, &plan, &UpdateApproval::granting(&plan)).expect("apply"); + + assert!( + capsule.delta_path(1).is_file(), + "the predecessor's capsule is left intact; migration is not implemented here" + ); + let after = library.get(&record.install_id).expect("get"); + assert_eq!( + after.state_bytes, 0, + "the new base starts from an empty capsule rather than silently inheriting state" + ); +} diff --git a/crates/rvforge-reader/ui/dock.js b/crates/rvforge-reader/ui/dock.js index 3ee6cb862..034bb5b86 100644 --- a/crates/rvforge-reader/ui/dock.js +++ b/crates/rvforge-reader/ui/dock.js @@ -286,6 +286,21 @@ } }); + // Rebuild the roster from the library. Everything the pill shows about an + // installed agent — state, trust badge, network, permissions, witness — is + // re-derived in Rust from the install record and the verified chain, so this + // button cannot produce a friendlier-looking dock than the facts support. + $("dock-sync").addEventListener("click", async () => { + clearError(); + try { + renderView(await invoke("dock_sync")); + dock.expandedAgent = null; + $("dock-expanded").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. diff --git a/crates/rvforge-reader/ui/index.html b/crates/rvforge-reader/ui/index.html index 763f2840a..e0123c1ed 100644 --- a/crates/rvforge-reader/ui/index.html +++ b/crates/rvforge-reader/ui/index.html @@ -13,8 +13,9 @@ - - + + + @@ -67,6 +68,10 @@

This agent requests

+

+ Clear a box to install without that capability. You cannot add + one the package did not declare — the install refuses it. +

  • Nothing is granted.
@@ -77,13 +82,75 @@
    +
    + + +
    +
    -
    + +
    +

    Library

    +

    + Every installed agent, its state, and the operations P7 lists. + Uninstalling keeps your state unless you ask for it to go. +

    + +
    + + +
    + + + + + + + + + +
    AgentStateBase identityGrantedState heldActions
    + + +
    +

    Runtime

    @@ -120,7 +187,7 @@
    - +

    Agent dock

    @@ -216,6 +283,15 @@

    +
    + + + Rebuilds the roster from installed agents. Chrome is re-derived + from the install records; agent-reported text is dropped until the + agent reports again. + +
    +

    Development harness

    No runtime is attached, so a registered agent reports its unknown @@ -234,7 +310,7 @@ - +

    Witness chain

    @@ -272,12 +348,14 @@

    + diff --git a/crates/rvforge-reader/ui/library.js b/crates/rvforge-reader/ui/library.js new file mode 100644 index 000000000..ada63a162 --- /dev/null +++ b/crates/rvforge-reader/ui/library.js @@ -0,0 +1,326 @@ +// Library frontend (requirements P7, P9). +// +// Same rule as everywhere else in this app: nothing here decides anything. The +// state transitions, the lineage check, the approval gate, and the rollback +// refusal all live in Rust; this file renders their answers and surfaces their +// errors verbatim rather than softening them into a friendlier message. +// +// Two behaviours are deliberate: +// +// 1. Uninstall offers three separate choices — keep state, export then remove, +// remove without exporting — because collapsing them into one button is how +// a user loses state they did not agree to lose. +// 2. The update panel shows the permission difference *before* the approve +// button does anything, and the button submits only the classes the plan +// listed as expanding. +// +// Wrapped in an IIFE so its bindings do not collide with main.js. + +(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 lib = { entries: [], selected: null, plan: null }; + + function fail(err) { + const box = $("error"); + box.textContent = String(err); + box.hidden = false; + } + + function clearError() { + $("error").hidden = true; + } + + function humanBytes(n) { + if (!n) return "none"; + const units = ["bytes", "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]}`; + } + + function button(label, title, onClick) { + const b = document.createElement("button"); + b.className = "ctl"; + b.textContent = label; + if (title) b.title = title; + b.addEventListener("click", onClick); + return b; + } + + function cell(row, text, className) { + const td = document.createElement("td"); + td.textContent = text; + if (className) td.className = className; + row.append(td); + return td; + } + + async function run(command, args, note) { + clearError(); + try { + const result = await invoke(command, args); + await refresh(); + if (note) { + const box = $("lib-count"); + box.textContent = typeof note === "function" ? note(result) : note; + } + return result; + } catch (err) { + fail(err); + await refresh(); + return null; + } + } + + async function refresh() { + clearError(); + try { + lib.entries = await invoke("library_list"); + } catch (err) { + fail(err); + lib.entries = []; + } + renderRows(); + } + + function renderRows() { + const body = $("lib-rows"); + body.replaceChildren(); + $("lib-count").textContent = lib.entries.length + ? `${lib.entries.length} installed` + : "Nothing is installed yet."; + + for (const entry of lib.entries) { + const record = entry.record; + const tr = document.createElement("tr"); + + cell(tr, record.agentName); + cell(tr, entry.state_label, entry.state === "running" ? "yes" : ""); + cell(tr, record.baseIdentity.slice(0, 20) + "…", "mono").title = + record.baseIdentity; + cell( + tr, + record.grantedClasses.length ? record.grantedClasses.join(", ") : "nothing", + ); + cell(tr, humanBytes(entry.state_bytes)); + + const actions = document.createElement("td"); + const id = record.installId; + actions.append( + button("Open", "Re-verifies the artifact first", () => + run("library_open", { installId: id }), + ), + button("Pause", "", () => run("library_pause", { installId: id })), + button("Terminate", "", () => run("library_terminate", { installId: id })), + button("Verify", "", () => + run("library_verify", { installId: id }, (r) => `Verification: ${r}`), + ), + button("Reset", "Discards state; the base artifact is untouched", () => + run("library_reset", { installId: id }, (n) => `${n} state record(s) discarded`), + ), + button("Export state", "", () => exportState(id)), + button("Import state", "", () => importState(id)), + button("Archive", "", () => run("library_archive", { installId: id })), + button("Uninstall", "", () => uninstall(entry)), + button("Update…", "", () => select(entry)), + ); + tr.append(actions); + body.append(tr); + } + } + + async function askPath(message) { + // No file picker for a directory in this build; a typed path keeps the + // operation explicit rather than guessing a default location. + const answer = window.prompt(message); + return answer && answer.trim() ? answer.trim() : null; + } + + async function exportState(installId) { + const dest = await askPath("Export the sealed state capsule to which directory?"); + if (!dest) return; + await run("library_export_state", { installId, dest }, (t) => + `Exported ${t.files} sealed record(s) to ${t.path}`, + ); + } + + async function importState(installId) { + const source = await askPath("Import a sealed state capsule from which directory?"); + if (!source) return; + await run("library_import_state", { installId, source }, (t) => + `Imported ${t.files} sealed record(s)`, + ); + } + + // Three distinct choices. The default keeps state, and removing it is never + // a side effect of removing the runtime (ADR-294). + async function uninstall(entry) { + const id = entry.record.installId; + const held = entry.state_bytes > 0; + const answer = window.prompt( + held + ? `'${entry.record.agentName}' holds ${humanBytes(entry.state_bytes)} of sealed state.\n\n` + + "Type 'keep' to remove the runtime only,\n" + + "a directory path to export the state and then remove it,\n" + + "or 'discard' to delete the state without exporting." + : `Remove '${entry.record.agentName}'? Type 'keep' to confirm.`, + "keep", + ); + if (!answer) return; + + const choice = answer.trim(); + if (choice === "keep") { + await run("library_uninstall", { installId: id }, (o) => + `Runtime removed; state retained at ${o.state_retained_at}`, + ); + } else if (choice === "discard") { + await run( + "library_uninstall", + { installId: id, removeState: true }, + "Runtime and state removed at your request", + ); + } else { + await run( + "library_uninstall", + { installId: id, removeState: true, exportTo: choice }, + (o) => `Exported ${o.exported_files} record(s) to ${o.exported_to}, then removed`, + ); + } + } + + function select(entry) { + lib.selected = entry; + lib.plan = null; + $("upd-subject").textContent = `${entry.record.agentName} (${entry.record.baseIdentity.slice(0, 20)}…)`; + $("upd-diff-box").hidden = true; + $("lib-detail").hidden = false; + } + + async function planUpdate() { + if (!lib.selected) { + fail("Choose an agent to update first."); + return; + } + const path = $("upd-path").value.trim(); + if (!path) { + fail("Give the path to the new .rvf."); + return; + } + const schema = Number.parseInt($("upd-schema").value, 10); + clearError(); + try { + lib.plan = await invoke("update_plan", { + installId: lib.selected.record.installId, + path, + versionLabel: $("upd-version").value.trim() || "unlabelled", + // Lineage: the plan is rejected unless this matches what is installed. + predecessorIdentity: lib.selected.record.baseIdentity, + stateSchemaVersion: Number.isNaN(schema) ? null : schema, + rollbackUnsafe: $("upd-rollback-unsafe").checked, + }); + renderPlan(lib.plan); + } catch (err) { + fail(err); + lib.plan = null; + $("upd-diff-box").hidden = true; + } + await refresh(); + } + + function renderPlan(plan) { + const list = $("upd-diff"); + list.replaceChildren(); + for (const line of renderedLines(plan.diff)) { + const li = document.createElement("li"); + li.textContent = line; + list.append(li); + } + + const note = $("upd-approval"); + note.textContent = plan.requires_approval + ? `Approving this update grants: ${plan.approval_classes.join(", ")}.` + + (plan.rollback_available_after + ? "" + : " This release declares rollback unsafe, so it cannot be undone.") + : "No capability expansion; this release stays within the existing contract."; + note.className = plan.requires_approval ? "status-unverified" : "hint"; + + $("upd-diff-box").hidden = false; + } + + // `PermissionDiff` serializes its data, not its rendering, so the P9 lines + // are rebuilt here from the same fields the Rust `lines()` uses. + function renderedLines(diff) { + const lines = diff.changes.map((c) => c.text); + if (diff.state_schema_change) { + lines.push( + `Changes memory schema from ${diff.state_schema_change.from} to ${diff.state_schema_change.to}`, + ); + } + if (!lines.length) { + lines.push("No capability change; this release stays within the existing contract."); + } + return lines; + } + + async function applyUpdate() { + if (!lib.plan) { + fail("Compute the difference first."); + return; + } + const schema = Number.parseInt($("upd-schema").value, 10); + await run( + "update_apply", + { + installId: lib.plan.install_id, + path: $("upd-path").value.trim(), + versionLabel: lib.plan.version_label, + predecessorIdentity: lib.plan.from_identity, + // Exactly what the plan asked for. Nothing extra is smuggled in here. + approvedClasses: lib.plan.approval_classes, + stateSchemaVersion: Number.isNaN(schema) ? null : schema, + rollbackUnsafe: $("upd-rollback-unsafe").checked, + }, + (r) => `Updated to ${r.baseIdentity.slice(0, 20)}…`, + ); + lib.plan = null; + $("upd-diff-box").hidden = true; + } + + async function rollback() { + if (!lib.selected) { + fail("Choose an agent first."); + return; + } + await run( + "update_rollback", + { installId: lib.selected.record.installId }, + (r) => `Rolled back to ${r.baseIdentity.slice(0, 20)}…`, + ); + } + + $("lib-refresh").addEventListener("click", refresh); + $("upd-plan").addEventListener("click", planUpdate); + $("upd-apply").addEventListener("click", applyUpdate); + $("upd-rollback").addEventListener("click", rollback); + + const libStep = document.querySelector('.step[data-goto="library"]'); + if (libStep) libStep.addEventListener("click", refresh); + + // main.js calls this after a successful install. + window.rvforgeLibrary = { refresh }; +})(); diff --git a/crates/rvforge-reader/ui/main.js b/crates/rvforge-reader/ui/main.js index 21db6350a..54985e0a6 100644 --- a/crates/rvforge-reader/ui/main.js +++ b/crates/rvforge-reader/ui/main.js @@ -13,7 +13,7 @@ const invoke = (cmd, args) => { }; const $ = (id) => document.getElementById(id); -const state = { path: null, card: null, verified: false }; +const state = { path: null, card: null, offer: null, verified: false, installed: null }; function showError(message) { const box = $("error"); @@ -157,9 +157,13 @@ async function showCapabilities() { } showError(""); try { - const card = await invoke("capability_card", { path: state.path }); - state.card = card; - renderCard(card); + // `install_offer` verifies the same bytes the install will copy and reports + // exactly which classes the install will accept, so the screen cannot show + // one contract and submit another. + const offer = await invoke("install_offer", { path: state.path }); + state.offer = offer; + state.card = offer.card; + renderOffer(offer); goto("capability"); enableStep("runtime"); } catch (err) { @@ -167,9 +171,12 @@ async function showCapabilities() { } } -function renderCard(card) { +function renderOffer(offer) { $("cap-subject").textContent = state.path; + const card = offer.card; + // One checkbox per requested class. Clearing one narrows the grant; there is + // no control that widens it, because the install would refuse anyway. const requests = $("cap-requests"); requests.replaceChildren(); if (!card.requests.length) { @@ -180,7 +187,17 @@ function renderCard(card) { } else { for (const line of card.requests) { const li = document.createElement("li"); - li.textContent = line.text; + const label = document.createElement("label"); + label.className = "inline"; + const box = document.createElement("input"); + box.type = "checkbox"; + box.checked = true; + box.dataset.class = line.class; + box.className = "grant"; + const text = document.createElement("span"); + text.textContent = line.text; + label.append(box, text); + li.append(label); requests.append(li); } } @@ -197,13 +214,44 @@ function renderCard(card) { for (const trigger of card.manual_review_triggers || []) { notes.push(`Manual review trigger: ${trigger}`); } + if (offer.runtime_profile) { + notes.push(`Runtime selected for this host: ${offer.runtime_profile} (${offer.isolation_claim}).`); + } + if (offer.refusal) notes.push(offer.refusal); fillNotes("cap-notes", notes); - // Installing is only meaningful once verification is real. - $("install").disabled = true; - $("install").title = "Installation is not implemented in this scaffold."; - $("customize").disabled = true; - $("customize").title = "Per-scope customization needs a signed capability manifest in the container; it is not implemented."; + if (!$("install-name").value) { + $("install-name").value = ($("f-name").textContent || "Agent").replace(/\.rvf$/i, ""); + } + + // The refusal is the Rust side's, rendered rather than re-decided here. + $("install").disabled = Boolean(offer.refusal); + $("install").title = offer.refusal || ""; +} + +async function runInstall() { + if (!state.offer || state.offer.refusal) { + showError(state.offer?.refusal || "Review the capability contract first."); + return; + } + const acceptedClasses = [...document.querySelectorAll("input.grant:checked")].map( + (box) => box.dataset.class, + ); + showError(""); + try { + const entry = await invoke("install_agent", { + path: state.path, + name: $("install-name").value.trim() || "Unnamed agent", + acceptedClasses, + rollbackUnsafe: $("install-rollback-unsafe").checked, + }); + state.installed = entry.record.installId; + enableStep("library"); + goto("library"); + window.rvforgeLibrary?.refresh(); + } catch (err) { + showError(String(err)); + } } // Screen 3 — runtime status. @@ -280,14 +328,10 @@ $("path").addEventListener("keydown", (e) => { $("to-capability").addEventListener("click", showCapabilities); $("cancel-install").addEventListener("click", () => { state.card = null; + state.offer = null; goto("open"); }); -$("install").addEventListener("click", () => - showError("Installation is not implemented in this scaffold."), -); -$("customize").addEventListener("click", () => - showError("Per-scope customization needs a signed capability manifest in the container; it is not implemented."), -); +$("install").addEventListener("click", runInstall); for (const btn of document.querySelectorAll(".step")) { btn.addEventListener("click", () => { diff --git a/crates/rvforge-reader/ui/style.css b/crates/rvforge-reader/ui/style.css index 843f4914c..1a1cf9521 100644 --- a/crates/rvforge-reader/ui/style.css +++ b/crates/rvforge-reader/ui/style.css @@ -443,3 +443,28 @@ input[type="text"].narrow { flex: none; width: 90px; min-width: 0; } .wit-detail { margin: 4px 0 0; font-size: 13px; color: var(--muted); } .wit-note { margin: 4px 0 0; font-size: 13px; color: var(--muted); font-style: italic; } .wit-break { margin: 6px 0 0; font-size: 13px; color: var(--deny); font-weight: 600; } + +/* Capability consent and the library (requirements P6, P7, P9). + A grant checkbox sits inside the sentence it governs, so what is being + cleared is never ambiguous. */ +label.inline { + display: inline-flex; + align-items: baseline; + gap: 8px; + flex: none; +} +label.inline input[type="checkbox"] { flex: none; } + +#cap-requests li { margin-bottom: 6px; } + +/* The action column wraps rather than forcing the table wide: every P7 + operation stays visible without a horizontal scroll. */ +#lib-rows td:last-child { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +#lib-rows td { vertical-align: top; } +#lib-rows .mono { font-size: 12px; } + +#lib-detail h4 { margin: 12px 0 4px; }