Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

uitest/src/rig.rs

//! The rig: a gunnar server and a console, both on loopback, both this
//! process's children, both gone when it exits.
//!
//! Why a rig and not the live site. The matrix mints invites, revokes
//! credentials, creates and deletes repositories, and — the situation that
//! matters most — proves that the break-glass admin token STOPS being accepted
//! once an admin passkey exists. None of that is something to do to a running
//! site, and all of it is exactly what nobody tests by hand.
//!
//! Shelling out to `gunnar-cli`, `git` and `ssh-keygen` is the same exemption
//! `sitetest/` records: the pure-Rust law is about PRODUCT code, and here those
//! binaries ARE the thing under test — the tools a person actually runs.

use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};

use crate::http;

/// The product the bench monetize sells: a selling console names it
/// (`GUNNAR_UI_PRODUCT`, OM-9 — no default product).
const BENCH_PRODUCT: &str = "gunnar";

pub struct Rig {
    pub work: PathBuf,
    pub ssh: u16,
    pub grpc: u16,
    pub ui: u16,
    pub token: String,
    pub admin_key: PathBuf,
    /// A key registered with NOTHING, so a situation can hand it a grant and
    /// then make stock git prove the grant is real.
    pub tenant_key: PathBuf,
    pub cli: String,
    /// Behind a lock so `restart_server` can take `&self`: every situation in
    /// the matrix is handed a `&Rig`, and threading `&mut` through all of them
    /// to stop one process would be a large change for a small reason.
    server: Mutex<Option<Child>>,
    console: Option<Child>,
    /// **The bench monetize with the FAKE Stripe** (`--bench-monetize`), when
    /// the rig runs one: the metered console sells through it, and its stripe
    /// vendor talks to an in-process `FakeStripe` — never Stripe, never a live
    /// key. `None` = a community rig, and every buy row says it did not run.
    pub bench: Option<String>,
    /// The bench's control door (`<endpoint>.control`): the vendor's side of
    /// a checkout — pay it, expire it — and a cap granted at once.
    pub bench_control: Option<String>,
    bench_child: Option<Child>,
    /// The bench monetize's signing key (hex), handed to gunnar on every
    /// start once the bench is up.
    monetize_pubkey: Option<String>,
    /// **The fake company identity provider** (`--fake-idp`, company SSO):
    /// gunnar-server's `fake_idp` example — a fake OpenID provider and a fake
    /// SAML IdP on loopback, never a real one. `None`: the SSO rows say they
    /// did not run.
    pub fake_idp: Option<FakeIdp>,
    fake_idp_child: Option<Child>,
    /// **A SOLD (provided) Gunnar's console** (one shop, 2026-10-07): a second
    /// console beside the first — the same gunnar, the same monetize, its own
    /// state and port — told it is acme's, sold by `https://gunnar.rs`
    /// (`GUNNAR_UI_PROVIDER`). Started by [`Rig::start_provided`].
    provided: Option<(u16, Child)>,
    /// What the first console was started with, for the second.
    console_with: Option<ConsoleWith>,
    /// What the bench monetize was started with (its binary, the appliance's
    /// key), for a second bench beside it.
    bench_with: Option<(String, String)>,
    /// **A console whose monetize sells NO private estates** (the sign-up
    /// page, owner 2026-10-08: test2's monetize has no `[estate_orders]`): a
    /// second bench monetize started `--no-estates`, and a console over it.
    /// Started by [`Rig::start_unsold`]: `(port, console, bench)`.
    unsold: Option<(u16, Child, Child)>,
    /// **A console whose monetize has `[estate_orders]` over a cloud that
    /// lays no estate** (round 5.3, test2 2026-10-08: every step of the
    /// ladder unavailable, "the upcloud cloud cannot lay an estate"): a third
    /// bench started `--estates-unlaid`, and a console over it. Started by
    /// [`Rig::start_unlaid`]: `(port, console, bench)`.
    unlaid: Option<(u16, Child, Child)>,
    keep: bool,
}

/// What [`Rig::up`] started the console with: the second one copies it.
struct ConsoleWith {
    ui_bin: String,
    ui_dir: Option<PathBuf>,
    ui_key: PathBuf,
    guest_key: PathBuf,
    monetize: Option<(String, String)>,
}

/// What the fake identity provider printed when it came up.
#[derive(Debug, Clone)]
pub struct FakeIdp {
    pub oidc_issuer: String,
    pub client_id: String,
    /// The client secret the rig handed it (a test value, never a real one).
    pub client_secret: String,
    #[allow(dead_code)] // the SAML half is driven by gunnar-server tests/sso.rs and the estate-box check
    pub saml_metadata_url: String,
    pub control: String,
}

fn free_port() -> Result<u16, String> {
    let l = TcpListener::bind("127.0.0.1:0").map_err(|e| format!("bind 0: {e}"))?;
    let p = l.local_addr().map_err(|e| format!("local_addr: {e}"))?.port();
    Ok(p)
}

