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
+10 -22
View File
@@ -9,27 +9,15 @@ Credential errors use `{"error":"kind","message":"kind"}` with static, secret-fr
Missing routes return 404. Invalid parameters return 400. Authorization failures return 403.
Conflicts return 409. The filing budget returns 429.
## Host listener
## World web mount
`sausage run WORLD` creates one `Cred`, one `Arc<Api>`, and one process runtime.
The API owns its passkey connection and retains ceremony and rate-limit state.
The hook and the host listener share that API.
The listener handles requests on a dedicated thread.
The console shares the credential store, including its decision notifications.
After the listener binds, the runtime denies its port on every local address.
The listener stops when the console returns.
Options on `sausage run`:
- `--api-listen 127.0.0.1:8090`: the default listener address.
- `--api-rp-id localhost`: the default WebAuthn RP ID.
- `--api-origin http://localhost:8090`: the default browser origin.
The origin must name the host pages, not the world server.
The listener speaks HTTP. A production HTTPS origin needs a reverse proxy to this listener.
The world's UI can link to the host pages. It does not serve their code.
The listener identifies browser calls as `/host/browser`, with the host PID and no conversation.
Passkey proofs supply the approver identity independently of that caller.
The world runs `web.wasm` on port 8080 and serves fresh files from `/srv/www`.
It forwards `/api/PATH` to `http://host.sausage/PATH` through WASI HTTP, including streaming bodies.
The host opens no separate network port.
Browser ceremonies derive the passkey RP ID and origin from the request Origin header.
The host retains separate pending ceremony state per origin against the same credential registry.
After the first registration, every decision and further registration requires a passkey assertion.
Host pages use relative assets and routes so they work under `/api/`.
## Pages and assets
@@ -69,7 +57,7 @@ Reads and granted SSH operations do not spend that budget.
The WASI transport dispatches buffered and streaming responses through one route table.
Harness routes use `http://host.sausage/harness/` and the console's input channel.
The host listener does not expose these routes.
The browser reaches these routes under `/api/harness/`.
The V8 harness answers queries and invokes actions without a second storage reader.
- `GET /harness/conversations`: `{conversations:[...]}` with records, titles, agents, starters, concluded state, and running state.
@@ -132,7 +120,7 @@ A replay or a mismatched binding cannot authorize another action.
- `POST /cred/passkeys/register/finish`: `{"challenge_id":"id","response":{...}}`.
WebAuthn options and responses use the library's browser JSON formats.
The host retains the RP configuration and pending ceremony state.
The host derives the RP configuration from each browser origin and retains pending ceremony state.
Restart discards pending ceremonies but retains registered passkeys.
## Secret entry
+2 -2
View File
@@ -2,7 +2,7 @@
const bytes = text => Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
const encoded = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const post = async (path, body) => {
const response = await fetch(path, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)});
const response = await fetch(new URL(path.replace(/^\//, ""), import.meta.url), {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)});
const result = await response.json();
if (!response.ok) throw new Error(result.message);
return result;
@@ -56,7 +56,7 @@ form.addEventListener('submit', async event => {
await post(`${path}/decide`, challenge.unlocked ? {decision, overrides} : await assertion(challenge));
} else if (document.body.dataset.page === 'register') {
const label = data.get('label');
const state = await (await fetch('/cred/passkeys')).json();
const state = await (await fetch(new URL('cred/passkeys', import.meta.url))).json();
let gate;
if (state.length) {
const proof = await assertion(await post('/cred/passkeys/register/challenge', {label}));
+1 -1
View File
@@ -8,4 +8,4 @@
<label>Duration in seconds <input name="duration" type="number" min="1" required value="{{duration}}"></label>
<label>Maximum uses (optional override) <input name="uses" type="number" min="1" value="{{uses}}" placeholder="Use the requested limit"></label></fieldset>
<button name="decision" value="approve">Approve</button><button name="decision" value="deny">Deny</button></form>
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
<p role="status"></p><script type="module" src="../app.js"></script></body></html>
+1 -1
View File
@@ -5,4 +5,4 @@
<h1>Enter a secret for {{item_name}}: {{field_name}}</h1>
<p>This host page encrypts the value before it sends the request.</p>
<form><label>Secret value <textarea name="secret" required autocomplete="off" spellcheck="false"></textarea></label><button>Store the secret</button></form>
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
<p role="status"></p><script type="module" src="../../app.js"></script></body></html>
+2 -5
View File
@@ -50,11 +50,8 @@ pub(super) async fn handle(
let mut value: Value = serde_json::from_slice(response.body()).expect("credential JSON");
if let Some(requests) = value.as_array_mut() {
for request in requests {
request["approvalUrl"] = json!(format!(
"{}/approve/{}",
api.approval_origin,
request["id"].as_str().unwrap()
));
request["approvalUrl"] =
json!(format!("/api/approve/{}", request["id"].as_str().unwrap()));
}
value = json!({"requests":value});
}
+54 -6
View File
@@ -8,8 +8,12 @@ mod harness;
pub(crate) async fn dispatch(
api: &Api,
caller: Caller,
request: Request<Bytes>,
mut request: Request<Bytes>,
) -> Response<wasmtime_wasi_http::WasiBody> {
// WASI reserves Host. The world web mount carries the browser host in this header.
if let Some(host) = request.headers_mut().remove("x-forwarded-host") {
request.headers_mut().insert(http::header::HOST, host);
}
use http_body_util::{BodyExt, Full};
enum Handler {
Cargo,
@@ -39,7 +43,7 @@ mod pages;
mod server;
mod ssh;
mod undo;
pub use server::{Listener, Options, run_with_userland};
pub use server::run_with_userland;
use crate::cred::{self, Caller, Cred, entry::EntryKey, webauthn::Passkeys};
use bytes::Bytes;
@@ -55,7 +59,8 @@ use std::{
pub struct Api {
pub cred: Arc<Cred>,
harness: Mutex<Option<crate::harness::input::Sender>>,
approval_origin: String,
host_dir: Option<std::path::PathBuf>,
origins: Mutex<HashMap<String, Arc<Api>>>,
pub(crate) builder: std::sync::OnceLock<Arc<crate::builder::Builder>>,
script: cred::quickjs::QuickJs,
pub passkeys: Mutex<Passkeys<rusqlite::Connection>>,
@@ -64,7 +69,7 @@ pub struct Api {
journal: Mutex<Option<Arc<sausage_vfs::journal::Journal>>>,
}
impl Api {
/// Own the connection and ceremony state shared by both transports.
/// Own the host-only store and per-origin browser ceremony state.
pub fn open(
cred: Arc<Cred>,
host_dir: &std::path::Path,
@@ -77,7 +82,7 @@ impl Api {
Passkeys::new(db, rp_id, origin)?,
EntryKey::open(&host_dir.join("entry.key"))?,
);
api.approval_origin = origin.trim_end_matches('/').to_owned();
api.host_dir = Some(host_dir.to_owned());
Ok(api)
}
/// Passkeys owns the RP configuration. Its database must remain host-only.
@@ -89,7 +94,8 @@ impl Api {
Self {
cred: cred.into(),
harness: Mutex::new(None),
approval_origin: String::new(),
host_dir: None,
origins: Mutex::new(HashMap::new()),
builder: Default::default(),
script: cred::quickjs::QuickJs::default(),
passkeys: Mutex::new(passkeys),
@@ -190,6 +196,48 @@ pub async fn handle(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Respo
respond(ctx, caller, request)
}
fn respond(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
if request.uri().path().starts_with("/cred/")
&& let Some(host_dir) = &ctx.host_dir
&& let Some(origin) = request
.headers()
.get("origin")
.and_then(|v| v.to_str().ok())
{
let api = (|| -> anyhow::Result<Arc<Api>> {
let url = url::Url::parse(origin)?;
let rp = url
.host_str()
.ok_or_else(|| anyhow::anyhow!("Missing origin host"))?;
let mut origins = ctx.origins.lock().unwrap();
if let Some(api) = origins.get(origin) {
return Ok(api.clone());
}
let mut api = Api::open(ctx.cred.clone(), host_dir, rp, origin)?;
api.host_dir = None;
let api = Arc::new(api);
origins.insert(origin.to_owned(), api.clone());
Ok(api)
})();
return match api {
Ok(api) => respond(&api, caller, request),
Err(_) => Response::builder()
.status(400)
.header("content-type", "application/json")
.body(Bytes::from_static(b"{\"error\":\"invalid_origin\"}"))
.unwrap(),
};
}
if ctx.host_dir.is_some()
&& request.method() == http::Method::POST
&& request.uri().path().starts_with("/cred/")
&& request.uri().path().contains("/register/")
{
return Response::builder()
.status(400)
.header("content-type", "application/json")
.body(Bytes::from_static(b"{\"error\":\"origin_required\"}"))
.unwrap();
}
let result = if request.method() == http::Method::GET {
match pages::get(ctx, request.uri().path()) {
Ok(Some(response)) => return response,
+1 -1
View File
@@ -3,4 +3,4 @@
<title>Register a passkey</title><body data-page="register">
<h1>Register a passkey</h1><p>The first passkey needs no gate. Later passkeys require an existing passkey.</p>
<form><label>Label <input name="label" required autocomplete="off"></label><button>Register</button></form>
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
<p role="status"></p><script type="module" src="app.js"></script></body></html>
+6 -108
View File
@@ -1,114 +1,17 @@
//! The host HTTP listener and console startup share one owned API and credentials store.
//! Start the world services and harness with one intercepted host API.
use super::Api;
use crate::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 request = http::Request::builder()
.method(request.method().as_str())
.uri(request.url())
.body(bytes::Bytes::from(body))?;
Ok(runtime.block_on(super::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();
}
}
}
use crate::cred::Cred;
use std::{path::Path, sync::Arc};
/// Preserve the console lifecycle with one API for guests and host pages.
pub async fn run_with_userland(
path: &Path,
userland: Option<&Path>,
options: Options,
) -> anyhow::Result<()> {
pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::Result<()> {
let world = crate::world::World::open_with_userland(path, userland)?;
let cred = Arc::new(Cred::open(&world.host_dir)?);
let api = Arc::new(Api::open(
cred.clone(),
&world.host_dir,
&options.api_rp_id,
&options.api_origin,
"localhost",
"http://localhost:8080",
)?);
let runtime = crate::process::Runtime::new_with_hooks(
crate::world::World {
@@ -122,10 +25,6 @@ pub async fn run_with_userland(
)?;
let (sender, receiver) = crate::harness::input::channel();
api.attach_harness(sender.clone());
let listener = Listener::start_with_api(api, options)?;
runtime
.network_policy()
.deny_host_port(listener.address().port());
let services = crate::process::supervise(runtime.clone());
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
tokio::runtime::Builder::new_current_thread()
@@ -154,6 +53,5 @@ pub async fn run_with_userland(
anyhow::anyhow!(text)
});
services.shutdown().await;
drop(listener);
result
}
+1 -15
View File
@@ -1,4 +1,4 @@
//! Fresh reads of the user-owned world configuration. Listener edits apply at host startup.
//! Fresh reads of the user-owned world proxy configuration.
use sausage_vfs::{Layer, Open};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -10,24 +10,10 @@ pub struct Proxy {
pub base_url: String,
pub api_key: String,
}
#[derive(Deserialize)]
pub struct Listener {
pub address: std::net::SocketAddr,
pub origin: String,
}
impl Default for Listener {
fn default() -> Self {
Self {
address: "127.0.0.1:8090".parse().unwrap(),
origin: "http://localhost:8090".into(),
}
}
}
#[derive(Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub proxy: Proxy,
pub listener: Listener,
}
impl Config {
pub async fn read(root: &Arc<dyn Layer>) -> anyhow::Result<Self> {
+1 -1
View File
@@ -83,7 +83,7 @@ pub(super) fn console(sender: Sender) {
pub fn stop_signal(
sender: Sender,
) -> std::io::Result<impl std::future::Future<Output = ()> + Send> {
// Register before V8 starts, so a stop cannot arrive before the listener.
// Register before V8 starts, so a stop cannot arrive before the receiver.
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
Ok(async move {
+1 -9
View File
@@ -57,15 +57,7 @@ async fn main() -> anyhow::Result<()> {
world.display(),
world.display()
);
let opened = sausage::world::World::open(&world)?;
let config = sausage::harness::config::Config::read(&opened.root).await?;
let origin = url::Url::parse(&config.listener.origin)?;
let api = sausage::api::Options {
api_listen: config.listener.address,
api_rp_id: origin.host_str().unwrap_or("localhost").into(),
api_origin: config.listener.origin,
};
sausage::api::run_with_userland(&world, None, api).await
sausage::api::run_with_userland(&world, None).await
}
Command::Sh {
world,
+2 -2
View File
@@ -191,7 +191,7 @@ impl Runtime {
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8090",
"http://localhost:8080",
)?),
};
router.attach_journal(world.root.journal());
@@ -226,7 +226,7 @@ impl Runtime {
cache::precompile(runtime.compiler.clone(), runtime.world.root.clone());
Ok(runtime)
}
/// Register native listener ports through the shared network policy.
/// Access the shared network policy for explicit host-port rules.
pub fn network_policy(&self) -> &net::NetPolicy {
&self.network
}
+1 -1
View File
@@ -1,5 +1,5 @@
//! Host-side network policy. `<dir>/host/network.toml` accepts `deny = ["address:port"]`.
//! Host listeners register their ports with `deny_host_port`. Guests cannot bind or connect there.
//! Explicit host-port rules supplement the address rules when needed.
use std::{
collections::HashSet,
net::{IpAddr, SocketAddr},
+3
View File
@@ -134,6 +134,9 @@ impl World {
for folder in ["bin", "src"] {
copy_tree(&bundle.join(folder), &root_dir.join(folder))?;
}
if bundle.join("srv").is_dir() {
copy_tree(&bundle.join("srv"), &root_dir.join("srv"))?;
}
if bundle.join("etc").is_dir() {
copy_tree(&bundle.join("etc"), &root_dir.join("etc"))?;
}
+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(