Skip to main content

max / alloy

pkg: create boxes from the spec, and split the dial's backends Creating was the half of the boxes tab the spec could describe but not build. `Backend::create` returns an Invocation like every other verb, so the create path is checkable without distrobox installed and the argv is displayed before it runs. Settling the flags surfaced that wiki alloy-package-ux named two different backends for the same level: the dial table said podman for `host` and `workspace`, the backend-pick section said distrobox for both. Creation is where that stops being cosmetic. Distrobox has no flag that withholds the host home, the host filesystem paths, or the host D-Bus socket -- host integration is what it is for -- so it cannot express two of the six rows `workspace` promises. The note now says distrobox for `host`, podman directly for `workspace`, and records that `workspace` therefore owes its own export wrapper, which is not written yet. Verified on fw13 against a real box rather than fixtures: no host home, no /run/user and so no D-Bus socket, only the named mounts with :ro honored, network up, files written inside landing as max:max through the rootless uid map, and the private home surviving a restart. That is all six rows of the level's definition. `a_real_workspace_box_reaches_only_ what_it_was_given` re-runs it. Two bugs fell out of building it. A declared sandboxed box read as ad-hoc, because a flatpak row's only handle is the app id while the spec is keyed by the box name and `app` is a field -- the old test hid it by keying its fixture spec by app id, which no real file does. And pressing `s` on a box that did not exist ran `podman start` against it, since the absence check sat in a branch the backend never reached. Declared boxes the system lacks now get a row rather than being missing, which read the same as never having declared them. A backend that failed to list reports nothing absent: it has said nothing about whether its boxes exist, and claiming otherwise would offer to create what may already be there. `--init` on workspace boxes takes a stop from the full ten-second SIGTERM timeout to none, since the keep-alive process is PID 1 and PID 1 ignores SIGTERM without a handler. `enter` branches on level rather than origin. A declared `workspace` box was made by podman and would have failed `distrobox enter`. 127 tests pass, clippy and rustdoc clean.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-19 22:30 UTC
Signed with PGP, not checked
Commit: e4b8b455112d39c152f8added68465292e6792d0
Parent: c01d758
1 file changed, +310 insertions, -41 deletions
@@ -23,6 +23,22 @@
23 23 //! decision — given software X, where does it go and what does that cost — and
24 24 //! a store is separable from that.
25 25 //!
26 + //! # The dial picks the backend
27 + //!
28 + //! A box's [`Level`] decides which backend implements it, never the reverse:
29 + //!
30 + //! | Level | Backend | Why that one |
31 + //! |---|---|---|
32 + //! | `host` | distrobox | host integration is the point, and `distrobox-export` puts binaries on the host PATH |
33 + //! | `workspace` | podman directly | distrobox has no flag that withholds the host home, the host filesystem paths, or the host D-Bus socket |
34 + //! | `sandboxed` | flatpak | portals, seccomp, and D-Bus proxying are a decade of work nobody should reimplement |
35 + //!
36 + //! Distrobox is a shell script over podman, so `workspace` is not a second
37 + //! runtime — it is the same one with the wrapper's opinions left off. The cost
38 + //! is that `workspace` has no `distrobox-export` and Alloy owes it a wrapper of
39 + //! its own, which is not written yet. See wiki `alloy-package-ux`, "Why
40 + //! `workspace` is podman directly".
41 + //!
26 42 //! # Backends build commands, they do not run them
27 43 //!
28 44 //! [`Backend`] returns [`Invocation`] values and the view runs them through
@@ -35,7 +51,7 @@
35 51 //!
36 52 //! <!-- wiki: alloy-package-ux -->
37 53
38 - use std::collections::BTreeMap;
54 + use std::collections::{BTreeMap, BTreeSet};
39 55
40 56 use alloy_tui::keys::{Action, classify};
41 57 use alloy_tui::{
@@ -131,6 +147,13 @@
131 147 Running,
132 148 /// A sandboxed box is one app; it is installed, not started and stopped.
133 149 Installed,
150 + /// Declared in the spec but not on the system.
151 + ///
152 + /// The only state no backend reports, because it is the absence of anything
153 + /// for a backend to report on. It exists so the spec's promise and the
154 + /// machine's reality are read in one list: a declared box with no row would
155 + /// otherwise look exactly like a box that was never declared.
156 + Absent,
134 157 Other(String),
135 158 }
136 159
@@ -139,17 +162,19 @@
139 162 match self {
140 163 BoxState::Running => "running",
141 164 BoxState::Installed => "installed",
165 + BoxState::Absent => "not created",
142 166 BoxState::Other(state) => state,
143 167 }
144 168 }
145 169
146 170 /// Resting states are healthy. `Other` is every state that is neither the
147 171 /// box running nor an app sitting installed — exited, paused, created —
148 - /// each of which is worth a glance.
172 + /// each of which is worth a glance. `Absent` is a gap between the spec and
173 + /// the system, which is the same kind of thing.
149 174 const fn severity(&self) -> Severity {
150 175 match self {
151 176 BoxState::Running | BoxState::Installed => Severity::Healthy,
152 - BoxState::Other(_) => Severity::Warn,
177 + BoxState::Absent | BoxState::Other(_) => Severity::Warn,
153 178 }
154 179 }
155 180
@@ -179,13 +204,45 @@
179 204 /// Image reference for host and workspace, app id for sandboxed.
180 205 pub source: String,
181 206 pub state: BoxState,
182 - pub origin: Origin,
207 + /// The spec entry this row belongs to, if any.
208 + ///
209 + /// Carried rather than reduced to an [`Origin`] on the spot because the name
210 + /// is what says *which* declared box this is, and that is what lets the view
211 + /// work out which declared boxes have no row at all.
212 + pub declared: Option<String>,
183 213 }
184 214
185 215 impl Box {
186 216 fn level_label(&self) -> &'static str {
187 217 self.level.map_or("unknown", Level::label)
188 218 }
219 +
220 + fn origin(&self) -> Origin {
221 + if self.declared.is_some() {
222 + Origin::Declared
223 + } else {
224 + Origin::AdHoc
225 + }
226 + }
227 +
228 + /// A row for a declared box the system does not have.
229 + fn absent(name: &str, spec: &SpecBox) -> Self {
230 + Self {
231 + name: name.to_string(),
232 + level: Some(spec.level),
233 + // Whichever source field the level uses. A spec entry missing it is
234 + // shown anyway and reports the mismatch when creating, so a
235 + // half-written entry is visible rather than silently skipped.
236 + source: spec
237 + .image
238 + .as_deref()
239 + .or(spec.app.as_deref())
240 + .unwrap_or("(no image or app)")
241 + .to_string(),
242 + state: BoxState::Absent,
243 + declared: Some(name.to_string()),
244 + }
245 + }
189 246 }
190 247
191 248 /// The declared boxes, read from the box spec.
@@ -195,7 +252,15 @@
195 252 /// correct reading of a system with no spec.
196 253 #[derive(Debug, Default)]
197 254 pub struct Spec {
198 - levels: BTreeMap<String, Level>,
255 + boxes: BTreeMap<String, SpecBox>,
256 + /// App id to declared name, so a sandboxed row finds its entry.
257 + ///
258 + /// A sandboxed box is identified to flatpak by its app id and to the user by
259 + /// its spec key, and those are different strings: `[box.chromium]` carries
260 + /// `app = "org.chromium.Chromium"`. Without this index a declared flatpak
261 + /// app reads as ad-hoc, because the row's only handle is the id and the spec
262 + /// is keyed by the name.
263 + by_app: BTreeMap<String, String>,
199 264 }
200 265
201 266 impl Spec {
@@ -216,26 +281,42 @@
216 281
217 282 fn parse(raw: &str) -> Result<Self> {
218 283 let file: SpecFile = toml::from_str(raw).context("box spec is not valid TOML")?;
284 + let by_app = file
285 + .r#box
286 + .iter()
287 + .filter_map(|(name, spec)| Some((spec.app.clone()?, name.clone())))
288 + .collect();
219 289 Ok(Self {
220 - levels: file
221 - .r#box
222 - .into_iter()
223 - .map(|(name, spec)| (name, spec.level))
224 - .collect(),
290 + boxes: file.r#box,
291 + by_app,
225 292 })
226 293 }
227 294
228 - /// The declared level for `name`, if it is in the spec.
229 - fn level(&self, name: &str) -> Option<Level> {
230 - self.levels.get(name).copied()
295 + /// The spec entry a row belongs to, by declared name or by app id.
296 + ///
297 + /// Both handles resolve here so callers do not have to know which one they
298 + /// hold: podman rows key on the container name, flatpak rows on the app id,
299 + /// and the entry is the same either way.
300 + fn resolve(&self, key: &str) -> Option<(&str, &SpecBox)> {
301 + if let Some((name, spec)) = self.boxes.get_key_value(key) {
302 + return Some((name, spec));
303 + }
304 + let name = self.by_app.get(key)?;
305 + Some((name, self.boxes.get(name)?))
231 306 }
232 307
233 - fn origin(&self, name: &str) -> Origin {
234 - if self.levels.contains_key(name) {
235 - Origin::Declared
236 - } else {
237 - Origin::AdHoc
238 - }
308 + /// The declared level for `key`, if it is in the spec.
309 + fn level(&self, key: &str) -> Option<Level> {
310 + Some(self.resolve(key)?.1.level)
311 + }
312 +
313 + fn declared_name(&self, key: &str) -> Option<String> {
314 + Some(self.resolve(key)?.0.to_string())
315 + }
316 +
317 + /// Every declared box, for finding the ones the system does not have.
318 + fn entries(&self) -> impl Iterator<Item = (&str, &SpecBox)> {
319 + self.boxes.iter().map(|(name, spec)| (name.as_str(), spec))
239 320 }
240 321 }
241 322
@@ -248,19 +329,60 @@
248 329 Some(base.join("alloy").join("boxes.toml"))
249 330 }
250 331
251 - /// The box spec file. Only `level` is read here; `image`, `app`, `mounts`, and
252 - /// `export` are what creating a box will need, and creation is not in this
253 - /// screen yet. Unknown keys are ignored rather than rejected so a spec written
254 - /// against a later Alloy still yields its levels.
332 + /// The box spec file.
333 + ///
334 + /// Unknown keys are ignored rather than rejected so a spec written against a
335 + /// later Alloy still yields its levels. `export` is parsed nowhere yet: at
336 + /// `host` it is `distrobox-export`'s job, and at `workspace` it is a wrapper
337 + /// Alloy has to write itself (wiki `alloy-package-ux`, "Why `workspace` is
338 + /// podman directly"), which is its own piece of work.
255 339 #[derive(Deserialize, Default)]
256 340 struct SpecFile {
257 341 #[serde(default)]
258 342 r#box: BTreeMap<String, SpecBox>,
259 343 }
260 344
261 - #[derive(Deserialize)]
262 - struct SpecBox {
345 + /// One declared box.
346 + ///
347 + /// The level picks which source field applies — `image` for `host` and
348 + /// `workspace`, `app` for `sandboxed` — so both are optional here and the
349 + /// mismatch is reported when creating, naming the box. Rejecting the file at
350 + /// parse time would cost every other box its declared marker over one bad entry,
351 + /// and the inventory is the half of the screen that does not depend on the spec.
352 + #[derive(Debug, Deserialize)]
353 + pub struct SpecBox {
263 354 level: Level,
355 + /// Image reference for `host` and `workspace`.
356 + image: Option<String>,
357 + /// App id for `sandboxed`.
358 + app: Option<String>,
359 + /// Host paths bind-mounted at the same path inside, `:ro` suffix supported.
360 + #[serde(default)]
361 + mounts: Vec<String>,
362 + /// Which flatpak remote to install from.
363 + ///
364 + /// Called `remote` rather than `origin` because [`Origin`] already means
365 + /// declared-versus-ad-hoc in this module, and flatpak's own noun for the
366 + /// thing you install from is `remote`. Absent lets flatpak resolve it, which
367 + /// is right when only one remote carries the app and an error worth seeing
368 + /// when several do.
369 + remote: Option<String>,
370 + }
371 +
372 + impl SpecBox {
373 + /// The image this box wants, or an error naming what the level requires.
374 + fn image(&self, name: &str) -> Result<&str> {
375 + self.image.as_deref().with_context(|| {
376 + format!("box `{name}` is {} and needs an `image`", self.level.label())
377 + })
378 + }
379 +
380 + /// The app id this box wants, or an error naming what the level requires.
381 + fn app(&self, name: &str) -> Result<&str> {
382 + self.app.as_deref().with_context(|| {
383 + format!("box `{name}` is {} and needs an `app`", self.level.label())
384 + })
385 + }
264 386 }
265 387
266 388 /// A source of boxes, and the commands that manage them.
@@ -271,12 +393,29 @@
271 393 /// The tool this fronts, for the view title.
272 394 fn name(&self) -> &'static str;
273 395
396 + /// Whether this backend implements `level`.
397 + ///
398 + /// The dial runs one way — a level selects a backend, never the reverse —
399 + /// and this is that mapping. It is what lets a declared box with no row find
400 + /// the backend that would create it, since an absent box has nothing but its
401 + /// spec entry to go on.
402 + fn implements(&self, level: Level) -> bool;
403 +
274 404 /// The command whose output [`Backend::parse`] reads.
275 405 fn list(&self) -> Invocation;
276 406
277 407 /// Turn that command's stdout into rows.
278 408 fn parse(&self, raw: &str, spec: &Spec) -> Result<Vec<Box>>;
279 409
410 + /// Build the box `name` describes in the spec.
411 + ///
412 + /// Fallible where the other verbs are not: the level decides which source
413 + /// field is required, and a spec entry that omits it cannot produce a
414 + /// command. The error names the box and what it is missing, which is the
415 + /// only place that mismatch can be reported usefully — parsing stays lenient
416 + /// so one bad entry does not cost every other box its declared marker.
417 + fn create(&self, name: &str, spec: &SpecBox) -> Result<Invocation>;
418 +
280 419 /// Start `boxed`, or `None` when the concept does not apply.
281 420 fn start(&self, boxed: &Box) -> Option<Invocation>;
282 421
@@ -287,7 +426,7 @@
287 426 fn remove(&self, boxed: &Box) -> Invocation;
288 427
289 428 /// Open a shell in `boxed`. Handed to
290 - /// [`Flow::Suspend`](crate::shell::Flow::Suspend), since these want a real
429 + /// [`crate::shell::Flow::Suspend`], since these want a real
291 430 /// TTY.
292 431 fn enter(&self, boxed: &Box) -> Invocation;
293 432 }
@@ -319,6 +458,10 @@
319 458 "podman"
320 459 }
321 460
461 + fn implements(&self, level: Level) -> bool {
462 + matches!(level, Level::Host | Level::Workspace)
463 + }
464 +
322 465 /// `--all` so stopped boxes stay visible. A box someone forgot they made is
323 466 /// exactly what an inventory is for, and the default `ps` hides it.
324 467 fn list(&self) -> Invocation {
@@ -335,7 +478,7 @@
335 478 let name = row.name();
336 479 Box {
337 480 level: spec.level(&name),
338 - origin: spec.origin(&name),
481 + declared: spec.declared_name(&name),
339 482 state: podman_state(&row.state),
340 483 source: row.image,
341 484 name,
@@ -346,6 +489,61 @@
346 489 Ok(boxes)
347 490 }
348 491
492 + /// `host` goes through distrobox; `workspace` goes to podman directly.
493 + ///
494 + /// Settled in wiki `alloy-package-ux`, "Why `workspace` is podman directly".
495 + /// Distrobox has no flag that withholds the host home, the host filesystem
496 + /// paths, or the host D-Bus socket — host integration is what it is for — so
497 + /// it cannot express two of the six rows `workspace` promises. Podman states
498 + /// all six as flags, and since distrobox is a shell script over podman this
499 + /// is the same runtime with the wrapper's opinions left off.
500 + ///
501 + /// `create` rather than `run`: it leaves the box stopped, which is a state
502 + /// the list already shows and the `s` key already acts on, so creating and
503 + /// starting stay two commands the user can see rather than one that does
504 + /// two things.
505 + fn create(&self, name: &str, spec: &SpecBox) -> Result<Invocation> {
506 + let image = spec.image(name)?;
507 + match spec.level {
508 + Level::Host => Ok(Invocation::new("distrobox").args([
509 + "create",
510 + "--name",
511 + name,
512 + "--image",
513 + image,
514 + // Non-interactive: distrobox otherwise prompts before pulling,
515 + // and the console has handed it no terminal to prompt on.
516 + "--yes",
517 + ])),
518 + Level::Workspace => {
519 + // `--init` so stopping the box is immediate. The keep-alive
520 + // process below runs as PID 1, and PID 1 ignores SIGTERM unless
521 + // it installs a handler, so without an init every `s` keypress
522 + // waits the full ten-second timeout and then SIGKILLs. Measured
523 + // on fw13: ten seconds becomes none. It adds a process, not a
524 + // mount, so nothing about the level's isolation moves.
525 + let mut invocation =
526 + Invocation::new("podman").args(["create", "--name", name, "--init"]);
527 + // A private home that survives the container, so caches do too.
528 + // Named rather than a host path: the point of the level is that
529 + // the box does not reach into the host filesystem except where
530 + // it was told to.
531 + invocation = invocation
532 + .args(["--volume", &format!("{}:{WORKSPACE_HOME}", home_volume(name))]);
533 + for mount in &spec.mounts {
534 + invocation = invocation.args(["--volume", &bind(mount)?]);
535 + }
536 + // Nothing keeps a created container alive on its own, and the
537 + // box has to be there for `podman exec` to enter. Everything
538 + // else is podman's default, which is already what the level
539 + // asks for: network on, no devices, no host D-Bus, no mounts
540 + // beyond the ones above.
541 + Ok(invocation.args([image, "sleep", "infinity"]))
542 + }
543 + Level::Sandboxed => unreachable!("the dial routes sandboxed to flatpak"),
544 + }
545 + }
546 +
349 547 fn start(&self, boxed: &Box) -> Option<Invocation> {
350 548 (!boxed.state.is_running())
351 549 .then(|| Invocation::new("podman").args(["start", &boxed.name]))
@@ -368,23 +566,63 @@
368 566 Invocation::new("podman").args(["rm", "--force", &boxed.name])
369 567 }
370 568
371 - /// `distrobox enter` for boxes Alloy declared, a plain shell otherwise.
569 + /// `distrobox enter` for a `host` box, `podman exec` for everything else.
372 570 ///
373 - /// Alloy makes its boxes with distrobox (wiki `alloy-package-ux`, "container
374 - /// backend pick"), so declared boxes answer to it and get the host
375 - /// integration that comes with it. A bare `podman run` container never went
376 - /// through distrobox and would fail that command, so it gets the primitive
377 - /// that does work on it.
571 + /// Branches on level rather than on origin, because level is what decides
572 + /// which tool made the box. Only `host` goes through distrobox, so only
573 + /// `host` answers to `distrobox enter`; a declared `workspace` box was made
574 + /// by podman and would fail it. An unknown level falls here too, which is
575 + /// right: a bare container never went through distrobox either.
378 576 fn enter(&self, boxed: &Box) -> Invocation {
379 - match boxed.origin {
380 - Origin::Declared => Invocation::new("distrobox").args(["enter", &boxed.name]),
381 - Origin::AdHoc => {
382 - Invocation::new("podman").args(["exec", "-it", &boxed.name, "/bin/sh"])
383 - }
577 + match boxed.level {
578 + Some(Level::Host) => Invocation::new("distrobox").args(["enter", &boxed.name]),
579 + _ => Invocation::new("podman").args(["exec", "-it", &boxed.name, "/bin/sh"]),
384 580 }
385 581 }
386 582 }
387 583
584 + /// Where a `workspace` box's private home lives inside the container.
585 + ///
586 + /// `/root` because a rootless podman container runs as root inside while mapping
587 + /// to the invoking user outside, so files it writes into a bind mount come out
588 + /// owned by the user on the host. Matching the host's own home path would gain
589 + /// nothing and would collide with a mount of that path.
590 + const WORKSPACE_HOME: &str = "/root";
591 +
592 + /// The named volume backing a box's private home.
593 + fn home_volume(name: &str) -> String {
594 + format!("alloy-{name}-home")
595 + }
596 +
597 + /// A host path as a podman `--volume` argument, bound at the same path inside.
598 + ///
599 + /// Same path in and out so a directory the user names is the directory they see,
600 + /// and a `:ro` suffix passes through to podman, which spells read-only the same
601 + /// way. `~` is expanded here because the spec is hand-written and podman does no
602 + /// expansion of its own — an unexpanded `~` would silently create a directory
603 + /// with that literal name rather than mounting the home path meant.
604 + fn bind(mount: &str) -> Result<String> {
605 + let (path, mode) = match mount.strip_suffix(":ro") {
606 + Some(path) => (path, ":ro"),
607 + None => (mount, ""),
608 + };
609 +
610 + let path = match path.strip_prefix("~/") {
611 + Some(rest) => {
612 + let home = std::env::var("HOME").context("`~` in a mount needs HOME set")?;
613 + format!("{home}/{rest}")
614 + }
615 + None => path.to_string(),
616 + };
617 +
618 + // Podman rejects a relative source, but only once the command runs, and by
619 + // then the message is podman's rather than one naming the spec.
620 + if !path.starts_with('/') {
621 + anyhow::bail!("mount `{mount}` must be an absolute path or start with `~/`");
622 + }
623 + Ok(format!("{path}:{path}{mode}"))
624 + }
625 +
388 626 /// Map podman's state word onto [`BoxState`], keeping anything unrecognized.
389 627 fn podman_state(raw: &str) -> BoxState {
390 628 if raw == "running" {
@@ -445,6 +683,10 @@
445 683 "flatpak"
446 684 }
447 685
686 + fn implements(&self, level: Level) -> bool {
687 + level == Level::Sandboxed
688 + }
689 +
448 690 /// `--app` so runtimes stay out. A user has one row's worth of interest in
449 691 /// Chromium and none in `org.freedesktop.Platform`, which is an
450 692 /// implementation detail of the app above it.
@@ -473,7 +715,10 @@
473 715 level: Some(Level::Sandboxed),
474 716 source: app.to_string(),
475 717 state: BoxState::Installed,
476 - origin: spec.origin(app),
718 + // By app id, which is the only handle a flatpak row has. The
719 + // spec is keyed by the box name, so this goes through the
720 + // app index rather than a direct lookup.
721 + declared: spec.declared_name(app),
477 722 })
478 723 })
479 724 .collect();
@@ -481,6 +726,21 @@
481 726 Ok(boxes)
482 727 }
483 728
729 + /// `flatpak install`, from the declared remote when the spec names one.
730 + ///
731 + /// Without a remote flatpak resolves the app across the ones configured,
732 + /// which is what should happen when only one carries it and an error worth
733 + /// seeing when several do. `--noninteractive` because the console has handed
734 + /// flatpak no terminal to ask on.
735 + fn create(&self, name: &str, spec: &SpecBox) -> Result<Invocation> {
736 + let app = spec.app(name)?;
737 + let mut invocation = Invocation::new("flatpak").args(["install", "--noninteractive"]);
738 + if let Some(remote) = &spec.remote {
739 + invocation = invocation.arg(remote);
740 + }
741 + Ok(invocation.arg(app))
742 + }
743 +
484 744 // A sandboxed box is one app. It is installed or it is not; there is no
485 745 // container to start and stop, and offering the verbs would imply a
486 746 // lifecycle that does not exist at this level.
@@ -511,12 +771,25 @@
511 771 /// declared first also puts the reproducible boxes where they are read first.
512 772 fn sort_boxes(boxes: &mut [Box]) {
513 773 boxes.sort_by(|a, b| {
514 - (a.origin != Origin::Declared)
515 - .cmp(&(b.origin != Origin::Declared))
774 + (a.origin() != Origin::Declared)
775 + .cmp(&(b.origin() != Origin::Declared))
516 776 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
517 777 });
518 778 }
519 779
780 + /// The same order as [`sort_boxes`], applied across backends.
781 + ///
782 + /// Each backend sorts what it parsed, but the list the user reads is the
783 + /// concatenation of all of them plus the absent rows, and that concatenation is
784 + /// in backend-detection order until it is sorted again here.
785 + fn sort_rows(rows: &mut [Row]) {
786 + rows.sort_by(|a, b| {
787 + (a.boxed.origin() != Origin::Declared)
788 + .cmp(&(b.boxed.origin() != Origin::Declared))
Lines truncated