v3: host crate skeleton, userland workspace, AGENTS.md for v3

The workspace is now host/ and vfs/. v2 crates leave the workspace as reference.
host/src/exec.rs is the contract between the process table and the harness.
This commit is contained in:
2026-11-06 15:14:49 +00:00
parent 9e9cde5241
commit 409da85124
12 changed files with 192 additions and 1027 deletions
+47 -37
View File
@@ -1,56 +1,66 @@
# Working on Sausage
# Working on Sausage (v3)
Sausage is meant to be read and changed by agents, including its own agent.
Keep it that way.
Sausage is a world of WASI programs and durable agents. It is meant to be read and changed by agents,
including its own. Keep it that way.
This branch (`v3`) rebuilds Sausage. The plan and its decisions (D1, D2, …) are in
`~/research/sausage-v3/PLAN.md`. `PROTOCOL.md`, `DECISIONS.md` and `README.md` still describe v2.
## The boundary
- **The world is the sandbox.** Everything inside the world sees and changes the whole world. Do not add
inner boundaries, permissions or per-agent views. The VFS is a safety net against mistakes, not a wall
against attackers.
- **Host calls pierce the sandbox.** The only host import beyond standard WASI is the process interface
(spawn, wait, kill). Do not add another one. Everything else uses standard WASI 0.2 interfaces: files
through the VFS, `wasi:http` (the host API on its reserved host name, credential injection, signing),
and `wasi:sockets`.
- **The credentials manager in the host is the one real security boundary.** Secrets never enter the
world. Approvals need a passkey.
- **Native code is the exception.** The only native work for the world is the Rust builder, and it runs in
user, mount and network namespaces.
## Priorities
1. **Correct.** Every behavior in `PROTOCOL.md` has a test in `cli/tests/host/`.
2. **Simple and short.** Prefer the obvious code. No abstraction for one
caller. No configuration that nobody asked for.
3. **Readable.** A new reader must understand a file without the others.
Each module starts with a doc comment that says what it owns.
4. **Efficient** on the paths that run per call.
5. **Raw and minimal.** Sausage has no permissions, sandboxing beyond WASI,
limits, or authentication. Do not add them to the core. Add nothing to
the host that a plugin (or the agent, by changing itself) could do.
1. **Correct.** Every behavior has a test.
2. **Simple and short.** Prefer the obvious code. No abstraction for one caller. No configuration that
nobody asked for.
3. **Readable.** A new reader must understand a file without the others. Each module starts with a doc
comment that says what it owns.
4. **Efficient** on the paths that run per command and per model turn.
## Layout
- `cli/`: the host (`sausage` binary and library): the core, the native
`cargo` plugin, the WASI test runner, and the console.
- `vfs/`: `sausage-vfs`, the virtual filesystem for WASI guests. It knows
nothing about Sausage.
- `interface/`: `sausage-interface`: OpenRPC documents, the compatibility
check, and the generator of client code.
- `sdk/`: `sausage-plugin`, the SDK for Rust plugins, and `sdk/macros/`, its
macros. Never published: `cargo` points `sausage-plugin = "0.1"` at its
local copy.
- `agent/`: the agent plugin. `sausage init` embeds and builds it.
- `examples/`: small plugins. They are not workspace members.
- `PROTOCOL.md`: the contract between the core and the plugins. Change it in
the same commit as the behavior. `DECISIONS.md` says why; when the two
disagree, `PROTOCOL.md` and the code win.
- `host/`: the host crate (package and binary `sausage`): the world, the process table, the harness.
`host/src/exec.rs` is the contract between the harness and the process table.
- `vfs/`: `sausage-vfs`, the virtual filesystem for WASI guests and for host code. It knows nothing
about Sausage. Host code reads and writes the world only through it.
- `userland/`: the default CLIs of a world, a separate workspace built for `wasm32-wasip2`.
- `spikes/`: accepted prototypes. Read them for evidence, copy from them, do not build on them.
- `cli/`, `interface/`, `sdk/`, `agent/`, `examples/`: v2 code, outside the workspace. Copy what v3
needs. They are deleted at the end of P1.
## Programs in the world
- Agents write CLIs and servers. That they are WASI binaries is a detail, not a concept to explain.
- The platform is WASI 0.2 components. Rust programs build for `wasm32-wasip2`. WASI 0.1 programs run
through an adapter.
## Checks
```sh
nix flake check -L # all tests below; CI runs this on every push
nix develop -c cargo test --workspace # fast with a warm cache: run it during development
nix develop -c cargo test -p sausage --test host -- core:: # one topic of the host tests
nix develop -c cargo build --release -p agent --target wasm32-wasip2
nix develop -c cargo clippy --workspace
nix develop -c cargo test --workspace # host and vfs
nix develop -c cargo clippy --workspace --all-targets -- -D warnings
nix develop -c cargo build --manifest-path userland/Cargo.toml --release
```
`nix run -- init` and `nix run -- run` must keep working without other
setup.
`nix flake check` still describes v2 and is updated later in P1.
Tests must stay fast:
- Only a few tests may wait for a timing (grace periods, deadlines).
- Poll for a condition with a deadline. Do not sleep.
- Do not test unrealistic load, such as many fast cycles.
- `#[ignore]` only disables a test for a short time, with the reason in the
attribute.
- Tests that build plugins use the persistent cache in
`env!("CARGO_TARGET_TMPDIR")`.
- `#[ignore]` only disables a test for a short time, with the reason in the attribute.
- Tests that build or compile guests use a persistent cache (for example under
`env!("CARGO_TARGET_TMPDIR")`), so a warm run does not compile again.
Generated
+3 -985
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -1,11 +1,12 @@
[workspace]
resolver = "3"
members = ["cli", "vfs", "interface", "sdk", "sdk/macros", "agent"]
default-members = ["cli"]
# Examples are standalone plugins, each with its own empty [workspace].
exclude = ["examples"]
members = ["host", "vfs"]
default-members = ["host"]
# Standalone crates with their own empty [workspace]: v2 code kept as reference
# until the end of P1, the spikes, and the userland (its own wasm32-wasip2 workspace).
exclude = ["cli", "interface", "sdk", "agent", "examples", "spikes", "userland"]
# Wasmtime compiles components with Cranelift. Optimize dependencies in dev
# builds, so that tests and debug runs start plugins quickly.
# builds, so that tests and debug runs start guests quickly.
[profile.dev.package."*"]
opt-level = 2
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "sausage"
version = "0.3.0"
edition = "2024"
description = "A world of WASI programs and durable agents."
[[bin]]
name = "sausage"
path = "src/main.rs"
[dependencies]
anyhow = "1"
async-trait = "0.1"
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
sausage-vfs = { version = "0.1.0", path = "../vfs" }
+37
View File
@@ -0,0 +1,37 @@
//! The contract between the harness and the process table: run a command in the world.
//! The process table implements it; the harness's `bash` tool uses it.
use std::time::Duration;
/// What to run: a shell string (run by the world's `sh -c`) or an argv without a shell.
#[derive(Clone, Debug)]
pub enum Command {
Shell(String),
Argv(Vec<String>),
}
/// How to run it. `cwd` is a world path. `timeout` ends the command and all its processes.
#[derive(Clone, Debug, Default)]
pub struct Options {
pub cwd: String,
pub env: Vec<(String, String)>,
pub timeout: Option<Duration>,
}
/// The stream that one output chunk came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stream {
Stdout,
Stderr,
}
/// Run commands in the world. Dropping the future kills the command and all processes it started.
#[async_trait::async_trait]
pub trait Exec: Send + Sync {
/// Run to completion and return the exit status. `output` gets each chunk as it arrives, in order.
async fn exec(
&self,
command: Command,
options: Options,
output: &mut (dyn FnMut(Stream, &[u8]) + Send),
) -> anyhow::Result<i32>;
}
+8
View File
@@ -0,0 +1,8 @@
//! The durable agent harness: V8 with pi-durable, harness storage, and the tools' environment.
//! Owned by task T9.
use std::path::Path;
/// Start the world at `world` with the harness and a console.
pub async fn run(_world: &Path) -> anyhow::Result<()> {
anyhow::bail!("the harness does not exist yet (T9)")
}
+7
View File
@@ -0,0 +1,7 @@
//! The Sausage v3 host: a world folder behind a virtual filesystem, WASI programs in a process table,
//! and the durable agent harness. See `~/research/sausage-v3/PLAN.md` until the docs move here.
pub mod exec;
pub mod harness;
pub mod process;
pub mod world;
+33
View File
@@ -0,0 +1,33 @@
//! The `sausage` binary: subcommands only, each delegates to its module.
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Start a world: the harness, its services and a console.
Run { world: PathBuf },
/// Run a shell in a world. With `-c`, run one command and exit with its status.
Sh {
world: PathBuf,
#[arg(short = 'c')]
command: Option<String>,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
match Cli::parse().command {
Command::Run { world } => sausage::harness::run(&world).await,
Command::Sh { world, command } => {
let status = sausage::process::sh(&world, command).await?;
std::process::exit(status);
}
}
}
+8
View File
@@ -0,0 +1,8 @@
//! The process table: WASI programs in their own stores, the process interface (spawn, wait, kill),
//! `/proc`, and the `Exec` implementation. Owned by task T8.
use std::path::Path;
/// Run a shell in the world at `world`, or one command with `command`. Returns the exit status.
pub async fn sh(_world: &Path, _command: Option<String>) -> anyhow::Result<i32> {
anyhow::bail!("the process table does not exist yet (T8)")
}
+18
View File
@@ -0,0 +1,18 @@
//! A world folder and the one virtual root that host code and guests share.
use sausage_vfs::{Host, Layer, Mounts, Process};
use std::{path::Path, sync::Arc};
/// An open world. `root` is the filesystem every guest sees as `/`; host code (the harness's file
/// tools) reads and writes through the same layer, so both always see the same files.
pub struct World {
pub root: Arc<dyn Layer>,
}
impl World {
/// Open the world folder at `path`. The process table adds more mounts (`/proc`, `/run`).
pub fn open(path: &Path) -> anyhow::Result<Self> {
let mut mounts = Mounts::default();
mounts.mount("/", Arc::new(Host::new(path, Process::default())?), false);
Ok(World { root: Arc::new(mounts) })
}
}
+2
View File
@@ -0,0 +1,2 @@
[build]
target = "wasm32-wasip2"
+7
View File
@@ -0,0 +1,7 @@
# The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's.
[workspace]
resolver = "3"
members = []
[profile.release]
opt-level = 3