Skip to main content

max / alloy

pkg: install sandboxed boxes per-user, and remove them where they live `flatpak install --noninteractive` with no scope flag defaults to the system installation, which is a polkit action. The console is holding the terminal an agent would prompt on, so the install fails on authorization rather than asking. A sandboxed box is one user's app; per-user is both the working answer and the honest one. Removal then has to follow: the two installations hold different sets of apps and a bare `flatpak uninstall` resolves against the system one, so an Alloy-created box would be reported as not installed while sitting on the screen. `flatpak list` grows an `installation` column, the row carries it, and `remove` addresses it. A named custom installation parses to no scope and removal stays unqualified, which is what it did before.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-03 22:06 UTC
Signed with PGP, not checked
Commit: 2f68e8f012482601dc9e595ef36523b897c1042a
Parent: 7c78c89
1 file changed, +156 insertions, -13 deletions
@@ -216,6 +216,45 @@
216 216 /// is what says *which* declared box this is, and that is what lets the view
217 217 /// work out which declared boxes have no row at all.
218 218 pub declared: Option<String>,
219 + /// Which flatpak installation the app lives in.
220 + ///
221 + /// Flatpak-only, and `None` everywhere else: podman has one place to put a
222 + /// container and rpm-ostree is not a backend here. It is carried on the row
223 + /// rather than assumed at removal time because the two installations hold
224 + /// different sets of apps, and `flatpak uninstall` addressed at the wrong
225 + /// one reports the app as not installed while it is sitting on the screen.
226 + pub scope: Option<Scope>,
227 + }
228 +
229 + /// A flatpak installation: the per-user one, or the system-wide one.
230 + ///
231 + /// Flatpak also supports named custom installations, which parse to `None`
232 + /// rather than to a member here. Alloy neither creates them nor knows their
233 + /// names, and inventing a third member would mean guessing a flag for one.
234 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
235 + pub(crate) enum Scope {
236 + User,
237 + System,
238 + }
239 +
240 + impl Scope {
241 + /// The flag that addresses this installation.
242 + const fn flag(self) -> &'static str {
243 + match self {
244 + Scope::User => "--user",
245 + Scope::System => "--system",
246 + }
247 + }
248 +
249 + /// Flatpak's own word for an installation, as the `installation` column
250 + /// prints it.
251 + fn parse(word: &str) -> Option<Self> {
252 + match word.trim() {
253 + "user" => Some(Scope::User),
254 + "system" => Some(Scope::System),
255 + _ => None,
256 + }
257 + }
219 258 }
220 259
221 260 impl Box {
@@ -247,6 +286,10 @@
247 286 .to_string(),
248 287 state: BoxState::Absent,
249 288 declared: Some(name.to_string()),
289 + // Nothing is installed, so there is no installation to name. A
290 + // sandboxed box gets one when it is created, and Alloy creates
291 + // them per-user.
292 + scope: None,
250 293 }
251 294 }
252 295 }
@@ -528,6 +571,7 @@
528 571 state: podman_state(&row.state),
529 572 source: row.image,
530 573 name,
574 + scope: None,
531 575 }
532 576 })
533 577 .collect();
@@ -886,7 +930,12 @@
886 930 /// contract here is `--columns`, which is documented and stable, against a
887 931 /// tab-separated body. Requesting explicit columns rather than parsing the
888 932 /// default table is what makes it a contract instead of a layout.
889 - const FLATPAK_COLUMNS: &str = "application,name,origin";
933 + ///
934 + /// `installation` is the last of them and is not cosmetic: it is what
935 + /// [`Backend::remove`] addresses. Nothing displays it, on the grounds that the
936 + /// user picked a level and the installation is how Alloy honors it, not a
937 + /// second dial.
938 + const FLATPAK_COLUMNS: &str = "application,name,origin,installation";
890 939
891 940 impl Backend for Flatpak {
892 941 fn name(&self) -> &'static str {
@@ -915,6 +964,8 @@
915 964 return None;
916 965 }
917 966 let name = fields.next().unwrap_or("").trim();
967 + let _origin = fields.next();
968 + let scope = fields.next().and_then(Scope::parse);
918 969 Some(Box {
919 970 // A sandboxed box contains exactly one app, so the app is
920 971 // the box. Its human name is the identity a user recognizes;
@@ -933,6 +984,7 @@
933 984 // spec is keyed by the box name, so this goes through the
934 985 // app index rather than a direct lookup.
935 986 declared: spec.declared_name(app),
987 + scope,
936 988 })
937 989 })
938 990 .collect();
@@ -940,15 +992,25 @@
940 992 Ok(boxes)
941 993 }
942 994
943 - /// `flatpak install`, from the declared remote when the spec names one.
995 + /// `flatpak install --user`, from the declared remote when the spec names
996 + /// one.
944 997 ///
945 998 /// Without a remote flatpak resolves the app across the ones configured,
946 999 /// which is what should happen when only one carries it and an error worth
947 1000 /// seeing when several do. `--noninteractive` because the console has handed
948 1001 /// flatpak no terminal to ask on.
1002 + ///
1003 + /// `--user` is the load-bearing flag and pairs with that one. Flatpak's
1004 + /// default installation is the system one, which is a polkit action, and a
1005 + /// non-interactive install with no agent registered fails on authorization
1006 + /// rather than asking — with nowhere to ask, since the console is holding
1007 + /// the terminal. A per-user install needs no privilege at all, which is the
1008 + /// answer this level should give anyway: `sandboxed` is one user's app, and
1009 + /// nothing about it wants to be installed for every account on the machine.
949 1010 fn create(&self, name: &str, spec: &SpecBox) -> Result<Invocation> {
950 1011 let app = spec.app(name)?;
951 - let mut invocation = Invocation::new("flatpak").args(["install", "--noninteractive"]);
1012 + let mut invocation =
1013 + Invocation::new("flatpak").args(["install", Scope::User.flag(), "--noninteractive"]);
952 1014 if let Some(remote) = &spec.remote {
953 1015 invocation = invocation.arg(remote);
954 1016 }
@@ -978,8 +1040,21 @@
978 1040 None
979 1041 }
980 1042
1043 + /// `flatpak uninstall`, addressed at the installation the row was found in.
1044 + ///
1045 + /// An app in the user installation and an app in the system one are removed
1046 + /// by different commands, and flatpak resolves a bare `uninstall` against
1047 + /// the system installation first. Alloy installs per-user, so a bare
1048 + /// uninstall of an Alloy-created box asks the wrong installation and is told
1049 + /// the app is not installed. An unrecognized installation (a named custom
1050 + /// one) drops the flag and lets flatpak resolve it, which is the same
1051 + /// behaviour this had before the column was read.
981 1052 fn remove(&self, boxed: &Box) -> Invocation {
982 - Invocation::new("flatpak").args(["uninstall", &boxed.source])
1053 + let mut invocation = Invocation::new("flatpak").arg("uninstall");
1054 + if let Some(scope) = boxed.scope {
1055 + invocation = invocation.arg(scope.flag());
1056 + }
1057 + invocation.arg(&boxed.source)
983 1058 }
984 1059
985 1060 /// A shell inside the app's sandbox, which is what "enter" means at this
@@ -1990,10 +2065,13 @@
1990 2065 }
1991 2066 ]"#;
1992 2067
1993 - // Captured from `flatpak list --app --columns=application,name,origin`.
1994 - // Tab-separated, and the app id is the first field.
1995 - const FLATPAK: &str = "org.chromium.Chromium\tChromium Web Browser\tflathub\n\
1996 - dev.edfloreshz.Tasks\tTasks\tflathub\n";
2068 + // Captured from
2069 + // `flatpak list --app --columns=application,name,origin,installation`.
2070 + // Tab-separated, and the app id is the first field. The two rows differ in
2071 + // installation on purpose: that is the field `remove` addresses, and a
2072 + // fixture where every row agrees cannot show it being read.
2073 + const FLATPAK: &str = "org.chromium.Chromium\tChromium Web Browser\tflathub\tuser\n\
2074 + dev.edfloreshz.Tasks\tTasks\tflathub\tsystem\n";
1997 2075
1998 2076 // Captured from a real `alloy install` booted in QEMU, 2026-07-22, by running
1999 2077 // `rpm-ostree status --json` on the installed system. The `base-commit-meta`
@@ -2210,11 +2288,70 @@
2210 2288 #[test]
2211 2289 fn a_flatpak_row_with_a_missing_name_falls_back_to_the_app_id() {
2212 2290 let boxes = Flatpak
2213 - .parse("org.example.Thing\t\tflathub\n", &Spec::default())
2291 + .parse("org.example.Thing\t\tflathub\tuser\n", &Spec::default())
2214 2292 .unwrap();
2215 2293 assert_eq!(boxes[0].name, "org.example.Thing");
2216 2294 }
2217 2295
2296 + // The installation a row was found in decides which command removes it, so
2297 + // the two rows of the fixture must not come out of the parser alike.
2298 + #[test]
2299 + fn a_sandboxed_row_carries_the_installation_it_was_found_in() {
2300 + let boxes = Flatpak.parse(FLATPAK, &Spec::default()).unwrap();
2301 + let chromium = boxes
2302 + .iter()
2303 + .find(|b| b.source == "org.chromium.Chromium")
2304 + .unwrap();
2305 + let tasks = boxes
2306 + .iter()
2307 + .find(|b| b.source == "dev.edfloreshz.Tasks")
2308 + .unwrap();
2309 + assert_eq!(chromium.scope, Some(Scope::User));
2310 + assert_eq!(tasks.scope, Some(Scope::System));
2311 + assert_eq!(
2312 + Flatpak.remove(tasks).display(),
2313 + "flatpak uninstall --system dev.edfloreshz.Tasks",
2314 + "a system app is removed from the system installation, not the user one"
2315 + );
2316 + }
2317 +
2318 + // Flatpak supports named custom installations. Alloy does not make them and
2319 + // has no flag for one, so the row keeps no scope and removal resolves the
2320 + // way it did before the column was read, rather than guessing at `--user`.
2321 + #[test]
2322 + fn an_unrecognized_installation_leaves_removal_unqualified() {
2323 + let boxes = Flatpak
2324 + .parse(
2325 + "org.example.Thing\tThing\tflathub\tmy-ssd\n",
2326 + &Spec::default(),
2327 + )
2328 + .unwrap();
2329 + assert_eq!(boxes[0].scope, None);
2330 + assert_eq!(
2331 + Flatpak.remove(&boxes[0]).display(),
2332 + "flatpak uninstall org.example.Thing"
2333 + );
2334 + }
2335 +
2336 + // The bug this pairs with: `flatpak install` defaults to the system
2337 + // installation, which is a polkit action, and the console has already taken
2338 + // the terminal an agent would prompt on. Asserted over both spec shapes
2339 + // because the remote is what varies between them and the flag is what must
2340 + // not.
2341 + #[test]
2342 + fn a_sandboxed_box_is_never_installed_system_wide() {
2343 + let with_remote = creation_spec();
2344 + let without =
2345 + Spec::parse("[box.thing]\nlevel = \"sandboxed\"\napp = \"org.example.T\"\n").unwrap();
2346 + for argv in [
2347 + create_argv(&Flatpak, &with_remote, "chromium"),
2348 + create_argv(&Flatpak, &without, "thing"),
2349 + ] {
2350 + assert!(argv.contains(" --user "), "got: {argv}");
2351 + assert!(!argv.contains("--system"), "got: {argv}");
2352 + }
2353 + }
2354 +
2218 2355 // `flatpak list` on a machine with no apps prints nothing at all, which must
2219 2356 // not read as a parse failure.
2220 2357 #[test]
@@ -2302,11 +2439,11 @@
2302 2439
2303 2440 assert_eq!(
2304 2441 Flatpak.list().display(),
2305 - "flatpak list --app --columns=application,name,origin"
2442 + "flatpak list --app --columns=application,name,origin,installation"
2306 2443 );
2307 2444 assert_eq!(
2308 2445 Flatpak.remove(chromium).display(),
2309 - "flatpak uninstall org.chromium.Chromium"
2446 + "flatpak uninstall --user org.chromium.Chromium"
2310 2447 );
2311 2448 assert_eq!(
2312 2449 Flatpak.enter(chromium).display(),
@@ -2560,7 +2697,7 @@
2560 2697 let spec = creation_spec();
2561 2698 assert_eq!(
2562 2699 create_argv(&Flatpak, &spec, "chromium"),
2563 - "flatpak install --noninteractive flathub org.chromium.Chromium"
2700 + "flatpak install --user --noninteractive flathub org.chromium.Chromium"
2564 2701 );
2565 2702 }
2566 2703
@@ -2571,7 +2708,7 @@
2571 2708 .unwrap();
2572 2709 assert_eq!(
2573 2710 create_argv(&Flatpak, &spec, "thing"),
2574 - "flatpak install --noninteractive org.example.Thing"
2711 + "flatpak install --user --noninteractive org.example.Thing"
2575 2712 );
2576 2713 }
2577 2714
@@ -2747,6 +2884,7 @@
2747 2884 source: "img".into(),
2748 2885 state: BoxState::Running,
2749 2886 declared: None,
2887 + scope: None,
2750 2888 },
2751 2889 Box {
2752 2890 name: "alpha".into(),
@@ -2754,6 +2892,7 @@
2754 2892 source: "img".into(),
2755 2893 state: BoxState::Running,
2756 2894 declared: None,
2895 + scope: None,
2757 2896 },
2758 2897 Box {
2759 2898 name: "yak".into(),
@@ -2761,6 +2900,7 @@
2761 2900 source: "img".into(),
2762 2901 state: BoxState::Running,
2763 2902 declared: Some("yak".into()),
2903 + scope: None,
2764 2904 },
2765 2905 ];
2766 2906 sort_boxes(&mut boxes);
@@ -2825,6 +2965,7 @@
2825 2965 source: "fedora-toolbox:43".into(),
2826 2966 state: BoxState::Running,
2827 2967 declared: Some("dev".into()),
2968 + scope: None,
2828 2969 },
2829 2970 Box {
2830 2971 name: "stray".into(),
@@ -2832,6 +2973,7 @@
2832 2973 source: "alpine".into(),
2833 2974 state: BoxState::Other("exited".into()),
2834 2975 declared: None,
2976 + scope: None,
2835 2977 },
2836 2978 ]
2837 2979 }
@@ -3260,6 +3402,7 @@
3260 3402 source: String::new(),
3261 3403 state: BoxState::Running,
3262 3404 declared: Some(name.to_string()),
3405 + scope: None,
3263 3406 })
3264 3407 .run(&mut log)
3265 3408 .unwrap();