diff --git a/Cargo.toml b/Cargo.toml index 3009259..828a9a7 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 0000000..55fca2c --- /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 0000000..0bb223f --- /dev/null +++ b/crates/ferrosa-control-vocabulary/src/lib.rs @@ -0,0 +1,391 @@ +//! 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, + /// 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. + VmResume, + /// 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::VmLaunch => "vm_launch", + Self::VmHibernate => "vm_hibernate", + Self::VmResume => "vm_resume", + 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, + "vm_launch" => Self::VmLaunch, + "vm_hibernate" => Self::VmHibernate, + "vm_resume" => Self::VmResume, + "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::VmLaunch + | Self::VmHibernate + | Self::VmResume + | 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::VmLaunch + | Self::VmHibernate + | Self::VmResume + | 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::VmLaunch, + Self::VmHibernate, + Self::VmResume, + Self::CoordinatorOffer, + Self::NoteOpen, + Self::NoteAppend, + Self::NoteCommit, + ] + } +} + +#[cfg(test)] +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] + 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. + #[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::VmLaunch + | Command::VmHibernate + | Command::VmResume + | 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 232ce17..144af8c 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_frame.rs b/crates/ferrosa-memory-sync/src/control_frame.rs new file mode 100644 index 0000000..6854582 --- /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/control_session.rs b/crates/ferrosa-memory-sync/src/control_session.rs index 773e92e..f2d256e 100644 --- a/crates/ferrosa-memory-sync/src/control_session.rs +++ b/crates/ferrosa-memory-sync/src/control_session.rs @@ -1360,6 +1360,36 @@ 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") + .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 + } 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 20737eb..950a617 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"); @@ -308,6 +349,66 @@ 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 })) + } + + + + /// 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 + /// 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 79af5b4..1fd05ff 100644 --- a/crates/ferrosa-memory-sync/src/coordinator_command.rs +++ b/crates/ferrosa-memory-sync/src/coordinator_command.rs @@ -26,6 +26,20 @@ 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, + /// 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. + VmResume, } /// What a command does to the world. @@ -87,32 +101,66 @@ 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), + 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` + // 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, + Self::VmLaunch => ferrosa_control_vocabulary::Command::VmLaunch, + Self::VmHibernate => ferrosa_control_vocabulary::Command::VmHibernate, + Self::VmResume => ferrosa_control_vocabulary::Command::VmResume, } } - /// 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", + 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"), + } + } + + /// 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 +179,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() } } @@ -191,6 +239,38 @@ 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!( + 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()] } @@ -423,3 +503,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() + ); + } + } +} diff --git a/crates/ferrosa-memory-sync/src/lib.rs b/crates/ferrosa-memory-sync/src/lib.rs index cef8bc0..991c6e9 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 1acde6e..46e725d 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(()) +}