From 48b428728710ba1872c0beab4b6974fc948dcd46 Mon Sep 17 00:00:00 2001 From: Sausage Agent Date: Thu, 22 Oct 2026 13:08:57 +0000 Subject: [PATCH] host: add a shared writable /config world mount --- PROTOCOL.md | 8 ++++++-- cli/src/broker.rs | 2 ++ cli/src/world.rs | 11 +++++++++-- cli/tests/host.rs | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/PROTOCOL.md b/PROTOCOL.md index 16a9014..100f1c9 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -59,13 +59,17 @@ it exits. After 5 seconds, it kills the instance. ## Filesystem -Each instance sees three folders: +Each instance sees four folders: | Path | Access | Contents | | --- | --- | --- | | `/data` | read-write by its actor; readable by every actor | the actor's state | | `/public` | read-write | the files that the actor publishes | | `/actor` | read-only | the `actor/` folder of the world | +| `/config` | read-write by every actor | the world's shared `config/` folder; persists across restarts | + +`/config` is the same world folder for every instance, including candidates; +writers coordinate by convention, not permissions. Do not put secrets there. An actor can write only its own `/data`, but can read every actor's `/data` through `/actor//data/`. `/data` is not private: any unprotected secret file there is readable by every actor. @@ -136,7 +140,7 @@ the request: the request's `type` in past tense (`spawned`, `upgraded`, **Headless.** `sausage run --headless [dir]` (or `sausage --headless`, or `SAUSAGE_HEADLESS=1`) turns the console input off: the host does not read stdin, so end of input (`/dev/null`) does not stop the world. `nix run .#loop -- --headless ` passes it through the loop. It runs until SIGTERM or SIGINT (stops every actor, exit code 0) or a broker `exit` request (exit code 75, as above). Without the flag, EOF on stdin or `/quit` ends the host. -Paths are `/actor/...`, or the sender's `/public/...` or `/data/...`. +Paths are `/actor/...`, `/config/...`, or the sender's `/public/...` or `/data/...`. **Taps.** After `tap`, the sender gets a copy of every message that the broker routes (direct messages, replies, and publishes, between any actors, diff --git a/cli/src/broker.rs b/cli/src/broker.rs index 8eb971d..c3675c8 100644 --- a/cli/src/broker.rs +++ b/cli/src/broker.rs @@ -70,6 +70,7 @@ pub struct Broker { impl Broker { pub fn new(world: World, runtime: Arc) -> Result> { + std::fs::create_dir_all(world.config_dir())?; let metas = world.actor_ids()?.into_iter().map(|id| (id.clone(), world.read_meta(&id))).collect(); Ok(Arc::new_cyclic(|me| Self { world, @@ -258,6 +259,7 @@ impl Broker { Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false }, Mount { guest: "/public".into(), host: own.join("public"), writable: true }, Mount { guest: "/data".into(), host: own.join("data"), writable: true }, + Mount { guest: "/config".into(), host: self.world.config_dir(), writable: true }, ], network: scratch.is_none(), stderr_log: own.join("stderr.log"), diff --git a/cli/src/world.rs b/cli/src/world.rs index 7164256..729837f 100644 --- a/cli/src/world.rs +++ b/cli/src/world.rs @@ -4,6 +4,7 @@ //! //! ```text //! / +//! config/ shared configuration, writable by every actor //! actor// //! actor.wasm the current version //! actor.json version, running, subscriptions, tapped, watches (broker) @@ -89,6 +90,9 @@ impl World { Self { root: root.into() } } + pub fn config_dir(&self) -> PathBuf { + self.root.join("config") + } pub fn actors_root(&self) -> PathBuf { self.root.join("actor") } @@ -191,7 +195,7 @@ impl World { /// Map a path in an actor's namespace to a host path. /// - /// - `/actor//...` is the global filesystem. + /// - `/actor//...` and `/config/...` are world paths. /// - `/public/...` and `/data/...` belong to `sender`. /// - Native senders (the console) can also give absolute host paths. pub fn resolve(&self, sender: &str, path: &str) -> Result { @@ -212,6 +216,9 @@ impl World { if let Some(rest) = prefixed("/actor") { return under(self.actors_root(), rest); } + if let Some(rest) = prefixed("/config") { + return under(self.config_dir(), rest); + } if let Some(rest) = prefixed("/public") { return under(self.public_dir(sender), rest); } @@ -221,7 +228,7 @@ impl World { if NATIVE.contains(&sender) && Path::new(path).is_absolute() { return Ok(PathBuf::from(path)); } - bail!("cannot resolve path {path} for {sender}: use /actor//..., /public/... or /data/...") + bail!("cannot resolve path {path} for {sender}: use /actor//..., /config/..., /public/... or /data/...") } /// The path of a host file in the global filesystem, if it has one. diff --git a/cli/tests/host.rs b/cli/tests/host.rs index 9c59aed..3cb85bf 100644 --- a/cli/tests/host.rs +++ b/cli/tests/host.rs @@ -175,6 +175,42 @@ async fn filesystem_permissions_and_public_sharing() { host.stop().await; } +#[tokio::test] +async fn config_is_shared_writable_and_survives_host_restart() { + let (world, host, mut mailbox) = filesystem_world("world-config").await; + let path = "/config/global.json"; + let first = r#"{"model":"first"}"#; + let second = r#"{"model":"second"}"#; + ask_type( + &host, &mut mailbox, "writer", + json!({"type": "write", "path": path, "text": first}), "ok", + ).await; + let read = ask_type( + &host, &mut mailbox, "reader", + json!({"type": "read", "path": path}), "content", + ).await; + assert_eq!(read.body["text"], first); + // The second actor can replace the same file too. + ask_type( + &host, &mut mailbox, "reader", + json!({"type": "write", "path": path, "text": second}), "ok", + ).await; + assert_eq!(std::fs::read_to_string(world.config_dir().join("global.json")).unwrap(), second); + assert_eq!(world.resolve("writer", path).unwrap(), world.config_dir().join("global.json")); + assert_eq!(world.resolve("console", path).unwrap(), world.config_dir().join("global.json")); + host.stop().await; + + let (host, mut mailbox) = start(&world).await; + for actor in ["writer", "reader"] { + let read = ask_type( + &host, &mut mailbox, actor, + json!({"type": "read", "path": path}), "content", + ).await; + assert_eq!(read.body["text"], second); + } + host.stop().await; +} + #[tokio::test] async fn actors_can_read_but_not_write_other_actor_data() { let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await;