/// Run a child and return its output.
///
/// Callers set `current_dir` to the rig's work directory without exception:
/// `gunnar-cli control bootstrap` writes its client certificate and PRIVATE KEY
/// into the current directory, and on 2026-08-30 that meant three `.pem` files
/// appearing inside this crate and being committed by a careless `git add`.
/// They were throwaway material for a rig that no longer exists, but the fix is
/// not to remember to delete them — it is that no child ever runs here.
fn run(what: &str, cmd: &mut Command) -> Result<String, String> {
    let out = cmd.output().map_err(|e| format!("{what}: {e}"))?;
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    if out.status.success() {
        Ok(stdout + &stderr)
    } else {
        Err(format!("{what} exited {}: {}", out.status, http::cut(&(stdout + &stderr), 400)))
    }
}

/// Start `gunnar serve` on the rig's ports. One writer, because the restart
/// below has to produce the SAME server and not a lookalike.
fn spawn_server(cli: &str, work: &Path, ssh: u16, grpc: u16, monetize_pubkey: Option<&str>) -> Result<Child, String> {
    let mut cmd = Command::new(cli);
    // The bench monetize's public key, when the rig sells through one: a
    // metered gunnar refuses every entitlement fact it holds no key for.
    match monetize_pubkey {
        // …and a metered gunnar holding a monetize key refuses to start
        // without the page a lapsed tenant is sent to.
        Some(k) => cmd.env("GUNNAR_MONETIZE_PUBKEY", k).env("GUNNAR_BILLING_URL", "http://localhost/account/storage?tenant={tenant}"),
        None => cmd.env_remove("GUNNAR_MONETIZE_PUBKEY").env_remove("GUNNAR_BILLING_URL"),
    };
    // A twin that stops polling is SILENT after this bound (gunnar's own,
    // shortened for the rig): the live Machine card's row waits for gunnar's
    // verdict, never decides one (`a-twin-that-stops-goes-red-in-the-open-card`).
    cmd.env("GUNNAR_HEARTBEAT_SILENT_MS", "5000");
    // Company SSO (2026-10-05): the rig's fake identity provider listens on
    // loopback over plain http; gunnar reaches it only through this test door.
    cmd.env("GUNNAR_SSO_ALLOW_LOOPBACK_HTTP", "1");
    // …and its network is ON (A34-16: off, airgap, unless the operator turns it on), with the
    // rig's own resolver named for a domain's DNS proof (A34-5, RA5-24: none is a default).
    cmd.env("GUNNAR_SSO_EGRESS", "on");
    cmd.env("GUNNAR_SSO_DNS_RESOLVER", crate::doh::url()?);
    cmd
        .current_dir(work)
        .args(["serve", "--data-root"])
        .arg(work.join("data"))
        .args([
            "--ssh-bind",
            &format!("127.0.0.1:{ssh}"),
            "--grpc-bind",
            &format!("127.0.0.1:{grpc}"),
        ])
        // Appended, not truncated: a restart must not throw away the log of
        // the run before it, which is where the reason for a restart failure
        // would be.
        .stdout(Stdio::from(
            std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(work.join("server.log"))
                .map_err(|e| e.to_string())?,
        ))
        .stderr(Stdio::from(
            std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(work.join("server.err"))
                .map_err(|e| e.to_string())?,
        ))
        .spawn()
        .map_err(|e| format!("spawn {cli} serve: {e}"))
}

/// Wait until gunnar answers on the control plane.
///
/// `control status` answers with no credential, so it is the honest readiness
/// probe: it is the same call a stranger makes.
fn wait_for_server(cli: &str, work: &Path, at: &str) -> Result<(), String> {
    // 30 s, or `GUNNAR_UITEST_START_S` on a build host shared with other
    // work (MEASURED 2026-10-17 on dwarf at load 35: 32 s from the layout
    // line to the sealed secret store on a first start).
    let wait = std::env::var("GUNNAR_UITEST_START_S").ok().and_then(|v| v.parse().ok()).unwrap_or(30);
    let deadline = Instant::now() + Duration::from_secs(wait);
    loop {
        if run(
            "control status",
            Command::new(cli).current_dir(work).args(["control", "status", "--at", at]),
        )
        .is_ok()
        {
            return Ok(());
        }
        if Instant::now() > deadline {
            return Err(format!(
                "gunnar did not answer on {at} within {wait} s: {}",
                http::cut(&std::fs::read_to_string(work.join("server.err")).unwrap_or_default(), 300)
            ));
        }
        std::thread::sleep(Duration::from_millis(200));
    }
}

