host: expose harness state and actions over the world API

This commit is contained in:
2026-11-07 19:22:06 +00:00
parent c3c5b36d09
commit 5dc4833e28
33 changed files with 33178 additions and 112 deletions
+36 -2
View File
@@ -4,8 +4,8 @@
The `wasi:http` hook supplies the caller from the process table.
It must not accept caller identity from headers, environment variables, or JSON.
Reads are open to every caller.
JSON routes return 200 on success.
Errors use `{"error":"kind","message":"kind"}` with static, secret-free text.
Credential JSON routes return 200 on success.
Credential errors use `{"error":"kind","message":"kind"}` with static, secret-free text.
Missing routes return 404. Invalid parameters return 400. Authorization failures return 403.
Conflicts return 409. The filing budget returns 429.
@@ -65,6 +65,40 @@ Explicit requests, automatic SSH requests, and new ceremonies share a per-proces
The budget permits 32 attempts per 60-second window.
Reads and granted SSH operations do not spend that budget.
## Harness routes in the world
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 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.
Records retain their owner and parent references.
- `GET /harness/conversations/{id}/entries?after=0`: `{entries:[...]}` in ascending entry order.
- `GET /harness/conversations/{id}/stream?after=0`: newline-delimited entry records, then new entries as commits arrive.
- `POST /harness/conversations/{id}/submit`: `{content:"text",requestId:"stable-id"}`.
The response is 202 with `{submissionId:number}`.
Duplicate request IDs use pi-durable's existing idempotent admission.
- `POST /harness/conversations/{id}/stop`: abort the conversation's active turns, including their owned work.
The response is `{ok:true}`. Queued inputs remain.
- `GET /harness/events?after=0`: newline-delimited T43 event records in ascending event order.
- `POST /harness/extensions/{name}/actions/{action}`: `{args:{...},conversationId?:number}`.
Only names in the extension's `userActions` export are callable.
The response is `{result:...}`. Disconnect aborts the action's signal.
- `GET /harness/models`: `{models:[...]}` with public catalog fields, without provider authentication data.
- `GET /harness/families`: `{families:{...}}` from the world's model definitions.
- `GET /harness/extensions`: the loaded extension status, also visible through `/proc/extensions`.
- `GET /harness/credential-requests`: `{requests:[...]}` with pending conversation requests and host approval links.
Approval stays on the host page.
The `after` cursor is an exclusive entry ID. Streams replay retained records before live records.
Commit observation starts before replay, so a concurrent commit cannot create a gap.
Clients resume from the last record ID after a disconnect.
Disconnect closes the observation. Harness shutdown closes all streams.
Responses use JSON, except streams use `application/x-ndjson`.
Invalid inputs return 400. Missing routes, conversations, and actions return 404.
An unavailable harness returns 503. Action failures return 500 with the action's error message.
## Decisions
- `POST /cred/requests/{id}/challenge`: `{"decision":"approve","overrides":{...}}`.
+99
View File
@@ -0,0 +1,99 @@
//! Route harness requests into its V8 inbox and preserve incremental NDJSON replies.
use bytes::Bytes;
use http::{Request, Response};
use http_body_util::{BodyExt, Full};
use serde_json::{Value, json};
use std::{
pin::Pin,
task::{Context, Poll},
};
use tokio::sync::mpsc;
struct Body(mpsc::UnboundedReceiver<Value>);
impl http_body::Body for Body {
type Data = Bytes;
type Error = wasmtime_wasi_http::Error;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Bytes>, Self::Error>>> {
self.0.poll_recv(cx).map(|value| {
value.map(|value| {
let mut bytes = serde_json::to_vec(&value).expect("harness JSON");
bytes.push(b'\n');
Ok(http_body::Frame::data(Bytes::from(bytes)))
})
})
}
}
fn json_response(status: u16, value: Value) -> Response<wasmtime_wasi_http::WasiBody> {
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(
Full::new(Bytes::from(value.to_string()))
.map_err(|never| match never {})
.boxed_unsync(),
)
.unwrap()
}
pub(super) async fn handle(
api: &super::Api,
caller: crate::cred::Caller,
mut request: Request<Bytes>,
) -> Response<wasmtime_wasi_http::WasiBody> {
if request.method() == http::Method::GET
&& request.uri().path() == "/harness/credential-requests"
{
*request.uri_mut() = "/cred/requests?state=pending".parse().unwrap();
let response = super::handle(api, caller, request).await;
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()
));
}
value = json!({"requests":value});
}
return json_response(response.status().as_u16(), value);
}
let sender = api.harness.lock().unwrap().clone();
let Some(sender) = sender else {
return json_response(503, json!({"error":"harness_unavailable"}));
};
let body = if request.body().is_empty() {
Value::Null
} else {
match serde_json::from_slice(request.body()) {
Ok(body) => body,
Err(_) => return json_response(400, json!({"error":"invalid_json"})),
}
};
let (reply, mut receiver) = mpsc::unbounded_channel();
let message = crate::harness::input::Input::Api {
id: uuid::Uuid::new_v4().to_string(),
method: request.method().to_string(),
path: request.uri().path_and_query().unwrap().to_string(),
body,
reply,
};
if sender.send(message).is_err() {
return json_response(503, json!({"error":"harness_unavailable"}));
}
let Some(first) = receiver.recv().await else {
return json_response(503, json!({"error":"harness_stopped"}));
};
let status = first["status"].as_u64().unwrap_or(500) as u16;
if first["stream"] == true {
Response::builder()
.status(status)
.header("content-type", "application/x-ndjson")
.body(Body(receiver).boxed_unsync())
.unwrap()
} else {
json_response(status, first["body"].clone())
}
}
+45 -3
View File
@@ -2,7 +2,39 @@
//! Host-owned pages use passkeys and typed proofs for every authorization ceremony.
mod auth;
mod cargo;
pub(crate) use cargo::stream;
mod harness;
/// Dispatch body-producing routes before the buffered credential API.
pub(crate) async fn dispatch(
api: &Api,
caller: Caller,
request: Request<Bytes>,
) -> Response<wasmtime_wasi_http::WasiBody> {
use http_body_util::{BodyExt, Full};
enum Handler {
Cargo,
Harness,
}
let routes = [("/cargo", Handler::Cargo), ("/harness/", Handler::Harness)];
for (path, handler) in routes {
if (path.ends_with('/') && request.uri().path().starts_with(path))
|| request.uri().path() == path
{
match handler {
Handler::Cargo if request.method() == http::Method::POST => {
return cargo::stream(api, request);
}
Handler::Harness => return harness::handle(api, caller, request).await,
_ => break,
}
}
}
handle(api, caller, request).await.map(|body| {
Full::new(body)
.map_err(|never| match never {})
.boxed_unsync()
})
}
mod pages;
mod server;
mod ssh;
@@ -22,6 +54,8 @@ use std::{
pub struct Api {
pub cred: Arc<Cred>,
harness: Mutex<Option<crate::harness::input::Sender>>,
approval_origin: String,
pub(crate) builder: std::sync::OnceLock<Arc<crate::builder::Builder>>,
script: cred::quickjs::QuickJs,
pub passkeys: Mutex<Passkeys<rusqlite::Connection>>,
@@ -38,11 +72,13 @@ impl Api {
origin: &str,
) -> anyhow::Result<Self> {
let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?;
Ok(Self::new(
let mut api = Self::new(
cred,
Passkeys::new(db, rp_id, origin)?,
EntryKey::open(&host_dir.join("entry.key"))?,
))
);
api.approval_origin = origin.trim_end_matches('/').to_owned();
Ok(api)
}
/// Passkeys owns the RP configuration. Its database must remain host-only.
pub fn new(
@@ -52,6 +88,8 @@ impl Api {
) -> Self {
Self {
cred: cred.into(),
harness: Mutex::new(None),
approval_origin: String::new(),
builder: Default::default(),
script: cred::quickjs::QuickJs::default(),
passkeys: Mutex::new(passkeys),
@@ -60,6 +98,10 @@ impl Api {
journal: Mutex::new(None),
}
}
/// Attach the same inbox that console submissions use.
pub fn attach_harness(&self, sender: crate::harness::input::Sender) {
*self.harness.lock().unwrap() = Some(sender);
}
pub(crate) fn attach_journal(&self, journal: Option<Arc<sausage_vfs::journal::Journal>>) {
*self.journal.lock().unwrap() = journal;
}
+9 -1
View File
@@ -120,6 +120,8 @@ pub async fn run_with_userland(
crate::process::default_script(),
Some(api.clone()),
)?;
let (sender, receiver) = crate::harness::input::channel();
api.attach_harness(sender.clone());
let listener = Listener::start_with_api(api, options)?;
runtime
.network_policy()
@@ -129,7 +131,13 @@ pub async fn run_with_userland(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(crate::harness::run_console(world, Arc::new(runtime), cred))
.block_on(crate::harness::run_console_with_input(
world,
Arc::new(runtime),
cred,
sender,
receiver,
))
})
.await
.map_err(anyhow::Error::from)