Skip to main content

max / makenotwork

bento: collect over a sync transport, not inline scp Artifact collection built its own transport inline -- `scp -r` remote, `cp -vR` local -- duplicating what SshExec/LocalExec already do with rsync, outside the abstraction that exists to prevent exactly that. No capability gate, no checksum, no resume, and two tools' worth of behavior to keep straight. The fix is NOT to route collect through the host's executor. A mac host's executor is AgentRpc, whose /pull is confined to a narrow pull_root (/Users/max/Dist) -- the confinement that stops an allow-listed caller reading ~/.tauri/passwords.env. Artifacts are built in the repo checkout, outside that root, so that route either 404s or forces pull_root wide enough to undo the confinement. So bento now holds two transports per host: an EXEC transport (may be the agent) and a SYNC transport (never the agent -- always SshExec/LocalExec). A mac host signs over AgentRpc and is collected from over ssh. The agent is an execution transport; bulk data moves over rsync. AgentRpc's refusals already said so. Adds Executor::pull_glob for the collect shape (a recipe knows `bundle/msi/*.msi`, not filenames). Expansion is per-transport and not uniform: SshExec lets the remote login shell expand, LocalExec expands in-process since rsync is spawned without a shell. Zero matches errors on both -- a collect that quietly gathers nothing ships an empty release. Verified against mbp, not just in tests: rsync expands the remote glob (2 of 3 files, correct ones), and a zero-match exits 12. topology::host_ssh and RecipeCtx::host_ssh drop out entirely -- they existed only to feed the inline scp.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-16 17:24 UTC
Signed with PGP, not checked
Commit: d4cb24d0bc8f7f1689f27c4044d9358bde480167
Parent: b0a97aa
16 files changed, +301 insertions, -60 deletions
@@ -664,6 +664,12 @@
664 664 "r-efi 6.0.0",
665 665 ]
666 666
667 + [[package]]
668 + name = "glob"
669 + version = "0.3.3"
670 + source = "registry+https://github.com/rust-lang/crates.io-index"
671 + checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
672 +
667 673 [[package]]
668 674 name = "hashbag"
669 675 version = "0.1.13"
@@ -1334,6 +1340,7 @@
1334 1340 dependencies = [
1335 1341 "anyhow",
1336 1342 "async-trait",
1343 + "glob",
1337 1344 "serde",
1338 1345 "thiserror 2.0.18",
1339 1346 "tokio",
@@ -658,6 +658,12 @@
658 658 "wasip3",
659 659 ]
660 660
661 + [[package]]
662 + name = "glob"
663 + version = "0.3.3"
664 + source = "registry+https://github.com/rust-lang/crates.io-index"
665 + checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
666 +
661 667 [[package]]
662 668 name = "hashbag"
663 669 version = "0.1.13"
@@ -1303,6 +1309,7 @@
1303 1309 "anyhow",
1304 1310 "async-trait",
1305 1311 "futures-util",
1312 + "glob",
1306 1313 "reqwest",
1307 1314 "serde",
1308 1315 "serde_json",
@@ -249,6 +249,12 @@
249 249 "wasip3",
250 250 ]
251 251
252 + [[package]]
253 + name = "glob"
254 + version = "0.3.3"
255 + source = "registry+https://github.com/rust-lang/crates.io-index"
256 + checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
257 +
252 258 [[package]]
253 259 name = "hashbrown"
254 260 version = "0.15.5"
@@ -603,6 +609,7 @@
603 609 "anyhow",
604 610 "async-trait",
605 611 "futures-util",
612 + "glob",
606 613 "reqwest",
607 614 "serde",
608 615 "serde_json",
@@ -740,6 +740,12 @@
740 740 "wasm-bindgen",
741 741 ]
742 742
743 + [[package]]
744 + name = "glob"
745 + version = "0.3.3"
746 + source = "registry+https://github.com/rust-lang/crates.io-index"
747 + checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
748 +
743 749 [[package]]
744 750 name = "hashbag"
745 751 version = "0.1.13"
@@ -1417,6 +1423,7 @@
1417 1423 "anyhow",
1418 1424 "async-trait",
1419 1425 "futures-util",
1426 + "glob",
1420 1427 "reqwest",
1421 1428 "serde",
1422 1429 "serde_json",
@@ -427,6 +427,12 @@
427 427 "wasip3",
428 428 ]
429 429
430 + [[package]]
431 + name = "glob"
432 + version = "0.3.3"
433 + source = "registry+https://github.com/rust-lang/crates.io-index"
434 + checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
435 +
430 436 [[package]]
431 437 name = "hashbrown"
432 438 version = "0.15.5"
@@ -833,6 +839,7 @@
833 839 dependencies = [
834 840 "anyhow",
835 841 "async-trait",
842 + "glob",
836 843 "serde",
837 844 "thiserror",
838 845 "tokio",
@@ -286,6 +286,12 @@
286 286 "wasip3",
287 287 ]
288 288
289 + [[package]]
290 + name = "glob"
291 + version = "0.3.3"
292 + source = "registry+https://github.com/rust-lang/crates.io-index"
293 + checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
294 +
289 295 [[package]]
290 296 name = "hashbrown"
291 297 version = "0.15.5"
@@ -660,6 +666,7 @@
660 666 "async-trait",
661 667 "axum",
662 668 "futures-util",
669 + "glob",
663 670 "reqwest",
664 671 "serde",
665 672 "serde_json",
@@ -28,6 +28,7 @@
28 28 tokio-stream = { version = "0.1", optional = true }
29 29 tracing-subscriber = { version = "0.3.22", features = ["env-filter"], optional = true }
30 30 toml = { version = "0.8", optional = true }
31 + glob = "0.3.3"
31 32
32 33 [[bin]]
33 34 name = "ops-agent"
@@ -19,11 +19,9 @@
19 19 use crate::state::ExecutorMap;
20 20 use anyhow::{Context as _, Result};
21 21 use ops_core::live_log::LiveLog;
22 - use ops_core::remote::RemoteHost;
23 - use ops_exec::{Action, ObserveKind, Step as OpStep};
22 + use ops_exec::{Action, Executor, ObserveKind, Step as OpStep, SyncOpts};
24 23 use rhai::{Engine, EvalAltResult, Map};
25 24 use sqlx::SqlitePool;
26 - use std::collections::HashMap;
27 25 use std::path::{Path, PathBuf};
28 26 use std::sync::atomic::{AtomicBool, Ordering};
29 27 use std::sync::{Arc, Mutex};
@@ -80,9 +78,10 @@
80 78 /// through these — the transport (local / ssh / in-session agent) and the
81 79 /// capability gate are the executor's, not the engine's.
82 80 pub execs: Arc<ExecutorMap>,
83 - /// Host name -> ssh string, used only by `collect` to name the remote scp
84 - /// source for a daemon-local artifact pull. Not an execution path.
85 - pub host_ssh: HashMap<String, String>,
81 + /// Sync transport per build host, used only by `collect` to pull artifacts
82 + /// back. Never the agent, even for an agent host — see `state::build_sync`.
83 + /// Not an execution path.
84 + pub syncs: Arc<ExecutorMap>,
86 85 pub pool: SqlitePool,
87 86 pub events: EventTx,
88 87 pub cfg: Arc<Config>,
@@ -114,7 +113,7 @@
114 113 repo: String,
115 114 target_run_id: i64,
116 115 execs: Arc<ExecutorMap>,
117 - host_ssh: HashMap<String, String>,
116 + syncs: Arc<ExecutorMap>,
118 117 pool: SqlitePool,
119 118 events: EventTx,
120 119 cfg: Arc<Config>,
@@ -130,7 +129,7 @@
130 129 repo,
131 130 target_run_id,
132 131 execs,
133 - host_ssh,
132 + syncs,
134 133 pool,
135 134 events,
136 135 cfg,
@@ -310,8 +309,11 @@
310 309 }
311 310
312 311 /// The ssh string for `name` (for `collect`'s remote scp source).
313 - fn host_ssh(&self, name: &str) -> Result<String> {
314 - self.host_ssh
312 + /// The transport that moves artifacts off `name`. Distinct from
313 + /// [`RecipeCtx::exec_for`]'s executor: an agent host signs over `AgentRpc`
314 + /// but is collected from over ssh (`state::build_sync`).
315 + fn host_sync(&self, name: &str) -> Result<Arc<dyn Executor>> {
316 + self.syncs
315 317 .get(name)
316 318 .cloned()
317 319 .ok_or_else(|| anyhow::anyhow!("unknown build host `{name}` (not in topology)"))
@@ -610,36 +612,26 @@
610 612 fn collect(self: &Arc<Self>, host: &str, glob: &str, app: &str, version: &str) -> Result<()> {
611 613 let dest = self.cfg.dist_root.join(app).join(version);
612 614 let dest_s = dest.to_string_lossy().into_owned();
613 - let ssh = self.host_ssh(host)?;
614 - let is_local = ssh == "local" || ssh.is_empty();
615 - // Both branches run the transfer through `sh -c`, and both need the
616 - // wildcard (`*?[]`) to reach the shell live — so the glob is left
617 - // UNQUOTED in both and validated against shell metacharacters instead.
618 - // (Quoting it on the local side, as before, neutered the wildcard so
619 - // `cp '*.dmg'` matched nothing.) Path/wildcard chars are allowed; command
620 - // metacharacters are not.
615 + // The glob reaches a remote login shell intact (that's what expands it),
616 + // so command metacharacters stay barred. Path/wildcard chars are fine.
617 + // Not a privilege boundary — a recipe already runs arbitrary shell via
618 + // `sh_ok` — but it keeps a malformed pattern from becoming a command.
621 619 anyhow::ensure!(
622 620 !glob.chars().any(|c| matches!(c, ';' | '&' | '|' | '$' | '`' | '\'' | '"' | '\\' | ' ' | '\n' | '(' | ')' | '<' | '>')),
623 621 "collect glob `{glob}` contains shell metacharacters"
624 622 );
625 - let d = ops_core::remote::sh_quote(&dest_s);
626 - let cmd = if is_local {
627 - format!("mkdir -p {d} && cp -vR {glob} {d}/")
628 - } else {
629 - format!(
630 - "mkdir -p {d} && scp -r {flags} {tgt}:{glob} {d}/",
631 - flags = ops_core::remote::SSH_FLAGS.join(" "),
632 - tgt = ops_core::remote::sh_quote(&ssh),
633 - )
634 - };
635 - // The daemon always runs the transfer itself (local cp or local scp),
636 - // regardless of which host built the artifact.
637 - let sink = self.ensure_step()?;
638 - let local = RemoteHost::new("local");
639 - let out = self.rt.block_on(async move { local.run_streaming(&cmd, sink).await })?;
640 - if !out.success() {
641 - anyhow::bail!("collect failed (exit {:?})", out.status.code());
642 - }
623 + std::fs::create_dir_all(&dest)
624 + .with_context(|| format!("creating collect dest {dest_s}"))?;
625 + // The SYNC transport, not the host's exec executor: artifacts move over
626 + // ssh/rsync even from an agent host, whose `/pull` is confined to a
627 + // narrow `pull_root` that deliberately excludes the repo checkout these
628 + // artifacts are built in (see `state::build_sync`). The daemon still
629 + // runs the transfer itself, as it always has.
630 + let sync = self.host_sync(host)?;
631 + let opts = SyncOpts::precompressed();
632 + self.rt
633 + .block_on(async { sync.pull_glob(glob, &dest, &opts).await })
634 + .with_context(|| format!("collect {glob} from `{host}`"))?;
643 635 // Best-effort size accounting for the event.
644 636 events::emit(
645 637 &self.events,
@@ -963,8 +955,8 @@
963 955 "fw13".into(),
964 956 "/tmp".into(),
965 957 1,
966 - Arc::new(HashMap::new()),
967 - HashMap::new(),
958 + Arc::new(std::collections::HashMap::new()),
959 + Arc::new(std::collections::HashMap::new()),
968 960 pool,
969 961 crate::events::channel(),
970 962 cfg,
@@ -56,6 +56,7 @@
56 56 }
57 57
58 58 let executors = Arc::new(state::build_executors(&topo));
59 + let syncs = Arc::new(state::build_syncs(&topo));
59 60 let app_state = state::AppState {
60 61 pool,
61 62 topo,
@@ -64,6 +65,7 @@
64 65 events: events::channel(),
65 66 ota: Arc::new(ota::OtaRegistry::standard(mnw_base_url())),
66 67 executors,
68 + syncs,
67 69 active: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
68 70 api_token,
69 71 };
@@ -344,6 +344,7 @@
344 344 )
345 345 .unwrap();
346 346 let executors = Arc::new(crate::state::build_executors(&topo));
347 + let syncs = Arc::new(crate::state::build_syncs(&topo));
347 348 AppState {
348 349 pool,
349 350 topo: Arc::new(topo),
@@ -352,6 +353,7 @@
352 353 events: crate::events::channel(),
353 354 ota: Arc::new(OtaRegistry::standard("https://makenot.work")),
354 355 executors,
356 + syncs,
355 357 active: Arc::new(Mutex::new(HashMap::new())),
356 358 api_token: None,
357 359 }