Merge v3 with the generic web server in the shared workspace
This commit is contained in:
+10
-22
@@ -9,27 +9,15 @@ Credential errors use `{"error":"kind","message":"kind"}` with static, secret-fr
|
||||
Missing routes return 404. Invalid parameters return 400. Authorization failures return 403.
|
||||
Conflicts return 409. The filing budget returns 429.
|
||||
|
||||
## Host listener
|
||||
## World web mount
|
||||
|
||||
`sausage run WORLD` creates one `Cred`, one `Arc<Api>`, and one process runtime.
|
||||
The API owns its passkey connection and retains ceremony and rate-limit state.
|
||||
The hook and the host listener share that API.
|
||||
The listener handles requests on a dedicated thread.
|
||||
The console shares the credential store, including its decision notifications.
|
||||
After the listener binds, the runtime denies its port on every local address.
|
||||
The listener stops when the console returns.
|
||||
|
||||
Options on `sausage run`:
|
||||
|
||||
- `--api-listen 127.0.0.1:8090`: the default listener address.
|
||||
- `--api-rp-id localhost`: the default WebAuthn RP ID.
|
||||
- `--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.
|
||||
The world's UI can link to the host pages. It does not serve their code.
|
||||
The listener identifies browser calls as `/host/browser`, with the host PID and no conversation.
|
||||
Passkey proofs supply the approver identity independently of that caller.
|
||||
The world runs `web.wasm` on port 8080 and serves fresh files from `/srv/www`.
|
||||
It forwards `/api/PATH` to `http://host.sausage/PATH` through WASI HTTP, including streaming bodies.
|
||||
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
|
||||
|
||||
@@ -69,7 +57,7 @@ Reads and granted SSH operations do not spend that budget.
|
||||
|
||||
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 browser reaches these routes under `/api/harness/`.
|
||||
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.
|
||||
@@ -132,7 +120,7 @@ A replay or a mismatched binding cannot authorize another action.
|
||||
- `POST /cred/passkeys/register/finish`: `{"challenge_id":"id","response":{...}}`.
|
||||
|
||||
WebAuthn options and responses use the library's browser JSON formats.
|
||||
The host retains the RP configuration and pending ceremony state.
|
||||
The host derives the RP configuration from each browser origin and retains pending ceremony state.
|
||||
Restart discards pending ceremonies but retains registered passkeys.
|
||||
|
||||
## Secret entry
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
const bytes = text => Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
|
||||
const encoded = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
const post = async (path, body) => {
|
||||
const response = await fetch(path, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)});
|
||||
const response = await fetch(new URL(path.replace(/^\//, ""), import.meta.url), {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.message);
|
||||
return result;
|
||||
@@ -56,7 +56,7 @@ form.addEventListener('submit', async event => {
|
||||
await post(`${path}/decide`, challenge.unlocked ? {decision, overrides} : await assertion(challenge));
|
||||
} else if (document.body.dataset.page === 'register') {
|
||||
const label = data.get('label');
|
||||
const state = await (await fetch('/cred/passkeys')).json();
|
||||
const state = await (await fetch(new URL('cred/passkeys', import.meta.url))).json();
|
||||
let gate;
|
||||
if (state.length) {
|
||||
const proof = await assertion(await post('/cred/passkeys/register/challenge', {label}));
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
<label>Duration in seconds <input name="duration" type="number" min="1" required value="{{duration}}"></label>
|
||||
<label>Maximum uses (optional override) <input name="uses" type="number" min="1" value="{{uses}}" placeholder="Use the requested limit"></label></fieldset>
|
||||
<button name="decision" value="approve">Approve</button><button name="decision" value="deny">Deny</button></form>
|
||||
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
|
||||
<p role="status"></p><script type="module" src="../app.js"></script></body></html>
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
<h1>Enter a secret for {{item_name}}: {{field_name}}</h1>
|
||||
<p>This host page encrypts the value before it sends the request.</p>
|
||||
<form><label>Secret value <textarea name="secret" required autocomplete="off" spellcheck="false"></textarea></label><button>Store the secret</button></form>
|
||||
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
|
||||
<p role="status"></p><script type="module" src="../../app.js"></script></body></html>
|
||||
|
||||
@@ -50,11 +50,8 @@ pub(super) async fn handle(
|
||||
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()
|
||||
));
|
||||
request["approvalUrl"] =
|
||||
json!(format!("/api/approve/{}", request["id"].as_str().unwrap()));
|
||||
}
|
||||
value = json!({"requests":value});
|
||||
}
|
||||
|
||||
+54
-6
@@ -8,8 +8,12 @@ mod harness;
|
||||
pub(crate) async fn dispatch(
|
||||
api: &Api,
|
||||
caller: Caller,
|
||||
request: Request<Bytes>,
|
||||
mut request: Request<Bytes>,
|
||||
) -> Response<wasmtime_wasi_http::WasiBody> {
|
||||
// WASI reserves Host. The world web mount carries the browser host in this header.
|
||||
if let Some(host) = request.headers_mut().remove("x-forwarded-host") {
|
||||
request.headers_mut().insert(http::header::HOST, host);
|
||||
}
|
||||
use http_body_util::{BodyExt, Full};
|
||||
enum Handler {
|
||||
Cargo,
|
||||
@@ -39,7 +43,7 @@ mod pages;
|
||||
mod server;
|
||||
mod ssh;
|
||||
mod undo;
|
||||
pub use server::{Listener, Options, run_with_userland};
|
||||
pub use server::run_with_userland;
|
||||
|
||||
use crate::cred::{self, Caller, Cred, entry::EntryKey, webauthn::Passkeys};
|
||||
use bytes::Bytes;
|
||||
@@ -55,7 +59,8 @@ use std::{
|
||||
pub struct Api {
|
||||
pub cred: Arc<Cred>,
|
||||
harness: Mutex<Option<crate::harness::input::Sender>>,
|
||||
approval_origin: String,
|
||||
host_dir: Option<std::path::PathBuf>,
|
||||
origins: Mutex<HashMap<String, Arc<Api>>>,
|
||||
pub(crate) builder: std::sync::OnceLock<Arc<crate::builder::Builder>>,
|
||||
script: cred::quickjs::QuickJs,
|
||||
pub passkeys: Mutex<Passkeys<rusqlite::Connection>>,
|
||||
@@ -64,7 +69,7 @@ pub struct Api {
|
||||
journal: Mutex<Option<Arc<sausage_vfs::journal::Journal>>>,
|
||||
}
|
||||
impl Api {
|
||||
/// Own the connection and ceremony state shared by both transports.
|
||||
/// Own the host-only store and per-origin browser ceremony state.
|
||||
pub fn open(
|
||||
cred: Arc<Cred>,
|
||||
host_dir: &std::path::Path,
|
||||
@@ -77,7 +82,7 @@ impl Api {
|
||||
Passkeys::new(db, rp_id, origin)?,
|
||||
EntryKey::open(&host_dir.join("entry.key"))?,
|
||||
);
|
||||
api.approval_origin = origin.trim_end_matches('/').to_owned();
|
||||
api.host_dir = Some(host_dir.to_owned());
|
||||
Ok(api)
|
||||
}
|
||||
/// Passkeys owns the RP configuration. Its database must remain host-only.
|
||||
@@ -89,7 +94,8 @@ impl Api {
|
||||
Self {
|
||||
cred: cred.into(),
|
||||
harness: Mutex::new(None),
|
||||
approval_origin: String::new(),
|
||||
host_dir: None,
|
||||
origins: Mutex::new(HashMap::new()),
|
||||
builder: Default::default(),
|
||||
script: cred::quickjs::QuickJs::default(),
|
||||
passkeys: Mutex::new(passkeys),
|
||||
@@ -190,6 +196,48 @@ pub async fn handle(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Respo
|
||||
respond(ctx, caller, request)
|
||||
}
|
||||
fn respond(ctx: &Api, caller: Caller, request: Request<Bytes>) -> Response<Bytes> {
|
||||
if request.uri().path().starts_with("/cred/")
|
||||
&& let Some(host_dir) = &ctx.host_dir
|
||||
&& let Some(origin) = request
|
||||
.headers()
|
||||
.get("origin")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
let api = (|| -> anyhow::Result<Arc<Api>> {
|
||||
let url = url::Url::parse(origin)?;
|
||||
let rp = url
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing origin host"))?;
|
||||
let mut origins = ctx.origins.lock().unwrap();
|
||||
if let Some(api) = origins.get(origin) {
|
||||
return Ok(api.clone());
|
||||
}
|
||||
let mut api = Api::open(ctx.cred.clone(), host_dir, rp, origin)?;
|
||||
api.host_dir = None;
|
||||
let api = Arc::new(api);
|
||||
origins.insert(origin.to_owned(), api.clone());
|
||||
Ok(api)
|
||||
})();
|
||||
return match api {
|
||||
Ok(api) => respond(&api, caller, request),
|
||||
Err(_) => Response::builder()
|
||||
.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.body(Bytes::from_static(b"{\"error\":\"invalid_origin\"}"))
|
||||
.unwrap(),
|
||||
};
|
||||
}
|
||||
if ctx.host_dir.is_some()
|
||||
&& request.method() == http::Method::POST
|
||||
&& request.uri().path().starts_with("/cred/")
|
||||
&& request.uri().path().contains("/register/")
|
||||
{
|
||||
return Response::builder()
|
||||
.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.body(Bytes::from_static(b"{\"error\":\"origin_required\"}"))
|
||||
.unwrap();
|
||||
}
|
||||
let result = if request.method() == http::Method::GET {
|
||||
match pages::get(ctx, request.uri().path()) {
|
||||
Ok(Some(response)) => return response,
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
<title>Register a passkey</title><body data-page="register">
|
||||
<h1>Register a passkey</h1><p>The first passkey needs no gate. Later passkeys require an existing passkey.</p>
|
||||
<form><label>Label <input name="label" required autocomplete="off"></label><button>Register</button></form>
|
||||
<p role="status"></p><script type="module" src="/app.js"></script></body></html>
|
||||
<p role="status"></p><script type="module" src="app.js"></script></body></html>
|
||||
|
||||
+6
-108
@@ -1,114 +1,17 @@
|
||||
//! The host HTTP listener and console startup share one owned API and credentials store.
|
||||
//! Start the world services and harness with one intercepted host API.
|
||||
use super::Api;
|
||||
use crate::cred::{Caller, Cred};
|
||||
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: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:8090")]
|
||||
pub api_origin: String,
|
||||
}
|
||||
|
||||
pub struct Listener {
|
||||
server: Arc<tiny_http::Server>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
impl Listener {
|
||||
pub fn start(cred: Arc<Cred>, host_dir: &Path, options: Options) -> anyhow::Result<Self> {
|
||||
let api = Arc::new(Api::open(
|
||||
cred,
|
||||
host_dir,
|
||||
&options.api_rp_id,
|
||||
&options.api_origin,
|
||||
)?);
|
||||
Self::start_with_api(api, options)
|
||||
}
|
||||
pub fn start_with_api(api: Arc<Api>, options: Options) -> anyhow::Result<Self> {
|
||||
let server = Arc::new(
|
||||
tiny_http::Server::http(options.api_listen)
|
||||
.map_err(|_| anyhow::anyhow!("host API address unavailable"))?,
|
||||
);
|
||||
let serving = server.clone();
|
||||
let thread = std::thread::spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("API runtime");
|
||||
for mut request in serving.incoming_requests() {
|
||||
let response = (|| -> anyhow::Result<_> {
|
||||
let mut body = Vec::new();
|
||||
request.as_reader().read_to_end(&mut body)?;
|
||||
let request = http::Request::builder()
|
||||
.method(request.method().as_str())
|
||||
.uri(request.url())
|
||||
.body(bytes::Bytes::from(body))?;
|
||||
Ok(runtime.block_on(super::handle(
|
||||
&api,
|
||||
Caller {
|
||||
program: "/host/browser".into(),
|
||||
pid: std::process::id(),
|
||||
conversations: Vec::new(),
|
||||
},
|
||||
request,
|
||||
)))
|
||||
})();
|
||||
let response = response.unwrap_or_else(|_| {
|
||||
http::Response::builder()
|
||||
.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.body(bytes::Bytes::from_static(
|
||||
b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}",
|
||||
))
|
||||
.expect("static response")
|
||||
});
|
||||
let content_type = response.headers()["content-type"].as_bytes();
|
||||
let reply = tiny_http::Response::from_data(response.body().to_vec())
|
||||
.with_status_code(response.status().as_u16())
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(b"Content-Type", content_type)
|
||||
.expect("static header"),
|
||||
);
|
||||
let _ = request.respond(reply);
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
server,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
pub fn address(&self) -> SocketAddr {
|
||||
self.server.server_addr().to_ip().expect("TCP listener")
|
||||
}
|
||||
}
|
||||
impl Drop for Listener {
|
||||
fn drop(&mut self) {
|
||||
self.server.unblock();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::cred::Cred;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
/// Preserve the console lifecycle with one API for guests and host pages.
|
||||
pub async fn run_with_userland(
|
||||
path: &Path,
|
||||
userland: Option<&Path>,
|
||||
options: Options,
|
||||
) -> anyhow::Result<()> {
|
||||
pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::Result<()> {
|
||||
let world = crate::world::World::open_with_userland(path, userland)?;
|
||||
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
||||
let api = Arc::new(Api::open(
|
||||
cred.clone(),
|
||||
&world.host_dir,
|
||||
&options.api_rp_id,
|
||||
&options.api_origin,
|
||||
"localhost",
|
||||
"http://localhost:8080",
|
||||
)?);
|
||||
let runtime = crate::process::Runtime::new_with_hooks(
|
||||
crate::world::World {
|
||||
@@ -122,10 +25,6 @@ pub async fn run_with_userland(
|
||||
)?;
|
||||
let (sender, receiver) = crate::harness::input::channel();
|
||||
api.attach_harness(sender.clone());
|
||||
let listener = Listener::start_with_api(api, options)?;
|
||||
runtime
|
||||
.network_policy()
|
||||
.deny_host_port(listener.address().port());
|
||||
let services = crate::process::supervise(runtime.clone());
|
||||
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
@@ -154,6 +53,5 @@ pub async fn run_with_userland(
|
||||
anyhow::anyhow!(text)
|
||||
});
|
||||
services.shutdown().await;
|
||||
drop(listener);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Fresh reads of the user-owned world configuration. Listener edits apply at host startup.
|
||||
//! Fresh reads of the user-owned world proxy configuration.
|
||||
use sausage_vfs::{Layer, Open};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
@@ -10,24 +10,10 @@ pub struct Proxy {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct Listener {
|
||||
pub address: std::net::SocketAddr,
|
||||
pub origin: String,
|
||||
}
|
||||
impl Default for Listener {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
address: "127.0.0.1:8090".parse().unwrap(),
|
||||
origin: "http://localhost:8090".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub proxy: Proxy,
|
||||
pub listener: Listener,
|
||||
}
|
||||
impl Config {
|
||||
pub async fn read(root: &Arc<dyn Layer>) -> anyhow::Result<Self> {
|
||||
|
||||
@@ -83,7 +83,7 @@ pub(super) fn console(sender: Sender) {
|
||||
pub fn stop_signal(
|
||||
sender: Sender,
|
||||
) -> std::io::Result<impl std::future::Future<Output = ()> + Send> {
|
||||
// Register before V8 starts, so a stop cannot arrive before the listener.
|
||||
// Register before V8 starts, so a stop cannot arrive before the receiver.
|
||||
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
|
||||
Ok(async move {
|
||||
|
||||
+1
-9
@@ -57,15 +57,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
world.display(),
|
||||
world.display()
|
||||
);
|
||||
let opened = sausage::world::World::open(&world)?;
|
||||
let config = sausage::harness::config::Config::read(&opened.root).await?;
|
||||
let origin = url::Url::parse(&config.listener.origin)?;
|
||||
let api = sausage::api::Options {
|
||||
api_listen: config.listener.address,
|
||||
api_rp_id: origin.host_str().unwrap_or("localhost").into(),
|
||||
api_origin: config.listener.origin,
|
||||
};
|
||||
sausage::api::run_with_userland(&world, None, api).await
|
||||
sausage::api::run_with_userland(&world, None).await
|
||||
}
|
||||
Command::Sh {
|
||||
world,
|
||||
|
||||
@@ -191,7 +191,7 @@ impl Runtime {
|
||||
cred.clone(),
|
||||
&world.host_dir,
|
||||
"localhost",
|
||||
"http://localhost:8090",
|
||||
"http://localhost:8080",
|
||||
)?),
|
||||
};
|
||||
router.attach_journal(world.root.journal());
|
||||
@@ -226,7 +226,7 @@ impl Runtime {
|
||||
cache::precompile(runtime.compiler.clone(), runtime.world.root.clone());
|
||||
Ok(runtime)
|
||||
}
|
||||
/// Register native listener ports through the shared network policy.
|
||||
/// Access the shared network policy for explicit host-port rules.
|
||||
pub fn network_policy(&self) -> &net::NetPolicy {
|
||||
&self.network
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Host-side network policy. `<dir>/host/network.toml` accepts `deny = ["address:port"]`.
|
||||
//! Host listeners register their ports with `deny_host_port`. Guests cannot bind or connect there.
|
||||
//! Explicit host-port rules supplement the address rules when needed.
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
net::{IpAddr, SocketAddr},
|
||||
|
||||
@@ -134,6 +134,9 @@ impl World {
|
||||
for folder in ["bin", "src"] {
|
||||
copy_tree(&bundle.join(folder), &root_dir.join(folder))?;
|
||||
}
|
||||
if bundle.join("srv").is_dir() {
|
||||
copy_tree(&bundle.join("srv"), &root_dir.join("srv"))?;
|
||||
}
|
||||
if bundle.join("etc").is_dir() {
|
||||
copy_tree(&bundle.join("etc"), &root_dir.join("etc"))?;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ async fn page(api: &Api, path: &str) -> Result<String> {
|
||||
);
|
||||
let text = std::str::from_utf8(response.body())?;
|
||||
assert!(!text.contains(SECRET));
|
||||
assert!(text.contains("/app.js"));
|
||||
assert!(text.contains("app.js"));
|
||||
Ok(text.into())
|
||||
}
|
||||
fn item(cred: &Cred, name: &str) -> Result<cred::items::Item> {
|
||||
@@ -338,7 +338,11 @@ async fn encrypt(api: &Api, public_key: Value) -> Result<Envelope> {
|
||||
..Default::default()
|
||||
});
|
||||
runtime.execute_script("browser.js","globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto; globalThis.TextEncoder = Deno.core.loadExtScript('ext:deno_web/08_text_encoding.js').TextEncoder; globalThis.document = {querySelector: () => ({addEventListener() {}})};")?;
|
||||
runtime.execute_script("app.js", std::str::from_utf8(script.body())?.to_string())?;
|
||||
runtime.execute_script(
|
||||
"app.js",
|
||||
std::str::from_utf8(script.body())?
|
||||
.replace("import.meta.url", "'http://localhost:8080/api/app.js'"),
|
||||
)?;
|
||||
runtime.execute_script("encrypt.js",format!("globalThis.done = encrypt({public_key},'{SECRET}').then(result => globalThis.envelope = JSON.stringify(result.envelope));"))?;
|
||||
runtime.run_event_loop(Default::default()).await?;
|
||||
let value = runtime.execute_script("result.js", "envelope")?;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
//! Host pages remain reachable. World config replaces listener flags on the CLI.
|
||||
use super::api_transport::{Listener, Options};
|
||||
// Host pages remain reachable. World config replaces listener flags on the CLI.
|
||||
use anyhow::Result;
|
||||
use sausage::{
|
||||
api::{Listener, Options},
|
||||
cred::Cred,
|
||||
};
|
||||
use sausage::cred::Cred;
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
sync::Arc,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! The host HTTP listener and console startup share one owned API and credentials store.
|
||||
use sausage::api::Api;
|
||||
use sausage::cred::{Caller, Cred};
|
||||
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: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:8090")]
|
||||
pub api_origin: String,
|
||||
}
|
||||
|
||||
pub struct Listener {
|
||||
server: Arc<tiny_http::Server>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
impl Listener {
|
||||
pub fn start(cred: Arc<Cred>, host_dir: &Path, options: Options) -> anyhow::Result<Self> {
|
||||
let api = Arc::new(Api::open(
|
||||
cred,
|
||||
host_dir,
|
||||
&options.api_rp_id,
|
||||
&options.api_origin,
|
||||
)?);
|
||||
Self::start_with_api(api, options)
|
||||
}
|
||||
pub fn start_with_api(api: Arc<Api>, options: Options) -> anyhow::Result<Self> {
|
||||
let server = Arc::new(
|
||||
tiny_http::Server::http(options.api_listen)
|
||||
.map_err(|_| anyhow::anyhow!("host API address unavailable"))?,
|
||||
);
|
||||
let serving = server.clone();
|
||||
let thread = std::thread::spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("API runtime");
|
||||
for mut request in serving.incoming_requests() {
|
||||
let response = (|| -> anyhow::Result<_> {
|
||||
let mut body = Vec::new();
|
||||
request.as_reader().read_to_end(&mut body)?;
|
||||
let mut builder = http::Request::builder()
|
||||
.method(request.method().as_str())
|
||||
.uri(request.url());
|
||||
for header in request.headers() {
|
||||
builder =
|
||||
builder.header(header.field.as_str().as_str(), header.value.as_str());
|
||||
}
|
||||
let request = builder.body(bytes::Bytes::from(body))?;
|
||||
Ok(runtime.block_on(sausage::api::handle(
|
||||
&api,
|
||||
Caller {
|
||||
program: "/host/browser".into(),
|
||||
pid: std::process::id(),
|
||||
conversations: Vec::new(),
|
||||
},
|
||||
request,
|
||||
)))
|
||||
})();
|
||||
let response = response.unwrap_or_else(|_| {
|
||||
http::Response::builder()
|
||||
.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.body(bytes::Bytes::from_static(
|
||||
b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}",
|
||||
))
|
||||
.expect("static response")
|
||||
});
|
||||
let content_type = response.headers()["content-type"].as_bytes();
|
||||
let reply = tiny_http::Response::from_data(response.body().to_vec())
|
||||
.with_status_code(response.status().as_u16())
|
||||
.with_header(
|
||||
tiny_http::Header::from_bytes(b"Content-Type", content_type)
|
||||
.expect("static header"),
|
||||
);
|
||||
let _ = request.respond(reply);
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
server,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
pub fn address(&self) -> SocketAddr {
|
||||
self.server.server_addr().to_ip().expect("TCP listener")
|
||||
}
|
||||
}
|
||||
impl Drop for Listener {
|
||||
fn drop(&mut self) {
|
||||
self.server.unblock();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
-26
@@ -19,13 +19,13 @@ async fn request(port: u16, path: &str, body: Option<&str>) -> anyhow::Result<(u
|
||||
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?;
|
||||
stream.write_all(format!("{method} {path} HTTP/1.1\r\nHost: localhost:{port}\r\nOrigin: http://localhost:{port}\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"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing HTTP headers for {path}"))?
|
||||
+ 4;
|
||||
let header = std::str::from_utf8(&bytes[..end])?;
|
||||
let status = header.split_whitespace().nth(1).unwrap().parse()?;
|
||||
@@ -33,7 +33,7 @@ async fn request(port: u16, path: &str, body: Option<&str>) -> anyhow::Result<(u
|
||||
}
|
||||
#[test]
|
||||
fn page_rendering() -> anyhow::Result<()> {
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/chat");
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/web");
|
||||
let page = std::fs::read_to_string(source.join("index.html"))?;
|
||||
let script = page
|
||||
.split_once("<script>")
|
||||
@@ -79,7 +79,7 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
|
||||
"--release",
|
||||
"--locked",
|
||||
"-p",
|
||||
"sausage-chat",
|
||||
"sausage-web",
|
||||
"-p",
|
||||
"sausage-init",
|
||||
"--target",
|
||||
@@ -96,30 +96,36 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
|
||||
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"),
|
||||
source.join("userland/web/web.service"),
|
||||
units.join("web.service"),
|
||||
)?;
|
||||
std::fs::write(units.join("multi-user.target.wants/chat.service"), "")?;
|
||||
std::fs::write(units.join("multi-user.target.wants/web.service"), "")?;
|
||||
std::fs::create_dir_all(bundle.path().join("bin"))?;
|
||||
for binary in ["chat", "init"] {
|
||||
for binary in ["web", "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"),
|
||||
&source.join("userland/web"),
|
||||
&bundle.path().join("src/sausage/userland/web"),
|
||||
)?;
|
||||
let www = bundle.path().join("srv/www");
|
||||
std::fs::create_dir_all(&www)?;
|
||||
std::fs::copy(
|
||||
source.join("userland/web/index.html"),
|
||||
www.join("index.html"),
|
||||
)?;
|
||||
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 seeded = dir.path().join("world/etc/systemd/system/web.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}"),
|
||||
"ExecStart=/bin/web.wasm",
|
||||
&format!("ExecStart=/bin/web.wasm --listen 127.0.0.1:{port}"),
|
||||
);
|
||||
std::fs::write(&seeded, &unit)?;
|
||||
let world = World::open_with_userland(dir.path(), Some(bundle.path()))?;
|
||||
@@ -130,12 +136,12 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
|
||||
);
|
||||
assert!(
|
||||
dir.path()
|
||||
.join("world/src/sausage/userland/chat/Cargo.toml")
|
||||
.join("world/src/sausage/userland/web/Cargo.toml")
|
||||
.is_file()
|
||||
);
|
||||
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
||||
let caller = Caller {
|
||||
program: "/bin/chat.wasm".into(),
|
||||
program: "/bin/web.wasm".into(),
|
||||
pid: 1,
|
||||
conversations: vec!["1".into()],
|
||||
};
|
||||
@@ -173,7 +179,7 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
|
||||
cred.clone(),
|
||||
&world.host_dir,
|
||||
"localhost",
|
||||
"http://localhost:8090",
|
||||
"http://localhost:8080",
|
||||
)?);
|
||||
let (sender, receiver) = input::channel();
|
||||
api.attach_harness(sender.clone());
|
||||
@@ -216,8 +222,17 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
|
||||
tokio::task::yield_now().await;
|
||||
};
|
||||
assert!(String::from_utf8_lossy(&page).contains("Sausage chat"));
|
||||
std::fs::write(dir.path().join("world/srv/www/index.html"), "Live edit")?;
|
||||
assert_eq!(request(port, "/", None).await?.1, b"Live edit");
|
||||
assert_eq!(request(port, "/missing", None).await?.0, 404);
|
||||
let (status, ndjson) = request(port, "/api/cargo", Some("{}")).await?;
|
||||
assert_eq!(status, 200);
|
||||
assert!(std::str::from_utf8(&ndjson)?.lines().count() >= 2);
|
||||
for line in std::str::from_utf8(&ndjson)?.lines() {
|
||||
let _: serde_json::Value = serde_json::from_str(line)?;
|
||||
}
|
||||
let root = loop {
|
||||
let (status, body) = request(port, "/api/conversations", None).await?;
|
||||
let (status, body) = request(port, "/api/harness/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()
|
||||
@@ -236,7 +251,7 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
|
||||
assert_eq!(
|
||||
request(
|
||||
port,
|
||||
&format!("/api/conversations/{root}/submit"),
|
||||
&format!("/api/harness/conversations/{root}/submit"),
|
||||
Some(&submit)
|
||||
)
|
||||
.await?
|
||||
@@ -247,7 +262,7 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
|
||||
loop {
|
||||
let (status, body) = request(
|
||||
port,
|
||||
&format!("/api/conversations/{root}/entries?after=0"),
|
||||
&format!("/api/harness/conversations/{root}/entries?after=0"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
@@ -268,18 +283,109 @@ async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
|
||||
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?;
|
||||
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
|
||||
stream.write_all(format!("GET /api/harness/conversations/{root}/stream?after=0 HTTP/1.1\r\nHost: localhost:{port}\r\nConnection: close\r\n\r\n").as_bytes()).await?;
|
||||
let mut streamed = Vec::new();
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
let mut chunk = [0; 4096];
|
||||
let count = stream.read(&mut chunk).await?;
|
||||
anyhow::ensure!(count > 0, "Stream closed before replay");
|
||||
streamed.extend_from_slice(&chunk[..count]);
|
||||
if String::from_utf8_lossy(&streamed).contains("Chat test reply") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await??;
|
||||
assert!(String::from_utf8_lossy(&streamed).contains("application/x-ndjson"));
|
||||
drop(stream);
|
||||
let (status, body) = request(port, "/api/harness/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}"))
|
||||
value["requests"].as_array().unwrap().iter().any(
|
||||
|r| r["id"] == pending && r["approvalUrl"] == format!("/api/approve/{pending}")
|
||||
)
|
||||
);
|
||||
assert_eq!(request(port, "/api/unknown", None).await?.0, 404);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&request(port, "/api/register", None).await?.1)
|
||||
.contains("Register a passkey")
|
||||
);
|
||||
use sausage::cred::webauthn::Challenge;
|
||||
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
||||
use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse};
|
||||
let origin = url::Url::parse(&format!("http://localhost:{port}"))?;
|
||||
let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||
let (status, start) = request(
|
||||
port,
|
||||
"/api/cred/passkeys/register/start",
|
||||
Some(r#"{"label":"Mounted browser"}"#),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, 200, "{}", String::from_utf8_lossy(&start));
|
||||
let start: Challenge<CreationChallengeResponse> = serde_json::from_slice(&start)?;
|
||||
let registration = authenticator.do_registration(origin.clone(), start.options)?;
|
||||
let finish =
|
||||
serde_json::json!({"challenge_id":start.id,"response":registration}).to_string();
|
||||
assert_eq!(
|
||||
request(port, "/api/cred/passkeys/register/finish", Some(&finish))
|
||||
.await?
|
||||
.0,
|
||||
200
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
port,
|
||||
"/api/cred/passkeys/register/start",
|
||||
Some(r#"{"label":"Ungated"}"#)
|
||||
)
|
||||
.await?
|
||||
.0,
|
||||
403
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(
|
||||
&request(port, &format!("/api/approve/{pending}"), None)
|
||||
.await?
|
||||
.1
|
||||
)
|
||||
.contains("Approve")
|
||||
);
|
||||
let choice = r#"{"decision":"approve","overrides":{}}"#;
|
||||
assert_ne!(
|
||||
request(
|
||||
port,
|
||||
&format!("/api/cred/requests/{pending}/decide"),
|
||||
Some(choice)
|
||||
)
|
||||
.await?
|
||||
.0,
|
||||
200
|
||||
);
|
||||
let challenge: Challenge<RequestChallengeResponse> = serde_json::from_slice(
|
||||
&request(
|
||||
port,
|
||||
&format!("/api/cred/requests/{pending}/challenge"),
|
||||
Some(choice),
|
||||
)
|
||||
.await?
|
||||
.1,
|
||||
)?;
|
||||
let assertion = authenticator.do_authentication(origin, challenge.options)?;
|
||||
let decision = serde_json::json!({"challenge_id":challenge.id,"assertion":assertion,"decision":"approve","overrides":{}}).to_string();
|
||||
assert_eq!(
|
||||
request(
|
||||
port,
|
||||
&format!("/api/cred/requests/{pending}/decide"),
|
||||
Some(&decision)
|
||||
)
|
||||
.await?
|
||||
.0,
|
||||
200
|
||||
);
|
||||
sender.send(Input::Stop)?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ pub fn write_config(dir: &Path, standing: &str, roster: serde_json::Value) -> an
|
||||
serde_yaml_ng::from_str(include_str!("../../../config/config.yaml"))?;
|
||||
config["standing"] = serde_json::json!(standing);
|
||||
config["roster"] = roster;
|
||||
config["listener"]["address"] = serde_json::json!("127.0.0.1:0");
|
||||
let folder = dir.join("world/etc/sausage");
|
||||
std::fs::create_dir_all(&folder)?;
|
||||
std::fs::write(
|
||||
|
||||
@@ -78,7 +78,6 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> {
|
||||
let world = World::open(&destination)?;
|
||||
assert!(destination.join("world/bin/git").exists());
|
||||
let config = sausage::harness::config::Config::read(&world.root).await?;
|
||||
assert_eq!(config.listener.origin, "http://localhost:8090");
|
||||
assert_eq!(config.proxy.base_url, "https://proxy.test");
|
||||
assert_eq!(config.proxy.api_key, "fixture-key");
|
||||
let source = destination.join("world/src/sausage");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The hook and native listener share ceremony state without exposing the native listener to guests.
|
||||
use super::api_transport::{Listener, Options as ApiOptions};
|
||||
// The hook and native listener share ceremony state without exposing the native listener to guests.
|
||||
use sausage::{
|
||||
api::{Api, Listener, Options as ApiOptions},
|
||||
api::Api,
|
||||
cred::{Caller, Cred},
|
||||
exec::{Command, Exec, Options},
|
||||
process::Runtime,
|
||||
@@ -56,6 +57,7 @@ async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> {
|
||||
conversations: vec![],
|
||||
},
|
||||
http::Request::post("http://host.sausage/cred/passkeys/register/start")
|
||||
.header("origin", origin)
|
||||
.body(bytes::Bytes::from_static(b"{\"label\":\"shared\"}"))?,
|
||||
)
|
||||
.await;
|
||||
@@ -72,7 +74,7 @@ async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> {
|
||||
let mut connection = std::net::TcpStream::connect(listener.address())?;
|
||||
write!(
|
||||
connection,
|
||||
"POST /cred/passkeys/register/finish HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
|
||||
"POST /cred/passkeys/register/finish HTTP/1.1\r\nHost: localhost\r\nOrigin: {origin}\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
)?;
|
||||
connection.write_all(&body)?;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Host integration tests share one executable. Modules retain their original test registrations.
|
||||
mod api;
|
||||
mod api_auth;
|
||||
mod api_transport;
|
||||
mod api_listener;
|
||||
mod automations;
|
||||
mod builder;
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ fn bundle() -> anyhow::Result<PathBuf> {
|
||||
|| !bundle.join("etc/sausage/config.yaml").exists()
|
||||
|| std::fs::read_to_string(&stamp).ok().as_deref() != Some(key.as_str())
|
||||
{
|
||||
if stamp.exists() {
|
||||
std::fs::remove_file(&stamp)?;
|
||||
if bundle.exists() {
|
||||
std::fs::remove_dir_all(&bundle)?;
|
||||
}
|
||||
assert!(
|
||||
std::process::Command::new("nu")
|
||||
|
||||
+10
-6
@@ -1,8 +1,9 @@
|
||||
//! P2 exit rehearsal: native host pages, software passkey, durable bash tools, and real SSH Git push.
|
||||
use super::api_transport::{Listener, Options};
|
||||
// P2 exit rehearsal: native host pages, software passkey, durable bash tools, and real SSH Git push.
|
||||
use anyhow::{Result, ensure};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use sausage::{
|
||||
api::{Api, Listener, Options},
|
||||
api::Api,
|
||||
cred::{Cred, entry::Envelope, webauthn::Challenge},
|
||||
world::World,
|
||||
};
|
||||
@@ -49,8 +50,8 @@ impl Browser {
|
||||
connection.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||
write!(
|
||||
connection,
|
||||
"{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
bytes.len()
|
||||
"{method} {path} HTTP/1.1\r\nHost: localhost\r\nOrigin: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
self.origin, bytes.len()
|
||||
)?;
|
||||
connection.write_all(&bytes)?;
|
||||
let mut response = String::new();
|
||||
@@ -146,7 +147,10 @@ impl Browser {
|
||||
..Default::default()
|
||||
});
|
||||
runtime.execute_script("browser.js", "globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto; globalThis.TextEncoder = Deno.core.loadExtScript('ext:deno_web/08_text_encoding.js').TextEncoder; globalThis.document = {querySelector: () => ({addEventListener() {}})};")?;
|
||||
runtime.execute_script("app.js", script)?;
|
||||
runtime.execute_script(
|
||||
"app.js",
|
||||
script.replace("import.meta.url", "'http://localhost:8080/api/app.js'"),
|
||||
)?;
|
||||
runtime.execute_script("entry.js", format!("encrypt({},{}).then(value => globalThis.envelope = JSON.stringify(value.envelope))", public["public_key"], serde_json::to_string(secret)?))?;
|
||||
runtime.run_event_loop(Default::default()).await?;
|
||||
let result = runtime.execute_script("result.js", "envelope")?;
|
||||
@@ -386,7 +390,7 @@ async fn request_approve_fetch_and_ssh_push() -> Result<()> {
|
||||
ensure!(
|
||||
browser
|
||||
.raw("GET", "/register", Value::Null)?
|
||||
.contains("/app.js"),
|
||||
.contains("src=\"app.js\""),
|
||||
"registration page missing"
|
||||
);
|
||||
let start: Challenge<CreationChallengeResponse> = serde_json::from_value(browser.json(
|
||||
|
||||
Reference in New Issue
Block a user