feat(reader): install, library and update flows over verified artifacts

Takes rvforge-reader from a verification surface to one that manages
installed agents: install, a library of what is installed, and update
with rollback. Each flow re-verifies rather than trusting the step
before it — an artifact that verified at download is verified again
at install and again at load, because the file on disk between those
points is not the same object the check covered.

The dock bridge keeps the trust boundary the Dock exists to enforce.
Chrome the system owns — trust badge, network indicator, pause — is
populated from SystemOwnedStatus only, and agent-supplied text stays
in AgentProvidedStatus and is sanitized before display. A hostile
agent cannot forge an approved badge or claim it has stopped while
running, because the types do not give it a channel to those fields.

Update binds to lineage: an update whose base identity does not match
the installed artifact is refused rather than applied, and rollback
restores the previous version with its state capsule intact.

189 tests, clippy clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01ParP55bZs2iTGEGvpnUecx
This commit is contained in:
ruvnet 2026-08-05 10:24:04 -04:00
parent a6177aff90
commit f4e2be7c3f
25 changed files with 4750 additions and 27 deletions

View file

@ -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<String>) -> Result<WitnessChainView, String> {
}
}
// ---------------------------------------------------------------------------
// 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<String>,
state_schema_version: Option<u32>,
rollback_unsafe: Option<bool>,
) -> Result<LibraryEntry, String> {
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<Vec<LibraryEntry>, 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<LibraryState, String> {
library.open(&install_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn library_pause(
library: tauri::State<'_, Library>,
install_id: String,
) -> Result<LibraryState, String> {
library.pause(&install_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn library_terminate(
library: tauri::State<'_, Library>,
install_id: String,
) -> Result<LibraryState, String> {
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<usize, String> {
library.reset(&install_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn library_verify(
library: tauri::State<'_, Library>,
install_id: String,
) -> Result<VerificationStatus, String> {
library.verify(&install_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn library_archive(
library: tauri::State<'_, Library>,
install_id: String,
) -> Result<LibraryState, String> {
library.archive(&install_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn library_restore(
library: tauri::State<'_, Library>,
install_id: String,
) -> Result<LibraryState, String> {
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<StateTransfer, String> {
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<StateTransfer, String> {
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<bool>,
export_to: Option<String>,
) -> Result<UninstallOutcome, String> {
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<u32>,
rollback_unsafe: Option<bool>,
) -> Result<UpdatePlan, String> {
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<String>,
state_schema_version: Option<u32>,
rollback_unsafe: Option<bool>,
) -> Result<InstallRecord, String> {
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<InstallRecord, String> {
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<u32>,
rollback_unsafe: Option<bool>,
) -> 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<RosterView, Stri
dock.with(|r| r.view())
}
/// Rebuild the roster from the library, so the dock shows real installations
/// rather than whatever was registered by hand.
///
/// The roster is replaced wholesale: an agent that was uninstalled leaves the
/// dock, and one whose library state moved is redrawn from the record rather
/// than from what the dock last believed. Agent-reported task text does not
/// survive the rebuild, which is the correct direction to lose data in — the
/// dock re-derives system chrome and waits for the agent to report again.
#[tauri::command]
pub fn dock_sync(
dock: tauri::State<'_, DockSurface>,
library: tauri::State<'_, Library>,
) -> Result<RosterView, String> {
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(

View file

@ -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<DockRoster, LibraryError> {
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)
}

View file

@ -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

View file

@ -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<String>,
pub signature: SignatureStatus,
pub card: CapabilityCard,
pub grantable_classes: Vec<String>,
pub runtime_profile: Option<String>,
pub isolation_claim: Option<String>,
/// Why installation is not offered, when it is not.
pub refusal: Option<String>,
}
/// 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<InstallRecord, InstallError> {
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<InstallRecord, InstallError> {
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)
}

View file

@ -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<PathBuf>, state_root: impl Into<PathBuf>) -> 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<InstallRecord, InstallError> {
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<Vec<String>, 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<String> = 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()
}
}

View file

@ -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 `<install_dir>/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_root>/<install-id>/
//! 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
//! <state_root>/
//! receipts.jsonl verification receipts
//! lifecycle.jsonl chained lifecycle witness receipts
//! <base-identity>/ 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<String>,
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<String>,
/// The base identity this release supersedes, forming the ADR-288 §7
/// lineage chain. `None` for a first install.
pub predecessor_identity: Option<String>,
}
impl InstallRequest {
/// A first install accepting exactly the classes the card granted.
pub fn new(
rvf_path: impl Into<PathBuf>,
agent_name: impl Into<String>,
accepted_classes: Vec<String>,
) -> 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:<hex>`.
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<String>,
/// The exact sentences the user was shown when they accepted.
pub granted_text: Vec<String>,
pub state_contract: StateContract,
pub predecessor_identity: Option<String>,
/// The runtime the FR004 ladder chose on this host at install time.
pub runtime_profile: Option<String>,
pub isolation_claim: Option<String>,
/// 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<String>,
}
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, StateError> {
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<String> },
/// 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<StateError> 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(),
}
}

View file

@ -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,

View file

@ -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<Vec<PathBuf>, 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<PathBuf> = 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<u64, LibraryError> {
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<usize, LibraryError> {
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<StateTransfer, LibraryError> {
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);
}
}
}

View file

@ -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<String>,
}
impl LibraryEntryState {
fn at(state: LibraryState, note: Option<String>) -> 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<String>,
/// 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<InstallError> for LibraryError {
fn from(e: InstallError) -> Self {
LibraryError::Install {
detail: e.to_string(),
}
}
}
impl From<StateError> 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(),
}
}

View file

@ -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<PathBuf>,
}
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<PathBuf>) -> 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<String>,
pub exported_files: usize,
/// Where state was left, when it was left.
pub state_retained_at: Option<String>,
}
/// 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,
}

