~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs-mesh-agents

atirc mesh agents on socialweb.computer VMs as an atlibs module

0
0
main
470 lines · 26.5 KB Raw History
use abi::repo::{delete_record, get_record, put_record};
use abi::{AtUri, Call, Event, Handle, Run, entries};
use serde::Deserialize;
use serde_json::{Value, json};

// An atirc agent on a VM you keep, on the atirc mesh (no atirc server, no token): two mesh nodes on the
// VM, the agent (<nick>.<handle>, `atirc agent run` beside it) and yours there (you-<nick>.<handle>), both
// in your channel and the agent's own. You write their computer.socialweb.peer records (iroh: the bare
// endpoint id of a key the VM made); the agents you run are one computer.socialweb.agents/self record.
const PEER: &str = "computer.socialweb.peer";
const ASSOCIATION: &str = "com.publicdomainrelay.temp.market.association";
const INDEX: &str = "computer.socialweb.agents";
const EXEC: &str = "computer.socialweb.cicd.process.exec";
const PREPARE: &str = include_str!("../prepare.sh");
const START: &str = include_str!("../start.sh");
const STOP: &str = "set -eu; read -r n; read -r y; systemctl disable --now atirc-agent-$n atirc-mesh-$n atirc-mesh-$y 2>/dev/null || true; rm -f /etc/systemd/system/atirc-agent-$n.service /etc/systemd/system/atirc-mesh-$n.service /etc/systemd/system/atirc-mesh-$y.service; systemctl daemon-reload; echo stopped";
const HARNESS: &str = r#"#!/usr/bin/env bash
export ANTHROPIC_MODEL="$(atirc agent anthropic model)"
export ANTHROPIC_BASE_URL="$(atirc agent anthropic base-url)"
export ANTHROPIC_API_KEY="$(atirc agent anthropic api-key)"
export ANTHROPIC_DEFAULT_OPUS_MODEL="${ANTHROPIC_MODEL}"
export ANTHROPIC_DEFAULT_SONNET_MODEL="${ANTHROPIC_MODEL}"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="${ANTHROPIC_MODEL}"
export CLAUDE_CODE_SUBAGENT_MODEL="${ANTHROPIC_MODEL}"
command -v claude >/dev/null 2>&1 || curl -fsSL https://claude.ai/install.sh | bash
exec claude --allow-dangerously-skip-permissions --dangerously-skip-permissions "$@""#;
const MAX_AGENTS: usize = 20;
// your node's local listener on the VM (atirc mesh run --local-listen), reached over the contract's "atirc" tunnel
const LISTEN_PORT: u64 = 6680;

#[derive(Deserialize, Default)]
struct Args {
    #[serde(default)]
    did: String,
    #[serde(default)]
    now: String,
    #[serde(default)]
    of: Option<String>,
    #[serde(default)]
    vm: Option<String>,
    #[serde(default)]
    shell: Option<String>,
    #[serde(default)]
    nick: Option<String>,
    #[serde(default)]
    model: Option<String>,
    #[serde(default)]
    base_url: Option<String>,
    #[serde(default)]
    expires: Option<String>,
    #[serde(default)]
    listen_port: Option<u64>,
    #[serde(default)]
    api_key: Option<String>,
    #[serde(default)]
    harness: Option<String>,
    #[serde(default)]
    atirc_url: Option<String>,
    #[serde(default)]
    atirc_sha256: Option<String>,
}

/// atirc's channel for a handle or subhandle: '#' + it, '.' -> '_', lowercase.
pub fn channel(name: &str) -> String {
    let c: String = name.chars().take(63).map(|c| if c == '.' || c == ',' || c.is_whitespace() { '_' } else { c }).collect();
    format!("#{}", c.to_ascii_lowercase())
}

fn nick_ok(n: &str) -> bool {
    let b = n.as_bytes();
    !b.is_empty() && b.len() <= 23 && b[0].is_ascii_lowercase() && b.iter().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-') && !n.starts_with("you-")
}

