From f932ab97196c37a4b7ad6ba78c69f2ef08cc36cb Mon Sep 17 00:00:00 2001 From: Sausage Agent Date: Thu, 22 Oct 2026 13:29:38 +0000 Subject: [PATCH] Mount the writable world root for every actor --- PROTOCOL.md | 29 ++++++++++++++++------ cli/src/broker.rs | 11 +++++---- cli/src/vfs.rs | 4 +-- cli/src/watch.rs | 18 ++++++++------ cli/src/world.rs | 21 ++++++++++------ cli/tests/host.rs | 62 ++++++++++++++++++++++++++++++++++++++--------- 6 files changed, 103 insertions(+), 42 deletions(-) diff --git a/PROTOCOL.md b/PROTOCOL.md index 16a9014..144a48b 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -59,15 +59,25 @@ it exits. After 5 seconds, it kills the instance. ## Filesystem -Each instance sees three folders: +The world folder is the foundation of every instance's filesystem: | 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 | +| `/` | read-write by every actor | the whole world folder | +| `/data` | read-write | alias for the actor's state folder | +| `/public` | read-write | alias for the actor's published files | +| `/actor` | read-write by every actor | the `actor/` folder of the world | -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. +Every actor can read and write every file in the world, including other actors' +`/actor//data/` and `/actor//public/`. Nothing there is private. +Creating a folder in the world makes it visible to running actors immediately: +`/config`, `/docs`, `/www`, and `/skills` need no new mounts or host restart. +New worlds include an empty `/config` for global configuration by convention; +existing worlds can simply create it. Keep secrets out of global configuration. + +Candidate instances see the same writable world root. Their `/public` and +`/data` aliases still point to scratch copies, and network access remains off; +absolute world paths are not isolated from the live world. `/actor//` holds everything about an actor: `actor.wasm`, `actor.json` (installed version, running, subscriptions, tapped, watches), `source/`, `public/`, `data/`, @@ -131,12 +141,15 @@ the request: the request's `type` in past tense (`spawned`, `upgraded`, | `remove` | `actor` | stop the actor (normal shutdown), then delete its whole folder `/actor//` (all versions, `data/`, `public/`, logs); answer `{"type": "removed", "actor"}`. Messages to it are then undeliverable, and the ID can be spawned again. An unknown actor or a native one (`broker`, `console`, `cargo`, `fs`) is an `error` | | `subscribe`, `unsubscribe` | `actor` | receive (or not) what `actor` publishes | | `tap`, `untap` | | receive (or not) a copy of every message the broker routes | -| `watch`, `unwatch` | `path` | receive (or not) `fs_changed` for every change of `path` or anything under it; answer `{"type": "watching" \| "unwatched", "path"}` with the path as an `/actor/...` path | +| `watch`, `unwatch` | `path` | receive (or not) `fs_changed` for every change of `path` or anything under it; answer `{"type": "watching" \| "unwatched", "path"}` with the path as an absolute world path | | `exit` | | answer `exiting`, stop every actor, and end the `sausage` process with exit code 75 (a wrapper like `nix run .#loop` starts it again, updated) | **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 absolute world paths, or the sender's `/public/...` or `/data/...` +aliases. Native senders (such as the console) can also supply absolute host +paths; apart from the three existing aliases (`/actor`, `/public`, `/data`), +these retain their host meaning. **Taps.** After `tap`, the sender gets a copy of every message that the broker routes (direct messages, replies, and publishes, between any actors, @@ -176,7 +189,7 @@ change of `path` or a path under it (`/actor/a` covers `/actor/a/x`, not - Events come from `fs`. Like tap copies, they are not routed, a full mailbox drops them, and they never block the writer. Never answer them. - If the OS drops events, every watcher gets one - `{"type": "fs_changed", "path": "/actor", "op": "rescan"}`: anything may + `{"type": "fs_changed", "path": "/", "op": "rescan"}`: anything may have changed. - A watch is saved in `actor.json` (`"watches": [...]`) and survives restarts and upgrades. `unwatch` with the same path ends it. diff --git a/cli/src/broker.rs b/cli/src/broker.rs index 8eb971d..dc26278 100644 --- a/cli/src/broker.rs +++ b/cli/src/broker.rs @@ -255,7 +255,8 @@ impl Broker { } InstanceConfig { mounts: vec![ - Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false }, + Mount { guest: "/".into(), host: self.world.root.clone(), writable: true }, + Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: true }, Mount { guest: "/public".into(), host: own.join("public"), writable: true }, Mount { guest: "/data".into(), host: own.join("data"), writable: true }, ], @@ -445,7 +446,7 @@ impl Broker { self.host_error("the file watcher dropped events; watchers get a rescan, actors a full pass"); let watchers: Vec = self.metas.lock().unwrap().iter().filter(|(_, m)| !m.watches.is_empty()).map(|(id, _)| id.clone()).collect(); - self.inject(&watchers, vfs::ID, json!({"type": "fs_changed", "path": "/actor", "op": "rescan"})); + self.inject(&watchers, vfs::ID, json!({"type": "fs_changed", "path": "/", "op": "rescan"})); let report = self.scan().await; for change in &report { eprintln!("sausage: {change}"); @@ -599,9 +600,9 @@ impl Broker { let on = ty == "watch"; let path_s = b["path"].as_str().ok_or_else(|| anyhow!("missing \"path\""))?; let global = self.world.global_path(&self.world.resolve(&env.from, path_s)?); - let path = global.ok_or_else(|| anyhow!("{path_s} is not under /actor"))?; + let path = global.ok_or_else(|| anyhow!("{path_s} is not under the world root"))?; let path = match path.trim_end_matches('/') { - "" => "/actor".to_string(), + "" => "/".to_string(), p => p.to_string(), }; self.update_meta(&env.from, |m| { @@ -649,7 +650,7 @@ fn same_file(a: &Path, b: &Path) -> bool { /// Whether the namespace path `path` is `prefix` or inside it. fn under(path: &str, prefix: &str) -> bool { - path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) + prefix == "/" || path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) } pub fn truncate(s: &str, max: usize) -> String { diff --git a/cli/src/vfs.rs b/cli/src/vfs.rs index 1aedf7c..e2db552 100644 --- a/cli/src/vfs.rs +++ b/cli/src/vfs.rs @@ -390,8 +390,8 @@ impl preopens::Host for VfsView<'_> { fn get_directories(&mut self) -> wasmtime::Result, String)>> { let dirs = preopens::Host::get_directories(&mut self.inner)?; for (fd, name) in &dirs { - if name == "/actor" { - self.state.dirs.insert(fd.rep(), "/actor".to_string()); + if name == "/actor" || name == "/" { + self.state.dirs.insert(fd.rep(), name.clone()); } } Ok(dirs) diff --git a/cli/src/watch.rs b/cli/src/watch.rs index e8ec96f..6f6bc0e 100644 --- a/cli/src/watch.rs +++ b/cli/src/watch.rs @@ -5,7 +5,7 @@ //! Events are coalesced per path: once a path is quiet for `QUIET` (or has //! kept changing for `MAX_DELAY`), it becomes one change //! `{path, op: write | create | remove | rename, to?}` with a namespace path -//! (`/actor/...`). Then the broker +//! (relative to the world root, such as `/actor/...` or `/config/...`). Then the broker //! //! - sends `fs_changed` to the actors that watch the path (`watch` request), //! - starts or upgrades an actor whose `actor//actor.wasm` (or folder @@ -75,7 +75,7 @@ impl Pending { } async fn run(broker: Arc, mut events: mpsc::UnboundedReceiver>) { - let root = broker.world.actors_root(); + let root = broker.world.root.clone(); let mut pending: HashMap = HashMap::new(); // Actor checks and full passes. Dropping the set (when the host stops) aborts them. let mut tasks = tokio::task::JoinSet::new(); @@ -122,11 +122,11 @@ async fn sleep_until(t: Option) { } } -/// The namespace path (`/actor/...`) of a host path, unless it is outside -/// `actor/` or a temporary file of the host. +/// The world namespace path of a host path, unless it is outside the +/// world or a temporary file of the host. fn namespace(root: &Path, path: &Path) -> Option { let rel = path.strip_prefix(root).ok()?; - let mut out = String::from("/actor"); + let mut out = String::new(); for part in rel.components() { let name = part.as_os_str().to_str()?; if name.starts_with(TMP_PREFIX) { @@ -135,7 +135,7 @@ fn namespace(root: &Path, path: &Path) -> Option { out.push('/'); out.push_str(name); } - Some(out) + Some(if out.is_empty() { "/".to_string() } else { out }) } /// One change: namespace path, op, and the new path of a rename. @@ -219,7 +219,7 @@ mod tests { #[test] fn a_rename_is_one_change_and_temp_files_are_skipped() { - let root = Path::new("/w/actor"); + let root = Path::new("/w"); let now = Instant::now(); let mut pending = HashMap::new(); let ev = |kind, paths: &[&str]| { @@ -236,7 +236,8 @@ mod tests { ev(name(RenameMode::Both), &["/w/actor/a/public/x", "/w/actor/a/public/y"]), ev(EventKind::Create(notify::event::CreateKind::File), &["/w/actor/a/.sausage-tmp-host-1-actor.json"]), ev(name(RenameMode::Both), &["/w/actor/a/.sausage-tmp-host-1-actor.json", "/w/actor/a/actor.json"]), - ev(EventKind::Create(notify::event::CreateKind::File), &["/w/.lock"]), + ev(EventKind::Create(notify::event::CreateKind::File), &["/outside/.lock"]), + ev(EventKind::Create(notify::event::CreateKind::File), &["/w/config/x"]), ] { for (path, op, to) in changes(root, &e) { add(&mut pending, path, op, to, now); @@ -250,6 +251,7 @@ mod tests { vec![ ("/actor/a/actor.json".to_string(), "write", None), ("/actor/a/public/x".to_string(), "rename", Some("/actor/a/public/y".to_string())), + ("/config/x".to_string(), "create", None), ] ); } diff --git a/cli/src/world.rs b/cli/src/world.rs index 7164256..d1e9fdd 100644 --- a/cli/src/world.rs +++ b/cli/src/world.rs @@ -3,13 +3,14 @@ //! watches the folder (`watch.rs`). //! //! ```text -//! / +//! / mounted at /, readable and writable by every actor +//! config/ global configuration by convention (no secrets) //! actor// //! actor.wasm the current version //! actor.json version, running, subscriptions, tapped, watches (broker) //! source/ source of the current version -//! public/ written by the actor, read-only for others -//! data/ state, writable only by the actor +//! public/ published files, readable and writable by everyone +//! data/ state, readable and writable by everyone //! stderr.log stderr of the actor and notes of the broker //! versions// every version that was installed: actor.wasm, source/, meta.json //! ``` @@ -131,6 +132,7 @@ impl World { for id in NATIVE { world.ensure_actor_dirs(id)?; } + std::fs::create_dir_all(world.root.join("config"))?; Ok(world) } @@ -191,8 +193,8 @@ impl World { /// Map a path in an actor's namespace to a host path. /// - /// - `/actor//...` is the global filesystem. - /// - `/public/...` and `/data/...` belong to `sender`. + /// - Absolute paths are relative to the world root. + /// - `/public/...` and `/data/...` alias `sender`'s folders. /// - Native senders (the console) can also give absolute host paths. pub fn resolve(&self, sender: &str, path: &str) -> Result { let under = |base: PathBuf, rest: &str| -> Result { @@ -221,13 +223,16 @@ 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/...") + if Path::new(path).is_absolute() { + return under(self.root.clone(), path); + } + bail!("cannot resolve path {path} for {sender}: use an absolute world path") } /// The path of a host file in the global filesystem, if it has one. pub fn global_path(&self, host: &Path) -> Option { - let rel = host.strip_prefix(self.actors_root()).ok()?; - Some(format!("/actor/{}", rel.display())) + let rel = host.strip_prefix(&self.root).ok()?; + Some(format!("/{}", rel.display())) } } diff --git a/cli/tests/host.rs b/cli/tests/host.rs index 9c59aed..b75672e 100644 --- a/cli/tests/host.rs +++ b/cli/tests/host.rs @@ -140,20 +140,20 @@ async fn exit_request_is_answered_and_signals_the_host() { } #[tokio::test] -async fn filesystem_permissions_and_public_sharing() { +async fn filesystem_is_writable_and_public_aliases_share_files() { let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await; for path in [ - "/actor/reader/public/denied.txt", - "/actor/writer/public/denied.txt", + "/actor/reader/public/shared.txt", + "/actor/writer/public/shared.txt", ] { - let denied = ask( + let written = ask( &host, &mut mailbox, "writer", - json!({"type": "write", "path": path, "text": "denied"}), + json!({"type": "write", "path": path, "text": "shared"}), ) .await; - assert_eq!(denied.body["type"], "error", "write to {path} succeeded"); + assert_eq!(written.body["type"], "ok", "write to {path} failed"); } let written = ask( @@ -176,7 +176,7 @@ async fn filesystem_permissions_and_public_sharing() { } #[tokio::test] -async fn actors_can_read_but_not_write_other_actor_data() { +async fn actors_can_read_and_write_other_actor_data() { let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await; ask_type( &host, @@ -201,10 +201,10 @@ async fn actors_can_read_but_not_write_other_actor_data() { &mut mailbox, "reader", json!({"type": "write", "path": "/actor/writer/data/x", "text": "changed"}), - "error", + "ok", ) .await; - let unchanged = ask_type( + let changed = ask_type( &host, &mut mailbox, "writer", @@ -212,7 +212,47 @@ async fn actors_can_read_but_not_write_other_actor_data() { "content", ) .await; - assert_eq!(unchanged.body["text"], "shared"); + assert_eq!(changed.body["text"], "changed"); + host.stop().await; +} + +#[tokio::test] +async fn world_root_is_shared_writable_and_survives_restart() { + let (world, host, mut mailbox) = filesystem_world("world-root").await; + assert!(world.root.join("config").is_dir()); + let file = "/config/x"; + ask_type(&host, &mut mailbox, "writer", json!({"type": "write", "path": file, "text": "first"}), "ok").await; + let read = json!({"type": "read", "path": file}); + assert_eq!(ask_type(&host, &mut mailbox, "reader", read.clone(), "content").await.body["text"], "first"); + ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": file, "text": "second"}), "ok").await; + assert_eq!(std::fs::read_to_string(world.root.join("config/x")).unwrap(), "second"); + for actor in ["writer", "reader"] { + assert_eq!(world.resolve(actor, file).unwrap(), world.root.join("config/x")); + } + assert_eq!(world.global_path(&world.root.join("config/x")).as_deref(), Some(file)); + + // Watches use the same world namespace, not only /actor paths. + let watching = request(&host, &mut mailbox, json!({"type": "watch", "path": world.root.join("config").display().to_string()})).await; + assert_eq!(watching.body, json!({"type": "watching", "path": "/config"})); + drain(&mut mailbox, Duration::from_millis(300)).await; + std::fs::write(world.root.join("config/event"), "watched").unwrap(); + assert_eq!(fs_events(drain(&mut mailbox, Duration::from_millis(600)).await), + vec![json!({"type": "fs_changed", "path": "/config/event", "op": "create"})]); + request(&host, &mut mailbox, json!({"type": "unwatch", "path": world.root.join("config").display().to_string()})).await; + + // A new top-level folder appears in already running instances, without a mount change. + std::fs::create_dir(world.root.join("docs")).unwrap(); + std::fs::write(world.root.join("docs/new"), "host-created").unwrap(); + let docs = json!({"type": "read", "path": "/docs/new"}); + assert_eq!(ask_type(&host, &mut mailbox, "reader", docs, "content").await.body["text"], "host-created"); + ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": "/docs/new", "text": "actor-updated"}), "ok").await; + assert_eq!(std::fs::read_to_string(world.root.join("docs/new")).unwrap(), "actor-updated"); + host.stop().await; + + let (host, mut mailbox) = start(&world).await; + for actor in ["writer", "reader"] { + assert_eq!(ask_type(&host, &mut mailbox, actor, read.clone(), "content").await.body["text"], "second"); + } host.stop().await; } @@ -1187,7 +1227,7 @@ async fn a_watcher_overflow_sends_one_rescan_and_runs_a_full_pass() { let report = host.broker.clone().overflowed().await; assert!(report.iter().any(|r| r["type"] == "spawned" && r["actor"] == "planted"), "{report:?}"); - let rescan = json!({"type": "fs_changed", "path": "/actor", "op": "rescan"}); + let rescan = json!({"type": "fs_changed", "path": "/", "op": "rescan"}); assert_eq!(fs_events(drain(&mut mailbox, Duration::from_millis(600)).await), vec![rescan]); assert!(bystander.try_recv().is_err(), "an actor without watches got the rescan"); host.stop().await;