Merge v3 with the generic web server in the shared workspace

This commit is contained in:
2026-11-12 00:36:50 +00:00
37 changed files with 486 additions and 302 deletions
+6 -2
View File
@@ -56,7 +56,7 @@ async fn page(api: &Api, path: &str) -> Result<String> {
);
let text = std::str::from_utf8(response.body())?;
assert!(!text.contains(SECRET));
assert!(text.contains("/app.js"));
assert!(text.contains("app.js"));
Ok(text.into())
}
fn item(cred: &Cred, name: &str) -> Result<cred::items::Item> {
@@ -338,7 +338,11 @@ async fn encrypt(api: &Api, public_key: Value) -> Result<Envelope> {
..Default::default()
});
runtime.execute_script("browser.js","globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto; globalThis.TextEncoder = Deno.core.loadExtScript('ext:deno_web/08_text_encoding.js').TextEncoder; globalThis.document = {querySelector: () => ({addEventListener() {}})};")?;
runtime.execute_script("app.js", std::str::from_utf8(script.body())?.to_string())?;
runtime.execute_script(
"app.js",
std::str::from_utf8(script.body())?
.replace("import.meta.url", "'http://localhost:8080/api/app.js'"),
)?;
runtime.execute_script("encrypt.js",format!("globalThis.done = encrypt({public_key},'{SECRET}').then(result => globalThis.envelope = JSON.stringify(result.envelope));"))?;
runtime.run_event_loop(Default::default()).await?;
let value = runtime.execute_script("result.js", "envelope")?;
+3 -5
View File
@@ -1,9 +1,7 @@
//! Host pages remain reachable. World config replaces listener flags on the CLI.
use super::api_transport::{Listener, Options};
// Host pages remain reachable. World config replaces listener flags on the CLI.
use anyhow::Result;
use sausage::{
api::{Listener, Options},
cred::Cred,
};
use sausage::cred::Cred;
use std::{
io::{Read, Write},
sync::Arc,
+101
View File
@@ -0,0 +1,101 @@
//! The host HTTP listener and console startup share one owned API and credentials store.
use sausage::api::Api;
use sausage::cred::{Caller, Cred};
use std::{net::SocketAddr, path::Path, sync::Arc};
#[derive(Clone, Debug, clap::Args)]
pub struct Options {
/// Address of the host's approval, enrollment, and secret-entry listener.
#[arg(long, default_value = "127.0.0.1:8090")]
pub api_listen: SocketAddr,
/// WebAuthn relying-party ID for the host pages.
#[arg(long, default_value = "localhost")]
pub api_rp_id: String,
/// Browser origin of the host pages, not the world's origin.
#[arg(long, default_value = "http://localhost:8090")]
pub api_origin: String,
}
pub struct Listener {
server: Arc<tiny_http::Server>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Listener {
pub fn start(cred: Arc<Cred>, host_dir: &Path, options: Options) -> anyhow::Result<Self> {
let api = Arc::new(Api::open(
cred,
host_dir,
&options.api_rp_id,
&options.api_origin,
)?);
Self::start_with_api(api, options)
}
pub fn start_with_api(api: Arc<Api>, options: Options) -> anyhow::Result<Self> {
let server = Arc::new(
tiny_http::Server::http(options.api_listen)
.map_err(|_| anyhow::anyhow!("host API address unavailable"))?,
);
let serving = server.clone();
let thread = std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("API runtime");
for mut request in serving.incoming_requests() {
let response = (|| -> anyhow::Result<_> {
let mut body = Vec::new();
request.as_reader().read_to_end(&mut body)?;
let mut builder = http::Request::builder()
.method(request.method().as_str())
.uri(request.url());
for header in request.headers() {
builder =
builder.header(header.field.as_str().as_str(), header.value.as_str());
}
let request = builder.body(bytes::Bytes::from(body))?;
Ok(runtime.block_on(sausage::api::handle(
&api,
Caller {
program: "/host/browser".into(),
pid: std::process::id(),
conversations: Vec::new(),
},
request,
)))
})();
let response = response.unwrap_or_else(|_| {
http::Response::builder()
.status(400)
.header("content-type", "application/json")
.body(bytes::Bytes::from_static(
b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}",
))
.expect("static response")
});
let content_type = response.headers()["content-type"].as_bytes();
let reply = tiny_http::Response::from_data(response.body().to_vec())
.with_status_code(response.status().as_u16())
.with_header(
tiny_http::Header::from_bytes(b"Content-Type", content_type)
.expect("static header"),
);
let _ = request.respond(reply);
}
});
Ok(Self {
server,
thread: Some(thread),
})
}
pub fn address(&self) -> SocketAddr {
self.server.server_addr().to_ip().expect("TCP listener")
}
}
impl Drop for Listener {
fn drop(&mut self) {
self.server.unblock();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
+132 -26
View File
@@ -19,13 +19,13 @@ async fn request(port: u16, path: &str, body: Option<&str>) -> anyhow::Result<(u
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?;
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"))?
.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()?;
@@ -33,7 +33,7 @@ async fn request(port: u16, path: &str, body: Option<&str>) -> anyhow::Result<(u
}
#[test]
fn page_rendering() -> anyhow::Result<()> {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/chat");
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("<script>")
@@ -79,7 +79,7 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
"--release",
"--locked",
"-p",
"sausage-chat",
"sausage-web",
"-p",
"sausage-init",
"--target",
@@ -96,30 +96,36 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
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"),
source.join("userland/web/web.service"),
units.join("web.service"),
)?;
std::fs::write(units.join("multi-user.target.wants/chat.service"), "")?;
std::fs::write(units.join("multi-user.target.wants/web.service"), "")?;
std::fs::create_dir_all(bundle.path().join("bin"))?;
for binary in ["chat", "init"] {
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/chat"),
&bundle.path().join("src/sausage/userland/chat"),
&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/chat.service");
let seeded = dir.path().join("world/etc/systemd/system/web.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}"),
"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()))?;
@@ -130,12 +136,12 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
);
assert!(
dir.path()
.join("world/src/sausage/userland/chat/Cargo.toml")
.join("world/src/sausage/userland/web/Cargo.toml")
.is_file()
);
let cred = Arc::new(Cred::open(&world.host_dir)?);
let caller = Caller {
program: "/bin/chat.wasm".into(),
program: "/bin/web.wasm".into(),
pid: 1,
conversations: vec!["1".into()],
};
@@ -173,7 +179,7 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8090",
"http://localhost:8080",
)?);
let (sender, receiver) = input::channel();
api.attach_harness(sender.clone());
@@ -216,8 +222,17 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
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/conversations", None).await?;
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()
@@ -236,7 +251,7 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
assert_eq!(
request(
port,
&format!("/api/conversations/{root}/submit"),
&format!("/api/harness/conversations/{root}/submit"),
Some(&submit)
)
.await?
@@ -247,7 +262,7 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
loop {
let (status, body) = request(
port,
&format!("/api/conversations/{root}/entries?after=0"),
&format!("/api/harness/conversations/{root}/entries?after=0"),
None,
)
.await?;
@@ -268,18 +283,109 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
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?;
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!("http://localhost:8090/approve/{pending}"))
value["requests"].as_array().unwrap().iter().any(
|r| r["id"] == pending && r["approvalUrl"] == format!("/api/approve/{pending}")
)
);
assert_eq!(request(port, "/api/unknown", None).await?.0, 404);
assert!(
String::from_utf8_lossy(&request(port, "/api/register", None).await?.1)
.contains("Register a passkey")
);
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<CreationChallengeResponse> = 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!(
String::from_utf8_lossy(
&request(port, &format!("/api/approve/{pending}"), None)
.await?
.1
)
.contains("Approve")
);
let choice = r#"{"decision":"approve","overrides":{}}"#;
assert_ne!(
request(
port,
&format!("/api/cred/requests/{pending}/decide"),
Some(choice)
)
.await?
.0,
200
);
let challenge: Challenge<RequestChallengeResponse> = 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>(())
};
-1
View File
@@ -14,7 +14,6 @@ pub fn write_config(dir: &Path, standing: &str, roster: serde_json::Value) -> an
serde_yaml_ng::from_str(include_str!("../../../config/config.yaml"))?;
config["standing"] = serde_json::json!(standing);
config["roster"] = roster;
config["listener"]["address"] = serde_json::json!("127.0.0.1:0");
let folder = dir.join("world/etc/sausage");
std::fs::create_dir_all(&folder)?;
std::fs::write(
-1
View File
@@ -78,7 +78,6 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> {
let world = World::open(&destination)?;
assert!(destination.join("world/bin/git").exists());
let config = sausage::harness::config::Config::read(&world.root).await?;
assert_eq!(config.listener.origin, "http://localhost:8090");
assert_eq!(config.proxy.base_url, "https://proxy.test");
assert_eq!(config.proxy.api_key, "fixture-key");
let source = destination.join("world/src/sausage");
+5 -3
View File
@@ -1,6 +1,7 @@
//! The hook and native listener share ceremony state without exposing the native listener to guests.
use super::api_transport::{Listener, Options as ApiOptions};
// The hook and native listener share ceremony state without exposing the native listener to guests.
use sausage::{
api::{Api, Listener, Options as ApiOptions},
api::Api,
cred::{Caller, Cred},
exec::{Command, Exec, Options},
process::Runtime,
@@ -56,6 +57,7 @@ async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> {
conversations: vec![],
},
http::Request::post("http://host.sausage/cred/passkeys/register/start")
.header("origin", origin)
.body(bytes::Bytes::from_static(b"{\"label\":\"shared\"}"))?,
)
.await;
@@ -72,7 +74,7 @@ async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> {
let mut connection = std::net::TcpStream::connect(listener.address())?;
write!(
connection,
"POST /cred/passkeys/register/finish HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
"POST /cred/passkeys/register/finish HTTP/1.1\r\nHost: localhost\r\nOrigin: {origin}\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
body.len()
)?;
connection.write_all(&body)?;
+1
View File
@@ -1,6 +1,7 @@
//! Host integration tests share one executable. Modules retain their original test registrations.
mod api;
mod api_auth;
mod api_transport;
mod api_listener;
mod automations;
mod builder;
+2 -2
View File
@@ -16,8 +16,8 @@ fn bundle() -> anyhow::Result<PathBuf> {
|| !bundle.join("etc/sausage/config.yaml").exists()
|| std::fs::read_to_string(&stamp).ok().as_deref() != Some(key.as_str())
{
if stamp.exists() {
std::fs::remove_file(&stamp)?;
if bundle.exists() {
std::fs::remove_dir_all(&bundle)?;
}
assert!(
std::process::Command::new("nu")
+10 -6
View File
@@ -1,8 +1,9 @@
//! P2 exit rehearsal: native host pages, software passkey, durable bash tools, and real SSH Git push.
use super::api_transport::{Listener, Options};
// P2 exit rehearsal: native host pages, software passkey, durable bash tools, and real SSH Git push.
use anyhow::{Result, ensure};
use base64::{Engine, engine::general_purpose::STANDARD};
use sausage::{
api::{Api, Listener, Options},
api::Api,
cred::{Cred, entry::Envelope, webauthn::Challenge},
world::World,
};
@@ -49,8 +50,8 @@ impl Browser {
connection.set_read_timeout(Some(Duration::from_secs(10)))?;
write!(
connection,
"{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
bytes.len()
"{method} {path} HTTP/1.1\r\nHost: localhost\r\nOrigin: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
self.origin, bytes.len()
)?;
connection.write_all(&bytes)?;
let mut response = String::new();
@@ -146,7 +147,10 @@ impl Browser {
..Default::default()
});
runtime.execute_script("browser.js", "globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto; globalThis.TextEncoder = Deno.core.loadExtScript('ext:deno_web/08_text_encoding.js').TextEncoder; globalThis.document = {querySelector: () => ({addEventListener() {}})};")?;
runtime.execute_script("app.js", script)?;
runtime.execute_script(
"app.js",
script.replace("import.meta.url", "'http://localhost:8080/api/app.js'"),
)?;
runtime.execute_script("entry.js", format!("encrypt({},{}).then(value => globalThis.envelope = JSON.stringify(value.envelope))", public["public_key"], serde_json::to_string(secret)?))?;
runtime.run_event_loop(Default::default()).await?;
let result = runtime.execute_script("result.js", "envelope")?;
@@ -386,7 +390,7 @@ async fn request_approve_fetch_and_ssh_push() -> Result<()> {
ensure!(
browser
.raw("GET", "/register", Value::Null)?
.contains("/app.js"),
.contains("src=\"app.js\""),
"registration page missing"
);
let start: Challenge<CreationChallengeResponse> = serde_json::from_value(browser.json(