max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | # |
| @@ -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 |