impl Rig {
    pub fn up(cli: &str, ui_bin: &str, ui_dir: Option<&Path>, work: &Path, keep: bool, bench_bin: Option<&str>) -> Result<Rig, String> {
        // Every run starts from nothing, because half the matrix is only true
        // once: a server can be bootstrapped exactly once, ever, and the admin
        // token retires the first time an admin passkey exists. Reusing a work
        // directory turns both of those situations into failures that look
        // like bugs.
        //
        // The marker is the safety catch: this deletes a directory, so it
        // refuses any directory it did not create itself.
        let marker = work.join(".gunnar-uitest");
        if work.exists() {
            if !marker.exists() {
                return Err(format!(
                    "{} already exists and was not made by gunnar-uitest; \
                     point --work somewhere else rather than have this delete it",
                    work.display()
                ));
            }
            std::fs::remove_dir_all(work).map_err(|e| format!("clearing {}: {e}", work.display()))?;
        }
        std::fs::create_dir_all(work.join("data")).map_err(|e| format!("work dir: {e}"))?;
        std::fs::write(&marker, "gunnar-uitest scratch\n").map_err(|e| format!("marker: {e}"))?;
        let (ssh, grpc, ui) = (free_port()?, free_port()?, free_port()?);
        let admin_key = work.join("admin");
        let ui_key = work.join("ui-id");
        let tenant_key = work.join("tenant");
        // The GUEST: the grantless principal the console serves strangers as.
        // A real deployment has one (gunnar.rs does), and without it the
        // console redirects every visitor without a session to /login, so
        // nothing a stranger sees — the repository list, a public graph, the
        // top bar's "Log in" button — can be exercised here. The rig ran
        // without one until 2026-09-10, which is why the button that did
        // nothing on gunnar.rs was never red in this suite.
        let guest_key = work.join("guest");
        for k in [&admin_key, &ui_key, &tenant_key, &guest_key] {
            let _ = std::fs::remove_file(k);
            let _ = std::fs::remove_file(k.with_extension("pub"));
            run(
                "ssh-keygen",
                Command::new("ssh-keygen")
                    .current_dir(work)
                    .args(["-q", "-t", "ed25519", "-N", "", "-C", "gunnar-uitest", "-f"])
                    .arg(k),
            )?;
        }
        // ★ **At least 32 characters, and that is the SERVER's rule, not a
        // preference here.** `uitest-<pid>` was thirteen or fourteen, and the
        // console refuses to start on a short break-glass token by name — so
        // every rig run died in `Rig::up` with "the console did not serve
        // /health within 30 s", thirty seconds of nothing and the real sentence
        // buried in the console's own stderr. The API matrix and the whole
        // browser half have been unreachable since that check landed.
        //
        // Unique per run, because two rigs on one box must not share a
        // credential, and the pid alone stops being unique the moment one is
        // reused: the clock goes in beside it, zero-padded so the LENGTH does
        // not depend on how large either number happens to be.
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        let token = format!("uitest-{:0>10}-{:0>24}", std::process::id(), nanos);

        let server = spawn_server(cli, work, ssh, grpc, None)?;

        let mut rig = Rig {
            work: work.to_path_buf(),
            ssh,
            grpc,
            ui,
            token,
            admin_key,
            tenant_key,
            cli: cli.to_owned(),
            server: Mutex::new(Some(server)),
            console: None,
            bench: None,
            bench_control: None,
            bench_child: None,
            monetize_pubkey: None,
            fake_idp: None,
            fake_idp_child: None,
            provided: None,
            console_with: None,
            bench_with: None,
            unsold: None,
            unlaid: None,
            keep,
        };

        let at = format!("127.0.0.1:{grpc}");
        wait_for_server(cli, work, &at)?;

        run(
            "control bootstrap",
            Command::new(cli)
                .current_dir(work)
                .args(["control", "bootstrap", "alice", "-i"])
                .arg(&rig.admin_key)
                .args(["--at", &at]),
        )?;

        run(
            "control user add guest",
            Command::new(cli)
                .current_dir(work)
                .args(["control", "user", "add", "guest", "--key"])
                .arg(format!("@{}", guest_key.with_extension("pub").display()))
                .arg("-i")
                .arg(&rig.admin_key)
                .args(["--at", &at]),
        )?;

        // ── The bench monetize, when asked for ───────────────────────────
        //
        // It verifies the appliance's tickets with the appliance's OWN key,
        // read off the box the way an operator wires a real monetize
        // (`control appliance-identity`), and its checkout returns to this
        // console's `/paid/{reference}`.
        let mut monetize: Option<(String, String)> = None;
        if let Some(bin) = bench_bin {
            let key = run(
                "control appliance-identity",
                Command::new(cli)
                    .current_dir(work)
                    .args(["control", "appliance-identity", "-i"])
                    .arg(&rig.admin_key)
                    .args(["--at", &at]),
            )?;
            let key = key
                .lines()
                .map(str::trim)
                .find(|l| l.len() == 64 && l.chars().all(|c| c.is_ascii_hexdigit()))
                .ok_or_else(|| format!("appliance-identity printed no key: {}", http::cut(&key, 200)))?
                .to_owned();
            rig.bench_with = Some((bin.to_owned(), key.clone()));
            let bearer = format!("bench-{:0>40}", nanos);
            let endpoint_file = work.join("bench-endpoint");
            let _ = std::fs::remove_file(&endpoint_file);
            let child = Command::new(bin)
                .current_dir(work)
                .args(["--bearer", &bearer, "--appliance-key", &key])
                .args(["--success-url", &format!("http://localhost:{ui}/paid/{{reference}}")])
                .arg("--endpoint-file")
                .arg(&endpoint_file)
                // "Add users": what monetize grants (the room, the users and
                // seats) lands on THIS gunnar, signed with the rig's admin key.
                .args(["--gunnar", &at, "--gunnar-key"])
                .arg(&rig.admin_key)
                .stdout(Stdio::from(std::fs::File::create(work.join("bench.log")).map_err(|e| e.to_string())?))
                .stderr(Stdio::from(std::fs::File::create(work.join("bench.err")).map_err(|e| e.to_string())?))
                .spawn()
                .map_err(|e| format!("spawn the bench monetize {bin}: {e}"))?;
            rig.bench_child = Some(child);
            let deadline = Instant::now() + Duration::from_secs(30);
            let endpoint = loop {
                if let Ok(e) = std::fs::read_to_string(&endpoint_file) {
                    if !e.trim().is_empty() {
                        break e.trim().trim_start_matches("http://").to_owned();
                    }
                }
                if Instant::now() > deadline {
                    return Err(format!(
                        "the bench monetize did not serve within 30 s: {}",
                        http::cut(&std::fs::read_to_string(work.join("bench.err")).unwrap_or_default(), 300)
                    ));
                }
                std::thread::sleep(Duration::from_millis(200));
            };
            let pubkey = std::fs::read_to_string(endpoint_file.with_extension("pubkey"))
                .map_err(|e| format!("the bench monetize wrote no public key: {e}"))?
                .trim()
                .to_owned();
            rig.monetize_pubkey = Some(pubkey);
            rig.bench_control = std::fs::read_to_string(endpoint_file.with_extension("control"))
                .ok()
                .map(|c| c.trim().trim_start_matches("http://").to_owned());
            // gunnar again, now holding monetize's key.
            rig.restart_server()?;
            rig.bench = Some(endpoint.clone());
            monetize = Some((endpoint, bearer));
        }

        let mut enroll = Command::new(ui_bin);
        enroll
            .current_dir(work)
            .args(["enroll", "--admin-key"])
            .arg(&rig.admin_key)
            .args(["--name", "uitest-console"])
            .env("GUNNAR_UI_CONTROL", &at)
            .env("GUNNAR_UI_IDENTITY", &ui_key)
            .env("GUNNAR_UI_STATE", work.join("state.redb"))
            .env_remove("GUNNAR_UI_WEBAUTHN_RP_ID")
            .env_remove("GUNNAR_UI_ADMIN_TOKEN");
        run("gunnar-ui-server enroll", &mut enroll)?;

        let bind = format!("127.0.0.1:{ui}");
        let mut console = Command::new(ui_bin);
        console
            .current_dir(work)
            .env("GUNNAR_UI_CONTROL", &at)
            .env("GUNNAR_UI_IDENTITY", &ui_key)
            .env("GUNNAR_UI_GUEST_IDENTITY", &guest_key)
            .env("GUNNAR_UI_STATE", work.join("state.redb"))
            .env("GUNNAR_UI_BIND", &bind)
            .env("GUNNAR_UI_ADMIN_TOKEN", &rig.token)
            // The fake company IdP is on loopback over http: the console's test door
            // lets a sign-in send the browser there (as gunnar's, above).
            .env("GUNNAR_UI_SSO_ALLOW_LOOPBACK_HTTP", "1")
            // `localhost` is a secure context in Chrome, so WebAuthn works
            // over plain http here exactly as it does over https on the site.
            // It must be a NAME: the console refuses a dotted-quad RP id,
            // because WebAuthn's relying-party id is a domain and an IP has
            // none — which is why the rig binds 127.0.0.1 but the browser is
            // sent to localhost.
            .env("GUNNAR_UI_WEBAUTHN_RP_ID", "localhost")
            .env("GUNNAR_UI_WEBAUTHN_ORIGIN", format!("http://localhost:{ui}"))
            .env("GUNNAR_UI_WEBAUTHN_RP_NAME", "gunnar uitest")
            // **Told it is acme's private Gunnar** (the sign-up page,
            // 2026-10-07), so the page's "Grow acme's Gunnar" — COMING, no
            // button — can be drawn and held. Display only: nothing else in
            // the console reads it, and this console still sells as gunnar.rs
            // does (its monetize), which is what the page's other modes need.
            .env("GUNNAR_UI_INSTANCE", "acme")
            .stdout(Stdio::from(std::fs::File::create(work.join("ui.log")).map_err(|e| e.to_string())?))
            .stderr(Stdio::from(std::fs::File::create(work.join("ui.err")).map_err(|e| e.to_string())?));
        match &monetize {
            Some((endpoint, bearer)) => {
                console.env("GUNNAR_UI_MONETIZE", endpoint).env("GUNNAR_UI_MONETIZE_TOKEN", bearer).env("GUNNAR_UI_PRODUCT", BENCH_PRODUCT);
            }
            None => {
                console.env_remove("GUNNAR_UI_MONETIZE").env_remove("GUNNAR_UI_MONETIZE_TOKEN");
            }
        }
        if let Some(d) = ui_dir {
            console.env("GUNNAR_UI_DIR", d);
        } else {
            console.env_remove("GUNNAR_UI_DIR");
        }
        rig.console = Some(console.spawn().map_err(|e| format!("spawn console: {e}"))?);
        rig.console_with = Some(ConsoleWith {
            ui_bin: ui_bin.to_owned(),
            ui_dir: ui_dir.map(Path::to_path_buf),
            ui_key: ui_key.clone(),
            guest_key: guest_key.clone(),
            monetize: monetize.clone(),
        });

        let deadline = Instant::now() + Duration::from_secs(30);
        loop {
            if let Ok(r) = http::req(&bind, "GET", "/health", &[], None) {
                if r.status == 200 {
                    break;
                }
            }
            if Instant::now() > deadline {
                return Err(format!(
                    "the console did not serve /health on {bind} within 30 s: {}",
                    http::cut(&std::fs::read_to_string(work.join("ui.err")).unwrap_or_default(), 300)
                ));
            }
            std::thread::sleep(Duration::from_millis(200));
        }
        Ok(rig)
    }

