//! 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:{port}\r\nOrigin: http://localhost:{port}\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 for {path}"))? + 4; let header = std::str::from_utf8(&bytes[..end])?; let status = header.split_whitespace().nth(1).unwrap().parse()?; Ok((status, bytes[end..].to_vec())) } #[test] fn empty_rule_summary() -> anyhow::Result<()> { let item = serde_json::from_value(serde_json::json!({"name":"Example","rules":[],"transform_js":"","fields":[]}))?; let summary = sausage::cred::summary::operation_summary(&sausage::cred::edits::Operation::CreateItem { config: item }); assert!(summary.contains("No access rules.")); assert!(!summary.contains("Rules: .")); Ok(()) } #[test] fn page_rendering() -> anyhow::Result<()> { let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/web"); let page = std::fs::read_to_string(source.join("index.html"))?; let script = page .split_once("") .unwrap() .0; let dir = tempfile::tempdir()?; let extracted = dir.path().join("chat.js"); std::fs::write(&extracted, script)?; let fixture = source.join("render.test.cjs"); for args in [ [Path::new("--check"), extracted.as_path()], [fixture.as_path(), extracted.as_path()], ] { let output = std::process::Command::new("node").args(args).output()?; anyhow::ensure!( output.status.success(), "Page script: {}", String::from_utf8_lossy(&output.stderr) ); } Ok(()) } #[tokio::test(flavor = "current_thread")] async fn seeded_chat() -> anyhow::Result<()> { seeded_chat_case(false).await } #[tokio::test(flavor = "current_thread")] async fn shutdown_with_open_connection() -> anyhow::Result<()> { seeded_chat_case(true).await } async fn seeded_chat_case(open_connection: bool) -> 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-web", "-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/web/web.service"), units.join("web.service"), )?; std::fs::write(units.join("multi-user.target.wants/web.service"), "")?; std::fs::create_dir_all(bundle.path().join("bin"))?; for binary in ["web", "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/web"), &bundle.path().join("src/sausage/userland/web"), )?; let www = bundle.path().join("srv/www"); std::fs::create_dir_all(&www)?; std::fs::copy( source.join("userland/web/index.html"), www.join("index.html"), )?; 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/web.service"); let unit = std::fs::read_to_string(&seeded)?.replace( "ExecStart=/bin/web.wasm", &format!("ExecStart=/bin/web.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/web/Cargo.toml") .is_file() ); let cred = Arc::new(Cred::open(&world.host_dir)?); let caller = Caller { program: "/bin/web.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:8080", )?); 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")); std::fs::write(dir.path().join("world/srv/www/index.html"), "Live edit")?; assert_eq!(request(port, "/", None).await?.1, b"Live edit"); assert_eq!(request(port, "/missing", None).await?.0, 404); let (status, ndjson) = request(port, "/api/cargo", Some("{}")).await?; assert_eq!(status, 200); assert!(std::str::from_utf8(&ndjson)?.lines().count() >= 2); for line in std::str::from_utf8(&ndjson)?.lines() { let _: serde_json::Value = serde_json::from_str(line)?; } let root = loop { let (status, body) = request(port, "/api/harness/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/harness/conversations/{root}/submit"), Some(&submit) ) .await? .0, 202 ); } loop { let (status, body) = request( port, &format!("/api/harness/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 mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?; stream.write_all(format!("GET /api/harness/conversations/{root}/stream?after=0 HTTP/1.1\r\nHost: localhost:{port}\r\nConnection: close\r\n\r\n").as_bytes()).await?; let mut streamed = Vec::new(); tokio::time::timeout(Duration::from_secs(10), async { loop { let mut chunk = [0; 4096]; let count = stream.read(&mut chunk).await?; anyhow::ensure!(count > 0, "Stream closed before replay"); streamed.extend_from_slice(&chunk[..count]); if String::from_utf8_lossy(&streamed).contains("Chat test reply") { break; } } Ok::<_, anyhow::Error>(()) }) .await??; assert!(String::from_utf8_lossy(&streamed).contains("application/x-ndjson")); drop(stream); let (status, body) = request(port, "/api/harness/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!("/#approve/{pending}") ) ); assert_eq!(request(port, "/api/unknown", None).await?.0, 404); assert_eq!(request(port, "/api/register", None).await?.0, 404); use sausage::cred::webauthn::Challenge; use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse}; let origin = url::Url::parse(&format!("http://localhost:{port}"))?; let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true)); let (status, start) = request( port, "/api/cred/passkeys/register/start", Some(r#"{"label":"Mounted browser"}"#), ) .await?; assert_eq!(status, 200, "{}", String::from_utf8_lossy(&start)); let start: Challenge = serde_json::from_slice(&start)?; let registration = authenticator.do_registration(origin.clone(), start.options)?; let finish = serde_json::json!({"challenge_id":start.id,"response":registration}).to_string(); assert_eq!( request(port, "/api/cred/passkeys/register/finish", Some(&finish)) .await? .0, 200 ); assert_eq!( request( port, "/api/cred/passkeys/register/start", Some(r#"{"label":"Ungated"}"#) ) .await? .0, 403 ); assert_eq!(request(port, &format!("/api/approve/{pending}"), None).await?.0, 404); let choice = r#"{"decision":"approve","overrides":{}}"#; assert_ne!( request( port, &format!("/api/cred/requests/{pending}/decide"), Some(choice) ) .await? .0, 200 ); let challenge: Challenge = serde_json::from_slice( &request( port, &format!("/api/cred/requests/{pending}/challenge"), Some(choice), ) .await? .1, )?; let assertion = authenticator.do_authentication(origin, challenge.options)?; let decision = serde_json::json!({"challenge_id":challenge.id,"assertion":assertion,"decision":"approve","overrides":{}}).to_string(); assert_eq!( request( port, &format!("/api/cred/requests/{pending}/decide"), Some(&decision) ) .await? .0, 200 ); 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; let connection = if open_connection { let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?; // Leave the request incomplete so the server must wait for more input. stream .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n") .await?; Some(stream) } else { None }; let started = std::time::Instant::now(); services.shutdown().await; let elapsed = started.elapsed(); drop(connection); result??; println!("Chat shutdown (open_connection={open_connection}): {elapsed:?}"); anyhow::ensure!( elapsed < Duration::from_secs(3), "Chat delayed shutdown: {elapsed:?}" ); Ok(()) }