View file

@ -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<PathBuf>, state_root: impl Into<PathBuf>) -> 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<LibraryEntry, LibraryError> {
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<Vec<LibraryEntry>, 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<LibraryEntry, LibraryError> {
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<VerificationStatus, LibraryError> {
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<LibraryState, LibraryError> {
// 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<LibraryState, LibraryError> {
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<LibraryState, LibraryError> {
self.move_to(install_id, LibraryState::Installed, None)
}
pub fn quarantine(&self, install_id: &str, reason: &str) -> Result<LibraryState, LibraryError> {
self.move_to(
install_id,
LibraryState::Quarantined,
Some(reason.to_string()),
)
}
pub fn archive(&self, install_id: &str) -> Result<LibraryState, LibraryError> {
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<LibraryState, LibraryError> {
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<LibraryState, LibraryError> {
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<usize, LibraryError> {
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<StateTransfer, LibraryError> {
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<StateTransfer, LibraryError> {
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<UninstallOutcome, LibraryError> {
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<LibraryState, LibraryError> {
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<String>,
) -> Result<LibraryState, LibraryError> {
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<String>,
) -> 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<LibraryEntryState, LibraryError> {
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<LibraryEntry, LibraryError> {
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()
}
}

View file

@ -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<String>,
pub to_scope: Option<String>,
/// 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<CapabilityChange>,
pub state_schema_change: Option<SchemaChange>,
}
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<String> {
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<String> {
let mut lines: Vec<String> = 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<String>,
/// 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>) -> String {
match scope {
Some(s) => format!("'{s}'"),
None => "unstated".to_string(),
}
}

View file

@ -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<UpdatePlan, UpdateError> {
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<InstallRecord, UpdateError> {
let missing: Vec<String> = 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<String> = 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<InstallRecord, UpdateError> {
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<PathBuf, UpdateError> {
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)
}

View file

@ -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<PathBuf>,
version_label: impl Into<String>,
predecessor_identity: impl Into<String>,
) -> 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<String>,
}
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<String> },
/// 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<LibraryError> for UpdateError {
fn from(e: LibraryError) -> Self {
UpdateError::Library {
detail: e.to_string(),
}
}
}
impl From<InstallError> 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(),
}
}

View file

@ -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<String> {
.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<String>,
event: impl Into<String>,
outcome: impl Into<String>,
actor: Actor,
details: impl Into<String>,
prev_receipt: Option<String>,
) -> 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<PathBuf>) -> 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<Vec<WitnessReceipt>> {
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::<WitnessReceipt>(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<Option<String>> {
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<WitnessReceipt> {
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 {

View file

@ -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 `<name>.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)
}

View file

@ -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<String> {
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::<Vec<_>>()),
))
.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"
);
}

View file

@ -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<String> {
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 { .. }));
}

View file

@ -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<String> {
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::<Vec<_>>()),
))
.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<String> = 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);
}

View file

@ -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<String> {
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<String> = 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"
);
}

View file

@ -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.

View file

@ -13,8 +13,9 @@
<button class="step" data-goto="open" aria-current="true">1 · Open</button>
<button class="step" data-goto="capability" disabled>2 · Capabilities</button>
<button class="step" data-goto="runtime" disabled>3 · Runtime</button>
<button class="step" data-goto="dock">4 · Dock</button>
<button class="step" data-goto="witness">5 · Witness</button>
<button class="step" data-goto="library">4 · Library</button>
<button class="step" data-goto="dock">5 · Dock</button>
<button class="step" data-goto="witness">6 · Witness</button>
</nav>
</header>
@ -67,6 +68,10 @@
<div class="contract">
<div class="card requests">
<h3>This agent requests</h3>
<p class="hint">
Clear a box to install without that capability. You cannot add
one the package did not declare — the install refuses it.
</p>
<ul id="cap-requests"><li class="empty">Nothing is granted.</li></ul>
</div>
<div class="card denials">
@ -77,13 +82,75 @@
<ul id="cap-notes" class="notes"></ul>
<div class="row">
<input id="install-name" type="text" placeholder="Name this agent in your library" />
<label class="inline">
<input id="install-rollback-unsafe" type="checkbox" />
This release declares rollback unsafe
</label>
</div>
<div class="row end">
<button id="cancel-install">Cancel</button>
<button id="customize">Customize permissions</button>
<button id="install" class="primary">Install</button>
</div>
</section>
<!-- Screen 4: the installed-agent library (requirements P7) -->
<section id="screen-library" class="screen" aria-labelledby="lib-title">
<h2 id="lib-title">Library</h2>
<p class="lede">
Every installed agent, its state, and the operations P7 lists.
Uninstalling keeps your state unless you ask for it to go.
</p>
<div class="row">
<button id="lib-refresh" class="primary">Refresh</button>
<span id="lib-count" class="hint"></span>
</div>
<table class="eval">
<thead>
<tr>
<th>Agent</th><th>State</th><th>Base identity</th>
<th>Granted</th><th>State held</th><th>Actions</th>
</tr>
</thead>
<tbody id="lib-rows"></tbody>
</table>
<div id="lib-detail" class="panel" hidden>
<h3>Update <span id="upd-subject" class="mono"></span></h3>
<p class="hint">
An update is a new base RVF. It is refused unless it declares the
installed release as its predecessor.
</p>
<div class="row">
<input id="upd-path" type="text" spellcheck="false"
placeholder="Path to the new .rvf" />
<input id="upd-version" type="text" class="narrow" placeholder="Version" />
<button id="upd-plan">Compute difference</button>
</div>
<div class="row">
<label class="inline">
<input id="upd-rollback-unsafe" type="checkbox" />
The new release declares rollback unsafe
</label>
<input id="upd-schema" type="text" class="narrow" placeholder="State schema" />
</div>
<div id="upd-diff-box" hidden>
<h4>Version changes</h4>
<ul id="upd-diff" class="notes"></ul>
<p id="upd-approval" class="hint"></p>
<div class="row end">
<button id="upd-apply" class="primary">Approve and update</button>
<button id="upd-rollback">Roll back</button>
</div>
</div>
</div>
</section>
<!-- Screen 3: runtime status and emergency controls -->
<section id="screen-runtime" class="screen" aria-labelledby="rt-title">
<h2 id="rt-title">Runtime</h2>
@ -120,7 +187,7 @@
<button disabled>Rollback state</button>
</div>
</section>
<!-- Screen 4: the agent dock (ADR-295) -->
<!-- Screen 5: the agent dock (ADR-295) -->
<section id="screen-dock" class="screen" aria-labelledby="dock-title">
<h2 id="dock-title">Agent dock</h2>
<p class="lede">
@ -216,6 +283,15 @@
</div>
</div>
<div class="row">
<button id="dock-sync" class="primary">Sync from library</button>
<span class="hint">
Rebuilds the roster from installed agents. Chrome is re-derived
from the install records; agent-reported text is dropped until the
agent reports again.
</span>
</div>
<h3>Development harness</h3>
<p class="hint">
No runtime is attached, so a registered agent reports its unknown
@ -234,7 +310,7 @@
<button id="dock-agent-send">Report as agent</button>
</div>
</section>
<!-- Screen 5: the witness viewer (requirements P15.11) -->
<!-- Screen 6: the witness viewer (requirements P15.11) -->
<section id="screen-witness" class="screen" aria-labelledby="wit-title">
<h2 id="wit-title">Witness chain</h2>
<p class="lede">
@ -272,12 +348,14 @@
<footer class="chrome">
<span id="stub-banner" class="banner">
Inspection and verification run through rvf-forge-core. No package is
executed, and every verification result is written to the local receipt log.
Inspection, verification, installation, and updates run through
rvf-forge-core and the reader's own library. No package is executed, and
every result — including every refusal — is written to the local logs.
</span>
</footer>
<script src="main.js"></script>
<script src="library.js"></script>
<script src="dock.js"></script>
<script src="witness.js"></script>
</body>

View file

@ -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 };
})();

View file

@ -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", () => {

View file

@ -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; }