    /// One POST to the bench's control door: `(status, body)`.
    pub fn bench_post(&self, path: &str) -> Result<(u16, String), String> {
        let at = self.bench_control.as_deref().ok_or("no bench control door (no --bench-monetize, or a bench without one)")?;
        let resp = http::req(at, "POST", path, &[], Some(""))?;
        Ok((resp.status, resp.body.clone()))
    }

    pub fn ui_addr(&self) -> String {
        format!("127.0.0.1:{}", self.ui)
    }
    /// What the BROWSER must use. Not interchangeable with [`Rig::ui_addr`]:
    /// a passkey is scoped to the origin, so a page opened on 127.0.0.1 could
    /// never assert a credential registered on localhost.
    pub fn origin(&self) -> String {
        format!("http://localhost:{}", self.ui)
    }
    pub fn at(&self) -> String {
        format!("127.0.0.1:{}", self.grpc)
    }

    /// Stop gunnar and start it again on the same ports and the same data.
    ///
    /// The console is deliberately left running: the whole point is what the
    /// BFF does when the server it is subscribed to goes away and comes back
    /// with its event sequence beginning at 1 again. Killing the console too
    /// would test a fresh page load, which is the case that was never broken.
    pub fn restart_server(&self) -> Result<(), String> {
        {
            let mut held = self.server.lock().unwrap_or_else(|e| e.into_inner());
            if let Some(mut server) = held.take() {
                server.kill().map_err(|e| format!("stopping gunnar: {e}"))?;
                server.wait().map_err(|e| format!("reaping gunnar: {e}"))?;
            }
            *held = Some(spawn_server(&self.cli, &self.work, self.ssh, self.grpc, self.monetize_pubkey.as_deref())?);
        }
        wait_for_server(&self.cli, &self.work, &self.at())
    }

