From 102e27bce41e492a1cbc9428e43c76b05bdcc217 Mon Sep 17 00:00:00 2001 From: Ben Kearns <35475+bkearns@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:08:27 -0700 Subject: [PATCH 1/4] One vocabulary for the control channel, shared by both sides The command names lived in two places -- ControlCommandType in the mobile core and CoordinatorCommand in the listener -- and had to match exactly or a command silently became 'unknown'. They drifted the first time anyone added one: coordinator_offer went into the listener, a build against a different worktree of the same repo compiled cleanly, and the resulting binary did not know the command. Nothing failed. The app simply waited. ferrosa-control-vocabulary defines the names, the read/write classification, which commands the coordinator answers, and which frame may carry a secret. Both sides compile it, so a name they disagree about cannot be expressed. No dependencies, deliberately: the phone, the desktop app, the listener and the streamer all compile it, and anything it pulls in they all pull in. Names and classifications need nothing. An unknown command is classified as a WRITE. Guessing 'read' for something a build cannot classify would let an unrecognised name past a read-only guard, and the safe direction for an unknown is restrictive. A path dependency, not a pinned rev -- these repos are all ours and a pin would be a second place to bump. The cost is that a consumer must sit beside ferrosa-memory on disk, which is what ferrosa-streamer's CI already arranges and what ferrosa-mobile's CI now does too. 8 vocabulary tests, plus agreement tests on both sides asserting each knows every name the other can send. --- Cargo.toml | 2 +- crates/ferrosa-control-vocabulary/Cargo.toml | 11 + crates/ferrosa-control-vocabulary/src/lib.rs | 308 ++++++++++++++++++ crates/ferrosa-memory-sync/Cargo.toml | 4 + .../src/control_session.rs | 3 + .../src/coordinator_client.rs | 13 + .../src/coordinator_command.rs | 152 ++++++++- 7 files changed, 474 insertions(+), 19 deletions(-) create mode 100644 crates/ferrosa-control-vocabulary/Cargo.toml create mode 100644 crates/ferrosa-control-vocabulary/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 30092597..828a9a70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/fmem-cli", "crates/ferrosa-memory-core", "crates/ferrosa-memory-mcp", "crates/ferrosa-memory-batch", "crates/ferrosa-memory-sync", "crates/ferrosa-memory-eval"] +members = ["crates/ferrosa-control-vocabulary", "crates/fmem-cli", "crates/ferrosa-memory-core", "crates/ferrosa-memory-mcp", "crates/ferrosa-memory-batch", "crates/ferrosa-memory-sync", "crates/ferrosa-memory-eval"] resolver = "2" [workspace.package] diff --git a/crates/ferrosa-control-vocabulary/Cargo.toml b/crates/ferrosa-control-vocabulary/Cargo.toml new file mode 100644 index 00000000..55fca2c9 --- /dev/null +++ b/crates/ferrosa-control-vocabulary/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ferrosa-control-vocabulary" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "The control-channel command vocabulary, defined once for every side that speaks it." + +# No dependencies on purpose. This crate is compiled by the phone, the desktop +# app, the listener and the streamer, and anything it pulls in they all pull in. +# The vocabulary is names and classifications; it needs nothing. +[dependencies] diff --git a/crates/ferrosa-control-vocabulary/src/lib.rs b/crates/ferrosa-control-vocabulary/src/lib.rs new file mode 100644 index 00000000..7ffa4d56 --- /dev/null +++ b/crates/ferrosa-control-vocabulary/src/lib.rs @@ -0,0 +1,308 @@ +//! Module: The control-channel command vocabulary, defined once. +//! +//! Every side that speaks the control channel needs the same answers about a +//! command: what it is called on the wire, whether it changes anything, what a +//! peer must hold to issue it, and whether the frame may contain a secret. +//! +//! Those answers used to live in two places -- `ControlCommandType` in the +//! mobile core and `CoordinatorCommand` in the listener -- and the wire names +//! had to match exactly or a command silently became "unknown". They drifted +//! the first time anyone added one: +//! +//! `coordinator_offer` was added to the listener, the streamer built cleanly +//! against a DIFFERENT worktree of the same repo, and the resulting binary +//! did not know the command. Nothing failed. The app simply waited. +//! +//! One definition removes the class of bug rather than that instance of it: a +//! name added here is a name both sides know, and a name they disagree about +//! cannot be expressed. +//! +//! ## Why it has no dependencies +//! +//! It is compiled by the phone, the desktop app, the listener and the +//! streamer. Anything it pulls in, they all pull in. Names and classifications +//! need nothing, so it takes nothing. +//! +//! Correctness: correct when a command's wire name round-trips, an unknown +//! name is preserved rather than discarded, and every command classifies its +//! effect, its capability and whether it may carry a secret. +//! +//! Last revised: 2026-08-31 +//! Last changed: Created, to stop the two definitions drifting again. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +/// The capability a peer must hold to drive a coordinator. +/// +/// One capability covers every coordinator command today. Named here rather +/// than repeated so a future command needing something narrower has one place +/// to say so. +pub const COORDINATOR_CAPABILITY: &str = "coordinator_control"; + +/// Whether a command changes anything on the machine that runs it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Effect { + /// Answers a question and alters nothing. + Read, + /// Changes state. + Write, +} + +/// A command that can travel the control channel. +/// +/// `Unknown` keeps the raw name rather than discarding it, because a peer +/// running a newer build will send names this one does not have, and the +/// difference between "a command I do not know" and "no command" decides +/// whether the reply is a refusal that says so or a silence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + /// Launch an agent with an instruction. + AgentLaunch, + /// List the team and its enforcement report. + TeammateList, + /// List secret requests awaiting a human. + SecretPendingList, + /// Supply the value for one secret request. + SecretFulfil, + /// Refuse one secret request. + SecretDeny, + /// List running microVMs. + VmList, + /// Report what this machine's coordinator can run: which tiers are live, + /// what images it holds, and how much room is left. + CoordinatorOffer, + /// Begin a note. + NoteOpen, + /// Add one finalised utterance, or a typed note's whole body. + NoteAppend, + /// Close a note. + NoteCommit, + /// A name this build does not know, kept verbatim. + Unknown(String), +} + +impl Command { + /// The wire name. + pub fn as_wire(&self) -> &str { + match self { + Self::AgentLaunch => "agent_launch", + Self::TeammateList => "teammate_list", + Self::SecretPendingList => "secret_pending_list", + Self::SecretFulfil => "secret_fulfil", + Self::SecretDeny => "secret_deny", + Self::VmList => "vm_list", + Self::CoordinatorOffer => "coordinator_offer", + Self::NoteOpen => "note_open", + Self::NoteAppend => "note_append", + Self::NoteCommit => "note_commit", + Self::Unknown(raw) => raw, + } + } + + /// Read a wire name. Never fails: an unrecognised name is preserved. + pub fn from_wire(raw: &str) -> Self { + match raw { + "agent_launch" => Self::AgentLaunch, + "teammate_list" => Self::TeammateList, + "secret_pending_list" => Self::SecretPendingList, + "secret_fulfil" => Self::SecretFulfil, + "secret_deny" => Self::SecretDeny, + "vm_list" => Self::VmList, + "coordinator_offer" => Self::CoordinatorOffer, + "note_open" => Self::NoteOpen, + "note_append" => Self::NoteAppend, + "note_commit" => Self::NoteCommit, + other => Self::Unknown(other.to_owned()), + } + } + + /// Whether this build can execute it. + pub fn is_known(&self) -> bool { + !matches!(self, Self::Unknown(_)) + } + + /// Whether this command is one the coordinator answers. + /// + /// The listener uses this to decide what to forward; the app uses it to + /// decide what needs the coordinator capability. One answer, so the two + /// cannot disagree about which commands those are. + pub fn is_coordinator_command(&self) -> bool { + matches!( + self, + Self::TeammateList + | Self::SecretPendingList + | Self::SecretFulfil + | Self::SecretDeny + | Self::VmList + | Self::CoordinatorOffer + ) + } + + /// Whether it changes anything. + /// + /// An unknown command is treated as a WRITE. Guessing "read" for something + /// this build cannot classify would let an unrecognised name past a + /// read-only guard, and the safe direction for an unknown is the + /// restrictive one. + pub fn effect(&self) -> Effect { + match self { + Self::TeammateList + | Self::SecretPendingList + | Self::VmList + | Self::CoordinatorOffer => Effect::Read, + Self::AgentLaunch + | Self::SecretFulfil + | Self::SecretDeny + | Self::NoteOpen + | Self::NoteAppend + | Self::NoteCommit + | Self::Unknown(_) => Effect::Write, + } + } + + /// Whether the frame carrying this command may contain a secret value. + /// + /// Consulted before rendering a frame in a log. The redaction that protects + /// the value on the app side does not travel with the JSON, so anything + /// logging frames must ask here. + pub fn carries_secret(&self) -> bool { + matches!(self, Self::SecretFulfil) + } + + /// Every command this build knows, for exhaustive tests and for listing + /// what a peer may send. + pub fn all_known() -> Vec { + vec![ + Self::AgentLaunch, + Self::TeammateList, + Self::SecretPendingList, + Self::SecretFulfil, + Self::SecretDeny, + Self::VmList, + Self::CoordinatorOffer, + Self::NoteOpen, + Self::NoteAppend, + Self::NoteCommit, + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The property the crate exists for: a name written by one side is read + /// back as the same command by the other. Both sides call these functions, + /// so a disagreement cannot be expressed. + #[test] + fn every_known_command_round_trips() { + for command in Command::all_known() { + let wire = command.as_wire().to_owned(); + assert_eq!( + Command::from_wire(&wire), + command, + "{wire} did not survive a round trip" + ); + } + } + + /// The exact command that drifted. Named on its own so a rename cannot + /// pass by only updating the generic round-trip above. + #[test] + fn coordinator_offer_is_spelled_the_way_both_sides_expect() { + assert_eq!(Command::CoordinatorOffer.as_wire(), "coordinator_offer"); + assert_eq!( + Command::from_wire("coordinator_offer"), + Command::CoordinatorOffer + ); + assert!(Command::CoordinatorOffer.is_coordinator_command()); + assert_eq!(Command::CoordinatorOffer.effect(), Effect::Read); + assert!(!Command::CoordinatorOffer.carries_secret()); + } + + /// A peer on a newer build sends names this one lacks. Keeping the raw + /// name lets the reply say "I do not know that" instead of nothing, which + /// is the difference between a diagnosable refusal and the silence the app + /// sat in. + #[test] + fn an_unknown_name_is_kept_rather_than_discarded() { + let unknown = Command::from_wire("something_from_a_newer_build"); + assert!(!unknown.is_known()); + assert_eq!(unknown.as_wire(), "something_from_a_newer_build"); + } + + /// An unclassifiable command must not slip past a read-only guard. + #[test] + fn an_unknown_command_is_treated_as_a_write() { + assert_eq!( + Command::from_wire("who_knows").effect(), + Effect::Write, + "an unknown command was classified as a read" + ); + } + + /// Exactly one command may carry a credential. Asserted across the whole + /// set, so adding one that carries a secret without saying so fails here. + #[test] + fn only_secret_fulfil_carries_a_secret() { + for command in Command::all_known() { + let expected = command == Command::SecretFulfil; + assert_eq!( + command.carries_secret(), + expected, + "{} classified its secret-carrying wrongly", + command.as_wire() + ); + } + } + + #[test] + fn only_coordinator_commands_are_coordinator_commands() { + for command in Command::all_known() { + let expected = matches!( + command, + Command::TeammateList + | Command::SecretPendingList + | Command::SecretFulfil + | Command::SecretDeny + | Command::VmList + | Command::CoordinatorOffer + ); + assert_eq!( + command.is_coordinator_command(), + expected, + "{}", + command.as_wire() + ); + } + } + + /// Two commands sharing a wire name would make one unreachable. + #[test] + fn no_two_commands_share_a_wire_name() { + let known = Command::all_known(); + let mut names: Vec<&str> = known.iter().map(|c| c.as_wire()).collect(); + let before = names.len(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), before, "two commands share a wire name"); + } + + /// A typo, an empty name, or one with stray whitespace must not silently + /// match a real command. + #[test] + fn near_misses_do_not_match() { + for raw in [ + "", + " coordinator_offer", + "coordinator_offer ", + "CoordinatorOffer", + ] { + assert!( + !Command::from_wire(raw).is_known(), + "{raw:?} matched a known command" + ); + } + } +} diff --git a/crates/ferrosa-memory-sync/Cargo.toml b/crates/ferrosa-memory-sync/Cargo.toml index 232ce177..144af8cb 100644 --- a/crates/ferrosa-memory-sync/Cargo.toml +++ b/crates/ferrosa-memory-sync/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true license.workspace = true [dependencies] +# The control-channel command vocabulary, shared with the app so the two +# cannot spell a command differently. A path dependency, not a pinned rev: +# these are all ours and a pin would be a second place to bump. +ferrosa-control-vocabulary = { path = "../ferrosa-control-vocabulary" } # Terminal output is raw bytes — escape sequences, and whatever encoding the # program chose. JSON strings cannot carry that, and lossy UTF-8 conversion # would corrupt exactly the control sequences the emulator needs. diff --git a/crates/ferrosa-memory-sync/src/control_session.rs b/crates/ferrosa-memory-sync/src/control_session.rs index 773e92eb..98204cb6 100644 --- a/crates/ferrosa-memory-sync/src/control_session.rs +++ b/crates/ferrosa-memory-sync/src/control_session.rs @@ -1360,6 +1360,9 @@ where coordinator.pending_secrets().await } crate::coordinator_command::CoordinatorCommand::VmList => coordinator.vms().await, + crate::coordinator_command::CoordinatorCommand::CoordinatorOffer => { + coordinator.offering().await + } crate::coordinator_command::CoordinatorCommand::SecretFulfil => { let request_id = payload .get("request_id") diff --git a/crates/ferrosa-memory-sync/src/coordinator_client.rs b/crates/ferrosa-memory-sync/src/coordinator_client.rs index 20737eb2..b1316a80 100644 --- a/crates/ferrosa-memory-sync/src/coordinator_client.rs +++ b/crates/ferrosa-memory-sync/src/coordinator_client.rs @@ -308,6 +308,19 @@ impl CoordinatorClient { self.get_json("/v1/vms").await } + /// What this machine can run: live tiers, images, and remaining capacity. + /// + /// Two calls in one command, because a controller needs both and asking + /// twice over the control channel would double the round trips on the one + /// question every machine is asked. `/v1/setup` is best effort: a + /// coordinator older than that endpoint reports no setup, which is not the + /// same as a host that needs nothing and must not render as one. + pub async fn offering(&self) -> Result { + let offering = self.get_json("/v1/offering").await?; + let setup = self.get_json("/v1/setup").await.ok(); + Ok(serde_json::json!({ "offering": offering, "setup": setup })) + } + /// Answer a secret request. /// /// The ONLY method that touches a secret. `value` is moved in, sent once, diff --git a/crates/ferrosa-memory-sync/src/coordinator_command.rs b/crates/ferrosa-memory-sync/src/coordinator_command.rs index 79af5b4f..d3d87ff8 100644 --- a/crates/ferrosa-memory-sync/src/coordinator_command.rs +++ b/crates/ferrosa-memory-sync/src/coordinator_command.rs @@ -26,6 +26,14 @@ pub enum CoordinatorCommand { SecretDeny, /// List running microVMs. VmList, + /// Report what this machine's coordinator can run: which runtime tiers are + /// live, what images it holds, and how much room is left. + /// + /// A read, and the one a controller issues before it can offer anything at + /// all. It replaced typing an address into the app: a coordinator listens + /// on loopback and is reached by the listener beside it, so nothing off the + /// machine needs a port. + CoordinatorOffer, } /// What a command does to the world. @@ -87,32 +95,57 @@ impl CoordinatorCommand { /// the commands it already handles, so this module does not have to know /// about them. pub fn from_wire(command_type: &str) -> Option { - match command_type { - "teammate_list" => Some(Self::TeammateList), - "secret_pending_list" => Some(Self::SecretPendingList), - "secret_fulfil" => Some(Self::SecretFulfil), - "secret_deny" => Some(Self::SecretDeny), - "vm_list" => Some(Self::VmList), + // Delegated. The wire names live in ferrosa-control-vocabulary, which + // the app compiles too, so the two sides cannot spell a command + // differently. They did: coordinator_offer was added here, the app + // spelled it the same by luck, and a streamer built against another + // worktree knew neither. + match ferrosa_control_vocabulary::Command::from_wire(command_type) { + ferrosa_control_vocabulary::Command::TeammateList => Some(Self::TeammateList), + ferrosa_control_vocabulary::Command::SecretPendingList => Some(Self::SecretPendingList), + ferrosa_control_vocabulary::Command::SecretFulfil => Some(Self::SecretFulfil), + ferrosa_control_vocabulary::Command::SecretDeny => Some(Self::SecretDeny), + ferrosa_control_vocabulary::Command::VmList => Some(Self::VmList), + ferrosa_control_vocabulary::Command::CoordinatorOffer => Some(Self::CoordinatorOffer), + // Not a coordinator command, or not one this build knows. `None` + // lets the caller fall through to what it already handles. _ => None, } } - /// The wire name. - pub fn as_wire(self) -> &'static str { + /// The shared vocabulary's name for this command. + fn shared(self) -> ferrosa_control_vocabulary::Command { match self { - Self::TeammateList => "teammate_list", - Self::SecretPendingList => "secret_pending_list", - Self::SecretFulfil => "secret_fulfil", - Self::SecretDeny => "secret_deny", - Self::VmList => "vm_list", + Self::TeammateList => ferrosa_control_vocabulary::Command::TeammateList, + Self::SecretPendingList => ferrosa_control_vocabulary::Command::SecretPendingList, + Self::SecretFulfil => ferrosa_control_vocabulary::Command::SecretFulfil, + Self::SecretDeny => ferrosa_control_vocabulary::Command::SecretDeny, + Self::VmList => ferrosa_control_vocabulary::Command::VmList, + Self::CoordinatorOffer => ferrosa_control_vocabulary::Command::CoordinatorOffer, } } - /// Whether this command changes anything. + /// The wire name, from the shared vocabulary. + pub fn as_wire(self) -> &'static str { + // Matched back to a literal because callers want `&'static str`, and + // the shared crate returns a borrow of the command. The round trip is + // asserted in the tests below, so this cannot drift from it silently. + match self.shared() { + ferrosa_control_vocabulary::Command::TeammateList => "teammate_list", + ferrosa_control_vocabulary::Command::SecretPendingList => "secret_pending_list", + ferrosa_control_vocabulary::Command::SecretFulfil => "secret_fulfil", + ferrosa_control_vocabulary::Command::SecretDeny => "secret_deny", + ferrosa_control_vocabulary::Command::VmList => "vm_list", + ferrosa_control_vocabulary::Command::CoordinatorOffer => "coordinator_offer", + _ => unreachable!("shared() only returns coordinator commands"), + } + } + + /// Whether this command changes anything. Classified once, shared. pub fn effect(self) -> Effect { - match self { - Self::TeammateList | Self::SecretPendingList | Self::VmList => Effect::Read, - Self::SecretFulfil | Self::SecretDeny => Effect::Write, + match self.shared().effect() { + ferrosa_control_vocabulary::Effect::Read => Effect::Read, + ferrosa_control_vocabulary::Effect::Write => Effect::Write, } } @@ -131,7 +164,7 @@ impl CoordinatorCommand { /// consult this before rendering one, because the redaction that protects /// the value on the app side does not travel with the JSON. pub fn carries_secret(self) -> bool { - matches!(self, Self::SecretFulfil) + self.shared().carries_secret() } } @@ -423,3 +456,86 @@ mod dispatch_contract_tests { assert!(!CoordinatorCommand::TeammateList.carries_secret()); } } + +#[cfg(test)] +mod shared_vocabulary_tests { + use super::*; + + /// Every coordinator command this listener knows must round-trip through + /// the SHARED vocabulary. This is the test that would have caught the + /// drift: a name spelled differently here than in the crate the app also + /// compiles fails immediately, rather than becoming a command the app + /// sends and the listener silently does not recognise. + #[test] + fn every_command_round_trips_through_the_shared_vocabulary() { + for command in [ + CoordinatorCommand::TeammateList, + CoordinatorCommand::SecretPendingList, + CoordinatorCommand::SecretFulfil, + CoordinatorCommand::SecretDeny, + CoordinatorCommand::VmList, + CoordinatorCommand::CoordinatorOffer, + ] { + let wire = command.as_wire(); + assert_eq!( + CoordinatorCommand::from_wire(wire), + Some(command), + "{wire} did not round trip" + ); + // And the shared crate agrees this is a coordinator command. + assert!( + ferrosa_control_vocabulary::Command::from_wire(wire).is_coordinator_command(), + "{wire} is not a coordinator command in the shared vocabulary" + ); + } + } + + /// A name the shared vocabulary knows but which is NOT a coordinator + /// command must not be accepted here. Otherwise the listener would forward + /// a note or an agent launch to the coordinator's HTTP API. + #[test] + fn non_coordinator_commands_are_refused() { + for wire in ["note_open", "note_append", "note_commit", "agent_launch"] { + assert_eq!( + CoordinatorCommand::from_wire(wire), + None, + "{wire} was accepted as a coordinator command" + ); + } + } + + /// A name from a newer build is not a coordinator command here. + #[test] + fn an_unknown_name_is_not_accepted() { + assert_eq!(CoordinatorCommand::from_wire("from_a_newer_build"), None); + } + + /// Effect and secret-carrying come from the shared crate, so the app and + /// the listener cannot disagree about whether a command is a read or + /// whether its frame may be logged. + #[test] + fn effect_and_secrecy_match_the_shared_vocabulary() { + for command in [ + CoordinatorCommand::TeammateList, + CoordinatorCommand::VmList, + CoordinatorCommand::CoordinatorOffer, + CoordinatorCommand::SecretFulfil, + CoordinatorCommand::SecretDeny, + ] { + let shared = ferrosa_control_vocabulary::Command::from_wire(command.as_wire()); + let shared_is_read = shared.effect() == ferrosa_control_vocabulary::Effect::Read; + assert_eq!( + command.effect() == Effect::Read, + shared_is_read, + "{} disagrees about its effect", + command.as_wire() + ); + assert_eq!( + command.carries_secret(), + shared.carries_secret(), + "{} disagrees about carrying a secret", + command.as_wire() + ); + } + } +} From f0b014dae8865a238a6be3cb209780e0f6b537c5 Mon Sep 17 00:00:00 2001 From: Ben Kearns <35475+bkearns@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:34:59 -0700 Subject: [PATCH 2/4] Carry hibernate and resume over the control channel The coordinator can hibernate a microVM to disk and wake it again. Nothing could ask it to over WebRTC, because the shared vocabulary had no name for either. Both are added in ferrosa-control-vocabulary, which is the point of that crate existing: a name added once is a name both sides know, and the listener's effect/capability answers follow from it rather than being restated. The classification is the part worth checking rather than the spelling -- vm_list sits beside these in every listing and is a Read, and copying that across would put a command that stops a running machine behind a read-only guard. The VM id arrives inside a frame written by a peer and goes straight into a URL path segment, so it is validated here as well as by the coordinator: a slash re-points the request at a different endpoint and an encoded one walks out of /v1/vms. A whitelist, not an escaper. Worth recording for the next person: the dispatch match is exhaustive and DID catch the two missing arms -- but only with --features webrtc-transport. Without it, control_session is not compiled at all, so a plain `cargo build` reported success while none of this was checked. --- crates/ferrosa-control-vocabulary/src/lib.rs | 51 +++++++++++++++ .../src/control_session.rs | 18 ++++++ .../src/coordinator_client.rs | 64 +++++++++++++++++++ .../src/coordinator_command.rs | 32 ++++++++++ 4 files changed, 165 insertions(+) diff --git a/crates/ferrosa-control-vocabulary/src/lib.rs b/crates/ferrosa-control-vocabulary/src/lib.rs index 7ffa4d56..3bf3b7f7 100644 --- a/crates/ferrosa-control-vocabulary/src/lib.rs +++ b/crates/ferrosa-control-vocabulary/src/lib.rs @@ -69,6 +69,10 @@ pub enum Command { SecretDeny, /// List running microVMs. VmList, + /// Write a running microVM to disk and stop it. + VmHibernate, + /// Wake a hibernated microVM from its snapshot. + VmResume, /// Report what this machine's coordinator can run: which tiers are live, /// what images it holds, and how much room is left. CoordinatorOffer, @@ -92,6 +96,8 @@ impl Command { Self::SecretFulfil => "secret_fulfil", Self::SecretDeny => "secret_deny", Self::VmList => "vm_list", + Self::VmHibernate => "vm_hibernate", + Self::VmResume => "vm_resume", Self::CoordinatorOffer => "coordinator_offer", Self::NoteOpen => "note_open", Self::NoteAppend => "note_append", @@ -109,6 +115,8 @@ impl Command { "secret_fulfil" => Self::SecretFulfil, "secret_deny" => Self::SecretDeny, "vm_list" => Self::VmList, + "vm_hibernate" => Self::VmHibernate, + "vm_resume" => Self::VmResume, "coordinator_offer" => Self::CoordinatorOffer, "note_open" => Self::NoteOpen, "note_append" => Self::NoteAppend, @@ -135,6 +143,8 @@ impl Command { | Self::SecretFulfil | Self::SecretDeny | Self::VmList + | Self::VmHibernate + | Self::VmResume | Self::CoordinatorOffer ) } @@ -152,6 +162,8 @@ impl Command { | Self::VmList | Self::CoordinatorOffer => Effect::Read, Self::AgentLaunch + | Self::VmHibernate + | Self::VmResume | Self::SecretFulfil | Self::SecretDeny | Self::NoteOpen @@ -180,6 +192,8 @@ impl Command { Self::SecretFulfil, Self::SecretDeny, Self::VmList, + Self::VmHibernate, + Self::VmResume, Self::CoordinatorOffer, Self::NoteOpen, Self::NoteAppend, @@ -192,6 +206,41 @@ impl Command { mod tests { use super::*; + /// Hibernation is the first pair of commands that CHANGES a VM rather than + /// listing one, so the classification matters more than the spelling. + #[test] + fn hibernate_and_resume_are_writes_not_reads() { + // vm_list sits next to these in every listing and is a Read. Copying + // its classification across would put a command that stops a running + // machine behind a read-only guard, which is the one direction that + // must never happen. + assert_eq!(Command::VmHibernate.effect(), Effect::Write); + assert_eq!(Command::VmResume.effect(), Effect::Write); + } + + #[test] + fn hibernate_and_resume_are_answered_by_the_coordinator() { + assert!(Command::VmHibernate.is_coordinator_command()); + assert!(Command::VmResume.is_coordinator_command()); + } + + #[test] + fn hibernate_and_resume_are_spelled_the_way_both_sides_expect() { + assert_eq!(Command::VmHibernate.as_wire(), "vm_hibernate"); + assert_eq!(Command::VmResume.as_wire(), "vm_resume"); + assert_eq!(Command::from_wire("vm_hibernate"), Command::VmHibernate); + assert_eq!(Command::from_wire("vm_resume"), Command::VmResume); + } + + #[test] + fn neither_hibernate_nor_resume_carries_a_secret() { + // Both name a VM and nothing else, so a frame carrying one is safe to + // render in a log. Saying so explicitly means a future field that DID + // carry something has to change this test to land. + assert!(!Command::VmHibernate.carries_secret()); + assert!(!Command::VmResume.carries_secret()); + } + /// The property the crate exists for: a name written by one side is read /// back as the same command by the other. Both sides call these functions, /// so a disagreement cannot be expressed. @@ -267,6 +316,8 @@ mod tests { | Command::SecretFulfil | Command::SecretDeny | Command::VmList + | Command::VmHibernate + | Command::VmResume | Command::CoordinatorOffer ); assert_eq!( diff --git a/crates/ferrosa-memory-sync/src/control_session.rs b/crates/ferrosa-memory-sync/src/control_session.rs index 98204cb6..b14d486b 100644 --- a/crates/ferrosa-memory-sync/src/control_session.rs +++ b/crates/ferrosa-memory-sync/src/control_session.rs @@ -1360,6 +1360,24 @@ where coordinator.pending_secrets().await } crate::coordinator_command::CoordinatorCommand::VmList => coordinator.vms().await, + crate::coordinator_command::CoordinatorCommand::VmHibernate => { + let id = payload + .get("id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + ControlSessionError::Protocol("vm_hibernate needs an id".to_owned()) + })?; + coordinator.hibernate_vm(id).await + } + crate::coordinator_command::CoordinatorCommand::VmResume => { + let id = payload + .get("id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + ControlSessionError::Protocol("vm_resume needs an id".to_owned()) + })?; + coordinator.resume_vm(id).await + } crate::coordinator_command::CoordinatorCommand::CoordinatorOffer => { coordinator.offering().await } diff --git a/crates/ferrosa-memory-sync/src/coordinator_client.rs b/crates/ferrosa-memory-sync/src/coordinator_client.rs index b1316a80..f433ed73 100644 --- a/crates/ferrosa-memory-sync/src/coordinator_client.rs +++ b/crates/ferrosa-memory-sync/src/coordinator_client.rs @@ -135,10 +135,51 @@ impl CoordinatorConfig { } } +/// A VM id that is safe to place in a URL path, or an error saying why not. +/// +/// The id arrives inside a control frame written by a peer and goes straight +/// into a path segment. A slash re-points the request at a different endpoint +/// and a percent-encoded one can walk out of `/v1/vms` altogether. The +/// coordinator validates ids as well, but a request that should never have been +/// sent is better refused here than answered with a 404 that reads as a missing +/// VM. +/// +/// A whitelist rather than an escaper, and deliberately narrower than what the +/// coordinator accepts: everything a real VM id has ever contained passes, and +/// anything that would need encoding does not. +fn safe_vm_id(id: &str) -> Result<&str, CoordinatorError> { + if id.is_empty() { + return Err(CoordinatorError::Malformed("a vm id is required".to_owned())); + } + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + { + return Err(CoordinatorError::Malformed(format!( + "vm id {id:?} contains a character that cannot go in a url path" + ))); + } + Ok(id) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_vm_id_that_could_reshape_the_url_is_refused() { + // The id arrives in a control frame from a peer and goes straight into + // a URL PATH. A slash makes it address a different endpoint; an encoded + // one can walk out of /v1/vms entirely. + for bad in ["../secrets", "a/b", "vm%2f..", "", "with space"] { + assert!(safe_vm_id(bad).is_err(), "{bad:?} was accepted into a url path"); + } + for good in ["vm-1", "hib-demo", "a_b.c"] { + assert!(safe_vm_id(good).is_ok(), "{good:?} was refused"); + } + } + + fn write_token(dir: &Path, contents: &str) -> PathBuf { let creds = dir.join("credentials"); std::fs::create_dir_all(&creds).expect("mkdir"); @@ -321,6 +362,29 @@ impl CoordinatorClient { Ok(serde_json::json!({ "offering": offering, "setup": setup })) } + + /// Write a running microVM to disk and stop it. + /// + /// Returns what the coordinator wrote -- both paths and the memory file's + /// size -- rather than an acknowledgement. A caller that only saw "ok" + /// could not tell a real snapshot from a plausible one. + pub async fn hibernate_vm(&self, id: &str) -> Result { + let id = safe_vm_id(id)?; + let body = self + .send(self.http.post(self.url(&format!("/v1/vms/{id}/hibernate")))) + .await?; + serde_json::from_str(&body).map_err(|e| CoordinatorError::Malformed(e.to_string())) + } + + /// Wake a hibernated microVM from its snapshot. + pub async fn resume_vm(&self, id: &str) -> Result { + let id = safe_vm_id(id)?; + let body = self + .send(self.http.post(self.url(&format!("/v1/vms/{id}/resume")))) + .await?; + serde_json::from_str(&body).map_err(|e| CoordinatorError::Malformed(e.to_string())) + } + /// Answer a secret request. /// /// The ONLY method that touches a secret. `value` is moved in, sent once, diff --git a/crates/ferrosa-memory-sync/src/coordinator_command.rs b/crates/ferrosa-memory-sync/src/coordinator_command.rs index d3d87ff8..dea4d2f2 100644 --- a/crates/ferrosa-memory-sync/src/coordinator_command.rs +++ b/crates/ferrosa-memory-sync/src/coordinator_command.rs @@ -34,6 +34,10 @@ pub enum CoordinatorCommand { /// on loopback and is reached by the listener beside it, so nothing off the /// machine needs a port. CoordinatorOffer, + /// Write a running microVM to disk and stop it. + VmHibernate, + /// Wake a hibernated microVM from its snapshot. + VmResume, } /// What a command does to the world. @@ -107,6 +111,8 @@ impl CoordinatorCommand { ferrosa_control_vocabulary::Command::SecretDeny => Some(Self::SecretDeny), ferrosa_control_vocabulary::Command::VmList => Some(Self::VmList), ferrosa_control_vocabulary::Command::CoordinatorOffer => Some(Self::CoordinatorOffer), + ferrosa_control_vocabulary::Command::VmHibernate => Some(Self::VmHibernate), + ferrosa_control_vocabulary::Command::VmResume => Some(Self::VmResume), // Not a coordinator command, or not one this build knows. `None` // lets the caller fall through to what it already handles. _ => None, @@ -122,6 +128,8 @@ impl CoordinatorCommand { Self::SecretDeny => ferrosa_control_vocabulary::Command::SecretDeny, Self::VmList => ferrosa_control_vocabulary::Command::VmList, Self::CoordinatorOffer => ferrosa_control_vocabulary::Command::CoordinatorOffer, + Self::VmHibernate => ferrosa_control_vocabulary::Command::VmHibernate, + Self::VmResume => ferrosa_control_vocabulary::Command::VmResume, } } @@ -137,6 +145,8 @@ impl CoordinatorCommand { ferrosa_control_vocabulary::Command::SecretDeny => "secret_deny", ferrosa_control_vocabulary::Command::VmList => "vm_list", ferrosa_control_vocabulary::Command::CoordinatorOffer => "coordinator_offer", + ferrosa_control_vocabulary::Command::VmHibernate => "vm_hibernate", + ferrosa_control_vocabulary::Command::VmResume => "vm_resume", _ => unreachable!("shared() only returns coordinator commands"), } } @@ -224,6 +234,28 @@ pub fn authorize( mod tests { use super::*; + #[test] + fn hibernate_and_resume_arrive_as_coordinator_commands() { + assert_eq!( + CoordinatorCommand::from_wire("vm_hibernate"), + Some(CoordinatorCommand::VmHibernate) + ); + assert_eq!( + CoordinatorCommand::from_wire("vm_resume"), + Some(CoordinatorCommand::VmResume) + ); + } + + #[test] + fn hibernating_is_a_write_even_though_it_sits_beside_vm_list() { + // vm_list is a Read and these are its neighbours. A command that stops + // a running machine must never be classified with it, because Effect is + // what a read-only guard consults. + assert_eq!(CoordinatorCommand::VmHibernate.effect(), Effect::Write); + assert_eq!(CoordinatorCommand::VmResume.effect(), Effect::Write); + } + + fn device() -> Vec { vec![COORDINATOR_CAPABILITY.to_owned()] } From 3c2448de91e10650e71148220c05511f75447b6c Mon Sep 17 00:00:00 2001 From: Ben Kearns <35475+bkearns@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:41:36 -0700 Subject: [PATCH 3/4] Drive the control channel from a CLI again, without an API key There was a control-connect subcommand on an old branch and it took --api-key. That credential no longer exists: control-listen says so in its own help -- "the identity signs every request, so there is no bearer secret sitting on the machine" -- and the controller half had simply not been carried across the change. So it is rebuilt on Credential::device, the same way device-approve and control-listen authenticate, and it sends a real command rather than a line of text. The frame is built in code rather than at a shell prompt because the listener is strict in three ways that are each easy to get wrong by hand and each refuse differently: frame_id must be present and at most 128 bytes, command_id must be a UUID of version SEVEN specifically, and command_type must be a name the shared vocabulary knows. A v4 id is refused with a message about the id, which reads as malformed rather than as the wrong kind of id. --- .../ferrosa-memory-sync/src/control_frame.rs | 91 ++++++++++++++++ crates/ferrosa-memory-sync/src/lib.rs | 1 + crates/ferrosa-memory-sync/src/main.rs | 102 ++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 crates/ferrosa-memory-sync/src/control_frame.rs diff --git a/crates/ferrosa-memory-sync/src/control_frame.rs b/crates/ferrosa-memory-sync/src/control_frame.rs new file mode 100644 index 00000000..6854582d --- /dev/null +++ b/crates/ferrosa-memory-sync/src/control_frame.rs @@ -0,0 +1,91 @@ +//! Module: Building a control frame the listener will accept. +//! +//! The listener's parser is strict in three ways that are easy to get wrong by +//! hand, and each produces a different unhelpful refusal: the frame needs a +//! non-empty `frame_id` of at most 128 bytes, `body.command_id` must parse as a +//! UUID and be **version 7** specifically, and `body.command_type` must be a +//! name the shared vocabulary knows. +//! +//! Written here rather than assembled at a shell prompt because a v7 UUID is +//! not something you type, and a v4 one is refused with "command_id must be a +//! UUIDv7" -- which reads like a malformed id rather than the wrong kind. +//! +//! Correctness: correct when the frame it produces satisfies every check +//! `dispatch_command` makes before it looks at the command itself. +//! +//! Last revised: 2026-08-31 +//! Last changed: New module. + +use uuid::Uuid; + +/// One control frame, ready to send as a line of text. +/// +/// `payload` is placed under `body.payload` verbatim, because that is where +/// every command's arguments are read from. +pub fn control_frame(command: &str, payload: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "frame_id": Uuid::now_v7().to_string(), + "body": { + // Version 7 specifically. The listener filters on the version, so a + // v4 id is refused with a message about the id rather than about + // its version. + "command_id": Uuid::now_v7().to_string(), + "command_type": command, + "payload": payload, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_command_id_is_a_uuidv7_because_the_listener_checks_the_version() { + let frame = control_frame("vm_hibernate", serde_json::json!({"id": "hib-demo"})); + let id = frame + .pointer("/body/command_id") + .and_then(serde_json::Value::as_str) + .expect("a command id"); + let parsed = Uuid::parse_str(id).expect("parses"); + assert_eq!( + parsed.get_version_num(), + 7, + "a v4 id is refused with a message about the id, not its version" + ); + } + + #[test] + fn the_frame_id_is_present_and_within_the_listeners_limit() { + let frame = control_frame("vm_resume", serde_json::json!({})); + let id = frame + .get("frame_id") + .and_then(serde_json::Value::as_str) + .expect("a frame id"); + assert!(!id.is_empty(), "an empty frame id is refused"); + assert!(id.len() <= 128, "the listener caps this at 128 bytes"); + } + + #[test] + fn the_payload_is_carried_verbatim_where_commands_read_it() { + let frame = control_frame("vm_hibernate", serde_json::json!({"id": "vm-1"})); + assert_eq!( + frame.pointer("/body/payload/id").and_then(serde_json::Value::as_str), + Some("vm-1") + ); + assert_eq!( + frame.pointer("/body/command_type").and_then(serde_json::Value::as_str), + Some("vm_hibernate") + ); + } + + #[test] + fn two_frames_do_not_share_an_id() { + // The listener keys durable commands by command_id; two frames with the + // same one would be the same command asked twice. + let a = control_frame("vm_list", serde_json::json!({})); + let b = control_frame("vm_list", serde_json::json!({})); + assert_ne!(a.pointer("/body/command_id"), b.pointer("/body/command_id")); + assert_ne!(a.get("frame_id"), b.get("frame_id")); + } +} diff --git a/crates/ferrosa-memory-sync/src/lib.rs b/crates/ferrosa-memory-sync/src/lib.rs index cef8bc0e..991c6e96 100644 --- a/crates/ferrosa-memory-sync/src/lib.rs +++ b/crates/ferrosa-memory-sync/src/lib.rs @@ -30,6 +30,7 @@ pub mod codex_runtime; #[cfg(feature = "webrtc-transport")] pub mod control_session; pub mod coordinator_client; +pub mod control_frame; pub mod coordinator_command; pub mod device_request; /// The control-listener runtime, so every binary hosting one shares it. diff --git a/crates/ferrosa-memory-sync/src/main.rs b/crates/ferrosa-memory-sync/src/main.rs index 1acde6ed..46e725da 100644 --- a/crates/ferrosa-memory-sync/src/main.rs +++ b/crates/ferrosa-memory-sync/src/main.rs @@ -117,6 +117,35 @@ enum Command { #[arg(long)] session: Option, }, + /// Offer a control session to one Ferrosa Memory server device, bind the + /// signed WebRTC channel, and send one command over it. + /// + /// The controller half of `control-listen`. It exists so the transport can + /// be exercised machine-to-machine through the real gateway without a + /// mobile shell in the way -- debugging the transport and a UniFFI/Swift + /// binding at the same time is a bad trade -- and it is the executable + /// reference for what the mobile controller does. + #[cfg(feature = "webrtc-transport")] + ControlConnect { + /// Gateway base URL. + #[arg(long)] + gateway: String, + /// This controller's enrolled device key file. + /// + /// The ONLY credential, exactly as for `control-listen`: the identity + /// signs every request and there is no bearer secret anywhere. + #[arg(long)] + identity: std::path::PathBuf, + /// Device id of the Ferrosa Memory server to control. + #[arg(long)] + server_device: Uuid, + /// Command type to send, e.g. `coordinator_offer` or `vm_hibernate`. + #[arg(long)] + command: String, + /// JSON object carried as the command's payload. + #[arg(long, default_value = "{}")] + payload: String, + }, /// Poll for mobile control offers addressed to this registered device, /// bind one direct signed WebRTC channel, and serve it until disconnect. #[cfg(feature = "webrtc-transport")] @@ -207,6 +236,14 @@ async fn main() -> anyhow::Result<()> { session, } => cmd_p2p_receive(&gateway, &identity, &out_dir, session).await, #[cfg(feature = "webrtc-transport")] + Command::ControlConnect { + gateway, + identity, + server_device, + command, + payload, + } => cmd_control_connect(&gateway, &identity, server_device, &command, &payload).await, + #[cfg(feature = "webrtc-transport")] Command::ControlListen { gateway, identity, @@ -881,3 +918,68 @@ async fn raw_query( } Ok((col_map, rows)) } + +/// Controller half of `control-listen`: offer a session to one server device, +/// bind the signed channel, and send one command over it. +/// +/// Deliberately has no Ferrosa store and no Codex runtime. The controller is a +/// thin client -- every durable effect belongs to the server side -- so this +/// stays a faithful model of what the mobile shell does, and a failure here +/// implicates the transport rather than local storage. +#[cfg(feature = "webrtc-transport")] +async fn cmd_control_connect( + gateway: &str, + identity_path: &std::path::Path, + server_device: Uuid, + command: &str, + payload: &str, +) -> anyhow::Result<()> { + use ferrosa_memory_sync::control_frame::control_frame; + use ferrosa_memory_sync::control_session::{ + run_control_controller_session, ControlSessionConfig, + }; + use ferrosa_memory_sync::peer_cli; + use ferrosa_memory_sync::signaling_client::{ + Credential, ControlSignalingApi, HttpSignalingClient, + }; + + // Parsed BEFORE a session is offered. A malformed payload discovered after + // binding wastes a session on the server and reports itself as a transport + // problem. + let payload: serde_json::Value = + serde_json::from_str(payload).context("the payload must be JSON")?; + + let identity = std::sync::Arc::new(peer_cli::load_identity(identity_path)?); + let fingerprint = identity.public_identity().public_key_fingerprint.0; + let api = HttpSignalingClient::with_credential( + gateway, + Credential::device(std::sync::Arc::clone(&identity)), + ); + let config = ControlSessionConfig::default(); + + println!("controller fingerprint: {fingerprint}"); + println!("offering a control session to {server_device} via {gateway}"); + let session_id = api + .control_offer(server_device, &fingerprint) + .await + .context("offering the control session to the gateway")?; + println!("session {session_id} offered; waiting for the server to accept and bind"); + + // Binds the signed WebRTC channel. The identity is checked against the + // gateway-vouched fingerprint pair inside, so a mis-vouched session fails + // here rather than after data flows. + let mut channel = run_control_controller_session(&api, &identity, session_id, &config) + .await + .context("binding the direct control channel")?; + println!("session {session_id} bound directly"); + + let frame = control_frame(command, payload); + println!("--> {command}"); + channel + .send_text(&frame.to_string()) + .await + .context("sending the command frame")?; + let reply = channel.recv_text().await.context("awaiting the reply")?; + println!("<-- {reply}"); + Ok(()) +} From c3eb70f8c341d45f4adbd5d3bdcb954f7d2f49c4 Mon Sep 17 00:00:00 2001 From: Ben Kearns <35475+bkearns@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:38:23 -0700 Subject: [PATCH 4/4] Let a controller ask a machine to start a microVM The vocabulary had no way to say it. /v1/launch is HTTP on loopback, reachable by the process beside the coordinator and by nothing else, so a phone could hibernate and resume VMs it had no way to create. vm_launch, not launch: it is one word from agent_launch, which starts a teammate INSIDE a runtime rather than creating one, and the listener would accept either. The payload is forwarded to the coordinator verbatim. It was built on the controller by shared Rust from the same offering this machine published, and rebuilding it here would give the two sides two ideas of what was asked for. The coordinator validates it again regardless; it does not trust the controller's copy either. --- crates/ferrosa-control-vocabulary/src/lib.rs | 32 +++++++++++++++++++ .../src/control_session.rs | 9 ++++++ .../src/coordinator_client.rs | 24 ++++++++++++++ .../src/coordinator_command.rs | 15 +++++++++ 4 files changed, 80 insertions(+) diff --git a/crates/ferrosa-control-vocabulary/src/lib.rs b/crates/ferrosa-control-vocabulary/src/lib.rs index 3bf3b7f7..0bb223fe 100644 --- a/crates/ferrosa-control-vocabulary/src/lib.rs +++ b/crates/ferrosa-control-vocabulary/src/lib.rs @@ -69,6 +69,11 @@ pub enum Command { SecretDeny, /// List running microVMs. VmList, + /// Start a microVM from an image the machine advertised. + /// + /// Named `vm_launch` and not `launch`, one word from `agent_launch`, which + /// starts a teammate INSIDE a runtime rather than creating one. + VmLaunch, /// Write a running microVM to disk and stop it. VmHibernate, /// Wake a hibernated microVM from its snapshot. @@ -96,6 +101,7 @@ impl Command { Self::SecretFulfil => "secret_fulfil", Self::SecretDeny => "secret_deny", Self::VmList => "vm_list", + Self::VmLaunch => "vm_launch", Self::VmHibernate => "vm_hibernate", Self::VmResume => "vm_resume", Self::CoordinatorOffer => "coordinator_offer", @@ -115,6 +121,7 @@ impl Command { "secret_fulfil" => Self::SecretFulfil, "secret_deny" => Self::SecretDeny, "vm_list" => Self::VmList, + "vm_launch" => Self::VmLaunch, "vm_hibernate" => Self::VmHibernate, "vm_resume" => Self::VmResume, "coordinator_offer" => Self::CoordinatorOffer, @@ -143,6 +150,7 @@ impl Command { | Self::SecretFulfil | Self::SecretDeny | Self::VmList + | Self::VmLaunch | Self::VmHibernate | Self::VmResume | Self::CoordinatorOffer @@ -162,6 +170,7 @@ impl Command { | Self::VmList | Self::CoordinatorOffer => Effect::Read, Self::AgentLaunch + | Self::VmLaunch | Self::VmHibernate | Self::VmResume | Self::SecretFulfil @@ -192,6 +201,7 @@ impl Command { Self::SecretFulfil, Self::SecretDeny, Self::VmList, + Self::VmLaunch, Self::VmHibernate, Self::VmResume, Self::CoordinatorOffer, @@ -206,6 +216,27 @@ impl Command { mod tests { use super::*; + #[test] + fn launching_a_vm_is_a_write_and_a_coordinator_command() { + // The last coordinator command that CREATES something. Classified with + // hibernate and resume rather than with the listings. + assert_eq!(Command::VmLaunch.effect(), Effect::Write); + assert!(Command::VmLaunch.is_coordinator_command()); + assert!(!Command::VmLaunch.carries_secret()); + } + + #[test] + fn vm_launch_is_not_agent_launch() { + // Two different things a peer can ask for, and the names are one word + // apart. agent_launch starts a teammate inside an existing runtime; + // vm_launch creates the runtime. Sending one for the other would be + // accepted by the listener and do something else entirely. + assert_eq!(Command::VmLaunch.as_wire(), "vm_launch"); + assert_eq!(Command::AgentLaunch.as_wire(), "agent_launch"); + assert_eq!(Command::from_wire("vm_launch"), Command::VmLaunch); + assert!(!Command::AgentLaunch.is_coordinator_command()); + } + /// Hibernation is the first pair of commands that CHANGES a VM rather than /// listing one, so the classification matters more than the spelling. #[test] @@ -316,6 +347,7 @@ mod tests { | Command::SecretFulfil | Command::SecretDeny | Command::VmList + | Command::VmLaunch | Command::VmHibernate | Command::VmResume | Command::CoordinatorOffer diff --git a/crates/ferrosa-memory-sync/src/control_session.rs b/crates/ferrosa-memory-sync/src/control_session.rs index b14d486b..f2d256ee 100644 --- a/crates/ferrosa-memory-sync/src/control_session.rs +++ b/crates/ferrosa-memory-sync/src/control_session.rs @@ -1360,6 +1360,15 @@ where coordinator.pending_secrets().await } crate::coordinator_command::CoordinatorCommand::VmList => coordinator.vms().await, + crate::coordinator_command::CoordinatorCommand::VmLaunch => { + // The whole payload is the request, built on the controller + // from this machine's own offering. Forwarded rather than + // rebuilt so both ends agree on what was asked for. + let body = serde_json::to_string(&payload).map_err(|e| { + ControlSessionError::Protocol(format!("vm_launch payload: {e}")) + })?; + coordinator.launch_vm(&body).await + } crate::coordinator_command::CoordinatorCommand::VmHibernate => { let id = payload .get("id") diff --git a/crates/ferrosa-memory-sync/src/coordinator_client.rs b/crates/ferrosa-memory-sync/src/coordinator_client.rs index f433ed73..950a617e 100644 --- a/crates/ferrosa-memory-sync/src/coordinator_client.rs +++ b/crates/ferrosa-memory-sync/src/coordinator_client.rs @@ -363,6 +363,30 @@ impl CoordinatorClient { } + + /// Start a microVM from an image this machine advertised. + /// + /// The body is passed through VERBATIM. It was built by shared Rust on the + /// controller from the same offering this coordinator published, and + /// rewriting it here would give the two sides two different ideas of what + /// was asked for. The coordinator validates it again regardless -- it does + /// not trust the controller's copy either. + pub async fn launch_vm(&self, body: &str) -> Result { + // Parsed only to reject a malformed body before a round trip; the + // original text is what gets sent. + let _: serde_json::Value = + serde_json::from_str(body).map_err(|e| CoordinatorError::Malformed(e.to_string()))?; + let reply = self + .send( + self.http + .post(self.url("/v1/launch")) + .header("content-type", "application/json") + .body(body.to_owned()), + ) + .await?; + serde_json::from_str(&reply).map_err(|e| CoordinatorError::Malformed(e.to_string())) + } + /// Write a running microVM to disk and stop it. /// /// Returns what the coordinator wrote -- both paths and the memory file's diff --git a/crates/ferrosa-memory-sync/src/coordinator_command.rs b/crates/ferrosa-memory-sync/src/coordinator_command.rs index dea4d2f2..1fd05ffb 100644 --- a/crates/ferrosa-memory-sync/src/coordinator_command.rs +++ b/crates/ferrosa-memory-sync/src/coordinator_command.rs @@ -34,6 +34,8 @@ pub enum CoordinatorCommand { /// on loopback and is reached by the listener beside it, so nothing off the /// machine needs a port. CoordinatorOffer, + /// Start a microVM from an image the machine advertised. + VmLaunch, /// Write a running microVM to disk and stop it. VmHibernate, /// Wake a hibernated microVM from its snapshot. @@ -111,6 +113,7 @@ impl CoordinatorCommand { ferrosa_control_vocabulary::Command::SecretDeny => Some(Self::SecretDeny), ferrosa_control_vocabulary::Command::VmList => Some(Self::VmList), ferrosa_control_vocabulary::Command::CoordinatorOffer => Some(Self::CoordinatorOffer), + ferrosa_control_vocabulary::Command::VmLaunch => Some(Self::VmLaunch), ferrosa_control_vocabulary::Command::VmHibernate => Some(Self::VmHibernate), ferrosa_control_vocabulary::Command::VmResume => Some(Self::VmResume), // Not a coordinator command, or not one this build knows. `None` @@ -128,6 +131,7 @@ impl CoordinatorCommand { Self::SecretDeny => ferrosa_control_vocabulary::Command::SecretDeny, Self::VmList => ferrosa_control_vocabulary::Command::VmList, Self::CoordinatorOffer => ferrosa_control_vocabulary::Command::CoordinatorOffer, + Self::VmLaunch => ferrosa_control_vocabulary::Command::VmLaunch, Self::VmHibernate => ferrosa_control_vocabulary::Command::VmHibernate, Self::VmResume => ferrosa_control_vocabulary::Command::VmResume, } @@ -145,6 +149,7 @@ impl CoordinatorCommand { ferrosa_control_vocabulary::Command::SecretDeny => "secret_deny", ferrosa_control_vocabulary::Command::VmList => "vm_list", ferrosa_control_vocabulary::Command::CoordinatorOffer => "coordinator_offer", + ferrosa_control_vocabulary::Command::VmLaunch => "vm_launch", ferrosa_control_vocabulary::Command::VmHibernate => "vm_hibernate", ferrosa_control_vocabulary::Command::VmResume => "vm_resume", _ => unreachable!("shared() only returns coordinator commands"), @@ -234,6 +239,16 @@ pub fn authorize( mod tests { use super::*; + #[test] + fn launching_arrives_as_a_coordinator_write() { + assert_eq!( + CoordinatorCommand::from_wire("vm_launch"), + Some(CoordinatorCommand::VmLaunch) + ); + assert_eq!(CoordinatorCommand::VmLaunch.effect(), Effect::Write); + } + + #[test] fn hibernate_and_resume_arrive_as_coordinator_commands() { assert_eq!(