Seed a minimal WASI web chat and move host approvals to port 8090

This commit is contained in:
2026-11-11 11:42:08 +00:00
parent feab7055ad
commit d72e40356c
22 changed files with 611 additions and 45 deletions
+36 -30
View File
@@ -1,11 +1,10 @@
# Run Sausage v3 on Citadel
The console is the UI. The browser handles credential approvals, not chat.
Use the bootstrap web chat for conversations. Credential approvals remain on separate host pages.
**TL;DR:** Use a separate v3 world and the release host. Keep its complete folder between starts.
The development bundle now seeds a complete source tree, including agents, skills, and extensions.
The Nix package does not yet supply that bundle. Verify the remaining cutover steps below before production use.
The rest of the root README describes v2.
**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.
Keep the complete world folder between starts.
## Build and start
@@ -15,12 +14,13 @@ Use Nix with flakes. The package supplies the native Rust builder and its tools.
The package entry point is:
```sh
nix run . -- run "$HOME/sausage-v3" --userland "$SAUSAGE_USERLAND"
nix run . -- run "$HOME/sausage-v3" </dev/null
```
`SAUSAGE_USERLAND` must name an assembled bundle with `bin/` and `src/` directories.
It is not a world folder. The CLI also accepts this variable without `--userland`.
The package does not set it for you.
The package sets `SAUSAGE_USERLAND` to its assembled bundle by default.
You can override it with `--userland BUNDLE` or the environment variable.
A bundle contains `bin/`, `src/`, and the seeded `etc/` tree. It is not a world folder.
Closing stdin only detaches the optional terminal console. The host and chat service continue.
The source-tree bundle builder is:
@@ -32,7 +32,10 @@ It builds the world commands and creates `target/userland/bundle` by default.
It snapshots committed source, not uncommitted edits.
The source archive includes the complete `agents/`, `skills/`, and `extensions/` directories.
The world copier preserves symlinks, including dangling links in the source tree.
A fresh development world no longer needs a manual source copy.
A fresh development world needs no manual source copy.
The bundle includes `/bin/chat.wasm` and an enabled `/etc/systemd/system/chat.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.
For a release host from the checkout:
@@ -63,41 +66,46 @@ Keep both `world/` and `host/` for recovery. Never copy secrets into the world.
Sources: [`main.rs`](../host/src/main.rs), [`world.rs`](../host/src/world.rs),
[`flake.nix`](../flake.nix), and [`bundle.nu`](../userland/bundle.nu).
## Use and stop the console
## Use the chat and stop the host
Each nonempty console line submits a message to the durable root conversation.
The chat selects the root conversation by default. Choose another conversation from the list.
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.
The terminal console remains optional. Each nonempty console line submits a message to the durable root conversation.
Assistant text appears on stdout. This is not a shell or the v2 command dispatcher.
Text such as `quit`, `help`, and `core.restart` has no special host meaning.
Ask the agent to execute world commands when you need tools.
Press **Ctrl-C** to stop the host. SIGTERM also requests orderly shutdown.
EOF only detaches the console input. It does not stop the host.
Keep the foreground terminal available if you want to continue chat.
The web chat remains available after console input closes.
After a crash, repeat the same start command with the same world folder.
The host opens `host/harness.sqlite`, restores extensions, and resumes durable work.
It does not replay the historical transcript into the console.
It cannot restore ordinary process memory or reverse completed network effects.
There is no automatic restart supervisor in this guide.
The existing flake loop still contains v2 assumptions. Do not use its no-argument mode for v3.
Sources: [`input.rs`](../host/src/harness/input.rs), [`main.ts`](../host/js/main.ts),
and [`harness/mod.rs`](../host/src/harness/mod.rs).
## Reach the approval pages
## Reach the chat and approval pages
The host listens on `127.0.0.1:8081` by default.
If another service owns port 8081, choose another host port with `--api-listen`.
Adjust the tunnel's remote port, but retain laptop port 8081 for the default origin.
From the laptop, create an SSH tunnel to Citadel:
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:
```sh
ssh -N -L 8081:127.0.0.1:8081 citadel.vpn.surma.technology
ssh -N -L 8080:127.0.0.1:8080 -L 8090:127.0.0.1:8090 citadel.vpn.surma.technology
```
Open **http://localhost:8081/register** on the laptop.
Keep the laptop port at 8081 to match the configured browser origin.
The defaults are `--api-rp-id localhost` and `--api-origin http://localhost:8081`.
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 are `--api-rp-id localhost` and `--api-origin http://localhost:8090`.
If the host port must change, use `--api-listen` and adjust the tunnel's remote port.
For direct VPN access, the default loopback listener is insufficient.
You need a reachable listener, HTTPS termination, and matching RP ID and origin options.
@@ -218,8 +226,9 @@ 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 8081 does **not** expose harness routes.
A browser UI needs a world server that forwards these requests.
The listener on port 8090 does **not** expose harness routes.
The seeded chat server forwards `/api/...` to `/harness/...` through WASI HTTP.
The bootstrap page polls `entries?after=` once per second rather than using a persistent stream.
Keep credential approval and secret entry on host pages.
| Method and route | Result or request |
@@ -246,15 +255,12 @@ Source: [`host API contract`](../host/src/api/README.md),
## Known gaps before cutover
- The Nix package wraps the host and builder tools but does not ship or select the default userland bundle.
- The development bundle passes fresh-world startup checks. The Nix package start remains unverified until its bundle integration lands.
- Laptop access, passkey ceremonies, secret entry, and browser approval remain **to check with Surma**.
- Startup checks use fake proxy credentials. They do not verify a real model session or external credential injection.
- A usable `llm-proxy` credential is mandatory even before the first chat.
- The browser has approval, passkey, and secret-entry pages, but no item editor, dashboard, or chat UI.
- The bootstrap chat is deliberately plain. There is no item editor or credential dashboard.
- The console has no shell commands, transcript navigation, or special quit command.
- `sausage sh` requires `-c`. Its interactive shell is not implemented.
- The flake's runtime-toolchain check and loop comments still describe v2 behavior.
- This guide does not migrate a v2 world. Keep the live v2 folder untouched.
A successful development start does not verify the Nix package, external services, or the production passkey origin.
A successful local start does not verify external services or the production passkey origin.
+3
View File
@@ -135,6 +135,9 @@
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
'';
doInstallCargoArtifacts = false;
# The upstream browser fixture links to an optional, unbuilt artifact.
+2 -2
View File
@@ -21,9 +21,9 @@ The listener stops when the console returns.
Options on `sausage run`:
- `--api-listen 127.0.0.1:8081`: the default listener address.
- `--api-listen 127.0.0.1:8090`: the default listener address.
- `--api-rp-id localhost`: the default WebAuthn RP ID.
- `--api-origin http://localhost:8081`: the default browser origin.
- `--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.
+2 -2
View File
@@ -6,13 +6,13 @@ 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:8081")]
#[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:8081")]
#[arg(long, default_value = "http://localhost:8090")]
pub api_origin: String,
}
+1 -1
View File
@@ -191,7 +191,7 @@ impl Runtime {
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
"http://localhost:8090",
)?),
};
router.attach_journal(world.root.journal());
+3
View File
@@ -65,6 +65,9 @@ impl World {
for folder in ["bin", "src"] {
copy_tree(&bundle.join(folder), &root_dir.join(folder))?;
}
if bundle.join("etc").is_dir() {
copy_tree(&bundle.join("etc"), &root_dir.join("etc"))?;
}
// Keep independent .wasm files and expose conventional executable names.
for entry in std::fs::read_dir(root_dir.join("bin"))? {
let path = entry?.path();
+1 -1
View File
@@ -12,7 +12,7 @@ use sausage::{
use serde_json::{Value, json};
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse, Url};
const ORIGIN: &str = "http://localhost:8081";
const ORIGIN: &str = "http://localhost:8090";
const SECRET: &str = "browser-only-value";
fn caller() -> Caller {
Caller {
+3 -3
View File
@@ -19,7 +19,7 @@ fn listener_serves_host_pages() -> Result<()> {
Options {
api_listen: "127.0.0.1:0".parse()?,
api_rp_id: "localhost".into(),
api_origin: "http://localhost:8081".into(),
api_origin: "http://localhost:8090".into(),
},
)?;
for (path, expected) in [
@@ -46,10 +46,10 @@ fn listener_serves_host_pages() -> Result<()> {
let help = std::str::from_utf8(&help.stdout)?;
for option in [
"--api-listen",
"127.0.0.1:8081",
"127.0.0.1:8090",
"--api-rp-id",
"--api-origin",
"http://localhost:8081",
"http://localhost:8090",
] {
assert!(help.contains(option));
}
+1 -1
View File
@@ -181,7 +181,7 @@ async fn script_inherits_creator_grant() -> Result<()> {
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
"http://localhost:8090",
)?);
let runtime = Arc::new(sausage::process::Runtime::new_with_hooks(
World {
+1 -1
View File
@@ -70,7 +70,7 @@ async fn ancestry_reaches_the_real_hook() -> Result<()> {
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
"http://localhost:8090",
)?);
let runtime = Arc::new(sausage::process::Runtime::new_with_hooks(
World {
+260
View File
@@ -0,0 +1,260 @@
//! A fresh world's seeded init service serves chat and proxies the real harness API.
use sausage::{
cred::{
Caller, Cred,
items::{Item, Rule},
requests::GrantRequest,
},
harness::{
input::{self, Input},
run_module_with_input,
},
process::Runtime,
world::World,
};
use std::{path::Path, sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn request(port: u16, path: &str, body: Option<&str>) -> anyhow::Result<(u16, Vec<u8>)> {
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?;
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"))?
+ 4;
let header = std::str::from_utf8(&bytes[..end])?;
let status = header.split_whitespace().nth(1).unwrap().parse()?;
Ok((status, bytes[end..].to_vec()))
}
#[tokio::test(flavor = "current_thread")]
async fn seeded_chat() -> anyhow::Result<()> {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = crate::common::target_dir();
crate::common::cwd_init();
anyhow::ensure!(
std::process::Command::new("cargo")
.args([
"build",
"--release",
"--locked",
"-p",
"sausage-chat",
"-p",
"sausage-init",
"--target",
"wasm32-wasip2",
"--target-dir"
])
.arg(&target)
.current_dir(source.join("userland"))
.status()?
.success(),
"Chat guest build failed"
);
let bundle = tempfile::tempdir()?;
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"),
)?;
std::fs::write(units.join("multi-user.target.wants/chat.service"), "")?;
std::fs::create_dir_all(bundle.path().join("bin"))?;
for binary in ["chat", "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"),
)?;
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 unit = std::fs::read_to_string(&seeded)?.replace(
"ExecStart=/bin/chat.wasm",
&format!("ExecStart=/bin/chat.wasm --listen 127.0.0.1:{port}"),
);
std::fs::write(&seeded, &unit)?;
let world = World::open_with_userland(dir.path(), Some(bundle.path()))?;
assert_eq!(
std::fs::read_to_string(&seeded)?,
unit,
"Reopen must not replace a world's unit"
);
assert!(
dir.path()
.join("world/src/sausage/userland/chat/Cargo.toml")
.is_file()
);
let cred = Arc::new(Cred::open(&world.host_dir)?);
let caller = Caller {
program: "/bin/chat.wasm".into(),
pid: 1,
conversations: vec!["1".into()],
};
let item = cred.create_item(
Item {
id: String::new(),
name: "Chat test credential".into(),
version: 0,
free_use: false,
rules: vec![Rule {
port: None,
scheme: "https".into(),
hosts: vec!["example.test".into()],
path_prefixes: vec!["/".into()],
methods: None,
}],
validate_js: None,
transform_js: String::new(),
fields: vec![],
},
&caller,
cred.unlocked()?,
)?;
let pending = cred.file_request(
&caller,
"chat-test",
GrantRequest {
items: vec![item.id],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let api = Arc::new(sausage::api::Api::open(
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8090",
)?);
let (sender, receiver) = input::channel();
api.attach_harness(sender.clone());
let runtime = Runtime::new_with_hooks(
World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
sausage::process::default_script(),
Some(api),
)?;
let services = sausage::process::supervise(runtime.clone());
let (library, _) = include_str!("../../js/harness-api-test-bundle.js")
.split_once("// harness-api-test.ts")
.unwrap();
let script = format!(
r#"{library}
const chatModels=createModels(),chatFaux=fauxProvider({{tokensPerSecond:100000}});
chatModels.setProvider(chatFaux.provider);chatFaux.setResponses([fauxAssistantMessage('Chat test reply')]);
const chatRegistry=createRegistry(),chatStorage=await openStorage(globalThis.storagePath);
const chatHarness=await Harness.open(chatStorage,{{models:chatModels,registry:chatRegistry}},BACKGROUND_CONTEXT);
await chatHarness.root(BACKGROUND_CONTEXT,{{agent:{{model:{{provider:'faux',modelId:'faux-1'}}}}}});
const chatApi=harnessApi(chatHarness,chatModels,{{state:()=>({{}})}},BACKGROUND_CONTEXT);
chatHarness.resume();await serve(chatHarness,BACKGROUND_CONTEXT,undefined,chatApi);
await chatApi.close();await chatHarness.close(BACKGROUND_CONTEXT);
"#
);
let client = async {
let deadline = std::time::Instant::now() + Duration::from_secs(60);
let page = loop {
if let Ok((200, body)) = request(port, "/", None).await {
break body;
}
anyhow::ensure!(
std::time::Instant::now() < deadline,
"Init did not start chat"
);
tokio::task::yield_now().await;
};
assert!(String::from_utf8_lossy(&page).contains("Sausage chat"));
let root = loop {
let (status, body) = request(port, "/api/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()
{
break id;
}
anyhow::ensure!(
std::time::Instant::now() < deadline,
"Harness was not ready"
);
tokio::task::yield_now().await;
};
let submit = serde_json::json!({"content":"Chat test input","requestId":"chat-stable-id"})
.to_string();
for _ in 0..2 {
assert_eq!(
request(
port,
&format!("/api/conversations/{root}/submit"),
Some(&submit)
)
.await?
.0,
202
);
}
loop {
let (status, body) = request(
port,
&format!("/api/conversations/{root}/entries?after=0"),
None,
)
.await?;
assert_eq!(status, 200);
if String::from_utf8_lossy(&body).contains("Chat test reply") {
let value: serde_json::Value = serde_json::from_slice(&body)?;
assert_eq!(
value["entries"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["kind"] == "pi.user")
.count(),
1
);
break;
}
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?;
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}"))
);
assert_eq!(request(port, "/api/unknown", None).await?.0, 404);
sender.send(Input::Stop)?;
Ok::<_, anyhow::Error>(())
};
let result = tokio::time::timeout(Duration::from_secs(90), async {
tokio::try_join!(
run_module_with_input(world, Arc::new(runtime.clone()), cred, receiver, &script),
client
)
})
.await;
services.shutdown().await;
result??;
Ok(())
}
+1 -1
View File
@@ -122,7 +122,7 @@ async fn harness_api_world_client() -> anyhow::Result<()> {
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
"http://localhost:8090",
)?);
let (sender, receiver) = sausage::harness::input::channel();
api.attach_harness(sender.clone());
+1 -1
View File
@@ -24,7 +24,7 @@ async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> {
dir.path().join("world/bin/guest.wasm"),
)?;
let cred = Arc::new(Cred::open(&world.host_dir)?);
let origin = "http://localhost:8081";
let origin = "http://localhost:8090";
let api = Arc::new(Api::open(
cred.clone(),
&world.host_dir,
+1
View File
@@ -5,6 +5,7 @@ mod api_listener;
mod automations;
mod builder;
mod chain;
mod chat;
mod common;
mod core_ext;
mod cred;
+1 -1
View File
@@ -39,7 +39,7 @@ async fn list_show_and_restore() {
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
"http://localhost:8090",
)
.unwrap(),
);
+8
View File
@@ -4390,6 +4390,14 @@ 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"
+1 -1
View File
@@ -1,7 +1,7 @@
# The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's.
[workspace]
resolver = "3"
members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box", "archive-tools", "fs-tools", "undo", "cargo"]
members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box", "archive-tools", "fs-tools", "undo", "cargo", "chat"]
# WASI compatibility patches: git/vendor/PATCHES.md.
[patch.crates-io]
+5
View File
@@ -35,6 +35,11 @@ def main [destination?: path] {
^cp --remove-destination $brush ($bin | path join $'($name).wasm')
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush copy failed'} }
}
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 repo = ($here | path join .. | path expand)
let revision = (^git -C $repo rev-parse HEAD | str trim)
let snapshot = ($destination | path join src sausage)
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "sausage-chat"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "chat"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
wasi = "0.14"
+7
View File
@@ -0,0 +1,7 @@
[Unit]
Description=Bootstrap web chat
[Service]
ExecStart=/bin/chat.wasm
Restart=always
[Install]
WantedBy=multi-user.target
+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sausage chat</title>
<style>
body{font:16px system-ui;margin:0;color:#202124;background:#fafafa}header{padding:1rem;border-bottom:1px solid #ccc}main{display:flex;height:calc(100dvh - 5rem)}nav{width:16rem;overflow:auto;padding:.6rem;border-right:1px solid #ccc}button,textarea{font:inherit}button{cursor:pointer;padding:.5rem}nav button{display:block;width:100%;text-align:left;margin-bottom:.4rem}button[aria-current=true]{background:#dceaff}section{flex:1;min-width:0;display:flex;flex-direction:column;padding:1rem}#entries{flex:1;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere}.entry{padding:.7rem;border-bottom:1px solid #ddd}.user{background:#eef4ff}pre{white-space:pre-wrap;overflow-wrap:anywhere}form{display:flex;gap:.5rem;padding-top:1rem}textarea{flex:1;min-width:0}#approvals{background:#fff1c1}#error{color:#a00}small{display:block}a{margin-right:1rem}@media(max-width:600px){nav{width:8rem}section{padding:.5rem}form{flex-wrap:wrap}}
</style>
<header><strong>Sausage chat</strong><span id="error" role="status"></span><div id="approvals" aria-live="polite"></div></header>
<main><nav id="conversations" aria-label="Conversations"></nav><section><h1 id="title">Conversation</h1><div id="entries" role="log" aria-live="polite"></div><form id="compose"><textarea id="input" aria-label="Message" rows="3" required></textarea><button>Send</button><button id="stop" type="button">Stop</button></form></section></main>
<script>
'use strict';
const $=id=>document.getElementById(id);
let selected=null,after=0,pending=null,sequence=0;
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 value=await response.json();
if(!response.ok)throw new Error(value.error||response.statusText);
return value;
}
const text=value=>typeof value==='string'?value:Array.isArray(value)?value.map(part=>part.type==='text'?part.text:'').join(''):'';
function entry(record){
if(!['pi.user','pi.assistant','pi.tool-result'].includes(record.kind))return;
const box=document.createElement('div');box.className='entry '+(record.kind==='pi.user'?'user':'');
const messages=Array.isArray(record.model)?record.model:[record.model||record.data||record];
for(const message of messages){
const calls=Array.isArray(message.content)?message.content.filter(part=>part.type==='toolCall'):[];
if(record.kind==='pi.tool-result'||calls.length){
const details=document.createElement('details'),summary=document.createElement('summary'),result=document.createElement('pre');
const name=message.toolName||message.name||calls.map(call=>call.name).join(', ')||'Tool';
const content=text(message.content)||JSON.stringify(message);
summary.textContent=name+' — '+content.slice(0,140);result.textContent=content;
details.append(summary,result);box.append(details);
}
const content=text(message.content)||text(message.text);
if(content&&record.kind!=='pi.tool-result'){
const label=document.createElement('strong'),paragraph=document.createElement('div');
label.textContent=record.kind==='pi.user'?'You':'Assistant';paragraph.textContent=content;box.append(label,paragraph);
}
}
if(box.childNodes.length)$('entries').append(box);
}
function select(id){selected=id;after=0;$('entries').replaceChildren();}
async function refresh(){
try{
const {conversations}=await api('conversations');
conversations.sort((a,b)=>a.id-b.id);
if(selected===null&&conversations.length)select((conversations.find(c=>!c.parent&&!c.owner)||conversations[0]).id);
$('conversations').replaceChildren();
const parent=c=>c.parent?.conversationId||c.owner?.conversationId||c.starter;
for(const c of conversations){
const button=document.createElement('button'),label=document.createElement('small'),p=parent(c);
button.type='button';button.setAttribute('aria-current',String(c.id===selected));
button.textContent=c.title||'Conversation #'+c.id;
label.textContent=(c.running?'Running':c.concluded?'Concluded':'Idle')+(p?' · parent #'+p+' · child '+(conversations.filter(other=>parent(other)===p).findIndex(other=>other.id===c.id)+1):' · root');
button.append(label);button.onclick=()=>select(c.id);$('conversations').append(button);
if(c.id===selected)$('title').textContent=c.title||'Conversation #'+c.id;
}
if(selected!==null){
const id=selected,wasNearBottom=$('entries').scrollHeight-$('entries').scrollTop-$('entries').clientHeight<80;
const {entries}=await api('conversations/'+id+'/entries?after='+after);
if(id===selected){for(const item of entries){entry(item);after=Math.max(after,item.id);}if(wasNearBottom)$('entries').scrollTop=$('entries').scrollHeight;}
}
const {requests}=await api('credential-requests');$('approvals').replaceChildren();
for(const request of requests){
const link=document.createElement('a');link.textContent='Approve: '+(request.summary||request.id);link.href=request.approvalUrl;link.target='_blank';link.rel='noopener';$('approvals').append(link);
}
$('error').textContent='';
}catch(error){$('error').textContent=' '+error.message;}
setTimeout(refresh,1000);
}
$('compose').onsubmit=async event=>{
event.preventDefault();const content=$('input').value;
if(selected===null||!content.trim())return;
if(!pending||pending.content!==content||pending.conversation!==selected)pending={content,conversation:selected,requestId:Date.now().toString(36)+'-'+(++sequence)+'-'+Math.random().toString(36).slice(2)};
try{await api('conversations/'+pending.conversation+'/submit',pending);if($('input').value===content)$('input').value='';pending=null;}catch(error){$('error').textContent=' '+error.message;}
};
$('stop').onclick=async()=>{if(selected!==null)try{await api('conversations/'+selected+'/stop',{});}catch(error){$('error').textContent=' '+error.message;}};
refresh();
</script></html>
+183
View File
@@ -0,0 +1,183 @@
//! A bootstrap HTTP chat server. Only the host harness API supplies chat data.
use clap::Parser;
use std::{io, net::SocketAddr};
use wasi::{
http::{
outgoing_handler,
types::{Fields, Method, OutgoingBody, OutgoingRequest, Scheme},
},
io::streams::{InputStream, OutputStream},
sockets::{
instance_network::instance_network, network::*, tcp_create_socket::create_tcp_socket,
},
};
#[derive(Parser)]
struct Options {
#[arg(long, default_value = "0.0.0.0:8080")]
listen: SocketAddr,
}
fn error(e: impl std::fmt::Debug) -> io::Error {
io::Error::other(format!("{e:?}"))
}
fn write(stream: &OutputStream, bytes: &[u8]) -> io::Result<()> {
for chunk in bytes.chunks(4096) {
stream.blocking_write_and_flush(chunk).map_err(error)?;
}
Ok(())
}
fn proxy(
method: &str,
path: &str,
content_type: Option<&str>,
body: &[u8],
) -> io::Result<(u16, String, Vec<u8>)> {
let headers = Fields::new();
headers
.append("content-length", &body.len().to_string().into_bytes())
.map_err(error)?;
if let Some(value) = content_type {
headers
.append("content-type", value.as_bytes())
.map_err(error)?;
}
let request = OutgoingRequest::new(headers);
let method = match method {
"GET" => Method::Get,
"POST" => Method::Post,
"PUT" => Method::Put,
"DELETE" => Method::Delete,
"PATCH" => Method::Patch,
"HEAD" => Method::Head,
"OPTIONS" => Method::Options,
other => Method::Other(other.into()),
};
request.set_method(&method).map_err(error)?;
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}")))
.map_err(error)?;
let outgoing = request.body().map_err(error)?;
let future = outgoing_handler::handle(request, None).map_err(error)?;
if !body.is_empty() {
let stream = outgoing.write().map_err(error)?;
let mut rest = body;
while !rest.is_empty() {
stream.subscribe().block();
let count = (stream.check_write().map_err(error)? as usize).min(rest.len());
stream.write(&rest[..count]).map_err(error)?;
rest = &rest[count..];
}
}
OutgoingBody::finish(outgoing, None).map_err(error)?;
future.subscribe().block();
let response = future
.get()
.ok_or_else(|| error("missing HTTP response"))?
.map_err(error)?
.map_err(error)?;
let status = response.status();
let content_type = response
.headers()
.get("content-type")
.first()
.map(|s| String::from_utf8_lossy(s).into_owned())
.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();
loop {
match stream.blocking_read(65536) {
Ok(chunk) => bytes.extend(chunk),
Err(wasi::io::streams::StreamError::Closed) => break,
Err(e) => return Err(error(e)),
}
}
Ok((status, content_type, bytes))
}
fn serve(input: &InputStream, output: &OutputStream) -> 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") {
break index + 4;
}
bytes.extend(input.blocking_read(65536).map_err(error)?);
};
let headers = std::str::from_utf8(&bytes[..end])
.map_err(error)?
.to_owned();
let mut lines = headers.lines();
let mut line = lines.next().unwrap_or("").split_whitespace();
let method = line.next().unwrap_or("");
let path = line.next().unwrap_or("");
let headers: Vec<_> = lines
.filter_map(|s| s.split_once(':'))
.map(|(k, v)| (k.to_ascii_lowercase(), v.trim()))
.collect();
let header = |key: &str| headers.iter().find(|(k, _)| k == key).map(|(_, v)| *v);
let length = header("content-length")
.unwrap_or("0")
.parse::<usize>()
.map_err(error)?;
while bytes.len() < end + length {
bytes.extend(input.blocking_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(
method,
path,
header("content-type"),
&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())
};
write(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(output, &body)
}
fn main() -> io::Result<()> {
let address = Options::parse().listen;
let (family, address) = match address {
SocketAddr::V4(a) => (
IpAddressFamily::Ipv4,
IpSocketAddress::Ipv4(Ipv4SocketAddress {
port: a.port(),
address: a.ip().octets().into(),
}),
),
SocketAddr::V6(a) => (
IpAddressFamily::Ipv6,
IpSocketAddress::Ipv6(Ipv6SocketAddress {
port: a.port(),
flow_info: a.flowinfo(),
address: a.ip().segments().into(),
scope_id: a.scope_id(),
}),
),
};
let socket = create_tcp_socket(family).map_err(error)?;
socket
.start_bind(&instance_network(), address)
.map_err(error)?;
socket.subscribe().block();
socket.finish_bind().map_err(error)?;
socket.start_listen().map_err(error)?;
socket.subscribe().block();
socket.finish_listen().map_err(error)?;
loop {
match socket.accept() {
Ok((_connection, input, output)) => {
let _ = serve(&input, &output);
}
Err(ErrorCode::WouldBlock) => socket.subscribe().block(),
Err(e) => return Err(error(e)),
}
}
}