    /// Stop gunnar, sign a transparency tree head over everything pushed so
    /// far (`gunnar attest head`), and start it again.
    ///
    /// Out of process, and with the server DOWN, on purpose: the control
    /// database takes an exclusive lock, so `attest head` is an operator verb
    /// for a quiet moment — this is exactly how an operator runs it. The chain
    /// situations need a signed head because an inclusion proof without one is
    /// arithmetic the server did about itself, and gunnar keeps the log link a
    /// named hole until somebody signs.
    pub fn attest_head(&self) -> Result<String, String> {
        let signed;
        {
            let mut held = self.server.lock().unwrap_or_else(|e| e.into_inner());
            if let Some(mut server) = held.take() {
                server.kill().map_err(|e| format!("stopping gunnar: {e}"))?;
                server.wait().map_err(|e| format!("reaping gunnar: {e}"))?;
            }
            signed = run(
                "attest head",
                Command::new(&self.cli)
                    .current_dir(&self.work)
                    .args(["attest", "head", "--data-root"])
                    .arg(self.work.join("data")),
            )?;
            *held = Some(spawn_server(&self.cli, &self.work, self.ssh, self.grpc, self.monetize_pubkey.as_deref())?);
        }
        wait_for_server(&self.cli, &self.work, &self.at())?;
        Ok(signed)
    }

    /// `gunnar-cli control …` as the bootstrapped admin.
    pub fn control(&self, args: &[&str]) -> Result<String, String> {
        self.control_as(&self.admin_key, args)
    }

    /// `gunnar-cli control` as somebody OTHER than the rig's admin.
    ///
    /// The same exemption `git_as` records, and for the same reason: a grant
    /// that lists correctly and opens nothing is the failure that matters, and
    /// the only way to ask whether gunnar enforces a grant is to make the call
    /// with a credential that does not hold it. The admin path above delegates
    /// here so there is one place that knows how a control call is spelled.
    pub fn control_as(&self, key: &Path, args: &[&str]) -> Result<String, String> {
        let mut c = Command::new(&self.cli);
        c.current_dir(&self.work).arg("control").args(args).arg("-i").arg(key).args(["--at", &self.at()]);
        run("control", &mut c)
    }

    /// stock `git`, pointed at the rig's SSH port with the rig's admin key.
    pub fn git(&self, dir: &Path, args: &[&str]) -> Result<String, String> {
        self.git_as(&self.admin_key, dir, args)
    }

    /// stock `git` as somebody else — the only honest way to ask whether a
    /// grant the console just made actually opens anything.
    pub fn git_as(&self, key: &Path, dir: &Path, args: &[&str]) -> Result<String, String> {
        self.ensure_sha256_target(dir, args);
        let mut c = Command::new("git");
        c.current_dir(dir).args(args).env(
            "GIT_SSH_COMMAND",
            format!(
                "ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=no \
                 -o UserKnownHostsFile=/dev/null",
                key.display()
            ),
        );
        run(&format!("git {}", args.join(" ")), &mut c)
    }

