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
Generated
+8 -8
View File
@@ -9151,14 +9151,6 @@ 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"
@@ -9362,6 +9354,14 @@ dependencies = [
"wiggle",
]
[[package]]
name = "sausage-web"
version = "0.1.0"
dependencies = [
"clap",
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "schannel"
version = "0.1.30"
+1 -1
View File
@@ -25,7 +25,7 @@ members = [
"userland/fs-tools",
"userland/undo",
"userland/cargo",
"userland/chat",
"userland/web",
"userland/brush/source/brush",
"userland/brush/source/brush-shell",
"userland/brush/source/brush-parser",
+5 -3
View File
@@ -17,15 +17,17 @@ The package includes the userland bundle.
At world creation, the host copies its tools into `/bin` as `<command>.wasm` files.
The bundle ships plain source files. `sausage init` creates their repository in `/src/sausage` with one commit on `main`.
The world config lives at `/etc/sausage/config.yaml`.
It defines the proxy, listener, standing agent, roster, and shared system prompt.
It defines the proxy, standing agent, roster, and shared system prompt.
`init` fills proxy settings from `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` when present. It asks nothing.
The host and chat can start with an empty proxy key. Model requests then name the config file in their error.
The proxy key in that file is intentionally visible to agents. Other managed secrets stay outside the world.
The chat and console use the configured standing conversation. A rename keeps that conversation.
Model, prompt, and proxy edits apply without a host restart. Listener edits require a restart.
Model, prompt, and proxy edits apply without a host restart.
The host creates `world/` for the shared filesystem and `host/` for private runtime state under the supplied directory.
Services come from `/etc/systemd/system` in the world.
Services come from `/etc/systemd/system` in the world. `web.service` serves fresh `/srv/www` files on port 8080.
Use `http://localhost:8080` for chat and `/api/register` for passkey registration.
The `/api/` mount forwards to `host.sausage`, including NDJSON streams. The host opens no separate port.
## Repository
+1 -4
View File
@@ -5,9 +5,6 @@ proxy:
api_key: ""
standing: sausage
# Listener changes require a host restart.
listener:
address: 127.0.0.1:8090
origin: http://localhost:8090
prompt: |-
## Sausage and the world
@@ -287,7 +284,7 @@ roster:
When the user requests specialization, read integrate-sausage and adopt-sausage-workflow before proposals.
Act directly when the user asks you to change your setup. Edit /etc/sausage/config.yaml to change your model, the roster, or the proxy. You can read and edit that file, including the proxy key. Edit extensions in /src/sausage and commit to main to reload them. Edit the chat UI in /src/sausage/userland/chat, rebuild it with cargo build, and restart it with systemctl restart chat. Do not only describe these changes. Make them when requested.
Act directly when the user asks you to change your setup. Edit /etc/sausage/config.yaml to change your model, the roster, or the proxy. You can read and edit that file, including the proxy key. Edit extensions in /src/sausage and commit to main to reload them. Edit the chat UI in /srv/www. The next request sees the edit without a build or restart. Do not only describe these changes. Make them when requested.
- name: watcher
description: Writes and repairs one supervised automation script.
tools:
+20 -30
View File
@@ -1,6 +1,6 @@
# Run Sausage v3 on Citadel
Use the bootstrap web chat for conversations. Credential approvals remain on separate host pages.
Use the web chat for conversations. The same web port serves host credential pages through `/api/`.
**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.
@@ -36,9 +36,9 @@ The source archive includes the complete `config/`, `skills/`, and `extensions/`
The bundle contains plain source files. `sausage init` creates their repository and its first commit on `main`.
The world copier preserves symlinks, including dangling links in the source tree.
A fresh development world needs no manual source copy.
The bundle includes `/bin/chat.wasm` and an enabled `/etc/systemd/system/chat.service`.
The bundle includes `/bin/web.wasm` and an enabled `/etc/systemd/system/web.service`.
World init starts that service on `0.0.0.0:8080`.
To change its address, edit `ExecStart` to add `--listen ADDR`, then run `systemctl restart chat` inside the world.
To change its address, edit `ExecStart` to add `--listen ADDR`, then run `systemctl restart web` inside the world.
For a release host from the checkout:
@@ -76,7 +76,7 @@ Root #1 remains only as history. A standing-agent rename keeps the same conversa
The page polls for new entries and shows text, collapsed tools, and pending credential requests.
Use **Send** to submit a message and **Stop** to stop the selected conversation's active turn.
The chat never approves credential requests. Its links open the host's approval pages.
Agents can change the chat source at `/src/sausage/userland/chat` and rebuild it later.
Agents edit `/srv/www/index.html`. Each request reads the file fresh without a build or restart.
The terminal console remains optional. Each nonempty console line submits a message to the standing conversation.
Assistant text appears on stdout. This is not a shell or the v2 command dispatcher.
@@ -98,37 +98,27 @@ and [`harness/mod.rs`](../host/src/harness/mod.rs).
## Reach the chat and approval pages
The chat listens on port 8080. The host listens on `127.0.0.1:8090` by default.
Port 8081 on Citadel belongs to Traefik.
From the laptop, create one SSH tunnel with both forwards:
The world web server listens on port 8080. The host opens no separate port.
From the laptop, create one SSH tunnel:
```sh
ssh -N -L 8080:127.0.0.1:8080 -L 8090:127.0.0.1:8090 citadel.vpn.surma.technology
ssh -N -L 8080:127.0.0.1:8080 citadel.vpn.surma.technology
```
Open **http://localhost:8080** for chat and **http://localhost:8090/register** for the first passkey.
Keep laptop port 8090 to match the host's configured browser origin.
The defaults in `/etc/sausage/config.yaml` are `listener.address: 127.0.0.1:8090` and `listener.origin: http://localhost:8090`.
The host derives the passkey RP ID from the origin's hostname.
If the host port must change, edit those fields, restart the host, and adjust the tunnel.
For direct VPN access, the default loopback listener is insufficient.
You need a reachable listener, HTTPS termination, and a matching origin in the config.
Plain HTTP on the VPN hostname does not provide the browser's secure context for passkeys.
The SSH tunnel avoids that extra setup for localhost tests.
**To check with Surma:** the laptop tunnel, browser passkey registration, secret entry, and request approval.
The planned production origin is `https://sausage.apps.surma.technology`, with RP ID `sausage.apps.surma.technology`.
Passkeys registered for `localhost` do not transfer to that RP ID. Plan a separate production registration.
Do not expose the listener before the first registration to people who must not approve requests.
Open **http://localhost:8080** for chat and **http://localhost:8080/api/register** for the first passkey.
The host derives the passkey RP ID and origin from the browser request Origin header.
For direct VPN access, passkeys need HTTPS because non-localhost HTTP lacks a secure context.
Passkeys registered for localhost do not transfer to a production hostname.
Do not expose the server to other people before the first registration.
### Register, create an item, enter a secret
1. Open `/register` and choose a label for the first passkey.
1. Open `/api/register` and choose a label for the first passkey.
2. Complete the browser's passkey ceremony.
3. Ask the agent to file an item creation request through `POST /cred/requests`.
4. Review the request on `/approve/REQUEST_ID` and approve it with your passkey.
4. Review the request on `/api/approve/REQUEST_ID` and approve it with your passkey.
5. Read the item's public metadata to find its item ID and stable field ID.
6. Open `/entry/ITEM_ID/FIELD_ID` on the host origin.
6. Open `/api/entry/ITEM_ID/FIELD_ID` on the web origin.
7. Enter the secret in the blank host form and complete its passkey ceremony.
Until the first passkey exists, D53 permits unlocked decisions and first registration.
@@ -149,7 +139,7 @@ They do not return stored secret values.
### Approve a request
The agent or world UI obtains pending requests from `/harness/credential-requests` or `/cred/requests?state=pending`.
Follow the approval link on the host origin through the tunnel.
Follow the `/api/approve/ID` link on the web origin through the tunnel.
Review the stored summary and public details, choose limits when applicable, then approve or deny.
The world can file a request. It cannot approve its own request.
@@ -159,7 +149,7 @@ Sources: [`host API`](../host/src/api/README.md), [`pages.rs`](../host/src/api/p
## Models
The world config is `/etc/sausage/config.yaml`, at `WORLD/world/etc/sausage/config.yaml` on the host.
It contains the proxy settings, standing-agent name, global prompt, roster, and listener settings.
It contains the proxy settings, standing-agent name, global prompt, and roster.
Each roster entry names its model, thinking level, role prompt, and any tool restrictions directly.
There are no model families or project overlays.
Every agent receives the global `prompt` before its role prompt.
@@ -175,7 +165,7 @@ Other managed secrets remain outside the world and require the normal credential
Edit `standing` to select a roster member. The same standing conversation adopts its name, model, and role prompt.
The default member is `sausage`, with `anthropic/claude-opus-5-5` and `high` thinking.
Model and prompt edits apply at the next run without a restart.
Proxy edits apply before the next model request. Listener edits require a host restart.
Proxy edits apply before the next model request.
All real model requests retain the host proxy transport.
Sources: [`credentials.rs`](../host/src/harness/credentials.rs), [`main.ts`](../host/js/main.ts),
@@ -232,8 +222,8 @@ Command reference and implementation evidence:
## Build a world UI with the host API
World programs call **`http://host.sausage`** through standard WASI HTTP.
The listener on port 8090 does **not** expose harness routes.
The seeded chat server forwards `/api/...` to `/harness/...` through WASI HTTP.
The world web server forwards `/api/PATH` to `http://host.sausage/PATH` through WASI HTTP.
It streams response chunks instead of buffering the entire NDJSON body.
The bootstrap page polls `entries?after=` once per second rather than using a persistent stream.
Keep credential approval and secret entry on host pages.
+4 -2
View File
@@ -91,8 +91,10 @@
installPhaseCommand = ''
mkdir -p $out
cp -a target/userland/bundle/. $out/
test -f $out/bin/chat.wasm
test -f $out/etc/systemd/system/chat.service
test -f $out/bin/web.wasm
test -f $out/srv/www/index.html
test -f $out/etc/systemd/system/web.service
test -f $out/etc/systemd/system/multi-user.target.wants/web.service
'';
doInstallCargoArtifacts = false;
dontCheckForBrokenSymlinks = true;
+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(
+7 -3
View File
@@ -41,9 +41,13 @@ def main [destination?: path] {
}
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 www = ($destination | path join srv www)
mkdir $www
^cp --remove-destination ($here | path join web index.html) ($www | path join index.html)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'web page copy failed'} }
^cp --remove-destination ($here | path join web web.service) ($units | path join web.service)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'web unit copy failed'} }
'' | save --force ($units | path join multi-user.target.wants web.service)
let repo = ($here | path join .. | path expand)
let snapshot = ($destination | path join src sausage)
if ($snapshot | path exists) { rm --recursive --force $snapshot }
@@ -1,10 +1,10 @@
[package]
name = "sausage-chat"
name = "sausage-web"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "chat"
name = "web"
path = "src/main.rs"
[dependencies]
@@ -47,7 +47,7 @@ function toolCard(call,box){
return tool;
}
async function api(path,body){
const response=await fetch('/api/'+path,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const response=await fetch('/api/harness/'+path,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const value=await response.json();
if(!response.ok)throw new Error(value.error||response.statusText);
return value;
@@ -1,4 +1,4 @@
//! A bootstrap HTTP chat server. Only the host harness API supplies chat data.
//! Serve fresh world files and stream the /api mount through standard WASI HTTP.
//! Stdin EOF stops the server, including waits for connections and HTTP.
use clap::Parser;
use std::{io, net::SocketAddr};
@@ -19,6 +19,8 @@ use wasi::{
struct Options {
#[arg(long, default_value = "0.0.0.0:8080")]
listen: SocketAddr,
#[arg(long, default_value = "/srv/www")]
root: std::path::PathBuf,
}
fn error(e: impl std::fmt::Debug) -> io::Error {
io::Error::other(format!("{e:?}"))
@@ -52,13 +54,34 @@ fn write(stdin: &InputStream, stream: &OutputStream, mut bytes: &[u8]) -> io::Re
}
}
}
fn wait_response(stdin: &InputStream, event: &Pollable, client: &InputStream) -> io::Result<()> {
let stop = stdin.subscribe();
let disconnect = client.subscribe();
loop {
let ready = poll(&[&stop, event, &disconnect]);
match stdin.read(65536) {
Err(StreamError::Closed) => std::process::exit(0),
Err(e) => return Err(error(e)),
Ok(_) => {}
}
if ready.contains(&2) {
client.read(65536).map_err(error)?;
}
if ready.contains(&1) {
return Ok(());
}
}
}
fn proxy(
stdin: &InputStream,
client: &InputStream,
method: &str,
path: &str,
content_type: Option<&str>,
headers: (Option<&str>, Option<&str>, Option<&str>),
body: &[u8],
) -> io::Result<(u16, String, Vec<u8>)> {
output: &OutputStream,
) -> io::Result<()> {
let (content_type, origin, host) = headers;
let headers = Fields::new();
headers
.append("content-length", &body.len().to_string().into_bytes())
@@ -68,6 +91,11 @@ fn proxy(
.append("content-type", value.as_bytes())
.map_err(error)?;
}
for (key, value) in [("origin", origin), ("x-forwarded-host", host)] {
if let Some(value) = value {
headers.append(key, value.as_bytes()).map_err(error)?;
}
}
let request = OutgoingRequest::new(headers);
let method = match method {
"GET" => Method::Get,
@@ -83,7 +111,7 @@ fn proxy(
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}")))
.set_path_with_query(Some(&format!("/{path}")))
.map_err(error)?;
let outgoing = request.body().map_err(error)?;
let future = outgoing_handler::handle(request, None).map_err(error)?;
@@ -92,7 +120,7 @@ fn proxy(
write(stdin, &stream, body)?;
}
OutgoingBody::finish(outgoing, None).map_err(error)?;
wait(stdin, &future.subscribe())?;
wait_response(stdin, &future.subscribe(), client)?;
let response = future
.get()
.ok_or_else(|| error("missing HTTP response"))?
@@ -107,18 +135,23 @@ fn proxy(
.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();
write(stdin, output, format!("HTTP/1.1 {status} Response\r\nContent-Type: {content_type}\r\nConnection: close\r\n\r\n").as_bytes())?;
loop {
wait(stdin, &stream.subscribe())?;
wait_response(stdin, &stream.subscribe(), client)?;
match stream.read(65536) {
Ok(chunk) => bytes.extend(chunk),
Err(wasi::io::streams::StreamError::Closed) => break,
Ok(chunk) => write(stdin, output, &chunk)?,
Err(StreamError::Closed) => break,
Err(e) => return Err(error(e)),
}
}
Ok((status, content_type, bytes))
Ok(())
}
fn serve(stdin: &InputStream, input: &InputStream, output: &OutputStream) -> io::Result<()> {
fn serve(
stdin: &InputStream,
input: &InputStream,
output: &OutputStream,
root: &std::path::Path,
) -> 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") {
@@ -147,29 +180,59 @@ fn serve(stdin: &InputStream, input: &InputStream, output: &OutputStream) -> io:
wait(stdin, &input.subscribe())?;
bytes.extend(input.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(
if let Some(path) = path.strip_prefix("/api/") {
return proxy(
stdin,
input,
method,
path,
header("content-type"),
(header("content-type"), header("origin"), header("host")),
&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())
output,
);
}
let mut file = root.to_path_buf();
let path = path.split('?').next().unwrap_or("");
for part in std::path::Path::new(path).components() {
match part {
std::path::Component::Normal(name) => file.push(name),
std::path::Component::RootDir | std::path::Component::CurDir => {}
_ => return not_found(stdin, output),
}
}
if file.is_dir() {
file.push("index.html");
}
let body = match std::fs::read(&file) {
Ok(bytes) => bytes,
Err(_) => return not_found(stdin, output),
};
let kind = match file.extension().and_then(|s| s.to_str()).unwrap_or("") {
"html" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"json" => "application/json",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"ico" => "image/x-icon",
"txt" | "md" => "text/plain; charset=utf-8",
"wasm" => "application/wasm",
_ => "application/octet-stream",
};
let status = 200;
if method == "HEAD" {
return write(stdin, output, format!("HTTP/1.1 200 OK\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes());
}
write(stdin, 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(stdin, output, &body)
}
fn not_found(stdin: &InputStream, output: &OutputStream) -> io::Result<()> {
write(stdin, output, b"HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot found")
}
fn main() -> io::Result<()> {
let address = Options::parse().listen;
let options = Options::parse();
let address = options.listen;
let stdin = wasi::cli::stdin::get_stdin();
let (family, address) = match address {
SocketAddr::V4(a) => (
@@ -201,7 +264,7 @@ fn main() -> io::Result<()> {
loop {
match socket.accept() {
Ok((_connection, input, output)) => {
let _ = serve(&stdin, &input, &output);
let _ = serve(&stdin, &input, &output, &options.root);
}
Err(ErrorCode::WouldBlock) => wait(&stdin, &socket.subscribe())?,
Err(e) => return Err(error(e)),
@@ -1,7 +1,7 @@
[Unit]
Description=Bootstrap web chat
Description=World web server
[Service]
ExecStart=/bin/chat.wasm
ExecStart=/bin/web.wasm
Restart=always
[Install]
WantedBy=multi-user.target