Skip to main content

max / alloy

Say when gopass has no age identity Alloy ships gopass and provisions no identity for it, settled in a56a8a8. Nothing on the machine said so, so the way a fresh install learned it was cloning the store and being refused by gopass. Add the sentence that was missing, as a System tab row in the shown-and-not-settable idiom the locale row uses: it names the directory gopass will look in and says the store will not decrypt until a key is placed there. Closed in both states, unlike every other closed row here, because there is no privilege that would open it. Writing an identity from a settings form is the thing the provisioning decision ruled out. The path follows gopass's own precedence (GOPASS_HOMEDIR, then XDG_CONFIG_HOME, then home) rather than hardcoding one that is wrong on a machine which moved its store. Resolution is a free function over three strings so the precedence is tested without touching process environment. An empty identities file counts as absent: gopass writes it before it has anything to put in it, and it refuses exactly like no file. The row reports whether a file is there and never opens it. Echoing key material into a scrollback would be worse than the silence being fixed.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-03 15:57 UTC
Signed with PGP, not checked
Commit: 7c78c89bc6dbea24c0ac5796c344c824c52c0005
Parent: a56a8a8
3 files changed, +237 insertions, -3 deletions
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. 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. 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. 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.
@@ -1991,8 +1991,10 @@
1991 1991 "locale.keymap",
1992 1992 "[appearance]",
1993 1993 "appearance.theme",
1994 + "[secrets]",
1995 + "secrets.age",
1994 1996 ],
1995 - "three fronts and the console's own preference, one form",
1997 + "three fronts, the console's own preference, and one file check, one form",
1996 1998 );
1997 1999 assert_eq!(
1998 2000 cell(form.bind.as_ref(), form.bind.field("time.zone").unwrap()).text,
@@ -63,10 +63,25 @@
63 63 //! locale to list. The theme row has always read it this way, since a search
64 64 //! path that found no themes still leaves `system` behind.
65 65 //!
66 + //! ## Rows with no front at all
67 + //!
68 + //! "Live state, fronted by commands" is the shape, not the whole membership.
69 + //! Two rows front a filesystem instead: the theme row reads makeover's search
70 + //! path, and the secrets row ([`Age`]) reports whether gopass has an identity to
71 + //! decrypt with. Both still produce a [`Field`] the form cannot distinguish, so
72 + //! the seam holds; only the source moved.
73 + //!
74 + //! The secrets row is also the only one that is closed in every state, because
75 + //! there is no privilege that would open it. Placing an age identity from a
76 + //! settings form is the thing the provisioning decision ruled out, so the row
77 + //! exists to say a key is missing and never to accept one.
78 + //!
66 79 //! See wiki note `alloy-privilege`.
67 80 //!
68 81 //! <!-- wiki: alloy-settings -->
69 82
83 + use std::path::{Path, PathBuf};
84 +
70 85 use anyhow::Result;
71 86 use toml::Value;
72 87
@@ -85,6 +100,8 @@
85 100 const KEYMAP: &str = "locale.keymap";
86 101 /// The one row that writes a file rather than running a command.
87 102 pub(crate) const THEME: &str = "appearance.theme";
103 + /// Shown, never set. See [`Age`].
104 + const AGE: &str = "secrets.age";
88 105
89 106 /// Live system settings, fronted by `timedatectl`, `hostnamectl`, `localectl`.
90 107 pub(crate) struct SystemBind {
@@ -93,6 +110,7 @@
93 110 time: Vec<(String, String)>,
94 111 host: Host,
95 112 locale: Locale,
113 + age: Age,
96 114 }
97 115
98 116 /// What `hostnamectl` reported.
@@ -115,6 +133,71 @@
115 133 answered: bool,
116 134 }
117 135
136 + /// Whether gopass has the age identity it decrypts with.
137 + ///
138 + /// The one row on this screen fronted by neither a command nor a config file.
139 + /// Alloy ships gopass and provisions no identity for it, on purpose: the key
140 + /// already exists elsewhere, and a fresh install has no channel to carry it in
141 + /// over. See `docs/STACK.md#secrets`. So the failure a new machine hits is
142 + /// cloning the store, running `gopass show`, and being refused, with nothing on
143 + /// the machine having said a key was needed. The row is that missing sentence.
144 + ///
145 + /// It answers "is there a file" and nothing more. The identity is a bearer
146 + /// secret, and a settings screen that read any part of it into a scrollback
147 + /// would be a worse bug than the silence it is here to fix.
148 + #[derive(Default)]
149 + struct Age {
150 + /// Where gopass would look, or `None` on a machine with no home directory
151 + /// to resolve against.
152 + dir: Option<PathBuf>,
153 + /// An `identities` file is there and is not empty.
154 + present: bool,
155 + }
156 +
157 + /// Where gopass keeps its age identity, following gopass's own precedence.
158 + ///
159 + /// `GOPASS_HOMEDIR` wins, then `XDG_CONFIG_HOME`, then the home directory,
160 + /// which is what `pkg/appdir` resolves. Taken as arguments rather than read
161 + /// here so the precedence is testable without setting process environment,
162 + /// which no test can do safely while its siblings are running.
163 + fn age_dir_from(homedir: Option<&str>, xdg: Option<&str>, home: Option<&str>) -> Option<PathBuf> {
164 + let base = match (homedir, xdg, home) {
165 + (Some(dir), _, _) if !dir.is_empty() => PathBuf::from(dir).join(".config").join("gopass"),
166 + (_, Some(dir), _) if !dir.is_empty() => PathBuf::from(dir).join("gopass"),
167 + (_, _, Some(dir)) if !dir.is_empty() => PathBuf::from(dir).join(".config").join("gopass"),
168 + _ => return None,
169 + };
170 + Some(base.join("age"))
171 + }
172 +
173 + /// Read the environment gopass reads, and look for the file.
174 + ///
175 + /// Hardcoding the tail of the path while honouring the variables that move it
176 + /// is the honest middle here. Shelling out to `gopass config` and parsing it
177 + /// would be exact, but it costs a subprocess on every reload of a row that
178 + /// reports one bit, and it fails on the machine this row most exists for: the
179 + /// one where gopass is not yet working.
180 + fn read_age() -> Age {
181 + let value = |key: &str| std::env::var(key).ok();
182 + let dir = age_dir_from(
183 + value("GOPASS_HOMEDIR").as_deref(),
184 + value("XDG_CONFIG_HOME").as_deref(),
185 + value("HOME").as_deref(),
186 + );
187 + let present = dir.as_deref().is_some_and(identity_present);
188 + Age { dir, present }
189 + }
190 +
191 + /// Whether `dir` holds a non-empty `identities`, which is the file the age
192 + /// backend decrypts with.
193 + ///
194 + /// Emptiness counts as absence: gopass writes the file before it has anything
195 + /// to put in it, and a zero-byte `identities` refuses exactly like no file at
196 + /// all. Metadata only, and the contents are never opened.
197 + fn identity_present(dir: &Path) -> bool {
198 + std::fs::metadata(dir.join("identities")).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
199 + }
200 +
118 201 impl SystemBind {
119 202 /// Read the machine's state and build the rows, or `None` when there is no
120 203 /// `timedatectl` answering.
@@ -144,6 +227,7 @@
144 227 time,
145 228 host: read_host(log),
146 229 locale: read_locale(log),
230 + age: read_age(),
147 231 };
148 232 bind.annotate();
149 233 Some(bind)
@@ -271,6 +355,27 @@
271 355 "How the console renders. Pinned, whatever the terminal does.".to_string()
272 356 };
273 357 set(self.field_mut(THEME), &note, bare);
358 +
359 + // Naming the path and the consequence, rather than reporting "not
360 + // configured" and leaving both to be guessed at. What hurts on a fresh
361 + // install is not placing the key, it is not learning one was wanted
362 + // until gopass refuses.
363 + let note = match (&self.age.dir, self.age.present) {
364 + (None, _) => {
365 + "No home directory, so there is nowhere for gopass to keep a key.".to_string()
366 + }
367 + (Some(_), true) => {
368 + "Where gopass keeps the identity it decrypts the store with.".to_string()
369 + }
370 + (Some(dir), false) => format!(
371 + "No age identity at {}. gopass will not decrypt until one is placed there.",
372 + dir.display(),
373 + ),
374 + };
375 + // Always closed. Writing an identity from a settings form is the one
376 + // thing the provisioning decision ruled out, so this row has no setter
377 + // to open even when everything answered.
378 + set(self.field_mut(AGE), &note, true);
274 379 }
275 380 }
276 381
@@ -312,6 +417,10 @@
312 417 path: "appearance".to_string(),
313 418 description: Some("How the console renders.".to_string()),
314 419 },
420 + Section {
421 + path: "secrets".to_string(),
422 + description: Some("Where gopass looks for its key.".to_string()),
423 + },
315 424 ]
316 425 }
317 426
@@ -358,6 +467,9 @@
358 467 enumerated(LOCALE, locales),
359 468 enumerated(KEYMAP, keymaps),
360 469 enumerated(THEME, themes),
470 + // Not a setting either, and for a stronger reason than the clock: there
471 + // is no command behind it that a wider grant could reach.
472 + text(AGE, true),
361 473 ]
362 474 }
363 475
@@ -534,7 +646,13 @@
534 646 HOSTNAME => text(self.host.fixed.as_ref().or(self.host.effective.as_ref())),
535 647 LOCALE => text(self.locale.lang.as_ref()),
536 648 KEYMAP => text(self.locale.keymap.as_ref()),
537 - THEME => Some(Value::String(crate::theme::current_id())),
649 + // The path, not the answer: "is a key there" is what the help line
650 + // carries, and the directory is the thing a reader wants to copy.
651 + AGE => self
652 + .age
653 + .dir
654 + .as_ref()
655 + .map(|dir| Value::String(dir.display().to_string())),
538 656 _ => None,
539 657 }
540 658 }
@@ -627,6 +745,9 @@
627 745 }
628 746 self.host = read_host(log);
629 747 self.locale = read_locale(log);
748 + // Cheap, and the case worth catching: the user reads the row, places
749 + // the identity in another window, and refreshes.
750 + self.age = read_age();
630 751 self.annotate();
631 752 }
632 753 }
@@ -639,6 +760,19 @@
639 760 /// tests and this module's cannot disagree about what a row holds.
640 761 #[cfg(test)]
641 762 pub(crate) fn fixture() -> Self {
763 + Self::fixture_with_age(Age {
764 + dir: Some(PathBuf::from("/home/tester/.config/gopass/age")),
765 + present: true,
766 + })
767 + }
768 +
769 + /// The fixture with the secrets row's state chosen.
770 + ///
771 + /// The age state is passed in rather than probed because the alternative is
772 + /// a test whose result depends on whether the developer running it happens
773 + /// to use gopass.
774 + #[cfg(test)]
775 + fn fixture_with_age(age: Age) -> Self {
642 776 let mut bind = Self {
643 777 sections: sections(),
644 778 fields: rows(
@@ -650,6 +784,7 @@
650 784 time: parse_show(tests::SHOW),
651 785 host: parse_host(tests::HOST),
652 786 locale: parse_locale(tests::LOCALE_STATUS),
787 + age,
653 788 };
654 789 bind.annotate();
655 790 bind
@@ -723,6 +858,101 @@
723 858 assert_eq!(bind.read(KEYMAP), None, "the fixture reports (unset)");
724 859 }
725 860
861 + // The pair the locale row got, applied to the row that reports a file
862 + // rather than a vocabulary. Both states are closed; only the note moves.
863 + #[test]
864 + fn the_secrets_row_is_quiet_when_the_identity_is_there() {
865 + let bind = bind();
866 + assert_eq!(
867 + bind.read(AGE),
868 + Some(Value::String("/home/tester/.config/gopass/age".into())),
869 + );
870 + assert_eq!(
871 + help(&bind, AGE),
872 + "Where gopass keeps the identity it decrypts the store with.",
873 + );
874 + assert!(bind.field(AGE).unwrap().readonly);
875 + }
876 +
877 + #[test]
878 + fn the_secrets_row_names_the_path_when_the_identity_is_missing() {
879 + let bind = SystemBind::fixture_with_age(Age {
880 + dir: Some(PathBuf::from("/home/tester/.config/gopass/age")),
881 + present: false,
882 + });
883 + let note = help(&bind, AGE);
884 + assert!(
885 + note.contains("/home/tester/.config/gopass/age"),
886 + "the note has to name the path a key goes at, got {note:?}",
887 + );
888 + assert!(note.contains("will not decrypt"), "and the consequence");
889 + assert!(
890 + bind.field(AGE).unwrap().readonly,
891 + "closed in both states: nothing here writes a key",
892 + );
893 + }
894 +
895 + // Placing an identity from a settings form is exactly what the provisioning
896 + // decision ruled out, so the row must refuse rather than quietly no-op.
897 + #[test]
898 + fn the_secrets_row_takes_no_write() {
899 + let mut bind = bind();
900 + assert_eq!(
901 + message(bind.commit(AGE, Value::String("AGE-SECRET-KEY-1".into()))),
902 + "`secrets.age` is shown, not set",
903 + );
904 + }
905 +
906 + // gopass's own precedence, which is what makes the row right on a machine
907 + // that has moved its store rather than confidently naming a path nothing
908 + // reads.
909 + #[test]
910 + fn the_age_path_follows_gopass() {
911 + let dir = |homedir, xdg, home| age_dir_from(homedir, xdg, home).unwrap();
912 + assert_eq!(
913 + dir(None, None, Some("/home/tester")),
914 + PathBuf::from("/home/tester/.config/gopass/age"),
915 + );
916 + assert_eq!(
917 + dir(None, Some("/elsewhere"), Some("/home/tester")),
918 + PathBuf::from("/elsewhere/gopass/age"),
919 + "XDG_CONFIG_HOME moves it, and is already the whole config root",
920 + );
921 + assert_eq!(
922 + dir(Some("/sandbox"), Some("/elsewhere"), Some("/home/tester")),
923 + PathBuf::from("/sandbox/.config/gopass/age"),
924 + "GOPASS_HOMEDIR wins over both",
925 + );
926 + assert_eq!(
927 + age_dir_from(None, None, None),
928 + None,
929 + "nothing to resolve against",
930 + );
931 + assert_eq!(
932 + dir(Some(""), None, Some("/home/tester")),
933 + PathBuf::from("/home/tester/.config/gopass/age"),
934 + "an empty variable is unset, not a path of nothing",
935 + );
936 + }
937 +
938 + // A file gopass wrote before it had anything to put in it refuses exactly
939 + // like no file at all, so the row must not call it present.
940 + #[test]
941 + fn an_empty_identities_file_is_not_an_identity() {
942 + let dir = std::env::temp_dir().join("alloy-age-identity-test");
943 + let _ = std::fs::remove_dir_all(&dir);
944 + std::fs::create_dir_all(&dir).unwrap();
945 + assert!(!identity_present(&dir), "no file at all");
946 +
947 + std::fs::write(dir.join("identities"), "").unwrap();
948 + assert!(!identity_present(&dir), "written, empty, decrypts nothing");
949 +
950 + std::fs::write(dir.join("identities"), "AGE-SECRET-KEY-1EXAMPLE\n").unwrap();
951 + assert!(identity_present(&dir));
952 +
953 + std::fs::remove_dir_all(&dir).unwrap();
954 + }
955 +
726 956 // The writing commands this bind is allowed, and no others. Each is in the
727 957 // polkit grant; if any argv changes, the grant has to change with it.
728 958 #[test]
@@ -855,6 +1085,7 @@
855 1085 time: parse_show(SHOW),
856 1086 host: parse_host(HOST),
857 1087 locale: parse_locale(LOCALE_STATUS),
1088 + age: Age::default(),
858 1089 };
859 1090 bind.annotate();
860 1091
@@ -879,6 +1110,7 @@
879 1110 time: parse_show(SHOW),
880 1111 host: parse_host(HOST),
881 1112 locale: parse_locale(LOCALE_STATUS),
1113 + age: Age::default(),
882 1114 };
883 1115 bind.annotate();
884 1116 bind