From d72e40356c0bd1f9de79224960e138c2544d2450 Mon Sep 17 00:00:00 2001 From: Surma Date: Wed, 11 Nov 2026 11:42:08 +0000 Subject: [PATCH] Seed a minimal WASI web chat and move host approvals to port 8090 --- docs/RUNNING.md | 66 +++++---- flake.nix | 3 + host/src/api/README.md | 4 +- host/src/api/server.rs | 4 +- host/src/process/mod.rs | 2 +- host/src/world.rs | 3 + host/tests/it/api_auth.rs | 2 +- host/tests/it/api_listener.rs | 6 +- host/tests/it/automations.rs | 2 +- host/tests/it/chain.rs | 2 +- host/tests/it/chat.rs | 260 ++++++++++++++++++++++++++++++++++ host/tests/it/harness_api.rs | 2 +- host/tests/it/hook_api.rs | 2 +- host/tests/it/main.rs | 1 + host/tests/it/undo_api.rs | 2 +- userland/Cargo.lock | 8 ++ userland/Cargo.toml | 2 +- userland/bundle.nu | 5 + userland/chat/Cargo.toml | 12 ++ userland/chat/chat.service | 7 + userland/chat/index.html | 78 ++++++++++ userland/chat/src/main.rs | 183 ++++++++++++++++++++++++ 22 files changed, 611 insertions(+), 45 deletions(-) create mode 100644 host/tests/it/chat.rs create mode 100644 userland/chat/Cargo.toml create mode 100644 userland/chat/chat.service create mode 100644 userland/chat/index.html create mode 100644 userland/chat/src/main.rs diff --git a/docs/RUNNING.md b/docs/RUNNING.md index 182520a..9897dda 100644 --- a/docs/RUNNING.md +++ b/docs/RUNNING.md @@ -1,11 +1,10 @@ # Run Sausage v3 on Citadel -The console is the UI. The browser handles credential approvals, not chat. +Use the bootstrap web chat for conversations. Credential approvals remain on separate host pages. -**TL;DR:** Use a separate v3 world and the release host. Keep its complete folder between starts. -The development bundle now seeds a complete source tree, including agents, skills, and extensions. -The Nix package does not yet supply that bundle. Verify the remaining cutover steps below before production use. -The rest of the root README describes v2. +**TL;DR:** Start a separate v3 world, create the SSH tunnel below, and open http://localhost:8080. +The Nix package supplies the userland bundle, source tree, and enabled chat service. +Keep the complete world folder between starts. ## Build and start @@ -15,12 +14,13 @@ Use Nix with flakes. The package supplies the native Rust builder and its tools. The package entry point is: ```sh -nix run . -- run "$HOME/sausage-v3" --userland "$SAUSAGE_USERLAND" +nix run . -- run "$HOME/sausage-v3" Caller { Caller { diff --git a/host/tests/it/api_listener.rs b/host/tests/it/api_listener.rs index f30c4ad..26b9077 100644 --- a/host/tests/it/api_listener.rs +++ b/host/tests/it/api_listener.rs @@ -19,7 +19,7 @@ fn listener_serves_host_pages() -> Result<()> { Options { api_listen: "127.0.0.1:0".parse()?, api_rp_id: "localhost".into(), - api_origin: "http://localhost:8081".into(), + api_origin: "http://localhost:8090".into(), }, )?; for (path, expected) in [ @@ -46,10 +46,10 @@ fn listener_serves_host_pages() -> Result<()> { let help = std::str::from_utf8(&help.stdout)?; for option in [ "--api-listen", - "127.0.0.1:8081", + "127.0.0.1:8090", "--api-rp-id", "--api-origin", - "http://localhost:8081", + "http://localhost:8090", ] { assert!(help.contains(option)); } diff --git a/host/tests/it/automations.rs b/host/tests/it/automations.rs index a342639..d08c722 100644 --- a/host/tests/it/automations.rs +++ b/host/tests/it/automations.rs @@ -181,7 +181,7 @@ async fn script_inherits_creator_grant() -> Result<()> { cred.clone(), &world.host_dir, "localhost", - "http://localhost:8081", + "http://localhost:8090", )?); let runtime = Arc::new(sausage::process::Runtime::new_with_hooks( World { diff --git a/host/tests/it/chain.rs b/host/tests/it/chain.rs index e77ed87..5c288aa 100644 --- a/host/tests/it/chain.rs +++ b/host/tests/it/chain.rs @@ -70,7 +70,7 @@ async fn ancestry_reaches_the_real_hook() -> Result<()> { cred.clone(), &world.host_dir, "localhost", - "http://localhost:8081", + "http://localhost:8090", )?); let runtime = Arc::new(sausage::process::Runtime::new_with_hooks( World { diff --git a/host/tests/it/chat.rs b/host/tests/it/chat.rs new file mode 100644 index 0000000..7dddf61 --- /dev/null +++ b/host/tests/it/chat.rs @@ -0,0 +1,260 @@ +//! A fresh world's seeded init service serves chat and proxies the real harness API. +use sausage::{ + cred::{ + Caller, Cred, + items::{Item, Rule}, + requests::GrantRequest, + }, + harness::{ + input::{self, Input}, + run_module_with_input, + }, + process::Runtime, + world::World, +}; +use std::{path::Path, sync::Arc, time::Duration}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +async fn request(port: u16, path: &str, body: Option<&str>) -> anyhow::Result<(u16, Vec)> { + let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?; + let method = if body.is_some() { "POST" } else { "GET" }; + let body = body.unwrap_or(""); + stream.write_all(format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await?; + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).await?; + let end = bytes + .windows(4) + .position(|s| s == b"\r\n\r\n") + .ok_or_else(|| anyhow::anyhow!("Missing HTTP headers"))? + + 4; + let header = std::str::from_utf8(&bytes[..end])?; + let status = header.split_whitespace().nth(1).unwrap().parse()?; + Ok((status, bytes[end..].to_vec())) +} +#[tokio::test(flavor = "current_thread")] +async fn seeded_chat() -> anyhow::Result<()> { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let target = crate::common::target_dir(); + crate::common::cwd_init(); + anyhow::ensure!( + std::process::Command::new("cargo") + .args([ + "build", + "--release", + "--locked", + "-p", + "sausage-chat", + "-p", + "sausage-init", + "--target", + "wasm32-wasip2", + "--target-dir" + ]) + .arg(&target) + .current_dir(source.join("userland")) + .status()? + .success(), + "Chat guest build failed" + ); + let bundle = tempfile::tempdir()?; + let units = bundle.path().join("etc/systemd/system"); + std::fs::create_dir_all(units.join("multi-user.target.wants"))?; + std::fs::copy( + source.join("userland/chat/chat.service"), + units.join("chat.service"), + )?; + std::fs::write(units.join("multi-user.target.wants/chat.service"), "")?; + std::fs::create_dir_all(bundle.path().join("bin"))?; + for binary in ["chat", "init"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + bundle.path().join(format!("bin/{binary}.wasm")), + )?; + } + crate::common::copy_tree( + &source.join("userland/chat"), + &bundle.path().join("src/sausage/userland/chat"), + )?; + let dir = tempfile::tempdir()?; + drop(World::open_with_userland(dir.path(), Some(bundle.path()))?); + let port = std::net::TcpListener::bind("127.0.0.1:0")? + .local_addr()? + .port(); + let seeded = dir.path().join("world/etc/systemd/system/chat.service"); + let unit = std::fs::read_to_string(&seeded)?.replace( + "ExecStart=/bin/chat.wasm", + &format!("ExecStart=/bin/chat.wasm --listen 127.0.0.1:{port}"), + ); + std::fs::write(&seeded, &unit)?; + let world = World::open_with_userland(dir.path(), Some(bundle.path()))?; + assert_eq!( + std::fs::read_to_string(&seeded)?, + unit, + "Reopen must not replace a world's unit" + ); + assert!( + dir.path() + .join("world/src/sausage/userland/chat/Cargo.toml") + .is_file() + ); + let cred = Arc::new(Cred::open(&world.host_dir)?); + let caller = Caller { + program: "/bin/chat.wasm".into(), + pid: 1, + conversations: vec!["1".into()], + }; + let item = cred.create_item( + Item { + id: String::new(), + name: "Chat test credential".into(), + version: 0, + free_use: false, + rules: vec![Rule { + port: None, + scheme: "https".into(), + hosts: vec!["example.test".into()], + path_prefixes: vec!["/".into()], + methods: None, + }], + validate_js: None, + transform_js: String::new(), + fields: vec![], + }, + &caller, + cred.unlocked()?, + )?; + let pending = cred.file_request( + &caller, + "chat-test", + GrantRequest { + items: vec![item.id], + duration_secs: None, + max_uses: None, + validator: None, + }, + )?; + let api = Arc::new(sausage::api::Api::open( + cred.clone(), + &world.host_dir, + "localhost", + "http://localhost:8090", + )?); + let (sender, receiver) = input::channel(); + api.attach_harness(sender.clone()); + let runtime = Runtime::new_with_hooks( + World { + root: world.root.clone(), + host_dir: world.host_dir.clone(), + processes: world.processes.clone(), + }, + cred.clone(), + sausage::process::default_script(), + Some(api), + )?; + let services = sausage::process::supervise(runtime.clone()); + let (library, _) = include_str!("../../js/harness-api-test-bundle.js") + .split_once("// harness-api-test.ts") + .unwrap(); + let script = format!( + r#"{library} + const chatModels=createModels(),chatFaux=fauxProvider({{tokensPerSecond:100000}}); + chatModels.setProvider(chatFaux.provider);chatFaux.setResponses([fauxAssistantMessage('Chat test reply')]); + const chatRegistry=createRegistry(),chatStorage=await openStorage(globalThis.storagePath); + const chatHarness=await Harness.open(chatStorage,{{models:chatModels,registry:chatRegistry}},BACKGROUND_CONTEXT); + await chatHarness.root(BACKGROUND_CONTEXT,{{agent:{{model:{{provider:'faux',modelId:'faux-1'}}}}}}); + const chatApi=harnessApi(chatHarness,chatModels,{{state:()=>({{}})}},BACKGROUND_CONTEXT); + chatHarness.resume();await serve(chatHarness,BACKGROUND_CONTEXT,undefined,chatApi); + await chatApi.close();await chatHarness.close(BACKGROUND_CONTEXT); + "# + ); + let client = async { + let deadline = std::time::Instant::now() + Duration::from_secs(60); + let page = loop { + if let Ok((200, body)) = request(port, "/", None).await { + break body; + } + anyhow::ensure!( + std::time::Instant::now() < deadline, + "Init did not start chat" + ); + tokio::task::yield_now().await; + }; + assert!(String::from_utf8_lossy(&page).contains("Sausage chat")); + let root = loop { + let (status, body) = request(port, "/api/conversations", None).await?; + let value: serde_json::Value = serde_json::from_slice(&body)?; + if status == 200 + && let Some(id) = value["conversations"][0]["id"].as_u64() + { + break id; + } + anyhow::ensure!( + std::time::Instant::now() < deadline, + "Harness was not ready" + ); + tokio::task::yield_now().await; + }; + let submit = serde_json::json!({"content":"Chat test input","requestId":"chat-stable-id"}) + .to_string(); + for _ in 0..2 { + assert_eq!( + request( + port, + &format!("/api/conversations/{root}/submit"), + Some(&submit) + ) + .await? + .0, + 202 + ); + } + loop { + let (status, body) = request( + port, + &format!("/api/conversations/{root}/entries?after=0"), + None, + ) + .await?; + assert_eq!(status, 200); + if String::from_utf8_lossy(&body).contains("Chat test reply") { + let value: serde_json::Value = serde_json::from_slice(&body)?; + assert_eq!( + value["entries"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["kind"] == "pi.user") + .count(), + 1 + ); + break; + } + anyhow::ensure!(std::time::Instant::now() < deadline, "Reply did not appear"); + tokio::task::yield_now().await; + } + let (status, body) = request(port, "/api/credential-requests", None).await?; + assert_eq!(status, 200); + let value: serde_json::Value = serde_json::from_slice(&body)?; + assert!( + value["requests"] + .as_array() + .unwrap() + .iter() + .any(|r| r["id"] == pending + && r["approvalUrl"] == format!("http://localhost:8090/approve/{pending}")) + ); + assert_eq!(request(port, "/api/unknown", None).await?.0, 404); + sender.send(Input::Stop)?; + Ok::<_, anyhow::Error>(()) + }; + let result = tokio::time::timeout(Duration::from_secs(90), async { + tokio::try_join!( + run_module_with_input(world, Arc::new(runtime.clone()), cred, receiver, &script), + client + ) + }) + .await; + services.shutdown().await; + result??; + Ok(()) +} diff --git a/host/tests/it/harness_api.rs b/host/tests/it/harness_api.rs index e5284f3..30de1c0 100644 --- a/host/tests/it/harness_api.rs +++ b/host/tests/it/harness_api.rs @@ -122,7 +122,7 @@ async fn harness_api_world_client() -> anyhow::Result<()> { cred.clone(), &world.host_dir, "localhost", - "http://localhost:8081", + "http://localhost:8090", )?); let (sender, receiver) = sausage::harness::input::channel(); api.attach_harness(sender.clone()); diff --git a/host/tests/it/hook_api.rs b/host/tests/it/hook_api.rs index 2da3a51..2741b1e 100644 --- a/host/tests/it/hook_api.rs +++ b/host/tests/it/hook_api.rs @@ -24,7 +24,7 @@ async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> { dir.path().join("world/bin/guest.wasm"), )?; let cred = Arc::new(Cred::open(&world.host_dir)?); - let origin = "http://localhost:8081"; + let origin = "http://localhost:8090"; let api = Arc::new(Api::open( cred.clone(), &world.host_dir, diff --git a/host/tests/it/main.rs b/host/tests/it/main.rs index 4dedb6a..2ca8913 100644 --- a/host/tests/it/main.rs +++ b/host/tests/it/main.rs @@ -5,6 +5,7 @@ mod api_listener; mod automations; mod builder; mod chain; +mod chat; mod common; mod core_ext; mod cred; diff --git a/host/tests/it/undo_api.rs b/host/tests/it/undo_api.rs index 1d606c7..e1d85df 100644 --- a/host/tests/it/undo_api.rs +++ b/host/tests/it/undo_api.rs @@ -39,7 +39,7 @@ async fn list_show_and_restore() { cred.clone(), &world.host_dir, "localhost", - "http://localhost:8081", + "http://localhost:8090", ) .unwrap(), ); diff --git a/userland/Cargo.lock b/userland/Cargo.lock index 029bddf..cd32fac 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -4390,6 +4390,14 @@ dependencies = [ "tar", ] +[[package]] +name = "sausage-chat" +version = "0.1.0" +dependencies = [ + "clap", + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "sausage-coreutils" version = "0.1.0" diff --git a/userland/Cargo.toml b/userland/Cargo.toml index 7f9c962..e6df760 100644 --- a/userland/Cargo.toml +++ b/userland/Cargo.toml @@ -1,7 +1,7 @@ # The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's. [workspace] resolver = "3" -members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box", "archive-tools", "fs-tools", "undo", "cargo"] +members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box", "archive-tools", "fs-tools", "undo", "cargo", "chat"] # WASI compatibility patches: git/vendor/PATCHES.md. [patch.crates-io] diff --git a/userland/bundle.nu b/userland/bundle.nu index d04a56c..c427e00 100644 --- a/userland/bundle.nu +++ b/userland/bundle.nu @@ -35,6 +35,11 @@ def main [destination?: path] { ^cp --remove-destination $brush ($bin | path join $'($name).wasm') if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush copy failed'} } } + let units = ($destination | path join etc systemd system) + mkdir ($units | path join multi-user.target.wants) + ^cp --remove-destination ($here | path join chat chat.service) ($units | path join chat.service) + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'chat unit copy failed'} } + '' | save --force ($units | path join multi-user.target.wants chat.service) let repo = ($here | path join .. | path expand) let revision = (^git -C $repo rev-parse HEAD | str trim) let snapshot = ($destination | path join src sausage) diff --git a/userland/chat/Cargo.toml b/userland/chat/Cargo.toml new file mode 100644 index 0000000..69d8db4 --- /dev/null +++ b/userland/chat/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sausage-chat" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "chat" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +wasi = "0.14" diff --git a/userland/chat/chat.service b/userland/chat/chat.service new file mode 100644 index 0000000..fbadfaa --- /dev/null +++ b/userland/chat/chat.service @@ -0,0 +1,7 @@ +[Unit] +Description=Bootstrap web chat +[Service] +ExecStart=/bin/chat.wasm +Restart=always +[Install] +WantedBy=multi-user.target diff --git a/userland/chat/index.html b/userland/chat/index.html new file mode 100644 index 0000000..4bc94e6 --- /dev/null +++ b/userland/chat/index.html @@ -0,0 +1,78 @@ + + +Sausage chat + +
Sausage chat
+

