diff --git a/README.md b/README.md index 3ac6ef8..f146520 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,17 @@ The package includes the userland bundle. At world creation, the host copies its tools into `/bin` as `.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 diff --git a/config/config.yaml b/config/config.yaml index a6d6afc..d771bc3 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -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: diff --git a/docs/RUNNING.md b/docs/RUNNING.md index 5280093..f02048d 100644 --- a/docs/RUNNING.md +++ b/docs/RUNNING.md @@ -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. diff --git a/flake.nix b/flake.nix index 0c7e5dd..c031df8 100644 --- a/flake.nix +++ b/flake.nix @@ -135,9 +135,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/etc/systemd/system/multi-user.target.wants/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; # The upstream browser fixture links to an optional, unbuilt artifact. diff --git a/host/src/api/README.md b/host/src/api/README.md index 78eaf13..3e8b9c5 100644 --- a/host/src/api/README.md +++ b/host/src/api/README.md @@ -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`, 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 diff --git a/host/src/api/app.js b/host/src/api/app.js index d289b42..03b822a 100644 --- a/host/src/api/app.js +++ b/host/src/api/app.js @@ -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})); diff --git a/host/src/api/approve.html b/host/src/api/approve.html index bfa30f1..3c6f045 100644 --- a/host/src/api/approve.html +++ b/host/src/api/approve.html @@ -8,4 +8,4 @@ -

+

diff --git a/host/src/api/entry.html b/host/src/api/entry.html index a853843..ce620bc 100644 --- a/host/src/api/entry.html +++ b/host/src/api/entry.html @@ -5,4 +5,4 @@

Enter a secret for {{item_name}}: {{field_name}}

This host page encrypts the value before it sends the request.

-

+

diff --git a/host/src/api/harness.rs b/host/src/api/harness.rs index de3b8c5..8cf7f27 100644 --- a/host/src/api/harness.rs +++ b/host/src/api/harness.rs @@ -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}); } diff --git a/host/src/api/mod.rs b/host/src/api/mod.rs index 1857eb6..ac80507 100644 --- a/host/src/api/mod.rs +++ b/host/src/api/mod.rs @@ -8,8 +8,12 @@ mod harness; pub(crate) async fn dispatch( api: &Api, caller: Caller, - request: Request, + mut request: Request, ) -> Response { + // 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, harness: Mutex>, - approval_origin: String, + host_dir: Option, + origins: Mutex>>, pub(crate) builder: std::sync::OnceLock>, script: cred::quickjs::QuickJs, pub passkeys: Mutex>, @@ -64,7 +69,7 @@ pub struct Api { journal: Mutex>>, } 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, 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) -> Respo respond(ctx, caller, request) } fn respond(ctx: &Api, caller: Caller, request: Request) -> Response { + 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> { + 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, diff --git a/host/src/api/register.html b/host/src/api/register.html index b49d554..998350a 100644 --- a/host/src/api/register.html +++ b/host/src/api/register.html @@ -3,4 +3,4 @@ Register a passkey

Register a passkey

The first passkey needs no gate. Later passkeys require an existing passkey.

-

+

diff --git a/host/src/api/server.rs b/host/src/api/server.rs index 5c88807..e584ec2 100644 --- a/host/src/api/server.rs +++ b/host/src/api/server.rs @@ -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, - thread: Option>, -} -impl Listener { - pub fn start(cred: Arc, host_dir: &Path, options: Options) -> anyhow::Result { - 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, options: Options) -> anyhow::Result { - 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 } diff --git a/host/src/harness/config.rs b/host/src/harness/config.rs index 0f7910a..989b802 100644 --- a/host/src/harness/config.rs +++ b/host/src/harness/config.rs @@ -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) -> anyhow::Result { diff --git a/host/src/harness/input.rs b/host/src/harness/input.rs index 92b6709..d4cd1f2 100644 --- a/host/src/harness/input.rs +++ b/host/src/harness/input.rs @@ -83,7 +83,7 @@ pub(super) fn console(sender: Sender) { pub fn stop_signal( sender: Sender, ) -> std::io::Result + 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 { diff --git a/host/src/main.rs b/host/src/main.rs index a889bd8..65992e0 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -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, diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index 3c59088..f9f95b9 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -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 } diff --git a/host/src/process/net.rs b/host/src/process/net.rs index 52fa22b..8e9939b 100644 --- a/host/src/process/net.rs +++ b/host/src/process/net.rs @@ -1,5 +1,5 @@ //! Host-side network policy. `/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}, diff --git a/host/src/world.rs b/host/src/world.rs index 4f9cd34..233df28 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -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"))?; } diff --git a/host/tests/it/api_auth.rs b/host/tests/it/api_auth.rs index 1d20949..2757f6f 100644 --- a/host/tests/it/api_auth.rs +++ b/host/tests/it/api_auth.rs @@ -56,7 +56,7 @@ async fn page(api: &Api, path: &str) -> Result { ); 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 { @@ -338,7 +338,11 @@ async fn encrypt(api: &Api, public_key: Value) -> Result { ..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")?; diff --git a/host/tests/it/api_listener.rs b/host/tests/it/api_listener.rs index 40d991f..a69064f 100644 --- a/host/tests/it/api_listener.rs +++ b/host/tests/it/api_listener.rs @@ -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, diff --git a/host/tests/it/api_transport.rs b/host/tests/it/api_transport.rs new file mode 100644 index 0000000..d7742dc --- /dev/null +++ b/host/tests/it/api_transport.rs @@ -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, + thread: Option>, +} +impl Listener { + pub fn start(cred: Arc, host_dir: &Path, options: Options) -> anyhow::Result { + 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, options: Options) -> anyhow::Result { + 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(); + } + } +} diff --git a/host/tests/it/chat.rs b/host/tests/it/chat.rs index 0818ea3..a30802d 100644 --- a/host/tests/it/chat.rs +++ b/host/tests/it/chat.rs @@ -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("