Seed a minimal WASI web chat and move host approvals to port 8090
This commit is contained in:
+36
-30
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ impl Runtime {
|
||||
cred.clone(),
|
||||
&world.host_dir,
|
||||
"localhost",
|
||||
"http://localhost:8081",
|
||||
"http://localhost:8090",
|
||||
)?),
|
||||
};
|
||||
router.attach_journal(world.root.journal());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@ mod api_listener;
|
||||
mod automations;
|
||||
mod builder;
|
||||
mod chain;
|
||||
mod chat;
|
||||
mod common;
|
||||
mod core_ext;
|
||||
mod cred;
|
||||
|
||||
@@ -39,7 +39,7 @@ async fn list_show_and_restore() {
|
||||
cred.clone(),
|
||||
&world.host_dir,
|
||||
"localhost",
|
||||
"http://localhost:8081",
|
||||
"http://localhost:8090",
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
Generated
+8
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,7 @@
|
||||
[Unit]
|
||||
Description=Bootstrap web chat
|
||||
[Service]
|
||||
ExecStart=/bin/chat.wasm
|
||||
Restart=always
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -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>
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user