diff --git a/README.md b/README.md index fe82ea6..ca66579 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ At world creation, the host copies its tools into `/bin` as `.wasm` fil The bundle ships plain source files. `sausage init` creates their repository in `/src/sausage` with one commit on `main`. The world config lives at `/etc/sausage/config.yaml`. It defines the proxy, standing agent, roster, and shared system prompt. -`init` fills proxy settings from `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` when present. It asks nothing. +Run `sausage init`, then fill `proxy.base_url` and `proxy.api_key` in that config, then run `sausage run`. The host and chat can start with an empty proxy key. Model requests then name the config file in their error. The proxy key in that file is intentionally visible to agents. Other managed secrets stay outside the world. The chat and console use the configured standing conversation. A rename keeps that conversation. @@ -26,7 +26,8 @@ Model, prompt, and proxy edits apply without a host restart. The host creates `world/` for the shared filesystem and `host/` for private runtime state under the supplied directory. Services come from `/etc/systemd/system` in the world. `web.service` serves fresh `/srv/www` files on port 8080. -Use `http://localhost:8080` for chat and `/api/register` for passkey registration. +Use `http://localhost:8080` for chat, passkeys, approvals, and secret entry. +The first-passkey notice opens the Passkeys view. Pending requests link to `/#approve/ID`. The `/api/` mount forwards to `host.sausage`, including NDJSON streams. The host opens no separate port. ## Repository diff --git a/docs/RUNNING.md b/docs/RUNNING.md index f02048d..4c4e4fe 100644 --- a/docs/RUNNING.md +++ b/docs/RUNNING.md @@ -1,6 +1,6 @@ # Run Sausage v3 on Citadel -Use the web chat for conversations. The same web port serves host credential pages through `/api/`. +Use the web UI for conversations, passkeys, approvals, and secret entry. The host serves only JSON through `/api/`. **TL;DR:** Start a separate v3 world, create the SSH tunnel below, and open http://localhost:8080. The Nix package supplies the userland bundle, source tree, and enabled chat service. @@ -75,7 +75,7 @@ The chat selects the configured standing agent by default. Choose another conver Root #1 remains only as history. A standing-agent rename keeps the same conversation. 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. +The pending-requests banner opens the approval view in the page. The host verifies the passkey assertion. Agents edit `/srv/www/index.html`. Each request reads the file fresh without a build or restart. The terminal console remains optional. Each nonempty console line submits a message to the standing conversation. @@ -105,7 +105,7 @@ From the laptop, create one SSH tunnel: ssh -N -L 8080:127.0.0.1:8080 citadel.vpn.surma.technology ``` -Open **http://localhost:8080** for chat and **http://localhost:8080/api/register** for the first passkey. +Open **http://localhost:8080**. The first-passkey notice opens the Passkeys view. The host derives the passkey RP ID and origin from the browser request Origin header. For direct VPN access, passkeys need HTTPS because non-localhost HTTP lacks a secure context. Passkeys registered for localhost do not transfer to a production hostname. @@ -113,13 +113,13 @@ Do not expose the server to other people before the first registration. ### Register, create an item, enter a secret -1. Open `/api/register` and choose a label for the first passkey. +1. Select **Register your first passkey** in the chat notice and choose a label. 2. Complete the browser's passkey ceremony. 3. Ask the agent to file an item creation request through `POST /cred/requests`. -4. Review the request on `/api/approve/REQUEST_ID` and approve it with your passkey. +4. Select the request in the pending-requests banner and approve it with your passkey. 5. Read the item's public metadata to find its item ID and stable field ID. -6. Open `/api/entry/ITEM_ID/FIELD_ID` on the web origin. -7. Enter the secret in the blank host form and complete its passkey ceremony. +6. Select **Enter a secret**, then select the item's secret field. +7. Enter the secret in the blank form and complete its passkey ceremony. Until the first passkey exists, D53 permits unlocked decisions and first registration. After registration, decisions require a passkey. Additional registrations require an existing passkey. @@ -139,12 +139,21 @@ They do not return stored secret values. ### Approve a request The agent or world UI obtains pending requests from `/harness/credential-requests` or `/cred/requests?state=pending`. -Follow the `/api/approve/ID` link on the web origin through the tunnel. +Follow the `/#approve/ID` link on the web origin through the tunnel. Review the stored summary and public details, choose limits when applicable, then approve or deny. The world can file a request. It cannot approve its own request. -Sources: [`host API`](../host/src/api/README.md), [`pages.rs`](../host/src/api/pages.rs), -[`edits.rs`](../host/src/cred/edits.rs), and [`app.js`](../host/src/api/app.js). +For a manual Chrome check against a fresh world on port 8080, run: + +```sh +node userland/web/browser-check.mjs +``` + +The script uses agent-browser and a CDP virtual authenticator. It registers a passkey and approves an item creation request. +Set `AGENT_BROWSER_EXECUTABLE_PATH` if Chrome needs an explicit path. + +Sources: [`host API`](../host/src/api/README.md), [`credential views`](../userland/web/credentials-view.js), +[`edits.rs`](../host/src/cred/edits.rs), and [`browser cryptography`](../userland/web/credentials.js). ## Models @@ -155,8 +164,8 @@ There are no model families or project overlays. Every agent receives the global `prompt` before its role prompt. The shared prompt explains how agents inspect their own model and the live harness state. -`sausage init` fills `proxy.base_url` and `proxy.api_key` from `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` when present. -Otherwise, it leaves them empty. The host and chat still start. +Run `sausage init`, fill `proxy.base_url` and `proxy.api_key` in the config, then run `sausage run`. +Init leaves both settings empty. The host and chat still start. A model request with missing settings fails with an error that names the config file. The proxy no longer requires an item in the credentials manager. The proxy key is intentionally visible to agents. Do not print it or commit the world config. @@ -225,7 +234,7 @@ World programs call **`http://host.sausage`** through standard WASI HTTP. The world web server forwards `/api/PATH` to `http://host.sausage/PATH` through WASI HTTP. It streams response chunks instead of buffering the entire NDJSON body. The bootstrap page polls `entries?after=` once per second rather than using a persistent stream. -Keep credential approval and secret entry on host pages. +Use the credential views in the web UI. They call only `/api/cred/...` JSON routes. | Method and route | Result or request | | --- | --- | @@ -238,7 +247,7 @@ Keep credential approval and secret entry on host pages. | `POST /harness/extensions/NAME/actions/ACTION` | `{args:{...},conversationId?:number}` invokes an exported user action. | | `GET /harness/models` | Public model catalog without authentication data. | | `GET /harness/extensions` | Loaded extension status. | -| `GET /harness/credential-requests` | Pending conversation requests with host approval links. | +| `GET /harness/credential-requests` | Pending conversation requests with `/#approve/ID` links. | The `after` cursor is exclusive. Reconnect with the last record ID. Streams close when the client disconnects or the harness stops. diff --git a/host/src/api/README.md b/host/src/api/README.md index 3e8b9c5..6117f8f 100644 --- a/host/src/api/README.md +++ b/host/src/api/README.md @@ -1,4 +1,4 @@ -# Host API and host pages +# Host JSON API `handle(&Api, Caller, Request)` answers requests without a transport. The `wasi:http` hook supplies the caller from the process table. @@ -17,21 +17,11 @@ The host opens no separate network port. Browser ceremonies derive the passkey RP ID and origin from the request Origin header. The host retains separate pending ceremony state per origin against the same credential registry. After the first registration, every decision and further registration requires a passkey assertion. -Host pages use relative assets and routes so they work under `/api/`. - -## Pages and assets - -- `GET /approve/{request}` renders the host's stored summary and public details. - The page sends only the human's decision and optional limit overrides. - The host builds the binding from the stored request and its payload digest. -- `GET /register` registers the first passkey or adds another passkey. -- `GET /entry/{item}/{field}` displays a blank secret-entry form. - The host embeds its public encryption key and the selected field's public metadata. -- `GET /app.js` serves the shared ceremony code and WebCrypto encryption. - -The checked-in pages use plain HTML and JavaScript with no build step or external dependencies. -The host escapes review data before it enters HTML. -No page includes a stored secret value. +The host serves no HTML or UI assets. The world web UI owns credential views in `/srv/www`. +The Passkeys view lists labels and registers passkeys. The bootstrap notice opens that view. +The approval view at `/#approve/ID` reads stored summaries and details through JSON routes. +The secret-entry view at `/#entry/ITEM/FIELD` encrypts the secret before submission. +The host constructs authorization bindings from stored requests and verifies every proof. ## Public JSON routes @@ -76,8 +66,8 @@ The V8 harness answers queries and invokes actions without a second storage read - `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. +- `GET /harness/credential-requests`: `{requests:[...]}` with pending conversation requests and `approvalUrl: "/#approve/ID"` links. + The web UI opens its internal approval view. 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. diff --git a/host/src/api/approve.html b/host/src/api/approve.html deleted file mode 100644 index 3c6f045..0000000 --- a/host/src/api/approve.html +++ /dev/null @@ -1,11 +0,0 @@ - - -Approve a credential request - -