    /// stock `git` offering NO key at all — gunnar's anonymous door.
    ///
    /// Offering an UNREGISTERED key is a different thing entirely: ssh fails at
    /// publickey auth before any repository ACL is consulted, so a row written
    /// that way tests key registration and never reaches visibility. The
    /// options are the ones `gunnar/sitetest`'s `ssh_cmd_anonymous` already
    /// established — one implementation, not a lookalike.
    pub fn git_anon(&self, dir: &Path, args: &[&str]) -> Result<String, String> {
        let mut c = Command::new("git");
        c.current_dir(dir).args(args).env(
            "GIT_SSH_COMMAND",
            "ssh -o IdentitiesOnly=yes -o IdentityAgent=none -o PubkeyAuthentication=no \
             -o PreferredAuthentications=none -o BatchMode=yes -o StrictHostKeyChecking=no \
             -o UserKnownHostsFile=/dev/null",
        );
        run(&format!("git {}", args.join(" ")), &mut c)
    }

    pub fn remote_as(&self, user: &str, path: &str) -> String {
        format!("ssh://{user}@127.0.0.1:{}/{path}", self.ssh)
    }

    /// **A SHA-256 working tree asks for its repository by name first.**
    ///
    /// The rig's fixtures are SHA-256 (`git init --object-format=sha256`), and
    /// gunnar's first-push door infers SHA-1 — stock `git init` — because git's
    /// v1 receive-pack advertises the object format before the client says a
    /// word (gunnar-ssh `push_create_spec`). A SHA-256 push to a repository
    /// that does not exist yet is therefore refused with "the receiving end
    /// does not support this repository's hash algorithm" (MEASURED dwarf
    /// 2026-10-01: 19 rows red, the whole walk stopped at the fixture). So the
    /// rig does what an operator does and what gunnar's own refusal text says:
    /// `repo create <path> --hash sha256` before the push. An "exists" answer
    /// is the ordinary case for every push after the first and is ignored; any
    /// other refusal is left for the push itself to report.
    fn ensure_sha256_target(&self, dir: &Path, args: &[&str]) {
        if args.first() != Some(&"push") {
            return;
        }
        let at = format!("127.0.0.1:{}/", self.ssh);
        let Some(path) = args.iter().filter(|a| a.starts_with("ssh://")).find_map(|a| a.split_once(&at).map(|(_, p)| p.trim_end_matches(".git").to_owned())) else {
            return;
        };
        let format = Command::new("git").current_dir(dir).args(["rev-parse", "--show-object-format"]).output();
        if format.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "sha256").unwrap_or(false) {
            let _ = self.control(&["repo", "create", &path, "--hash", "sha256"]);
        }
    }

    pub fn remote(&self, path: &str) -> String {
        format!("ssh://alice@127.0.0.1:{}/{path}", self.ssh)
    }
}

impl Rig {
    /// **Start the fake company identity provider** (`--fake-idp <path>`):
    /// read its one line of JSON, keep the child until the rig goes.
    pub fn start_fake_idp(&mut self, bin: &str) -> Result<(), String> {
        use std::io::BufRead as _;
        let secret = format!("uitest-oidc-secret-{}", std::process::id());
        let mut child = Command::new(bin)
            .current_dir(&self.work)
            .args(["--client-id", "uitest-console", "--client-secret", &secret])
            .stdout(Stdio::piped())
            .stderr(Stdio::from(std::fs::File::create(self.work.join("fake-idp.err")).map_err(|e| e.to_string())?))
            .spawn()
            .map_err(|e| format!("spawn {bin}: {e}"))?;
        let stdout = child.stdout.take().ok_or("the fake IdP has no stdout")?;
        let mut line = String::new();
        std::io::BufReader::new(stdout).read_line(&mut line).map_err(|e| format!("the fake IdP said nothing: {e}"))?;
        let j = crate::json::parse(&line).map_err(|e| format!("the fake IdP's line is not JSON: {e}: {line:?}"))?;
        self.fake_idp = Some(FakeIdp {
            oidc_issuer: j.text("oidc_issuer"),
            client_id: j.text("client_id"),
            client_secret: secret,
            saml_metadata_url: j.text("saml_metadata_url"),
            control: j.text("control"),
        });
        self.fake_idp_child = Some(child);
        Ok(())
    }
}

