Merge v3 with the generic web server in the shared workspace
This commit is contained in:
Generated
+8
-8
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,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> {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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},
|
||||
|
||||
@@ -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"))?;
|
||||
}
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>(())
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user