Review the credential request

-

{{summary}}

{{details}}
-
Grant limits - -
-
-

diff --git a/host/src/api/entry.html b/host/src/api/entry.html deleted file mode 100644 index ce620bc..0000000 --- a/host/src/api/entry.html +++ /dev/null @@ -1,8 +0,0 @@ - - -Enter a credential secret - -

Enter a secret for {{item_name}}: {{field_name}}

-

This host page encrypts the value before it sends the request.

-
-

diff --git a/host/src/api/harness.rs b/host/src/api/harness.rs index 8cf7f27..8899726 100644 --- a/host/src/api/harness.rs +++ b/host/src/api/harness.rs @@ -51,7 +51,7 @@ pub(super) async fn handle( if let Some(requests) = value.as_array_mut() { for request in requests { request["approvalUrl"] = - json!(format!("/api/approve/{}", request["id"].as_str().unwrap())); + json!(format!("/#approve/{}", request["id"].as_str().unwrap())); } value = json!({"requests":value}); } diff --git a/host/src/api/mod.rs b/host/src/api/mod.rs index ac80507..d7ec0c1 100644 --- a/host/src/api/mod.rs +++ b/host/src/api/mod.rs @@ -1,5 +1,5 @@ //! JSON routes for host.sausage. The transport supplies the trusted caller. -//! Host-owned pages use passkeys and typed proofs for every authorization ceremony. +//! JSON ceremonies use passkeys and typed proofs for every authorization. mod auth; mod cargo; mod harness; @@ -39,7 +39,6 @@ pub(crate) async fn dispatch( .boxed_unsync() }) } -mod pages; mod server; mod ssh; mod undo; @@ -238,15 +237,7 @@ fn respond(ctx: &Api, caller: Caller, request: Request) -> Response return response, - Ok(None) => route(ctx, &caller, request), - Err(error) => Err(error), - } - } else { - route(ctx, &caller, request) - }; + let result = route(ctx, &caller, request); let (status, body) = match result { Ok(body) => ( if body.get("error").and_then(Value::as_str) == Some("grant_required") { diff --git a/host/src/api/pages.rs b/host/src/api/pages.rs deleted file mode 100644 index ec9ece8..0000000 --- a/host/src/api/pages.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Host-owned HTML and JavaScript. Public review data enters HTML only as escaped text. -use super::{Api, Error, Result, request_view}; -use bytes::Bytes; -use http::Response; - -fn escape(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} -pub(super) fn get(ctx: &Api, path: &str) -> Result>> { - let parts: Vec<_> = path.trim_matches('/').split('/').collect(); - let (mime, text) = match parts.as_slice() { - ["app.js"] => ( - "text/javascript; charset=utf-8", - include_str!("app.js").to_string(), - ), - ["register"] => ( - "text/html; charset=utf-8", - include_str!("register.html").to_string(), - ), - ["approve", id] => { - let request = ctx - .cred - .request_list()? - .into_iter() - .find(|request| request.id == *id) - .ok_or(Error(404, "request_missing"))?; - let items = request - .payload - .get("items") - .and_then(serde_json::Value::as_array) - .cloned() - .unwrap_or_default(); - let grant = request - .payload - .get("grant_create") - .unwrap_or(&request.payload); - let grant_request = request.payload.get("grant_create").is_some() || !items.is_empty(); - let duration = grant - .get("duration_secs") - .and_then(serde_json::Value::as_u64) - .or_else(|| { - grant - .get("exp") - .and_then(serde_json::Value::as_i64) - .map(|expiry| { - expiry.saturating_sub(crate::cred::store::now()).max(1) as u64 - }) - }) - .unwrap_or(3600); - let uses = grant - .get("max_uses") - .and_then(serde_json::Value::as_u64) - .map(|uses| uses.to_string()) - .unwrap_or_default(); - let mut review = request_view(request)?; - if !items.is_empty() { - review["items"] = super::value( - ctx.cred - .item_list()? - .into_iter() - .filter(|item| items.iter().any(|id| id.as_str() == Some(&item.id))) - .collect::>(), - )?; - } - let text = include_str!("approve.html") - .replace( - "{{grant_request}}", - if grant_request { "true" } else { "false" }, - ) - .replace( - "{{limits_hidden}}", - if grant_request { "" } else { "hidden disabled" }, - ) - .replace("{{duration}}", &duration.to_string()) - .replace("{{uses}}", &uses) - .replace("{{request_id}}", &escape(id)) - .replace( - "{{summary}}", - &escape(review["summary"].as_str().expect("summary string")), - ) - .replace( - "{{details}}", - &escape( - &serde_json::to_string_pretty(&review) - .map_err(|_| Error(500, "serialization"))?, - ), - ); - ("text/html; charset=utf-8", text) - } - ["entry", item, field] => { - let snapshot = ctx.cred.item_get(item)?; - let selected = super::auth::field(ctx, item, field)?; - let text = include_str!("entry.html") - .replace("{{item_id}}", &escape(item)) - .replace("{{field_id}}", &escape(field)) - .replace("{{item_name}}", &escape(&snapshot.name)) - .replace("{{field_name}}", &escape(&selected.name)) - .replace( - "{{public_key}}", - &serde_json::to_string(&ctx.entry.public_key()) - .map_err(|_| Error(500, "serialization"))?, - ); - ("text/html; charset=utf-8", text) - } - _ => return Ok(None), - }; - Ok(Some( - Response::builder() - .header("content-type", mime) - .body(Bytes::from(text)) - .expect("static headers"), - )) -} diff --git a/host/src/api/register.html b/host/src/api/register.html deleted file mode 100644 index 998350a..0000000 --- a/host/src/api/register.html +++ /dev/null @@ -1,6 +0,0 @@ - - -Register a passkey -

Register a passkey

The first passkey needs no gate. Later passkeys require an existing passkey.

-
-

diff --git a/host/src/api/server.rs b/host/src/api/server.rs index e584ec2..9036687 100644 --- a/host/src/api/server.rs +++ b/host/src/api/server.rs @@ -40,18 +40,7 @@ pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow:: }) .await .map_err(anyhow::Error::from) - .and_then(|result| result) - .map_err(|error| { - let mut text = error.to_string(); - for key in ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"] { - if let Ok(value) = std::env::var(key) - && !value.is_empty() - { - text = text.replace(&value, "[redacted]"); - } - } - anyhow::anyhow!(text) - }); + .and_then(|result| result); services.shutdown().await; result } diff --git a/host/src/harness/mod.rs b/host/src/harness/mod.rs index 5ba647c..0327570 100644 --- a/host/src/harness/mod.rs +++ b/host/src/harness/mod.rs @@ -118,17 +118,6 @@ async fn run_with_shared_credentials( .block_on(run_console(world, exec, cred)) }) .await? - .map_err(|error: anyhow::Error| { - let mut text = error.to_string(); - for key in ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"] { - if let Ok(value) = std::env::var(key) - && !value.is_empty() - { - text = text.replace(&value, "[redacted]"); - } - } - anyhow::anyhow!(text) - }) } /// Evaluate a bundled harness module with the same storage and tool operations as the console. diff --git a/host/src/world.rs b/host/src/world.rs index 233df28..8123f46 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -3,7 +3,7 @@ //! Layout of a world directory: //! - `world/`: the virtual root `/` that every guest sees. Agents change everything in it. //! - `host/`: host state outside the sandbox (harness storage, later the vault). No guest sees it. -use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Open, Process}; +use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Process}; use std::{ os::unix::fs::PermissionsExt, path::{Path, PathBuf}, @@ -51,32 +51,6 @@ impl World { dir.display() ); let world = Self::open_with_userland(dir, Some(bundle))?; - let path = crate::harness::config::PATH; - let config = world.root.open(path, Open::default()).await?; - let mut text = String::from_utf8(config.read(0, 16 * 1024 * 1024).await?)?; - for (field, variable) in [ - ("base_url", "PI_PROXY_BASE_URL"), - ("api_key", "PI_PROXY_API_KEY"), - ] { - if let Ok(value) = std::env::var(variable) { - text = text.replace( - &format!("{field}: \"\""), - &format!("{field}: {}", serde_json::to_string(&value)?), - ); - } - } - let file = world - .root - .open( - path, - Open { - write: true, - truncate: true, - ..Default::default() - }, - ) - .await?; - file.write(0, text.into_bytes()).await?; let exec = crate::process::exec(Self { root: world.root.clone(), host_dir: world.host_dir.clone(), diff --git a/host/tests/it/api_auth.rs b/host/tests/it/api_auth.rs index 2757f6f..6b8617d 100644 --- a/host/tests/it/api_auth.rs +++ b/host/tests/it/api_auth.rs @@ -49,14 +49,11 @@ async fn page(api: &Api, path: &str) -> Result { http::Request::builder().uri(path).body(Bytes::new())?, ) .await; - assert_eq!(response.status(), 200); - assert_eq!( - response.headers()["content-type"], - "text/html; charset=utf-8" - ); + assert_eq!(response.status(), 404); + assert_eq!(response.headers()["content-type"], "application/json"); let text = std::str::from_utf8(response.body())?; assert!(!text.contains(SECRET)); - assert!(text.contains("app.js")); + assert!(text.contains("route_missing")); Ok(text.into()) } fn item(cred: &Cred, name: &str) -> Result { @@ -161,11 +158,9 @@ async fn approvals_registration_and_revocation() -> Result<()> { let id = file(&api, &second.id, "approval").await?; let other = file(&api, &second.id, "other").await?; let path = format!("/cred/requests/{id}"); - let review = page(&api, &format!("/approve/{id}")).await?; - assert!(review.contains("data-grant=\"true\"")); - assert!(review.contains("value=\"300\"")); - assert!(review.contains("<script>not markup</script>")); - assert!(!review.contains("")); + page(&api, &format!("/approve/{id}")).await?; + let review = ok(&api, "GET", &path, json!({})).await?; + assert_eq!(review["payload"]["duration_secs"], 300); assert_ne!( send( &api, @@ -268,9 +263,7 @@ async fn approvals_registration_and_revocation() -> Result<()> { assert!(api.cred.grant_status(&grant.id)?.revoked); let edit = ok(&api,"POST","/cred/requests",json!({"kind":"edit","key":"rename","request":{"item":second.id,"base_version":api.cred.item_get(&second.id)?.version,"change":[{"op":"set_name","name":"renamed"}]}})).await?; let edit = edit["request"].as_str().unwrap(); - let edit_page = page(&api, &format!("/approve/{edit}")).await?; - assert!(edit_page.contains("data-grant=\"false\"")); - assert!(edit_page.contains("