From 5e701ab8461bed4cece95207d7ebafd27725ec50 Mon Sep 17 00:00:00 2001 From: Sausage Agent Date: Thu, 22 Oct 2026 13:05:03 +0000 Subject: [PATCH] host: mount owner-written non-secret global config --- PROTOCOL.md | 26 ++++++++++++++- cli/src/broker.rs | 4 +++ cli/src/candidate.rs | 2 +- cli/src/lib.rs | 1 + cli/src/runtime.rs | 4 ++- cli/src/vfs.rs | 2 ++ cli/src/world.rs | 37 +++++++++++++++++++++ cli/tests/host.rs | 77 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 150 insertions(+), 3 deletions(-) diff --git a/PROTOCOL.md b/PROTOCOL.md index 16a9014..df53d18 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -59,13 +59,15 @@ it exits. After 5 seconds, it kills the instance. ## Filesystem -Each instance sees three folders: +Each instance sees these mounts: | 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-only | one non-secret global JSON file, owned by `shared` | +| `/global` | read-write for `shared`, read-only for all other actors | backing directory containing that same `config` file | 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. @@ -77,6 +79,28 @@ Files are plain POSIX files: a write is visible at once. To publish a file so that readers never see half of it, write a temporary file and `rename` it (the Rust SDK has `write_atomic`). +`/config` is global configuration, never credentials (in particular, never the +LLM proxy token). The host initializes it to `{}` without reading or copying +legacy actor settings. Configuration schema and typed edits belong to `shared`, +not the host. Ordinary actors cannot write either `/config` or its alias +`/global/config`: WASI mounts are read-only, with no message sender checks. + +WASI preopens directories rather than individual files. The host mounts the +isolated world `global/` directory at `/` read-only and at `/global` with the +owner's write access. `shared` publishes atomically by writing a sibling such +as `/global/config.tmp`, closing it, then renaming it to `/global/config`. +New opens of `/config` see the replacement, including in already-running +actors; an already-open file retains its old inode. The root mount does not +expose the rest of the host world. Candidate tests get a scratch copy of +`global/`, so even `shared` tests cannot modify the live file. + +On disk, the one JSON file is `/global/config`. `/config` is a +relative symlink to it, for native/world filesystem readers (including an +export of the world directory). Host-side atomic writers must replace +`/global/config`, not replace the symlink. Starting an older world +adds the empty file and alias without changing actor state; an existing +regular `/config` is moved into the backing directory on first use. + Send large or binary data as a path under `/actor//public/`, not inside a message. diff --git a/cli/src/broker.rs b/cli/src/broker.rs index 8eb971d..565749d 100644 --- a/cli/src/broker.rs +++ b/cli/src/broker.rs @@ -255,6 +255,10 @@ impl Broker { } InstanceConfig { mounts: vec![ + // WASI preopens directories, not files. This isolated directory + // supplies /config without exposing the rest of the world. + Mount { guest: "/".into(), host: scratch.map_or_else(|| self.world.global_dir(), |p| p.join("global")), writable: false }, + Mount { guest: "/global".into(), host: scratch.map_or_else(|| self.world.global_dir(), |p| p.join("global")), writable: id == "shared" }, 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 }, diff --git a/cli/src/candidate.rs b/cli/src/candidate.rs index 703414f..3d919ab 100644 --- a/cli/src/candidate.rs +++ b/cli/src/candidate.rs @@ -97,7 +97,7 @@ impl Candidate<'_> { let dir = self.broker.world.scratch_dir().join(format!("{}-{}-{n}", self.id, std::process::id())); let scratch = Scratch { dir: dir.clone() }; let world = &self.broker.world; - for (from, name) in [(world.data_dir(self.id), "data"), (world.public_dir(self.id), "public")] { + for (from, name) in [(world.data_dir(self.id), "data"), (world.public_dir(self.id), "public"), (world.global_dir(), "global")] { std::fs::create_dir_all(dir.join(name))?; if from.is_dir() { world::copy_tree(&from, &dir.join(name))?; diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 042f4c8..a0ba430 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -48,6 +48,7 @@ impl Host { if lock.try_lock().is_err() { bail!("another sausage process runs {}", root.display()); } + world.ensure_config()?; let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?; world::write_atomic(&world.public_dir("broker").join("PROTOCOL.md"), PROTOCOL.as_bytes())?; diff --git a/cli/src/runtime.rs b/cli/src/runtime.rs index ce0c9ad..ddb1c36 100644 --- a/cli/src/runtime.rs +++ b/cli/src/runtime.rs @@ -23,7 +23,9 @@ use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpVi use crate::broker::Broker; use crate::vfs::{Vfs, VfsState, VfsView}; -/// A host folder that the guest sees at `guest`. +/// A host folder that the guest sees at `guest`. File-like root paths such +/// as `/config` use an isolated backing directory mounted at `/`; owners +/// get a separate writable directory alias for atomic replacement. #[derive(Clone, Debug)] pub struct Mount { pub guest: String, diff --git a/cli/src/vfs.rs b/cli/src/vfs.rs index 1aedf7c..96ceb4e 100644 --- a/cli/src/vfs.rs +++ b/cli/src/vfs.rs @@ -6,6 +6,8 @@ //! actor `` with a message from `fs` and waits up to `ASK_TIMEOUT` for //! the reply. Other paths go to `wasmtime-wasi` unchanged except that an //! actor directory listing advertises `fs/` when its source docs declare it. +//! `/config` and `/global/config` are native mount paths: they deliberately +//! use WASI mount permissions rather than actor-served write requests. //! //! - `fs_read {path}` -> `fs_file {text | base64}`, `fs_dir {entries: [{name, type}]}` //! - `fs_write {path, text | base64}`, `fs_mkdir {path}`, `fs_remove {path}`, diff --git a/cli/src/world.rs b/cli/src/world.rs index 7164256..7dac0b6 100644 --- a/cli/src/world.rs +++ b/cli/src/world.rs @@ -4,6 +4,8 @@ //! //! ```text //! / +//! global/config non-secret global JSON (shared owns writes) +//! config -> global/config world-facing read alias //! actor// //! actor.wasm the current version //! actor.json version, running, subscriptions, tapped, watches (broker) @@ -89,6 +91,37 @@ impl World { Self { root: root.into() } } + /// Isolated backing directory for the root /config mount and the + /// owner's writable /global alias. Never copy legacy actor config here. + pub fn global_dir(&self) -> PathBuf { + self.root.join("global") + } + + pub fn config_path(&self) -> PathBuf { + self.global_dir().join("config") + } + + /// Add the non-secret file to new and pre-existing worlds. The symlink + /// is for native world readers; actor mounts use the backing directory. + pub fn ensure_config(&self) -> Result<()> { + std::fs::create_dir_all(self.global_dir())?; + let alias = self.root.join("config"); + if !self.config_path().exists() { + if alias.is_file() && !alias.is_symlink() { + std::fs::rename(&alias, self.config_path())?; + } else { + write_atomic(&self.config_path(), b"{}\n")?; + } + } + match std::fs::read_link(&alias) { + Ok(target) if target == Path::new("global/config") => {} + Ok(_) => bail!("{} must point to global/config", alias.display()), + Err(_) if alias.try_exists()? => bail!("{} already exists", alias.display()), + Err(_) => std::os::unix::fs::symlink("global/config", alias)?, + } + Ok(()) + } + pub fn actors_root(&self) -> PathBuf { self.root.join("actor") } @@ -131,6 +164,7 @@ impl World { for id in NATIVE { world.ensure_actor_dirs(id)?; } + world.ensure_config()?; Ok(world) } @@ -209,6 +243,9 @@ impl World { let prefixed = |prefix: &str| { path.strip_prefix(prefix).filter(|rest| rest.is_empty() || rest.starts_with('/')) }; + if path == "/config" || path == "/global/config" { + return Ok(self.config_path()); + } if let Some(rest) = prefixed("/actor") { return under(self.actors_root(), rest); } diff --git a/cli/tests/host.rs b/cli/tests/host.rs index 9c59aed..3f83fd3 100644 --- a/cli/tests/host.rs +++ b/cli/tests/host.rs @@ -47,6 +47,83 @@ async fn receive_shutdown_messages( .expect("shutdown output or lifecycle reply did not arrive"); } +#[tokio::test] +async fn global_config_is_readable_owner_written_and_atomically_replaced() { + let world = create_world("global-config"); + let (host, mut mailbox) = start(&world).await; + let wasm = toolbox_wasm().clone(); + for actor in ["shared", "reader"] { + let source = passing_source(&world, actor); + spawn_type(&host, &mut mailbox, actor, &wasm, &source).await; + } + let initial = ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": "/config"}), "content").await; + assert_eq!(initial.body["text"], "{}\n"); + for path in ["/config", "/global/config", "/global/config.tmp", "/actor/shared/data/../../../../global/config"] { + ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": path, "text": "not allowed"}), "error").await; + } + ask_type(&host, &mut mailbox, "reader", json!({"type": "rename", "path": "/config", "to": "/global/stolen"}), "error").await; + // The root view stays read-only even for the owner; publication uses its + // writable directory alias, with a sibling temporary file and rename. + ask_type(&host, &mut mailbox, "shared", json!({"type": "write", "path": "/config", "text": "not allowed"}), "error").await; + let next = "{\"llm\":{\"thinking\":\"medium\"}}\n"; + ask_type(&host, &mut mailbox, "shared", json!({"type": "write", "path": "/global/config.tmp", "text": next}), "ok").await; + let before = ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": "/config"}), "content").await; + assert_eq!(before.body["text"], "{}\n"); + // A native/world reader holding the old inode sees the complete old file. + let mut old = std::fs::File::open(world.root.join("config")).unwrap(); + ask_type(&host, &mut mailbox, "shared", json!({"type": "rename", "path": "/global/config.tmp", "to": "/global/config"}), "ok").await; + let after = ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": "/config"}), "content").await; + assert_eq!(after.body["text"], next); + assert_eq!(std::fs::read_to_string(world.root.join("config")).unwrap(), next); + assert_eq!(std::fs::read_to_string(world.resolve("reader", "/config").unwrap()).unwrap(), next); + let mut previous = String::new(); + std::io::Read::read_to_string(&mut old, &mut previous).unwrap(); + assert_eq!(previous, "{}\n"); + assert!(!world.global_dir().join("config.tmp").exists()); + // The mount must not give shared writable access to any other world data. + ask_type(&host, &mut mailbox, "shared", json!({"type": "write", "path": "/global/../actor/reader/data/escape", "text": "no"}), "error").await; + host.stop().await; + let (host, mut mailbox) = start(&world).await; + let persisted = ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": "/config"}), "content").await; + assert_eq!(persisted.body["text"], next); + host.stop().await; +} + +#[tokio::test] +async fn global_config_candidates_cannot_change_the_live_file() { + let world = create_world("candidate-global-config"); + let (host, mut mailbox) = start(&world).await; + let source = source_with_test(&world, "shared", json!({"steps": [ + {"send": {"type": "write", "path": "/global/next", "text": "{\"test\":true}"}, "expect": {"type": "ok"}}, + {"send": {"type": "rename", "path": "/global/next", "to": "/global/config"}, "expect": {"type": "ok"}}, + {"send": {"type": "read", "path": "/config"}, "expect": {"type": "content", "text": "{\"test\":true}"}} + ]})); + spawn_type(&host, &mut mailbox, "shared", toolbox_wasm(), &source).await; + assert_eq!(std::fs::read_to_string(world.config_path()).unwrap(), "{}\n"); + host.stop().await; +} + +#[tokio::test] +async fn global_config_is_added_to_existing_worlds_without_copying_actor_settings() { + let world = create_world("old-world-config"); + std::fs::remove_file(world.root.join("config")).unwrap(); + std::fs::remove_dir_all(world.global_dir()).unwrap(); + world.ensure_actor_dirs("shared").unwrap(); + // Synthetic marker only; no actual credentials are read in this test. + std::fs::write(world.data_dir("shared").join("config.json"), "legacy-private-marker").unwrap(); + let (host, _) = start(&world).await; + assert_eq!(std::fs::read_to_string(world.root.join("config")).unwrap(), "{}\n"); + assert_eq!(std::fs::read_to_string(world.data_dir("shared").join("config.json")).unwrap(), "legacy-private-marker"); + host.stop().await; + // Preserve an operator-provided global config when adding the mounts. + std::fs::remove_file(world.root.join("config")).unwrap(); + std::fs::remove_dir_all(world.global_dir()).unwrap(); + std::fs::write(world.root.join("config"), "{\"operator\":true}").unwrap(); + let (host, _) = start(&world).await; + assert_eq!(std::fs::read_to_string(world.root.join("config")).unwrap(), "{\"operator\":true}"); + host.stop().await; +} + #[tokio::test] async fn routes_replies_publishes_and_reports_undeliverable() { let world = create_world("routing");