// A value that goes into config.toml inside "...": no quote, backslash or control character.
fn plain(key: &str, v: &Option<String>) -> Result<String, String> {
    let v = v.as_deref().map(str::trim).unwrap_or_default();
    if v.is_empty() || v.len() > 2048 || v.chars().any(|c| c == '"' || c == '\\' || c.is_control()) {
        return Err(format!("{key}: required, no quotes, backslashes or control characters"));
    }
    Ok(v.to_string())
}

fn b64(s: &str) -> String {
    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::new();
    for chunk in s.as_bytes().chunks(3) {
        let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
        for i in 0..4 {
            out.push(if i <= chunk.len() { A[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' });
        }
    }
    out
}

fn b64url_decode(s: &str) -> Option<Vec<u8>> {
    let mut bits = 0u32;
    let mut n = 0;
    let mut out = Vec::new();
    for c in s.trim_end_matches('=').bytes() {
        let v = match c {
            b'A'..=b'Z' => c - b'A',
            b'a'..=b'z' => c - b'a' + 26,
            b'0'..=b'9' => c - b'0' + 52,
            b'-' | b'+' => 62,
            b'_' | b'/' => 63,
            _ => return None,
        } as u32;
        bits = bits << 6 | v;
        n += 6;
        if n >= 8 {
            n -= 8;
            out.push((bits >> n) as u8);
        }
    }
    Some(out)
}

/// The receipt a webrtc:/// or iroh:/// shell URL names: its ref is base64url JSON {receipt: {uri, cid}, ...}.
fn receipt_of(shell: &str) -> Option<String> {
    let reference = shell.split('/').nth(3)?;
    let v: Value = serde_json::from_slice(&b64url_decode(reference)?).ok()?;
    v["receipt"]["uri"].as_str().filter(|u| u.contains("/com.publicdomainrelay.temp.market.receipt/")).map(str::to_string)
}

/// kcp's contract-scoped association key: requester_associate-<first 32 hex of sha256(subject)>-<accept rkey>.
fn lease_rkey(subject: &str, accept_uri: &str) -> String {
    use sha2::Digest;
    let hex: String = sha2::Sha256::digest(subject.as_bytes()).iter().take(16).map(|b| format!("{b:02x}")).collect();
    format!("requester_associate-{hex}-{}", accept_uri.rsplit('/').next().unwrap_or_default())
}

fn prepare_stdin(a: &Args, s: &Value) -> String {
    format!(
        "NICK={}\nYOU={}\nATIRC_URL={}\nATIRC_SHA256={}\nMODEL_B64={}\nBASE_URL_B64={}\nAPI_KEY_B64={}\nHARNESS_B64={}\n",
        s["nick"].as_str().unwrap_or_default(),
        s["you"].as_str().unwrap_or_default(),
        s["atirc_url"].as_str().unwrap_or_default(),
        s["atirc_sha256"].as_str().unwrap_or_default(),
        b64(a.model.as_deref().unwrap_or_default().trim()),
        b64(a.base_url.as_deref().unwrap_or_default().trim()),
        b64(a.api_key.as_deref().unwrap_or_default().trim()),
        b64(s["harness"].as_str().unwrap_or_default()),
    )
}

fn exec(id: &str, url: &str, command: &str, stdin: String, timeout_ms: u64) -> Call {
    Call::new(id, EXEC).input(json!({ "url": url, "command": command, "stdin": stdin, "timeoutMs": timeout_ms }))
}

fn read(id: &str, repo: &str, collection: &str, rkey: &str) -> Call {
    get_record(&AtUri::new(repo, collection, rkey)).id(id).service(repo)
}

fn agents_of(result: &abi::CallResult) -> Vec<Value> {
    result.output().ok().and_then(|o| o["value"]["agents"].as_array().cloned()).unwrap_or_default()
}

fn exec_out(result: &abi::CallResult, what: &str) -> Result<String, String> {
    let out = result.output().map_err(|e| format!("{what}: {e}"))?;
    if out["exitCode"].as_i64() != Some(0) {
        return Err(format!("{what} exited {}: {}", out["exitCode"], out["stderr"].as_str().unwrap_or_default().chars().rev().take(600).collect::<String>().chars().rev().collect::<String>()));
    }
    Ok(out["stdout"].as_str().unwrap_or_default().to_string())
}

fn index_put(did: &str, agents: Vec<Value>, now: &str) -> Call {
    put_record(&AtUri::new(did, INDEX, "self"), json!({ "$type": INDEX, "agents": agents, "updatedAt": now })).id("index-put")
}

// launch {vm, shell, nick, model, base_url, api_key, harness?}: returns {nick, subhandle, you, channels, vm}.
fn launch_handler(run: Run<Args>) -> Result<Handle, String> {
    let a = &run.args;
    let me = a.did.clone();
    if !me.starts_with("did:") {
        return Err("sign in first".into());
    }
    let mut s = run.state.clone();
    Ok(match run.event {
        Event::Start => {
            let nick = a.nick.clone().unwrap_or_default().trim().to_ascii_lowercase();
            if !nick_ok(&nick) {
                return Err("nick: a lowercase letter, then letters, digits or -, at most 23, not you-*".into());
            }
            let shell = a.shell.clone().filter(|u| u.starts_with("webrtc:///") || u.starts_with("iroh:///")).ok_or("shell: the VM's shell URL (vm_list)")?;
            let base = plain("base_url", &a.base_url)?;
            if !base.starts_with("https://") && !base.starts_with("http://") {
                return Err("base_url: an http(s) URL".into());
            }
            plain("model", &a.model)?;
            plain("api_key", &a.api_key)?;
            let harness = a.harness.clone().filter(|h| !h.trim().is_empty()).unwrap_or(HARNESS.into());
            if !harness.starts_with("#!") || harness.contains("'''") {
                return Err("harness: a #! script without '''".into());
            }
            let sha = a.atirc_sha256.clone().filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit())).ok_or("atirc_sha256: the pinned binary's sha256")?;
            let url = a.atirc_url.clone().filter(|u| u.starts_with("https://") && !u.contains(char::is_whitespace)).ok_or("atirc_url: an https URL")?;
            let receipt = receipt_of(&shell).ok_or("shell: its ref names no market receipt")?;
            let expires = a.expires.clone().filter(|e| e.len() >= 20 && e.as_bytes()[4] == b'-' && e.contains('T')).ok_or("expires: when the VM ends (vm_list's expires)")?;
            let listen_port = a.listen_port.unwrap_or(LISTEN_PORT);
            if !(1024..=65535).contains(&listen_port) {
                return Err("listen_port: 1024-65535".into());
            }
            let state = json!({ "listen_port": listen_port, "nick": nick, "you": format!("you-{nick}"), "shell": shell, "receipt": receipt, "expires": expires, "vm": a.vm, "harness": harness, "atirc_url": url, "atirc_sha256": sha });
            Handle::new().call(Call::new("repo", "com.atproto.repo.describeRepo").params(json!({ "repo": me })).service(&me)).state(state)
        }
        Event::Result { id, result, .. } => match id.as_str() {
            "repo" => {
                let handle = result.output()?["handle"].as_str().filter(|h| h.contains('.')).ok_or("your handle did not resolve")?.to_string();
                s["handle"] = json!(handle);
                Handle::new().call(read("index", &me, INDEX, "self")).state(s)
            }
            "index" => {
                let agents = agents_of(&result);
                let nick = s["nick"].as_str().unwrap_or_default().to_string();
                if agents.iter().any(|x| x["nick"] == nick.as_str()) {
                    return Err(format!("{nick} is running already: stop it first"));
                }
                if agents.len() >= MAX_AGENTS {
                    return Err(format!("at most {MAX_AGENTS} agents"));
                }
                s["agents"] = json!(agents);
                let receipt = s["receipt"].as_str().unwrap_or_default().to_string();
                let bidder = receipt.split('/').nth(2).unwrap_or_default().to_string();
                Handle::new().call(get_record(&receipt.parse::<AtUri>().map_err(|_| "bad receipt uri")?).id("receipt").service(&bidder)).state(s)
            }
            // the lease (kcp 0004): a contract-scoped association of your own DID, ending with the VM
            "receipt" => {
                let accept = &result.output()?["value"]["accept"];
                let (uri, cid) = (accept["uri"].as_str().unwrap_or_default(), accept["cid"].as_str().unwrap_or_default());
                if !uri.starts_with(&format!("at://{me}/com.publicdomainrelay.temp.market.accept/")) || cid.is_empty() {
                    return Err("the VM's receipt names no accept of yours".into());
                }
                let rkey = lease_rkey(&me, uri);
                let record = json!({ "$type": ASSOCIATION, "subject": me, "service": "requester_associate", "contract": { "uri": uri, "cid": cid }, "expiresAt": s["expires"], "note": format!("atirc agent {}", s["nick"].as_str().unwrap_or_default()), "createdAt": run.args.now });
                Handle::new().call(put_record(&AtUri::new(&me, ASSOCIATION, &rkey), record).id("lease")).state(s)
            }
            "lease" => {
                let out = result.output()?;
                s["lease"] = json!({ "uri": out["uri"], "cid": out["cid"] });
                let shell = s["shell"].as_str().unwrap_or_default().to_string();
                Handle::new().call(exec("prepare", &shell, PREPARE, prepare_stdin(a, &s), 300_000)).state(s)
            }
            "prepare" => {
                let out = exec_out(&result, "prepare")?;
                let ids: Value = serde_json::from_str(out.trim().lines().last().unwrap_or_default()).map_err(|_| format!("prepare printed no ids: {out}"))?;
                let handle = s["handle"].as_str().unwrap_or_default().to_string();
                let nick = s["nick"].as_str().unwrap_or_default().to_string();
                let you = s["you"].as_str().unwrap_or_default().to_string();
                let channels = json!([channel(&handle), channel(&format!("{nick}.{handle}"))]);
                for (k, id) in [("agent", &ids["agent"]), ("you", &ids["you"])] {
                    let id = id.as_str().filter(|i| i.len() == 64 && i.bytes().all(|b| b.is_ascii_hexdigit())).ok_or(format!("prepare: no {k} endpoint id"))?;
                    s[format!("{k}_id")] = json!(id);
                }
                s["channels"] = channels.clone();
                let lease = s["lease"].clone();
                let record = |id: &Value| json!({ "$type": PEER, "iroh": id, "channels": channels, "lease": lease, "createdAt": run.args.now });
                Handle::new()
                    .call(put_record(&AtUri::new(&me, PEER, &format!("{nick}.{handle}")), record(&s["agent_id"])).id("peer-agent"))
                    .call(put_record(&AtUri::new(&me, PEER, &format!("{you}.{handle}")), record(&s["you_id"])).id("peer-you"))
                    .state(s)
            }
            "peer-agent" | "peer-you" => {
                result.output()?;
                s[format!("{id}-done")] = json!(true);
                if s["peer-agent-done"] != true || s["peer-you-done"] != true {
                    return Ok(Handle::new().state(s));
                }
                let handle = s["handle"].as_str().unwrap_or_default();
                let peers: Vec<String> = s["agents"].as_array().into_iter().flatten().flat_map(|x| [x["subhandle"].as_str().unwrap_or_default().to_string(), format!("{}.{handle}", x["you"].as_str().unwrap_or_default())]).filter(|p| !p.starts_with('.') && p.contains('.')).collect();
                let channels: Vec<&str> = s["channels"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
                let stdin = format!("NICK={}\nYOU={}\nHANDLE={handle}\nCHANNELS={}\nPEERS={}\nLISTEN={}\n", s["nick"].as_str().unwrap_or_default(), s["you"].as_str().unwrap_or_default(), channels.join(" "), peers.join(" "), s["listen_port"]);
                let shell = s["shell"].as_str().unwrap_or_default().to_string();
                Handle::new().call(exec("start", &shell, START, stdin, 120_000)).state(s)
            }
            "start" => {
                let active = exec_out(&result, "start")?;
                let handle = s["handle"].as_str().unwrap_or_default();
                let nick = s["nick"].as_str().unwrap_or_default().to_string();
                let agent = json!({ "nick": nick, "subhandle": format!("{nick}.{handle}"), "you": s["you"], "vm": s["vm"], "shell": s["shell"], "channels": s["channels"], "listen_port": s["listen_port"], "lease": s["lease"], "expires": s["expires"], "createdAt": run.args.now });
                let mut agents = s["agents"].as_array().cloned().unwrap_or_default();
                agents.push(agent.clone());
                s["agent"] = agent;
                s["active"] = json!(active.trim());
                Handle::new().call(index_put(&me, agents, &run.args.now)).state(s)
            }
            "index-put" => {
                result.output()?;
                let mut out = s["agent"].clone();
                out["units"] = s["active"].clone();
                Handle::new().result(out).done()
            }
            _ => Handle::new(),
        },
        _ => Handle::new(),
    })
}

// list {of?}: the agents an account runs (its computer.socialweb.agents/self).
fn list_handler(run: Run<Args>) -> Result<Handle, String> {
    let of = run.args.of.clone().filter(|s| !s.is_empty()).unwrap_or(run.args.did.clone());
    if !of.starts_with("did:") {
        return Err("of: a DID (or sign in)".into());
    }
    Ok(match run.event {
        Event::Start => Handle::new().call(read("index", &of, INDEX, "self")),
        Event::Result { id, result, .. } if id == "index" => Handle::new().result(json!({ "of": of, "agents": agents_of(&result) })).done(),
        _ => Handle::new(),
    })
}

// stop {nick}: stops its units (if the VM still answers), deletes both peer records, drops it from the list.
fn stop_handler(run: Run<Args>) -> Result<Handle, String> {
    let me = run.args.did.clone();
    if !me.starts_with("did:") {
        return Err("sign in first".into());
    }
    let nick = run.args.nick.clone().unwrap_or_default();
    let mut s = run.state.clone();
    Ok(match run.event {
        Event::Start => Handle::new().call(read("index", &me, INDEX, "self")),
        Event::Result { id, result, .. } => match id.as_str() {
            "index" => {
                let agents = agents_of(&result);
                let agent = agents.iter().find(|x| x["nick"] == nick.as_str()).cloned().ok_or(format!("no agent {nick}"))?;
                let handle = agent["subhandle"].as_str().unwrap_or_default().strip_prefix(&format!("{nick}.")).unwrap_or_default().to_string();
                let you = agent["you"].as_str().unwrap_or_default().to_string();
                let lease = agent["lease"]["uri"].as_str().and_then(|u| u.parse::<AtUri>().ok()).filter(|u| u.repo == me && u.collection == ASSOCIATION);
                s = json!({ "agents": agents.into_iter().filter(|x| x["nick"] != nick.as_str()).collect::<Vec<_>>(), "pending": 3 + lease.is_some() as u64, "stopped": false });
                let mut h = Handle::new()
                    .call(exec("stop", agent["shell"].as_str().unwrap_or_default(), STOP, format!("{nick}\n{you}\n"), 60_000))
                    .call(delete_record(&AtUri::new(&me, PEER, &format!("{nick}.{handle}"))).id("del-agent"))
                    .call(delete_record(&AtUri::new(&me, PEER, &format!("{you}.{handle}"))).id("del-you"));
                // each call its own id: the host runs one call per id
                if let Some(lease) = lease {
                    h = h.call(delete_record(&lease).id("del-lease"));
                }
                h.state(s)
            }
            "stop" | "del-agent" | "del-you" | "del-lease" => {
                if id == "stop" {
                    s["stopped"] = json!(result.ok());
                }
                let pending = s["pending"].as_u64().unwrap_or(1) - 1;
                s["pending"] = json!(pending);
                if pending > 0 {
                    return Ok(Handle::new().state(s));
                }
                let agents = s["agents"].as_array().cloned().unwrap_or_default();
                Handle::new().call(index_put(&me, agents, &run.args.now)).state(s)
            }
            "index-put" => {
                result.output()?;
                Handle::new().result(json!({ "nick": nick, "stopped": s["stopped"] })).done()
            }
            _ => Handle::new(),
        },
        _ => Handle::new(),
    })
}

entries!(
    launch => launch_handler,
    list => list_handler,
    stop => stop_handler
);

#[cfg(test)]
mod tests {
    use serde_json::{Value, json};

    type H = fn(abi::Run<super::Args>) -> Result<abi::Handle, String>;
    fn step(f: H, args: &Value, event: Value, state: &Value) -> Result<Value, String> {
        let mut input = args.clone();
        input["event"] = event;
        input["state"] = state.clone();
        f(serde_json::from_value(input).unwrap()).map(|h| serde_json::to_value(h).unwrap())
    }
    fn res(id: &str, output: Value) -> Value {
        json!({ "type": "result", "id": id, "ok": true, "output": output })
    }
    const ME: &str = "did:plc:alice";
    const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    const Y: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";

    fn shell() -> String {
        let reference = super::b64(r#"{"receipt":{"uri":"at://did:plc:bidder/com.publicdomainrelay.temp.market.receipt/r1","cid":"c"},"bidder":"did:plc:bidder"}"#);
        format!("webrtc:///{}/shell/mcp", reference.replace('+', "-").replace('/', "_").trim_end_matches('='))
    }

    fn args() -> Value {
        json!({ "did": ME, "now": "2026-11-10T00:00:00.000Z", "vm": "vm-1", "shell": shell(), "expires": "2026-11-10T04:00:00.000Z", "nick": "Scout", "model": "m", "base_url": "https://api.example.com", "api_key": "sk-1", "atirc_url": "https://app.example/dl/atirc-x86_64", "atirc_sha256": "a".repeat(64) })
    }

    #[test]
    fn channels_are_atircs() {
        assert_eq!(super::channel("aliceoa.bsky.social"), "#aliceoa_bsky_social");
        assert_eq!(super::channel("scout.aliceoa.bsky.social"), "#scout_aliceoa_bsky_social");
        assert_eq!(super::b64("sk-1"), "c2stMQ==");
    }

    #[test]
    fn launch_prepares_writes_both_peer_records_then_starts() {
        let a = args();
        let s = step(super::launch_handler, &a, json!({ "type": "start" }), &Value::Null).unwrap();
        let s = step(super::launch_handler, &a, res("repo", json!({ "handle": "alice.test" })), &s["state"]).unwrap();
        let other = json!({ "nick": "old", "subhandle": "old.alice.test", "you": "you-old" });
        let s = step(super::launch_handler, &a, res("index", json!({ "uri": "u", "cid": "c", "value": { "agents": [other] } })), &s["state"]).unwrap();
        assert_eq!(s["calls"][0]["params"]["rkey"], "r1");
        assert_eq!(s["calls"][0]["service"], "did:plc:bidder");
        let accept = format!("at://{ME}/com.publicdomainrelay.temp.market.accept/acc1");
        let s = step(super::launch_handler, &a, res("receipt", json!({ "value": { "accept": { "uri": accept, "cid": "ca" } } })), &s["state"]).unwrap();
        let lease = &s["calls"][0]["input"];
        assert_eq!(lease["collection"], "com.publicdomainrelay.temp.market.association");
                assert_eq!(lease["rkey"], "requester_associate-a304c5c525080ebfa7b6205c6c31508c-acc1");
        assert_eq!(lease["record"]["subject"], ME);
        assert_eq!(lease["record"]["contract"], json!({ "uri": accept, "cid": "ca" }));
        assert_eq!(lease["record"]["expiresAt"], "2026-11-10T04:00:00.000Z");
        let s = step(super::launch_handler, &a, res("lease", json!({ "uri": "at://lease", "cid": "cl" })), &s["state"]).unwrap();
        let call = &s["calls"][0];
        assert_eq!(call["nsid"], "computer.socialweb.cicd.process.exec");
        let stdin = call["input"]["stdin"].as_str().unwrap();
        assert!(stdin.contains("NICK=scout\nYOU=you-scout\n") && stdin.contains("API_KEY_B64=c2stMQ==\n"));
        assert!(!call["input"]["command"].as_str().unwrap().contains("sk-1"), "no secret in the command");
        let s = step(super::launch_handler, &a, res("prepare", json!({ "exitCode": 0, "stdout": format!("{{\"agent\":\"{A}\",\"you\":\"{Y}\"}}\n") })), &s["state"]).unwrap();
        assert_eq!(s["calls"][0]["input"]["rkey"], "scout.alice.test");
        assert_eq!(s["calls"][0]["input"]["record"]["iroh"], A);
        assert_eq!(s["calls"][0]["input"]["record"]["channels"], json!(["#alice_test", "#scout_alice_test"]));
        assert_eq!(s["calls"][0]["input"]["record"]["lease"], json!({ "uri": "at://lease", "cid": "cl" }));
        assert_eq!(s["calls"][1]["input"]["rkey"], "you-scout.alice.test");
        let s = step(super::launch_handler, &a, res("peer-agent", json!({})), &s["state"]).unwrap();
        assert!(s["calls"].as_array().is_none_or(|c| c.is_empty()));
        let s = step(super::launch_handler, &a, res("peer-you", json!({})), &s["state"]).unwrap();
        let stdin = s["calls"][0]["input"]["stdin"].as_str().unwrap();
        assert!(stdin.contains("CHANNELS=#alice_test #scout_alice_test\n") && stdin.contains("PEERS=old.alice.test you-old.alice.test\n") && stdin.contains("LISTEN=6680\n"));
        let s = step(super::launch_handler, &a, res("start", json!({ "exitCode": 0, "stdout": "active active active " })), &s["state"]).unwrap();
        assert_eq!(s["calls"][0]["input"]["record"]["agents"][1]["subhandle"], "scout.alice.test");
        let done = step(super::launch_handler, &a, res("index-put", json!({})), &s["state"]).unwrap();
        assert_eq!(done["result"]["you"], "you-scout");
        assert_eq!(done["result"]["units"], "active active active");
    }

    #[test]
    fn launch_refuses_bad_input() {
        for (k, v) in [("nick", "you-x"), ("api_key", "a\"b"), ("base_url", "ftp://x"), ("shell", "https://x"), ("shell", "webrtc:///abc/shell/mcp"), ("expires", "soon")] {
            let mut a = args();
            a[k] = json!(v);
            assert!(step(super::launch_handler, &a, json!({ "type": "start" }), &Value::Null).is_err(), "{k}");
        }
    }

    #[test]
    fn stop_deletes_both_records_even_if_the_vm_is_gone() {
        let a = json!({ "did": ME, "now": "n", "nick": "scout" });
        let lease = format!("at://{ME}/com.publicdomainrelay.temp.market.association/requester_associate-x-acc1");
        let agent = json!({ "nick": "scout", "subhandle": "scout.alice.test", "you": "you-scout", "shell": "webrtc:///abc/shell/mcp", "lease": { "uri": lease, "cid": "cl" } });
        let s = step(super::stop_handler, &a, res("index", json!({ "uri": "u", "cid": "c", "value": { "agents": [agent] } })), &Value::Null).unwrap();
        assert_eq!(s["calls"][1]["input"]["rkey"], "scout.alice.test");
        assert_eq!(s["calls"][2]["input"]["rkey"], "you-scout.alice.test");
        assert_eq!(s["calls"][3]["input"]["collection"], "com.publicdomainrelay.temp.market.association");
        let ids: Vec<&str> = s["calls"].as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap()).collect();
        assert_eq!(ids, ["stop", "del-agent", "del-you", "del-lease"], "one call per id");
        let s = step(super::stop_handler, &a, json!({ "type": "result", "id": "stop", "ok": false, "error": "gone" }), &s["state"]).unwrap();
        let s = step(super::stop_handler, &a, res("del-agent", json!({})), &s["state"]).unwrap();
        let s = step(super::stop_handler, &a, res("del-you", json!({})), &s["state"]).unwrap();
        let s = step(super::stop_handler, &a, res("del-lease", json!({})), &s["state"]).unwrap();
        assert_eq!(s["calls"][0]["input"]["record"]["agents"], json!([]));
        let done = step(super::stop_handler, &a, res("index-put", json!({})), &s["state"]).unwrap();
        assert_eq!(done["result"], json!({ "nick": "scout", "stopped": false }));
    }
}