impl Rig {
    /// **Start a SOLD (provided) Gunnar's console** beside the first (one
    /// shop, 2026-10-07): the same gunnar and monetize, its own state and
    /// port, `GUNNAR_UI_PROVIDER=<provider>` and `GUNNAR_UI_INSTANCE=acme`, as
    /// the kit feeds a laid estate's front. Its break-glass token is the
    /// rig's (its own state holds no admin passkey).
    pub fn start_provided(&mut self, provider: &str) -> Result<(), String> {
        let w = self.console_with.as_ref().ok_or("the first console was never started")?;
        let port = free_port()?;
        let bind = format!("127.0.0.1:{port}");
        let mut console = Command::new(&w.ui_bin);
        console
            .current_dir(&self.work)
            .env("GUNNAR_UI_CONTROL", self.at())
            .env("GUNNAR_UI_IDENTITY", &w.ui_key)
            .env("GUNNAR_UI_GUEST_IDENTITY", &w.guest_key)
            .env("GUNNAR_UI_STATE", self.work.join("state-provided.redb"))
            .env("GUNNAR_UI_BIND", &bind)
            .env("GUNNAR_UI_ADMIN_TOKEN", &self.token)
            .env("GUNNAR_UI_WEBAUTHN_RP_ID", "localhost")
            .env("GUNNAR_UI_WEBAUTHN_ORIGIN", format!("http://localhost:{port}"))
            .env("GUNNAR_UI_WEBAUTHN_RP_NAME", "gunnar uitest (sold)")
            .env("GUNNAR_UI_INSTANCE", "acme")
            .env("GUNNAR_UI_PROVIDER", provider)
            .stdout(Stdio::from(std::fs::File::create(self.work.join("ui-provided.log")).map_err(|e| e.to_string())?))
            .stderr(Stdio::from(std::fs::File::create(self.work.join("ui-provided.err")).map_err(|e| e.to_string())?));
        match &w.monetize {
            Some((endpoint, bearer)) => {
                console.env("GUNNAR_UI_MONETIZE", endpoint).env("GUNNAR_UI_MONETIZE_TOKEN", bearer).env("GUNNAR_UI_PRODUCT", BENCH_PRODUCT);
            }
            None => return Err("no monetize behind the rig (--bench-monetize): a sold Gunnar's console runs one".into()),
        }
        match &w.ui_dir {
            Some(d) => console.env("GUNNAR_UI_DIR", d),
            None => console.env_remove("GUNNAR_UI_DIR"),
        };
        let child = console.spawn().map_err(|e| format!("spawn the sold Gunnar's console: {e}"))?;
        self.provided = Some((port, child));
        let deadline = Instant::now() + Duration::from_secs(30);
        loop {
            if let Ok(r) = http::req(&bind, "GET", "/health", &[], None) {
                if r.status == 200 {
                    return Ok(());
                }
            }
            if Instant::now() > deadline {
                return Err(format!(
                    "the sold Gunnar's console did not serve /health on {bind} within 30 s: {}",
                    http::cut(&std::fs::read_to_string(self.work.join("ui-provided.err")).unwrap_or_default(), 300)
                ));
            }
            std::thread::sleep(Duration::from_millis(200));
        }
    }

    /// **Start a console whose monetize sells no private estates** (the
    /// sign-up page, owner 2026-10-08): a second bench monetize,
    /// `--no-estates` (no `[estate_orders]`, as test2's monetize), on its own
    /// endpoint, and a console over it — the same gunnar, its own state and
    /// port, a passkey door, so it sells an account and nothing private.
    pub fn start_unsold(&mut self) -> Result<(), String> {
        let started = self.start_beside_a_bench("unsold", "--no-estates", "sells no private Gunnar")?;
        self.unsold = Some(started);
        Ok(())
    }

    /// **Start a console whose monetize's `[estate_orders]` can sell nothing**
    /// (round 5.3, test2 2026-10-08): a third bench monetize,
    /// `--estates-unlaid` — `[estate_orders]` set over a cloud that lays no
    /// estate, so every step of the ladder is unavailable — and a console
    /// over it, as [`Rig::start_unsold`]'s.
    pub fn start_unlaid(&mut self) -> Result<(), String> {
        let started = self.start_beside_a_bench("unlaid", "--estates-unlaid", "lays no private Gunnar")?;
        self.unlaid = Some(started);
        Ok(())
    }

