Skip to main content

max / alloy

settings: answer a polkit prompt the grant does not cover Tier 1 grants the five System-tab actions to an active local session in wheel, which an ssh login is not. Over ssh those rows prompt, correctly — "the person sitting at the machine" is the whole argument for granting them — and until now the console had nothing to prompt with: the setter came back "Interactive authentication required" and that was the end of it, twice, with the same message. Tier 2 of the ladder in wiki alloy-privilege. A setter that fails that way is held, and the view raises Flow::Authorize, which tears the TUI down the way distrobox enter already does and runs the command again with pkttyagent registered for the console's process. polkit's own text agent, no GTK, no Qt, no new dependency, and no widening of the grant. Two things fall out. Views gain a resumed() hook, so coming back from a child refreshes now rather than at the next multiple of a poll interval, which is what the shell's post-suspend tick() had claimed to do. And the Containerfile asserts pkttyagent is in the image, since it ships in the polkit package and its absence is silent until a user over ssh hits it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-03 22:14 UTC
Signed with PGP, not checked
Commit: b8940e3c05f582a20d90e3e17aece39b3f8e5a66
Parent: 2f68e8f
7 files changed, +406 insertions, -7 deletions
@@ -1535,6 +1535,26 @@
1535 1535 done; \
1536 1536 echo "polkit: granted $(echo "$granted" | wc -l) actions, all defined"
1537 1537
1538 + # =====================================================================
1539 + # pkttyagent — the console's answer when the grant above does not apply.
1540 + #
1541 + # The rule grants five actions to an *active local* session in wheel. An
1542 + # ssh login is not that, which is correct and is the whole argument for
1543 + # granting them at all, so the same settings rows prompt over ssh. The
1544 + # console handles that by running the setter again with `pkttyagent`
1545 + # beside it (crates/alloy/src/shell.rs, `Flow::Authorize`), which is
1546 + # polkit's own text agent and needs no GTK or Qt.
1547 + #
1548 + # It ships in the polkit package, so the failure this checks for is not
1549 + # "someone forgot to install it" but "the base split it out" — silent on
1550 + # a built image, and discovered by a user over ssh whose timezone change
1551 + # fails twice with the same message.
1552 + # =====================================================================
1553 + RUN set -eux; \
1554 + command -v pkttyagent >/dev/null \
1555 + || { echo "no pkttyagent; the console has no way to answer a polkit prompt" >&2; exit 1; }; \
1556 + echo "pkttyagent: present"
1557 +
1538 1558 # =====================================================================
1539 1559 # run0 — assert the way to root that Alloy documents is in the image.
1540 1560 #
M docs/CONSOLE.md +1 -1
@@ -156,7 +156,7 @@
156 156
157 157 The thesis at the top of this document, built. One view, two tabs, and the same form under both:
158 158
159 - - **System**, first, because it is the one a user goes looking for. General settings held as live state: time (zone, NTP), hostname, locale and keymap, theme, and whether gopass has an age identity. Time, hostname and locale are built, fronting `timedatectl`, `hostnamectl` and `localectl`; theme is the one left. Each writing row runs exactly one command, and every one of those is in the five actions the shipped polkit rule grants, so none of them prompts and none can reach an action deliberately left prompting. A row whose front did not answer, or whose vocabulary came back empty, is shown and not settable and says why: hiding it would answer "where do I set this" with silence, and offering it would promise a command that is not there. A vocabulary of one closes the row on the same grounds, since the single entry is whatever is already in force and picking it changes nothing. The locale row is the case that reaches users: the image carries no glibc langpacks, so `localectl` has one locale to list, and the row names it and says that adding a langpack adds a choice. The gate is the count rather than a langpack probe, so the row reopens by itself once one is layered in. The secrets row applies the same idiom to something absent rather than to a front that stayed quiet: Alloy ships gopass and provisions no age identity for it, so the row names the directory gopass will look in and says the store will not decrypt until a key is placed there. It is shown and closed in both states, because writing an identity from a settings form is exactly what the provisioning decision ruled out, and it reports whether a file is there without ever reading it. Its rows commit as they are edited rather than at Ctrl-S, and the form reads that from the bind rather than from which tab it is on. Each is a handful of rows over one front with machine-readable output, which is the bar `net` (`nmcli`) and `audio` (`pactl -f json`) were already held to.
159 + - **System**, first, because it is the one a user goes looking for. General settings held as live state: time (zone, NTP), hostname, locale and keymap, theme, and whether gopass has an age identity. Time, hostname and locale are built, fronting `timedatectl`, `hostnamectl` and `localectl`; theme is the one left. Each writing row runs exactly one command, and every one of those is in the five actions the shipped polkit rule grants, so none of them prompts and none can reach an action deliberately left prompting. The grant is for an active *local* session in `wheel`, which an ssh login is not, so those same rows do prompt over ssh — correctly, since "the person sitting at the machine" is the whole of the argument for granting them. The console answers that with polkit's own text agent rather than by widening the grant: a setter that comes back saying interactive authentication is required is run again with `pkttyagent` alive beside it, which means the screen tears down, polkit asks in the terminal's own colors, and the console rebuilds. It is the honest fallback and looks like one. A row whose front did not answer, or whose vocabulary came back empty, is shown and not settable and says why: hiding it would answer "where do I set this" with silence, and offering it would promise a command that is not there. A vocabulary of one closes the row on the same grounds, since the single entry is whatever is already in force and picking it changes nothing. The locale row is the case that reaches users: the image carries no glibc langpacks, so `localectl` has one locale to list, and the row names it and says that adding a langpack adds a choice. The gate is the count rather than a langpack probe, so the row reopens by itself once one is layered in. The secrets row applies the same idiom to something absent rather than to a front that stayed quiet: Alloy ships gopass and provisions no age identity for it, so the row names the directory gopass will look in and says the store will not decrypt until a key is placed there. It is shown and closed in both states, because writing an identity from a settings form is exactly what the provisioning decision ruled out, and it reports whether a file is there without ever reading it. Its rows commit as they are edited rather than at Ctrl-S, and the form reads that from the bind rather than from which tab it is on. Each is a handful of rows over one front with machine-readable output, which is the bar `net` (`nmcli`) and `audio` (`pactl -f json`) were already held to.
160 160 - **Applications**, the adopted stack's config files, grouped behind the app each one configures rather than presented as file paths. The user picks **rio**, not `~/.config/rio/config.toml`; left pane lists the apps a schema ships for, right pane is the form for the selected one, reusing `audio`'s two-pane routing layout. An app with no schema (sway, whose i3-style syntax is neither TOML nor KDL) still appears in the list and opens the text-edit fallback, because someone looking for "where do I configure sway" should find an answer rather than an absence.
161 161
162 162 Display and power/idle are deliberately not here. `alloy display` is a verb of its own, now shipped, and idle behaviour belongs with it; folding them in would duplicate a screen rather than unify one.
@@ -445,6 +445,34 @@
445 445 }
446 446 }
447 447
448 + /// What a systemd tool says when polkit wants a password and no authentication
449 + /// agent is registered to ask for one.
450 + ///
451 + /// The wording is `sd-bus`'s, not each tool's: it is the message attached to
452 + /// `org.freedesktop.DBus.Error.InteractiveAuthorizationRequired`, so
453 + /// timedatectl, hostnamectl and localectl all report it identically and it does
454 + /// not vary with which of them was called. Matched as a substring because each
455 + /// tool wraps it in its own sentence ("Failed to set time zone: ...").
456 + ///
457 + /// Matching a message is not how anyone would choose to detect this. The
458 + /// alternative is speaking D-Bus from the console to see the error name rather
459 + /// than its rendering, which means a bus client for one branch of one screen.
460 + /// The cost of being wrong is bounded in both directions: a missed match leaves
461 + /// the error on the row, where it already was, and a false match runs the same
462 + /// command a second time under an agent that will not be asked anything.
463 + pub(crate) const INTERACTIVE_AUTH: &str = "Interactive authentication required";
464 +
465 + /// Whether this failure is polkit asking for a password with nowhere to ask.
466 + ///
467 + /// The whole chain is checked, not the outermost context: the message arrives
468 + /// as the stderr of a failed [`Invocation`] and callers add their own context
469 + /// on top of it.
470 + pub(crate) fn wants_authentication(error: &anyhow::Error) -> bool {
471 + error
472 + .chain()
473 + .any(|cause| cause.to_string().contains(INTERACTIVE_AUTH))
474 + }
475 +
448 476 /// Something the console does, which is usually but not always a command.
449 477 ///
450 478 /// Backends return these rather than performing them, for the same reason
@@ -500,6 +528,20 @@
500 528 }
501 529 }
502 530
531 + /// This effect as a [`Command`], for the paths that hand a child the
532 + /// terminal: [`Flow::Suspend`](crate::shell::Flow::Suspend) and
533 + /// [`Flow::Authorize`](crate::shell::Flow::Authorize).
534 + ///
535 + /// `None` for the effects that run nothing. A file write and a config-store
536 + /// set are the console's own doing and cannot be handed to another process,
537 + /// which is also why neither can ever want an authentication agent.
538 + pub(crate) fn command(&self) -> Option<Command> {
539 + match self {
540 + Effect::Run(invocation) => Some(invocation.command()),
541 + Effect::Write { .. } | Effect::Config { .. } => None,
542 + }
543 + }
544 +
503 545 /// Perform it, recording what was done and whether it worked.
504 546 pub(crate) fn apply(&self, log: &mut CommandLog) -> Result<()> {
505 547 match self {
@@ -600,6 +642,63 @@
600 642 mod tests {
601 643 use super::*;
602 644
645 + // The message arrives as the stderr of a failed command and gets context
646 + // added on top of it on the way up, so the whole chain is what is read. The
647 + // wording is the one `sd-bus` attaches to
648 + // `InteractiveAuthorizationRequired`, wrapped in each tool's own sentence.
649 + #[test]
650 + fn a_polkit_prompt_with_nowhere_to_ask_is_recognized_through_its_context() {
651 + let bare = anyhow::anyhow!(
652 + "`timedatectl set-timezone Europe/Berlin`: Failed to set time zone: \
653 + Interactive authentication required."
654 + );
655 + assert!(wants_authentication(&bare));
656 + assert!(wants_authentication(&bare.context("setting `time.zone`")));
657 + }
658 +
659 + // Both halves matter. A denial is not a missing agent — the user answered
660 + // and polkit said no — and re-running it under an agent would ask a
661 + // question that has already been answered.
662 + #[test]
663 + fn an_ordinary_failure_is_not_a_missing_agent() {
664 + for message in [
665 + "`hostnamectl --static hostname box`: Could not set hostname: Access denied",
666 + "`localectl set-keymap dvorak`: Failed to set keymap: No such file or directory",
667 + "failed to invoke `timedatectl`",
668 + ] {
669 + assert!(
670 + !wants_authentication(&anyhow::anyhow!(message)),
671 + "got: {message}"
672 + );
673 + }
674 + }
675 +
676 + // Only a command can be handed to an agent. The other two are the console's
677 + // own writes, which is also why neither can ever need one.
678 + #[test]
679 + fn only_a_run_effect_can_be_handed_to_an_agent() {
680 + let run = Effect::Run(Invocation::new("timedatectl").args(["set-ntp", "true"]));
681 + assert!(run.command().is_some());
682 + assert!(
683 + Effect::Write {
684 + path: PathBuf::from("/tmp/x"),
685 + contents: String::new(),
686 + mode: 0o644,
687 + }
688 + .command()
689 + .is_none()
690 + );
691 + assert!(
692 + Effect::Config {
693 + db: PathBuf::from("/tmp/console.db"),
694 + key: "theme".into(),
695 + value: "akari-night".into(),
696 + }
697 + .command()
698 + .is_none()
699 + );
700 + }
701 +
603 702 // A write cannot round-trip into a shell the way an argv can — the contents
604 703 // are a whole file — so it names the verb and the path instead of pretending
605 704 // to be a command.
@@ -158,6 +158,18 @@
158 158 folded: BTreeSet<String>,
159 159 cursor: Cursor,
160 160 mode: Mode,
161 + /// A setter that came back asking for a password, waiting to be run again
162 + /// with an agent to ask through.
163 + ///
164 + /// Held rather than raised because [`commit`](Form::commit) is called from
165 + /// three keys and returns nothing: the form knows the command needs
166 + /// authorizing, and only [`View::handle`] can tell the shell to go and do
167 + /// it. Taken on the way out of the key, so it cannot outlive the keypress
168 + /// that produced it.
169 + ///
170 + /// Carries the argv as well, since a `Command` renders as a debug struct
171 + /// and the log pane shows commands the way a user would type them.
172 + reauth: Option<(String, std::process::Command)>,
161 173 }
162 174
163 175 /// What the form is doing with the row under the cursor.
@@ -288,6 +300,7 @@
288 300 folded,
289 301 cursor: Cursor::new(),
290 302 mode: Mode::Navigate,
303 + reauth: None,
291 304 };
292 305 form.cursor.resize(form.rows().len());
293 306 form
@@ -618,7 +631,19 @@
618 631 Ok(effects) => {
619 632 for effect in effects {
620 633 if let Err(error) = effect.apply(log) {
634 + // polkit wanted a password and had nowhere to ask.
635 + // Tier 1 grants the five System-tab actions to an
636 + // active local session in wheel, so reaching here means
637 + // this session is not that — an ssh login to a server
638 + // profile, most often. The row keeps the reason as
639 + // well: if the second attempt never happens, or the
640 + // password is refused, the error the user is left
641 + // looking at should be the one the command gave.
621 642 self.fail_edit(format!("{error:#}"));
643 + if crate::cli::wants_authentication(&error) {
644 + self.reauth =
645 + effect.command().map(|command| (effect.display(), command));
646 + }
622 647 return;
623 648 }
624 649 }
@@ -982,6 +1007,24 @@
982 1007 self.apps.get(self.cursor.selected()?)
983 1008 }
984 1009
1010 + /// What the shell should do next, once a key that could commit is done.
1011 + ///
1012 + /// [`Flow::Authorize`] when the setter that just ran came back wanting a
1013 + /// password, [`Flow::Continue`] otherwise, which is every other key and
1014 + /// every commit on a machine whose polkit grant covers the session.
1015 + ///
1016 + /// Logged here rather than in the shell, the same way `alloy pkg` logs the
1017 + /// shell it is about to drop the user into: the pane should carry what the
1018 + /// console is handing the terminal to before it goes, not after it comes
1019 + /// back.
1020 + fn authorize_flow(&mut self, log: &mut CommandLog) -> Flow {
1021 + let Some((argv, command)) = self.form_mut().and_then(|form| form.reauth.take()) else {
1022 + return Flow::Continue;
1023 + };
1024 + log.record(argv, Severity::Info);
1025 + Flow::Authorize(command)
1026 + }
1027 +
985 1028 fn form_mut(&mut self) -> Option<&mut Form> {
986 1029 match self.tab() {
987 1030 Tab::System => self.system.as_mut(),
@@ -1292,6 +1335,20 @@
1292 1335 /// comes up. Quiet, because this is the console looking rather than the
1293 1336 /// user asking, and skipped while a row is open so a re-read cannot pull
1294 1337 /// the ground out from under an edit in progress.
1338 + /// Back from the authentication agent, so look again.
1339 + ///
1340 + /// The command that just ran is the one this view asked for, and whether it
1341 + /// took is not something the console can know from out here: the password
1342 + /// may have been refused. So the answer is the same one the System tab
1343 + /// gives after every commit — re-read the fronts and show what the machine
1344 + /// now says. Not gated on the poll interval the way [`tick`](View::tick)
1345 + /// is: this is a resume from a user action, not a background sweep.
1346 + fn resumed(&mut self, log: &mut CommandLog) {
1347 + if let Some(form) = self.system.as_mut() {
1348 + log.quiet(|log| form.bind.reload(log));
1349 + }
1350 + }
1351 +
1295 1352 fn tick(&mut self, log: &mut CommandLog) {
1296 1353 self.ticks += 1;
1297 1354 if !self.ticks.is_multiple_of(SYSTEM_POLL_TICKS) || self.typing() {
@@ -1347,7 +1404,7 @@
1347 1404 }
1348 1405 }
1349 1406 }
1350 - return Flow::Continue;
1407 + return self.authorize_flow(log);
1351 1408 }
1352 1409
1353 1410 match classify(key) {
@@ -1419,6 +1476,9 @@
1419 1476 {
1420 1477 form.toggle_bool(log);
1421 1478 }
1479 + // Space is the third key that can commit: the network-time row
1480 + // is a bool, and flipping it is a setter like any other.
1481 + return self.authorize_flow(log);
1422 1482 }
1423 1483 _ => {}
1424 1484 }
@@ -1429,6 +1489,7 @@
1429 1489 #[cfg(test)]
1430 1490 mod tests {
1431 1491 use super::*;
1492 + use crate::cli::{Effect, Invocation};
1432 1493
1433 1494 const SCHEMA: &str = "\
1434 1495 [schema]
@@ -1963,6 +2024,104 @@
1963 2024 std::fs::remove_file(&path).ok();
1964 2025 }
1965 2026
2027 + // ---- privilege: tier 2, the fallback when the grant does not cover us ----
2028 +
2029 + /// A one-row command front whose setter fails with whatever `stderr` says.
2030 + ///
2031 + /// `sh` rather than a fake: the whole question is what the console does
2032 + /// with the stderr of a command that exited nonzero, and a bind that
2033 + /// returned a hand-built error would be testing the test. It also keeps the
2034 + /// [`Effect::Run`] real, which is what has to survive the failure to be
2035 + /// handed to an agent.
2036 + struct FailingBind {
2037 + sections: Vec<Section>,
2038 + fields: Vec<Field>,
2039 + stderr: &'static str,
2040 + }
2041 +
2042 + impl FailingBind {
2043 + fn new(stderr: &'static str) -> Self {
2044 + Self {
2045 + sections: vec![Section {
2046 + path: "time".into(),
2047 + description: None,
2048 + }],
2049 + fields: vec![Field {
2050 + path: "time.zone".into(),
2051 + description: None,
2052 + required: false,
2053 + readonly: false,
2054 + // The schema crate's, not the widget crate's: this view
2055 + // imports both under the same name.
2056 + kind: schema::FieldKind::Str {
2057 + default: None,
2058 + pattern: None,
2059 + },
2060 + }],
2061 + stderr,
2062 + }
2063 + }
2064 + }
2065 +
2066 + impl Bind for FailingBind {
2067 + fn origin(&self) -> String {
2068 + "sh".into()
2069 + }
2070 + fn sections(&self) -> &[Section] {
2071 + &self.sections
2072 + }
2073 + fn fields(&self) -> &[Field] {
2074 + &self.fields
2075 + }
2076 + fn read(&self, _path: &str) -> Option<Value> {
2077 + None
2078 + }
2079 + fn commit(&mut self, _path: &str, _value: Value) -> anyhow::Result<Vec<Effect>> {
2080 + Ok(vec![Effect::Run(Invocation::new("sh").args([
2081 + "-c",
2082 + &format!("echo '{}' >&2; exit 1", self.stderr),
2083 + ]))])
2084 + }
2085 + fn dirty(&self) -> bool {
2086 + false
2087 + }
2088 + fn save(&mut self) -> anyhow::Result<Vec<Effect>> {
2089 + Ok(Vec::new())
2090 + }
2091 + }
2092 +
2093 + fn commit_zone(stderr: &'static str) -> Form {
2094 + let mut log = CommandLog::new();
2095 + let mut form = Form::new(Box::new(FailingBind::new(stderr)));
2096 + focus(&mut form, "time.zone");
2097 + form.commit("time.zone", Value::String("Europe/Berlin".into()), &mut log);
2098 + form
2099 + }
2100 +
2101 + // The setter came back saying polkit wanted a password and had nowhere to
2102 + // ask, so the command is kept for a second run under an agent. This is the
2103 + // whole of tier 2's trigger: the console does not decide in advance whether
2104 + // the session is covered by the grant, it asks and reads the answer.
2105 + #[test]
2106 + fn a_setter_that_wanted_a_password_is_held_for_a_second_run() {
2107 + let form = commit_zone("Failed to set time zone: Interactive authentication required.");
2108 + let (argv, _) = form.reauth.as_ref().expect("the command is held");
2109 + assert!(argv.starts_with("sh -c"), "the argv is the one that failed");
2110 + }
2111 +
2112 + // Every other failure ends there. Re-running a denial under an agent asks a
2113 + // question polkit has already answered, and re-running a typo asks nothing
2114 + // at all.
2115 + #[test]
2116 + fn any_other_failure_is_not_retried_under_an_agent() {
2117 + for stderr in [
2118 + "Failed to set time zone: Access denied",
2119 + "Failed to set time zone: Invalid time zone",
2120 + ] {
2121 + assert!(commit_zone(stderr).reauth.is_none(), "got: {stderr}");
2122 + }
2123 + }
2124 +
1966 2125 // ---- the seam, from the view's side ----
1967 2126
1968 2127 /// A form over the System tab's bind, built from a fixed `timedatectl show`
@@ -14,7 +14,7 @@
14 14 AlloyKeymap, AlloyLog, AlloyModal, AlloyStatusBar, Hint, KeyGroup, Severity, Theme, binding,
15 15 hint, layout,
16 16 };
17 - use anyhow::Result;
17 + use anyhow::{Context, Result};
18 18 use ratatui::Frame;
19 19 use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind};
20 20 use ratatui::layout::Rect;
@@ -42,6 +42,19 @@
42 42 /// For children that want a real TTY (`distrobox enter`, an editor). The
43 43 /// shell tears the TUI down, runs it to completion, and re-initializes.
44 44 Suspend(Command),
45 + /// Run a command that polkit will ask about, with an agent to ask through.
46 + ///
47 + /// [`Suspend`](Flow::Suspend) with `pkttyagent` alive beside the child. A
48 + /// view raises this when a command came back saying interactive
49 + /// authentication was required, which on Alloy means the session is not the
50 + /// one the grant in `usr/share/polkit-1/rules.d/50-alloy-settings.rules`
51 + /// covers — over SSH, most often, where "the person sitting at the machine"
52 + /// stops being true and the prompt is correct.
53 + ///
54 + /// Tier 2 of wiki `alloy-privilege`'s ladder. It is the honest fallback and
55 + /// not the seamless one: the screen tears down, polkit's own text prompt
56 + /// appears in the terminal's colors, and the console rebuilds after.
57 + Authorize(Command),
45 58 }
46 59
47 60 /// A confirmation prompt raised by a view.
@@ -178,6 +191,18 @@
178 191 /// belongs inside [`CommandLog::quiet`].
179 192 fn tick(&mut self, _log: &mut CommandLog) {}
180 193
194 + /// The console has just taken the terminal back from a child.
195 + ///
196 + /// Raised after [`Flow::Suspend`] and [`Flow::Authorize`], where the child
197 + /// usually changed what the view is looking at: entering a box starts it,
198 + /// and an authorized setter is the write the view asked for in the first
199 + /// place. Default is a tick, which is what the shell did before this hook
200 + /// existed — a view that polls on a schedule can override to refresh now
201 + /// rather than at the next multiple of its poll interval.
202 + fn resumed(&mut self, log: &mut CommandLog) {
203 + self.tick(log);
204 + }
205 +
181 206 /// How long the loop should wait for input before ticking this view.
182 207 ///
183 208 /// Default is [`TICK`], right for a view that only changes when acted on:
@@ -256,6 +281,7 @@
256 281 Flow::Exit => return Ok(()),
257 282 Flow::Confirm(raised) => modal = Some(raised),
258 283 Flow::Suspend(command) => suspend(terminal, view, log, command)?,
284 + Flow::Authorize(command) => authorize(terminal, view, log, command)?,
259 285 Flow::Continue => {}
260 286 }
261 287 }
@@ -296,6 +322,7 @@
296 322 Flow::Continue => {}
297 323 Flow::Confirm(confirm) => modal = Some(confirm),
298 324 Flow::Suspend(command) => suspend(terminal, view, log, command)?,
325 + Flow::Authorize(command) => authorize(terminal, view, log, command)?,
299 326 }
300 327 }
301 328 }
@@ -410,14 +437,95 @@
410 437 terminal.clear()?;
411 438
412 439 // The child usually changed what the view is looking at — entering a box
413 - // starts it. Refresh through the tick path, which is where console
440 + // starts it. Refresh through the resume path, which is where console
414 441 // bookkeeping belongs.
415 - view.tick(log);
442 + view.resumed(log);
416 443
417 444 status?;
418 445 Ok(())
419 446 }
420 447
448 + /// polkit's own text agent, registered for the console's process while an
449 + /// authorized command runs.
450 + ///
451 + /// `--process` and not `--system-bus-name`: the registration then covers this
452 + /// process and the children it spawns, which is exactly the command about to
453 + /// run and nothing else on the machine. It is what `pkttyagent -p $$` does from
454 + /// a shell, and it is why the agent must not outlive the command.
455 + const AGENT: &str = "pkttyagent";
456 +
457 + /// How long the agent gets to register before the command it exists to serve
458 + /// runs.
459 + ///
460 + /// Registration is a synchronous D-Bus call the agent makes at startup, and
461 + /// polkit offers nothing to wait on from out here: `--notify-fd` wants an
462 + /// inherited descriptor, which std cannot hand a child without `pre_exec`, and
463 + /// nothing else reports the agent as ready. So this is a wait, and it is stated
464 + /// as one rather than dressed up.
465 + ///
466 + /// Getting it wrong is a nuisance and not a hazard: the command runs before the
467 + /// agent is listening, polkit finds no agent, and the same "interactive
468 + /// authentication required" comes back — the state the user was already in, and
469 + /// the same keypress tries again. A quarter of a second is far longer than
470 + /// registration takes and is invisible next to a password prompt.
471 + const AGENT_REGISTRATION: Duration = Duration::from_millis(250);
472 +
473 + /// Run `command` with an authentication agent alive beside it.
474 + ///
475 + /// The teardown and rebuild are [`suspend`]'s, for the same reason: polkit's
476 + /// text agent prompts on the terminal, which the TUI is holding. What differs
477 + /// is the second process, and that it is killed on every path out — an agent
478 + /// left registered would keep answering for a console that has stopped asking.
479 + fn authorize(
480 + terminal: &mut ratatui::DefaultTerminal,
481 + view: &mut dyn View,
482 + log: &mut CommandLog,
483 + mut command: Command,
484 + ) -> Result<()> {
485 + ratatui::restore();
486 +
487 + let agent = Command::new(AGENT)
488 + .args(["--process", &std::process::id().to_string()])
489 + .spawn();
490 +
491 + let outcome = match agent {
492 + Ok(mut agent) => {
493 + std::thread::sleep(AGENT_REGISTRATION);
494 + let status = command.status();
495 + // Both, in this order: the kill asks and the wait collects, and
496 + // skipping the wait leaves a zombie for as long as the console
497 + // runs. Neither is worth failing the authorization over, since the
498 + // command has already run.
499 + agent.kill().ok();
500 + agent.wait().ok();
501 + status
502 + .map(drop)
503 + .context("the authorized command could not run")
504 + }
505 + // No agent, so nothing can ask and the command would fail exactly the
506 + // way it already did. Naming the missing binary is more use than
507 + // running it again to produce the same message.
508 + Err(error) => Err(anyhow::Error::new(error).context(format!("`{AGENT}` could not run"))),
509 + };
510 +
511 + *terminal = ratatui::init();
512 + terminal.clear()?;
513 + view.resumed(log);
514 +
515 + // The agent is a process the console started, so the pane says so. Recorded
516 + // after the fact rather than before, because unlike the command it wraps it
517 + // has no outcome of its own worth waiting on: it is killed either way.
518 + log.record(
519 + format!("{AGENT} --process {}", std::process::id()),
520 + if outcome.is_ok() {
521 + Severity::Healthy
522 + } else {
523 + Severity::Error
524 + },
525 + );
526 + outcome
527 + }
528 +
421 529 fn draw(
422 530 frame: &mut Frame,
423 531 theme: &Theme,
@@ -36,8 +36,14 @@
36 36 //! of the desktop follows at the next login.
37 37 //!
38 38 //! Every one is in the five `usr/share/polkit-1/rules.d/50-alloy-settings.rules`
39 - //! grants, so none prompts, and nothing here can reach an action deliberately
40 - //! left prompting. Two consequences that look like quirks and are not:
39 + //! grants, so none prompts for the session that rule describes — an active
40 + //! local one, in wheel — and nothing here can reach an action deliberately left
41 + //! prompting. Over ssh the same five do prompt, since nobody is sitting at the
42 + //! machine and that is the whole of the grant's argument. The console answers
43 + //! that at the next rung rather than by widening the grant: a setter that comes
44 + //! back wanting a password is run again under `pkttyagent`, which is
45 + //! [`Flow::Authorize`](crate::shell::Flow::Authorize) and belongs to the view,
46 + //! not to this table. Two consequences that look like quirks and are not:
41 47 //!
42 48 //! - **`--static` is load-bearing.** `hostnamectl hostname NAME` with no scope
43 49 //! also sets the pretty name, which needs `set-machine-info`, which is not
@@ -9,6 +9,13 @@
9 9 * `alloy-privilege` for the ladder this is the first rung of, and for what is
10 10 * deliberately left prompting.
11 11 *
12 + * `active && local` is doing real work in the rule below: an ssh login is
13 + * neither, so these five rows prompt over ssh exactly as they would without
14 + * this file. That is not a gap. It is the same rule read honestly — nobody is
15 + * sitting at the machine — and the console answers it at the second rung, by
16 + * running the setter again under `pkttyagent`. Widening the grant to cover
17 + * remote sessions would trade the whole justification for one less prompt.
18 + *
12 19 * The list is short on purpose. A rules file is a security artifact and should
13 20 * be readable in one sitting; the failure mode to avoid is a grant that quietly
14 21 * covers more than anyone remembers asking for. Omarchy issue 5708 is the