Skip to main content

max / makenotwork

bento: cover the SSH and Agent transports end to end with a fake Item (5) of the bento test-coverage task, the last open item. Every other runner test declares ssh = "local", so the two-plane transport split was only exercised at construction (state::agent_host_syncs_over_ssh_never_the_agent), never through start_build on a non-local host. New RecordingExec fake records shell commands and artifact pulls on separate logs; a shared run_two_plane helper injects it as BOTH the exec and sync transport of a host named h1, so no real ssh/agent transport is dialed. Two tests: - an_agent_host_signs_over_the_agent_and_collects_over_ssh_end_to_end: a transport = "agent" macOS host. The build + codesign commands land on the agent (exec) plane, the collect glob on the ssh (sync) plane, and -- the load-bearing half -- the agent plane is asked to move NO artifacts (AgentRpc's confined /pull is refused by design; routing collect there would 404 or widen pull_root over the secret-bearing home dir). - a_non_local_ssh_host_runs_a_recipe_end_to_end: a plain host whose ssh is a remote alias, not "local" -- the case every other test avoids. Build rides the exec plane, collect the sync plane. Non-vacuity checked by mutation (pointed collect at self.exec instead of self.host_sync; both tests failed on "the agent transport must never collect", reverted). Full lib suite 119 pass, clippy clean. Not pushed; Max pushes to the mirrors.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 16:32 UTC
Signed with PGP, not checked
Commit: 05772517064fd2c1891cc0ae9852890e8aa25aa2
Parent: 3c0a41a
1 file changed, +320 insertions, -0 deletions
@@ -2405,6 +2405,326 @@
2405 2405 .count();
2406 2406 assert_eq!(notary_calls, 3, "the retry is bounded at three attempts");
2407 2407 }
2408 + // ---- item (5): SSH and Agent transports end to end, via a recording fake ----
2409 + //
2410 + // Every other test declares `ssh = "local"`, so the runner's two-plane
2411 + // routing is only exercised at construction
2412 + // (state::agent_host_syncs_over_ssh_never_the_agent): steps run over the
2413 + // EXEC transport (state.executors -- SshExec, or the in-session AgentRpc for
2414 + // a mac host) while artifacts move over the SYNC transport (state.syncs --
2415 + // always ssh, NEVER the agent, whose confined `/pull` would 404 or force
2416 + // `pull_root` wide enough to expose `~/.tauri/passwords.env`). These drive a
2417 + // real recipe through `start_build` on a NON-local topology, replace the
2418 + // real ssh/agent transports with a recording fake, and assert which plane
2419 + // handled which operation -- the runtime form of that construction-time
2420 + // invariant, on the ssh string every other test pins to "local".
2421 +
2422 + /// Records shell commands and artifact pulls on separate logs, so a test can
2423 + /// prove the exec transport built/signed and the sync transport collected --
2424 + /// and that neither did the other's job -- without a live host or ssh.
2425 + struct RecordingExec {
2426 + caps: CapabilitySet,
2427 + commands: Arc<std::sync::Mutex<Vec<String>>>,
2428 + pulls: Arc<std::sync::Mutex<Vec<String>>>,
2429 + }
2430 +
2431 + impl RecordingExec {
2432 + fn new() -> Arc<Self> {
2433 + Arc::new(Self {
2434 + // A mac build host's real grant. Nothing in this fake gates on it
2435 + // (the real transports do), but keep it coherent so
2436 + // `capabilities()` is not a lie.
2437 + caps: CapabilitySet::from_tokens(
2438 + ["build", "sign", "notarize", "staple"],
2439 + ["build-log", "artifact"],
2440 + ),
2441 + commands: Arc::new(std::sync::Mutex::new(Vec::new())),
2442 + pulls: Arc::new(std::sync::Mutex::new(Vec::new())),
2443 + })
2444 + }
2445 + fn commands(&self) -> Vec<String> {
2446 + self.commands.lock().unwrap().clone()
2447 + }
2448 + fn pulls(&self) -> Vec<String> {
2449 + self.pulls.lock().unwrap().clone()
2450 + }
2451 + }
2452 +
2453 + #[async_trait]
2454 + impl Executor for RecordingExec {
2455 + async fn run_streaming(
2456 + &self,
2457 + step: &ops_exec::Step,
2458 + _sink: &mut dyn LogSink,
2459 + ) -> anyhow::Result<RunOutput> {
2460 + self.commands
2461 + .lock()
2462 + .unwrap()
2463 + .push(step.argv.last().cloned().unwrap_or_default());
2464 + Ok(RunOutput {
2465 + status: std::process::ExitStatus::from_raw(0),
2466 + stdout: Vec::new(),
2467 + stderr: Vec::new(),
2468 + })
2469 + }
2470 + async fn pull_file(
2471 + &self,
2472 + r: &std::path::Path,
2473 + _l: &std::path::Path,
2474 + _o: &SyncOpts,
2475 + ) -> anyhow::Result<()> {
2476 + self.pulls
2477 + .lock()
2478 + .unwrap()
2479 + .push(r.to_string_lossy().into_owned());
2480 + Ok(())
2481 + }
2482 + async fn pull_dir(
2483 + &self,
2484 + r: &std::path::Path,
2485 + _l: &std::path::Path,
2486 + _o: &SyncOpts,
2487 + ) -> anyhow::Result<()> {
2488 + self.pulls
2489 + .lock()
2490 + .unwrap()
2491 + .push(r.to_string_lossy().into_owned());
2492 + Ok(())
2493 + }
2494 + async fn pull_glob(
2495 + &self,
2496 + g: &str,
2497 + _l: &std::path::Path,
2498 + _o: &SyncOpts,
2499 + ) -> anyhow::Result<()> {
2500 + self.pulls.lock().unwrap().push(g.to_string());
2501 + Ok(())
2502 + }
2503 + async fn push_dir(
2504 + &self,
2505 + _l: &std::path::Path,
2506 + _r: &std::path::Path,
2507 + _o: &SyncOpts,
2508 + ) -> anyhow::Result<()> {
2509 + Ok(())
2510 + }
2511 + async fn preflight(&self) -> anyhow::Result<()> {
2512 + Ok(())
2513 + }
2514 + fn capabilities(&self) -> &CapabilitySet {
2515 + &self.caps
2516 + }
2517 + }
2518 +
2519 + /// Stand up a single-target app (host named `h1`) whose recipe is
2520 + /// `recipe_body` (with `REPO` replaced by the checkout path), inject
2521 + /// `exec_fake` as the host's EXEC transport and `sync_fake` as its SYNC
2522 + /// transport, run the build to a terminal state, and return the tmpdir plus
2523 + /// `(status, error)`. Unlike `test_state`'s `build_executors`, this replaces
2524 + /// BOTH planes so no real ssh/agent transport is dialed. The returned
2525 + /// [`tempfile::TempDir`] holds the sqlite DB and must outlive the caller's
2526 + /// assertions.
2527 + async fn run_two_plane(
2528 + host_toml: &str,
2529 + target: &str,
2530 + recipe_file: &str,
2531 + recipe_body: &str,
2532 + exec_fake: Arc<dyn Executor>,
2533 + sync_fake: Arc<dyn Executor>,
2534 + ) -> (tempfile::TempDir, String, String) {
2535 + let tmp = tempfile::tempdir().unwrap();
2536 + let root = tmp.path();
2537 + let repo = root.join("app");
2538 + std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
2539 + std::fs::write(
2540 + repo.join("src-tauri/tauri.conf.json"),
2541 + r#"{"version":"0.0.1"}"#,
2542 + )
2543 + .unwrap();
2544 + std::fs::create_dir_all(repo.join("dist/recipes")).unwrap();
2545 + std::fs::write(
2546 + repo.join("dist/recipes").join(recipe_file),
2547 + recipe_body.replace("REPO", repo.to_str().unwrap()),
2548 + )
2549 + .unwrap();
2550 + std::fs::write(
2551 + repo.join("bento.toml"),
2552 + format!("targets = [\"{target}\"]\n"),
2553 + )
2554 + .unwrap();
2555 +
2556 + let cfg = Config::for_tests(root);
2557 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
2558 + let topo = Topology::from_str_for_tests(&format!(
2559 + "{}\n[app.demo]\nrepo = \"{}\"\n",
2560 + host_toml.replace("REPO", repo.to_str().unwrap()),
2561 + repo.display()
2562 + ))
2563 + .unwrap();
2564 +
2565 + let mut state = test_state(pool.clone(), topo, cfg);
2566 + state.executors = Arc::new(HashMap::from([("h1".to_string(), exec_fake)]));
2567 + state.syncs = Arc::new(HashMap::from([("h1".to_string(), sync_fake)]));
2568 +
2569 + let build_id = start_build(
2570 + state.clone(),
2571 + AppId::new("demo"),
2572 + Version::parse("0.0.1").unwrap(),
2573 + vec![target.parse().unwrap()],
2574 + )
2575 + .await
2576 + .unwrap();
2577 +
2578 + let mut status = String::new();
2579 + let mut error = String::new();
2580 + for _ in 0..100 {
2581 + let row: Option<(String, Option<String>)> =
2582 + sqlx::query_as("SELECT status, error FROM target_runs WHERE build_id = ?")
2583 + .bind(build_id)
2584 + .fetch_optional(&pool)
2585 + .await
2586 + .unwrap();
2587 + if let Some((s, e)) = row {
2588 + status = s;
2589 + error = e.unwrap_or_default();
2590 + if status != "running" {
2591 + break;
2592 + }
2593 + }
2594 + tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2595 + }
2596 + (tmp, status, error)
2597 + }
2598 +
2599 + /// An agent (macOS) host signs over the AGENT transport but is collected
2600 + /// from over SSH -- driven through the whole runner, not just `build_sync`.
2601 + /// The sign chain's commands land on the exec plane and the artifact pull on
2602 + /// the sync plane; crucially, the agent plane is asked to move NOTHING (a
2603 + /// regression to one transport would route collect at `AgentRpc::pull_glob`,
2604 + /// refused by design, or widen `pull_root` over the secret-bearing home dir).
2605 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2606 + async fn an_agent_host_signs_over_the_agent_and_collects_over_ssh_end_to_end() {
2607 + let agent = RecordingExec::new(); // exec plane (AgentRpc in prod)
2608 + let ssh = RecordingExec::new(); // sync plane (SshExec in prod)
2609 + let host = r#"
2610 + [[host]]
2611 + name = "h1"
2612 + ssh = "mbp"
2613 + targets = ["macos/aarch64"]
2614 + transport = "agent"
2615 + agent_url = "http://mbp:8765"
2616 + actuate = ["build", "sign", "notarize", "staple"]
2617 + observe = ["build-log", "gatekeeper", "artifact"]
2618 + pull_root = "REPO"
2619 + "#;
2620 + let recipe = r#"
2621 + let h = build_host();
2622 + step("build");
2623 + sh_ok(h, "echo built");
2624 + step("sign");
2625 + codesign(h, "Developer ID Application: Test", "REPO/out/demo.dmg");
2626 + step("collect");
2627 + collect(h, "REPO/out/*.dmg", "demo", "0.0.1");
2628 + "#;
2629 + let (_tmp, status, error) = run_two_plane(
2630 + host,
2631 + "macos/aarch64",
2632 + "macos.rhai",
2633 + recipe,
2634 + agent.clone(),
2635 + ssh.clone(),
2636 + )
2637 + .await;
2638 + assert_eq!(status, "ok", "the recipe should complete: {error}");
2639 +
2640 + // The build + sign commands ran on the AGENT (exec) transport.
2641 + let agent_cmds = agent.commands();
2642 + assert!(
2643 + agent_cmds.iter().any(|c| c.contains("codesign")),
2644 + "codesign rides the agent exec transport: {agent_cmds:?}"
2645 + );
2646 + assert!(
2647 + agent_cmds.iter().any(|c| c.contains("echo built")),
2648 + "the build step rides the agent exec transport: {agent_cmds:?}"
2649 + );
2650 + // ...and the agent moved NO artifacts. This is the load-bearing half:
2651 + // AgentRpc::pull_glob is refused by design, so collect must not touch it.
2652 + assert!(
2653 + agent.pulls().is_empty(),
2654 + "the agent transport must never collect artifacts: {:?}",
2655 + agent.pulls()
2656 + );
2657 +
2658 + // The artifact was collected over the SSH (sync) transport...
2659 + let ssh_pulls = ssh.pulls();
2660 + assert!(
2661 + ssh_pulls
2662 + .iter()
2663 + .any(|p| p.contains("demo.dmg") || p.contains("*.dmg")),
2664 + "collect rides the ssh sync transport: {ssh_pulls:?}"
2665 + );
2666 + // ...and the sync transport was never asked to run a build/sign command.
2667 + assert!(
2668 + ssh.commands().is_empty(),
2669 + "the sync transport must never run host commands: {:?}",
2670 + ssh.commands()
2671 + );
2672 + }
2673 +
2674 + /// A plain (non-agent) host whose `ssh` is a remote alias, not "local" --
2675 + /// the case every other test avoids. The recipe runs end to end through the
2676 + /// fake, proving the runner drives a non-local host and still splits exec
2677 + /// (build) from sync (collect) across the two transport maps.
2678 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2679 + async fn a_non_local_ssh_host_runs_a_recipe_end_to_end() {
2680 + let exec = RecordingExec::new();
2681 + let sync = RecordingExec::new();
2682 + let host = r#"
2683 + [[host]]
2684 + name = "h1"
2685 + ssh = "astra"
2686 + targets = ["linux/x86_64"]
2687 + pull_root = "REPO"
2688 + "#;
2689 + let recipe = r#"
2690 + let h = build_host();
2691 + step("build");
2692 + sh_ok(h, "echo compiling");
2693 + step("collect");
2694 + collect(h, "REPO/out/demo.bin", "demo", "0.0.1");
2695 + "#;
2696 + let (_tmp, status, error) = run_two_plane(
2697 + host,
2698 + "linux/x86_64",
2699 + "linux.rhai",
2700 + recipe,
2701 + exec.clone(),
2702 + sync.clone(),
2703 + )
2704 + .await;
2705 + assert_eq!(status, "ok", "the recipe should complete: {error}");
2706 +
2707 + assert!(
2708 + exec.commands().iter().any(|c| c.contains("echo compiling")),
2709 + "the build command rides the exec transport: {:?}",
2710 + exec.commands()
2711 + );
2712 + assert!(
2713 + exec.pulls().is_empty(),
2714 + "the exec transport must not collect: {:?}",
2715 + exec.pulls()
2716 + );
2717 + assert!(
2718 + sync.pulls().iter().any(|p| p.contains("demo.bin")),
2719 + "collect rides the sync transport: {:?}",
2720 + sync.pulls()
2721 + );
2722 + assert!(
2723 + sync.commands().is_empty(),
2724 + "the sync transport must not run commands: {:?}",
2725 + sync.commands()
2726 + );
2727 + }
2408 2728 }
2409 2729
2410 2730 /// Every recipe of every configured app must parse.