    /// A bench monetize started with `flag`, on its own endpoint, and a console
    /// over it (files and state named by `tag`), serving: `(port, console, bench)`.
    fn start_beside_a_bench(&self, tag: &str, flag: &str, words: &str) -> Result<(u16, Child, Child), String> {
        let (bin, key) = self.bench_with.clone().ok_or("no bench monetize behind the rig (--bench-monetize)")?;
        let w = self.console_with.as_ref().ok_or("the first console was never started")?;
        let port = free_port()?;
        let endpoint_file = self.work.join(format!("bench-{tag}-endpoint"));
        let _ = std::fs::remove_file(&endpoint_file);
        let bearer = format!("bench-{tag}-{}", std::process::id());
        let mut bench = Command::new(&bin)
            .current_dir(&self.work)
            .args(["--bearer", &bearer, "--appliance-key", &key, flag])
            .args(["--success-url", &format!("http://localhost:{port}/paid/{{reference}}")])
            .arg("--endpoint-file")
            .arg(&endpoint_file)
            .stdout(Stdio::from(std::fs::File::create(self.work.join(format!("bench-{tag}.log"))).map_err(|e| e.to_string())?))
            .stderr(Stdio::from(std::fs::File::create(self.work.join(format!("bench-{tag}.err"))).map_err(|e| e.to_string())?))
            .spawn()
            .map_err(|e| format!("spawn the bench monetize {flag} {bin}: {e}"))?;
        let deadline = Instant::now() + Duration::from_secs(30);
        let endpoint = loop {
            if let Ok(e) = std::fs::read_to_string(&endpoint_file) {
                if !e.trim().is_empty() {
                    break e.trim().trim_start_matches("http://").to_owned();
                }
            }
            if Instant::now() > deadline {
                let _ = bench.kill();
                return Err(format!("the bench monetize {flag} did not serve within 30 s: {}", http::cut(&std::fs::read_to_string(self.work.join(format!("bench-{tag}.err"))).unwrap_or_default(), 300)));
            }
            std::thread::sleep(Duration::from_millis(200));
        };
        let bind = format!("127.0.0.1:{port}");
        let mut console = Command::new(&w.ui_bin);
        console
            .current_dir(&self.work)
            .env("GUNNAR_UI_CONTROL", self.at())
            .env("GUNNAR_UI_IDENTITY", &w.ui_key)
            .env("GUNNAR_UI_GUEST_IDENTITY", &w.guest_key)
            .env("GUNNAR_UI_STATE", self.work.join(format!("state-{tag}.redb")))
            .env("GUNNAR_UI_BIND", &bind)
            .env("GUNNAR_UI_ADMIN_TOKEN", &self.token)
            .env("GUNNAR_UI_WEBAUTHN_RP_ID", "localhost")
            .env("GUNNAR_UI_WEBAUTHN_ORIGIN", format!("http://localhost:{port}"))
            .env("GUNNAR_UI_WEBAUTHN_RP_NAME", format!("gunnar uitest ({words})"))
            .env("GUNNAR_UI_MONETIZE", &endpoint)
            .env("GUNNAR_UI_MONETIZE_TOKEN", &bearer)
            .env("GUNNAR_UI_PRODUCT", BENCH_PRODUCT)
            .env_remove("GUNNAR_UI_INSTANCE")
            .env_remove("GUNNAR_UI_PROVIDER")
            .stdout(Stdio::from(std::fs::File::create(self.work.join(format!("ui-{tag}.log"))).map_err(|e| e.to_string())?))
            .stderr(Stdio::from(std::fs::File::create(self.work.join(format!("ui-{tag}.err"))).map_err(|e| e.to_string())?));
        match &w.ui_dir {
            Some(d) => console.env("GUNNAR_UI_DIR", d),
            None => console.env_remove("GUNNAR_UI_DIR"),
        };
        let mut child = match console.spawn() {
            Ok(c) => c,
            Err(e) => {
                let _ = bench.kill();
                let _ = bench.wait();
                return Err(format!("spawn the {tag} console: {e}"));
            }
        };
        let deadline = Instant::now() + Duration::from_secs(30);
        loop {
            if let Ok(r) = http::req(&bind, "GET", "/health", &[], None) {
                if r.status == 200 {
                    return Ok((port, child, bench));
                }
            }
            if Instant::now() > deadline {
                for c in [&mut child, &mut bench] {
                    let _ = c.kill();
                    let _ = c.wait();
                }
                return Err(format!(
                    "the {tag} console did not serve /health on {bind} within 30 s: {}",
                    http::cut(&std::fs::read_to_string(self.work.join(format!("ui-{tag}.err"))).unwrap_or_default(), 300)
                ));
            }
            std::thread::sleep(Duration::from_millis(200));
        }
    }

    /// The console that sells no private Gunnar, for an API row.
    pub fn unsold_addr(&self) -> Option<String> {
        self.unsold.as_ref().map(|(p, _, _)| format!("127.0.0.1:{p}"))
    }

    /// The console that sells no private Gunnar, for the browser.
    pub fn unsold_origin(&self) -> Option<String> {
        self.unsold.as_ref().map(|(p, _, _)| format!("http://localhost:{p}"))
    }

    /// The console whose monetize's cloud lays no estate, for an API row.
    pub fn unlaid_addr(&self) -> Option<String> {
        self.unlaid.as_ref().map(|(p, _, _)| format!("127.0.0.1:{p}"))
    }

    /// The console whose monetize's cloud lays no estate, for the browser.
    pub fn unlaid_origin(&self) -> Option<String> {
        self.unlaid.as_ref().map(|(p, _, _)| format!("http://localhost:{p}"))
    }

    /// The sold Gunnar's console, for an API row: `127.0.0.1:<port>`.
    pub fn provided_addr(&self) -> Option<String> {
        self.provided.as_ref().map(|(p, _)| format!("127.0.0.1:{p}"))
    }

    /// The sold Gunnar's console, for the browser: `http://localhost:<port>`.
    pub fn provided_origin(&self) -> Option<String> {
        self.provided.as_ref().map(|(p, _)| format!("http://localhost:{p}"))
    }
}

impl Drop for Rig {
    fn drop(&mut self) {
        if let Some((_, c)) = self.provided.as_mut() {
            let _ = c.kill();
            let _ = c.wait();
        }
        for (_, console, bench) in [self.unsold.as_mut(), self.unlaid.as_mut()].into_iter().flatten() {
            for c in [console, bench] {
                let _ = c.kill();
                let _ = c.wait();
            }
        }
        let mut server = self.server.lock().unwrap_or_else(|e| e.into_inner());
        for c in [self.console.as_mut(), server.as_mut(), self.bench_child.as_mut(), self.fake_idp_child.as_mut()].into_iter().flatten() {
            let _ = c.kill();
            let _ = c.wait();
        }
        drop(server);
        if !self.keep {
            let _ = std::fs::remove_dir_all(self.work.join("data"));
        }
    }
}