Conversation

+ diff --git a/userland/chat/src/main.rs b/userland/chat/src/main.rs new file mode 100644 index 0000000..7f6a552 --- /dev/null +++ b/userland/chat/src/main.rs @@ -0,0 +1,183 @@ +//! A bootstrap HTTP chat server. Only the host harness API supplies chat data. +use clap::Parser; +use std::{io, net::SocketAddr}; +use wasi::{ + http::{ + outgoing_handler, + types::{Fields, Method, OutgoingBody, OutgoingRequest, Scheme}, + }, + io::streams::{InputStream, OutputStream}, + sockets::{ + instance_network::instance_network, network::*, tcp_create_socket::create_tcp_socket, + }, +}; +#[derive(Parser)] +struct Options { + #[arg(long, default_value = "0.0.0.0:8080")] + listen: SocketAddr, +} +fn error(e: impl std::fmt::Debug) -> io::Error { + io::Error::other(format!("{e:?}")) +} +fn write(stream: &OutputStream, bytes: &[u8]) -> io::Result<()> { + for chunk in bytes.chunks(4096) { + stream.blocking_write_and_flush(chunk).map_err(error)?; + } + Ok(()) +} +fn proxy( + method: &str, + path: &str, + content_type: Option<&str>, + body: &[u8], +) -> io::Result<(u16, String, Vec)> { + let headers = Fields::new(); + headers + .append("content-length", &body.len().to_string().into_bytes()) + .map_err(error)?; + if let Some(value) = content_type { + headers + .append("content-type", value.as_bytes()) + .map_err(error)?; + } + let request = OutgoingRequest::new(headers); + let method = match method { + "GET" => Method::Get, + "POST" => Method::Post, + "PUT" => Method::Put, + "DELETE" => Method::Delete, + "PATCH" => Method::Patch, + "HEAD" => Method::Head, + "OPTIONS" => Method::Options, + other => Method::Other(other.into()), + }; + request.set_method(&method).map_err(error)?; + request.set_scheme(Some(&Scheme::Http)).map_err(error)?; + request.set_authority(Some("host.sausage")).map_err(error)?; + request + .set_path_with_query(Some(&format!("/harness/{path}"))) + .map_err(error)?; + let outgoing = request.body().map_err(error)?; + let future = outgoing_handler::handle(request, None).map_err(error)?; + if !body.is_empty() { + let stream = outgoing.write().map_err(error)?; + let mut rest = body; + while !rest.is_empty() { + stream.subscribe().block(); + let count = (stream.check_write().map_err(error)? as usize).min(rest.len()); + stream.write(&rest[..count]).map_err(error)?; + rest = &rest[count..]; + } + } + OutgoingBody::finish(outgoing, None).map_err(error)?; + future.subscribe().block(); + let response = future + .get() + .ok_or_else(|| error("missing HTTP response"))? + .map_err(error)? + .map_err(error)?; + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .first() + .map(|s| String::from_utf8_lossy(s).into_owned()) + .unwrap_or_else(|| "application/octet-stream".into()); + let incoming = response.consume().map_err(error)?; + let stream = incoming.stream().map_err(error)?; + let mut bytes = Vec::new(); + loop { + match stream.blocking_read(65536) { + Ok(chunk) => bytes.extend(chunk), + Err(wasi::io::streams::StreamError::Closed) => break, + Err(e) => return Err(error(e)), + } + } + Ok((status, content_type, bytes)) +} +fn serve(input: &InputStream, output: &OutputStream) -> io::Result<()> { + let mut bytes = Vec::new(); + let end = loop { + if let Some(index) = bytes.windows(4).position(|s| s == b"\r\n\r\n") { + break index + 4; + } + bytes.extend(input.blocking_read(65536).map_err(error)?); + }; + let headers = std::str::from_utf8(&bytes[..end]) + .map_err(error)? + .to_owned(); + let mut lines = headers.lines(); + let mut line = lines.next().unwrap_or("").split_whitespace(); + let method = line.next().unwrap_or(""); + let path = line.next().unwrap_or(""); + let headers: Vec<_> = lines + .filter_map(|s| s.split_once(':')) + .map(|(k, v)| (k.to_ascii_lowercase(), v.trim())) + .collect(); + let header = |key: &str| headers.iter().find(|(k, _)| k == key).map(|(_, v)| *v); + let length = header("content-length") + .unwrap_or("0") + .parse::() + .map_err(error)?; + while bytes.len() < end + length { + bytes.extend(input.blocking_read(65536).map_err(error)?); + } + let (status, kind, body) = if method == "GET" && path == "/" { + ( + 200, + "text/html; charset=utf-8".into(), + include_bytes!("../index.html").to_vec(), + ) + } else if let Some(path) = path.strip_prefix("/api/") { + proxy( + method, + path, + header("content-type"), + &bytes[end..end + length], + ) + .unwrap_or_else(|e| (502, "text/plain".into(), e.to_string().into_bytes())) + } else { + (404, "text/plain".into(), b"Not found".to_vec()) + }; + write(output,format!("HTTP/1.1 {status} Response\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",body.len()).as_bytes())?; + write(output, &body) +} +fn main() -> io::Result<()> { + let address = Options::parse().listen; + let (family, address) = match address { + SocketAddr::V4(a) => ( + IpAddressFamily::Ipv4, + IpSocketAddress::Ipv4(Ipv4SocketAddress { + port: a.port(), + address: a.ip().octets().into(), + }), + ), + SocketAddr::V6(a) => ( + IpAddressFamily::Ipv6, + IpSocketAddress::Ipv6(Ipv6SocketAddress { + port: a.port(), + flow_info: a.flowinfo(), + address: a.ip().segments().into(), + scope_id: a.scope_id(), + }), + ), + }; + let socket = create_tcp_socket(family).map_err(error)?; + socket + .start_bind(&instance_network(), address) + .map_err(error)?; + socket.subscribe().block(); + socket.finish_bind().map_err(error)?; + socket.start_listen().map_err(error)?; + socket.subscribe().block(); + socket.finish_listen().map_err(error)?; + loop { + match socket.accept() { + Ok((_connection, input, output)) => { + let _ = serve(&input, &output); + } + Err(ErrorCode::WouldBlock) => socket.subscribe().block(), + Err(e) => return Err(error(e)), + } + } +}