host: expose harness state and actions over the world API
This commit is contained in:
+36
-2
@@ -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":{...}}`.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user