// Caller handlers for compositions on the CLI (a manifest's `handlers`). Only the command // line supplies them, never a manifest or lockfile, each one named by the composition: // // --handler =wasm:# another module's entry, run with that module's own grants and // identity; it may call caps passed in its input (callbacks) // --handler =https://host/path POST the call as JSON, the response JSON is the answer // (http:// only to loopback); no credentials are sent // --handler =exec: `sh -c `: the call as JSON on stdin, the answer as JSON // on stdout; needs --allow-exec; runs with an empty environment // (PATH, HOME, LANG only: no app password, no tokens) // // What the http and exec handlers get: { handler, caller, nsid, reason, input } (caps in input stay // { "$cap": token } and cannot be called from there). use std::collections::{HashMap, HashSet}; use std::sync::Arc; use atlibs_host::compose::{Handler, Info}; use atlibs_host::run::{Call, Ctx, Module, Options, Request, Sub, run_module}; use atlibs_host::wasm::Bound; use atlibs_host::{BoxFut, js_string}; use serde_json::{Map, Value, json}; use crate::run::{Host, Target, target}; const EXEC_OUTPUT_BYTES: usize = 1 << 20; #[derive(Clone)] pub enum Spec { Wasm { module: Arc, entry: String }, Http { url: String }, Exec { command: String }, } /// `--handler` values: name=spec. exec needs `allow_exec`; the names are checked against the manifest /// when the composition runs. pub async fn parse(cli: &crate::Cli, values: &[&str], allow_exec: bool) -> Result, String> { let mut out: Vec<(String, Spec)> = vec![]; for value in values { let (name, spec) = value.split_once('=').ok_or_else(|| format!("--handler {value}: use =wasm:# | https://... | exec:"))?; if name.is_empty() || name.len() > 256 || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') { return Err(format!("--handler {value}: the name must match /^[A-Za-z0-9_-]{{1,256}}$/")); } if out.iter().any(|(n, _)| n == name) { return Err(format!("--handler {name} given twice")); } let spec = if let Some(command) = spec.strip_prefix("exec:") { if !allow_exec { return Err(format!("--handler {name}=exec:... runs a local command: pass --allow-exec to allow it")); } if command.trim().is_empty() { return Err(format!("--handler {name}: exec needs a command")); } Spec::Exec { command: command.into() } } else if let Some(rest) = spec.strip_prefix("wasm:") { let (reference, entry) = rest.rsplit_once('#').ok_or_else(|| format!("--handler {name}: wasm:#"))?; if !atlibs_host::wasm::entry_ok(entry) { return Err(format!("--handler {name}: {entry} is not an export name")); } let words = [reference.to_string()]; let (found, rest) = target(&cli.net, &words).await.map_err(|e| format!("--handler {name}: {e}"))?; let Target::Module(module, _) = found else { return Err(format!("--handler {name}: {reference} is a composition; a handler is one module's entry")) }; if !rest.is_empty() { return Err(format!("--handler {name}: {reference} needs one word")); } Spec::Wasm { module: Arc::new(module), entry: entry.into() } } else if spec.starts_with("https://") || spec.starts_with("http://") { let url = reqwest::Url::parse(spec).map_err(|e| format!("--handler {name}: {e}"))?; let host_name = url.host_str().unwrap_or_default().trim_matches(['[', ']']); let loopback = host_name == "localhost" || host_name.parse::().is_ok_and(|ip| ip.is_loopback()); if url.scheme() == "http" && !loopback { return Err(format!("--handler {name}: http:// only to localhost; use https://")); } Spec::Http { url: url.to_string() } } else { return Err(format!("--handler {name}={spec}: use wasm:#, https://..., or exec:")); }; out.push((name.to_string(), spec)); } Ok(out) } /// The handlers a composition run gets: each supplied name must be one the manifest declares. pub fn build(host: &Host, manifest: &Value, specs: &[(String, Spec)]) -> Result, String> { let declared: Vec = manifest["handlers"].as_array().into_iter().flatten().map(|h| js_string(h.get("name"))).collect(); let mut handlers = HashMap::new(); for (name, spec) in specs { if !declared.contains(name) { return Err(format!("--handler {name}: the composition declares no handler {name} (it declares: {})", if declared.is_empty() { "none".into() } else { declared.join(", ") })); } let (host, key, spec) = (host.clone(), name.clone(), spec.clone()); let handler: Handler = Arc::new(move |input: Value, info: Info| { let (host, name, spec) = (host.clone(), key.clone(), spec.clone()); Box::pin(async move { answer(&host, &name, &spec, input, info).await }) }); handlers.insert(name.clone(), handler); } Ok(handlers) } fn envelope(name: &str, input: &Value, info: &Info) -> Value { json!({ "handler": name, "caller": info.caller, "nsid": info.nsid, "reason": info.reason, "input": input }) } async fn answer(host: &Host, name: &str, spec: &Spec, input: Value, info: Info) -> Result { let signal = info.signal.clone(); let work = async { match spec { Spec::Wasm { module, entry } => wasm(host, module, entry, input, info).await, Spec::Http { url } => http(host, name, url, &envelope(name, &input, &info)).await, Spec::Exec { command } => exec(name, command, &envelope(name, &input, &info)).await, } }; tokio::select! { out = work => out, reason = signal.aborted() => Err(reason) } } /// Cap tokens anywhere in a value. fn tokens(value: &Value, out: &mut HashSet) { match value { Value::Object(m) if m.len() == 1 && m.get("$cap").and_then(Value::as_str).is_some_and(|t| t.starts_with("cap.")) => { out.insert(m["$cap"].as_str().unwrap_or_default().to_string()); } Value::Object(m) => m.values().for_each(|v| tokens(v, out)), Value::Array(a) => a.iter().for_each(|v| tokens(v, out)), _ => {} } } /// The handler module's call: the caps it was handed go back to the composition; anything else is /// its own host call (its module.json grants, its identity). struct CapCall { info: Info, held: HashSet, inner: Arc, } impl Call for CapCall { fn call(&self, nsid: String, request: Request) -> BoxFut<'_, Result, String>> { if self.held.contains(&nsid) { let invoked = self.info.invoke(&nsid, request.input.unwrap_or(Value::Null)); return Box::pin(async move { invoked.await.map(Some) }); } self.inner.call(nsid, request) } fn subscribe(&self, nsid: String, request: Request) -> BoxFut<'_, Result> { self.inner.subscribe(nsid, request) } } async fn wasm(host: &Host, module: &Module, entry: &str, input: Value, info: Info) -> Result { let mut run_input = Map::new(); if let Some(s) = &host.session { run_input.insert("did".into(), json!(s.did())); } match input { Value::Object(m) => run_input.extend(m), other => drop(run_input.insert("input".into(), other)), } run_input.insert("caller".into(), json!({ "instance": info.caller, "nsid": info.nsid, "reason": info.reason })); let mut held = HashSet::new(); tokens(&Value::Object(run_input.clone()), &mut held); let signal = info.signal.clone(); let inner = host.call_for(module, module.id.as_deref().unwrap_or_default()).await?; // its allowlist stays its own, plus the caps it was handed (holding the token is the grant) let mut module = module.clone(); if let Some(calls) = module.calls.as_mut() { calls.extend(held.iter().cloned()); } let module = &module; let call: Arc = Arc::new(CapCall { info, held, inner }); let ctx = Ctx { runner: Arc::new(Bound { wasm: host.wasm.clone(), bytes: module.bytes.clone(), sha256: module.sha256.clone() }), ledger: Some(host.store.ledger("managed_records")), now: None, }; let ran = run_module(&ctx, module, entry, &run_input, call, Options { signal: Some(signal), budget: host.budget.clone(), provides: Some(host.provides.clone()), http: host.http.clone(), ..Default::default() }).await; match ran.get("error") { Some(e) if !e.is_null() => Err(js_string(Some(e))), _ => Ok(ran.get("result").cloned().unwrap_or(Value::Null)), } } async fn http(host: &Host, name: &str, url: &str, body: &Value) -> Result { let response = host.fetch.fetch("POST", url, vec![("content-type".into(), "application/json".into())], Some(body.to_string().into_bytes())).await?; let text = String::from_utf8_lossy(&response.body); if !(200..300).contains(&response.status) { return Err(format!("handler {name}: {url} answered {}: {}", response.status, text.chars().take(300).collect::())); } if text.trim().is_empty() { return Ok(Value::Null); } serde_json::from_str(&text).map_err(|_| format!("handler {name}: {url} did not answer JSON")) } async fn exec(name: &str, command: &str, body: &Value) -> Result { use tokio::io::AsyncWriteExt; let mut cmd = tokio::process::Command::new("sh"); cmd.arg("-c").arg(command).env_clear(); for key in ["PATH", "HOME", "LANG"] { if let Ok(v) = std::env::var(key) { cmd.env(key, v); } } let mut child = cmd .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true) .spawn() .map_err(|e| format!("handler {name}: {e}"))?; let mut stdin = child.stdin.take().expect("piped"); let data = format!("{body}\n"); tokio::spawn(async move { let _ = stdin.write_all(data.as_bytes()).await; // the command may not read it all }); let out = child.wait_with_output().await.map_err(|e| format!("handler {name}: {e}"))?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); let tail: String = stderr.trim().chars().rev().take(300).collect::>().into_iter().rev().collect(); return Err(format!("handler {name}: {command} exited {}{}", out.status.code().map_or("on a signal".into(), |c| c.to_string()), if tail.is_empty() { String::new() } else { format!(": {tail}") })); } if out.stdout.len() > EXEC_OUTPUT_BYTES { return Err(format!("handler {name}: output exceeds {EXEC_OUTPUT_BYTES} bytes")); } let text = String::from_utf8_lossy(&out.stdout); if text.trim().is_empty() { return Ok(Value::Null); } serde_json::from_str(text.trim()).map_err(|_| format!("handler {name}: {command} did not print JSON")) } #[cfg(test)] mod tests { use super::*; use crate::fake::Pds; use crate::registry::tests::tempdir; async fn host_for(dir: &std::path::Path) -> (crate::Cli, Host) { let cli = Pds::new().cli(dir); let host = Host::new(&cli).await.unwrap(); (cli, host) } #[tokio::test] async fn specs_are_explicit_and_checked() { let dir = tempdir("handlers"); let (cli, host) = host_for(&dir).await; let parse = |v: &'static [&'static str], allow: bool| { let cli = &cli; async move { parse(cli, v, allow).await } }; assert!(parse(&["confirm=exec:cat"], false).await.err().unwrap().contains("--allow-exec")); assert!(matches!(parse(&["confirm=exec:cat"], true).await.unwrap()[0].1, Spec::Exec { .. })); assert!(parse(&["confirm=http://example.com/x"], false).await.err().unwrap().contains("only to localhost")); assert!(matches!(parse(&["confirm=http://127.0.0.1:9/x"], false).await.unwrap()[0].1, Spec::Http { .. })); assert!(matches!(parse(&["confirm=https://h.example/x"], false).await.unwrap()[0].1, Spec::Http { .. })); assert!(parse(&["confirm=ftp://x"], false).await.err().unwrap().contains("use wasm:")); assert!(parse(&["bad name=exec:x"], true).await.err().unwrap().contains("must match")); assert!(parse(&["a=exec:x", "a=exec:y"], true).await.err().unwrap().contains("twice")); assert!(parse(&["a=wasm:nowhere"], false).await.err().unwrap().contains("wasm:#")); assert!(parse(&["a=wasm:no-such-module#go"], false).await.err().unwrap().contains("no module no-such-module")); let manifest = json!({ "handlers": [{ "name": "confirm" }] }); let specs = parse(&["other=exec:cat"], true).await.unwrap(); assert!(build(&host, &manifest, &specs).err().unwrap().contains("declares no handler other (it declares: confirm)")); } #[tokio::test] async fn exec_gets_the_call_on_stdin_with_an_empty_environment() { let body = json!({ "handler": "confirm", "input": { "n": 1 } }); let echoed = exec("confirm", "cat", &body).await.unwrap(); assert_eq!(echoed, body); // nothing from the host's environment but PATH, HOME and LANG let env = exec("confirm", r#"printf '{"vars":"%s"}' "$(env | cut -d= -f1 | sort | tr '\n' ' ')""#, &body).await.unwrap(); let vars: Vec<&str> = env["vars"].as_str().unwrap().split_whitespace().filter(|v| !["PATH", "HOME", "LANG", "PWD", "SHLVL", "_", "OLDPWD"].contains(v)).collect(); assert!(vars.is_empty(), "leaked: {vars:?}"); assert!(exec("confirm", "echo nope >&2; exit 3", &body).await.unwrap_err().ends_with("exited 3: nope")); assert!(exec("confirm", "echo not json", &body).await.unwrap_err().contains("did not print JSON")); assert_eq!(exec("confirm", "true", &body).await.unwrap(), Value::Null); } }