9 Commits
Author SHA1 Message Date
Gabriel Brown f42b3cfe0e Let the password prompt say why
panama-sudo is pkexec with a stated reason: the reason travels to the
shell over the existing polkit IPC target, and the prompt renders it
labeled "Stated reason (unverified)" beside polkitd's real action
message -- beside, never instead of, because any process can claim any
reason and the action text is the trust anchor. Reasons are single-shot
and expire in ten seconds, so a stale one cannot dress up an unrelated
prompt; without a reason, a running shell, or qs the wrapper is exactly
pkexec. Built for agents, so the person typing their password learns
what for. Verified live end to end -- reason shown, consumed once,
expired when stale, cleared on dismissal -- and pinned by the polkit
reason contract.
2026-08-21 18:57:26 -04:00
Gabriel Brown 51ceb19480 One command from fresh Fedora to the front door
`boot` is the script the README now leads with: curl it, and it installs
git if the machine lacks it, clones the repository to PANAMA_PATH, and
hands off to ./install -- reattaching the terminal first, because a
piped stdin would strand the interview. Deliberately dumb: a curled copy
leaves the repository the moment it runs, so nothing that can drift
lives in it. Re-running is the recovery path: an existing clone is
fast-forwarded, never re-cloned, and a refused fast-forward installs
from what is there rather than stopping mid-repair. All of it pinned by
the boot contract, against stub git and a throwaway clone.
2026-08-21 18:47:17 -04:00
Gabriel Brown 25e2328658 Your name is an answer, not a constant
The :fn/:em espanso triggers hardcoded one person's name and email in a
match file shipped to every user -- a misfire that lands inside their
own prose, where they are least likely to notice. setup-identity now
seeds match/identity.yml, per-machine and gitignored, from the same
interview answers that already configure git.
2026-08-21 17:43:33 -04:00
Gabriel Brown 9f563c8f94 Configure the Kuycon by what it is, not where it is plugged in
The panel's 4500x3000 mode, 1.5 scale, and 10-bit request were a rule
for connector DP-2 outright, which handed them to whatever monitor a
stranger's machine had on its most common DisplayPort connector. The
rule is now matched by description, the per-output prefs loop covers
every connector including DP-2, and the displays contract pins the
policy to the description rather than the port.
2026-08-21 17:43:33 -04:00
Gabriel Brown 7d633eb06e Alias only what the machine will actually have
vim aliased to vimx, which no list installed; bat invoked by its Debian
name, which Fedora does not ship; sunshine restarted by an alias when
nothing here installs sunshine. vim-X11 is now declared so vimx exists,
ff previews with bat, and the sunshine alias moved to the gitignored
env of the one machine that has it.
2026-08-21 17:43:33 -04:00
Gabriel Brown 6d1f3f3763 Keep the MOK password off the command line it was promised off of
The comment above the prompt says the password never reaches a command
line; --generate-hash="$first" put it on one, world-readable in
/proc/<pid>/cmdline while mokutil ran. It now arrives on stdin, and the
hash is taken from the last output line because mokutil prints its
prompts to stdout too.
2026-08-21 17:43:33 -04:00
Gabriel Brown de923cb4d5 Honor PANAMA_PATH in the two stages that clobbered it
install and link-dotfiles treat it as a default; install-packages and
change-settings overwrote an exported override, so a clone anywhere but
~/.local/share/Panama sourced the extras catalog from a path that does
not exist and copied config/copy from the wrong tree.
2026-08-21 17:43:33 -04:00
Gabriel Brown bd9a55c8eb Fail when failing, stop when stopped, and survive what is neither
Four installer bugs, all in the space between exit codes and intent:

- A flatpak-only extras category -- most of them -- died at the grep
  that filters out its dnf half, because grep exits 1 on zero matches
  and set -e read that as failure. sed deletes lines without editorial
  comment. The extras contract now runs a flatpak-only category under
  the installer's own strict options so this stays fixed.
- A rate-limited GitHub API call aborted the whole package stage while
  resolving the RustDesk URL, even though the empty-result fallback was
  sitting right below it. The pipeline is now guarded so the fallback
  is reachable.
- Ctrl-C did not stop the install: the INT trap ran cleanup and bash
  carried on with the remaining stages, MOK enrollment and firmware
  included. INT and TERM now exit explicitly; cleanup rides EXIT.
- change-settings and link-dotfiles ran without set -e, so a failed
  copy over / or a failed symlink fell through to guarded no-ops and
  the stage reported success. Turning strictness on immediately caught
  what it had been hiding: link-dotfiles never created ~/.config, so on
  a truly fresh HOME every symlink was failing silently.
2026-08-21 16:10:42 -04:00
Gabriel Brown cab7699711 Start the text expander that was only ever installed
espanso-wayland was installed and its config linked, and then nothing
anywhere launched it -- the RPM ships no unit, autostart.lua started
every session daemon except this one, and the seed machine only worked
because the service had once been registered by hand. change-settings
now runs `espanso service register` (upstream's own mechanism, and
unlike hypridle it conflicts with nothing GNOME runs), and autostart.lua
starts the unit so the first Hyprland login after a fresh install
expands text rather than the second.
2026-08-21 16:10:32 -04:00
24 changed files with 559 additions and 54 deletions
+2
View File
@@ -1,5 +1,7 @@
# Ignore bash environment variables.
/config/bash/env
# Personal espanso triggers (name, email), seeded per-machine by setup-identity.
/config/dot/espanso/match/identity.yml
# Ignore backups of old config files
/config/old
# Ignore Wireguard config of course!
+19 -1
View File
@@ -3,11 +3,23 @@
Formerly Sunhat. A personal config for Fedora, with the intention of helping a
user set up their Fedora system with one command.
```sh
bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot)
```
`boot` installs git if the machine lacks it, clones this repository to
`~/.local/share/Panama` (or `$PANAMA_PATH`), and hands off to `install`. It is
deliberately small enough to read first, and the same two steps by hand work
identically:
```sh
git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama
~/.local/share/Panama/install
```
Both are safe to run again: an existing clone is fast-forwarded rather than
replaced, and `install` is the upgrade path.
`install` asks its questions first and then runs the stages in `setup/scripts/`
in order, without stopping again:
@@ -101,7 +113,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
131 of them, under `tests/`. Run the lot, or a subset by pattern:
133 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
@@ -159,6 +171,12 @@ package, a `flatpak:` line is a Flathub id, `| Name` gives the menu something
readable, and an indented line belongs to the entry above it — which is how OBS
carries its sixteen plugin extensions as one thing to tick.
`panama-sudo` is pkexec with a stated reason: `panama-sudo --reason "why" --
command` shows the reason on Panama's password prompt, clearly labeled as an
unverified claim beside polkitd's own action text — meant for agents and
scripts, so the person typing the password learns why before they do. Without
a reason, a running shell, or `qs` it behaves exactly like pkexec.
`panama app` is deliberately not part of `./install`. Everything else Panama
installs comes from dnf or Flathub; these are built from source because no
packaged form exists, and a source build is slow, wants the network throughout,
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# pkexec, with a stated reason on the password prompt.
#
# panama-sudo --reason "Installing gamemode hooks" -- dnf install gamemode
#
# The reason travels to the shell over Quickshell IPC before pkexec runs, and
# Panama's prompt shows it clearly labeled beside polkitd's own action message
# -- beside, never instead of: anything can claim any reason, so the real
# action text stays the trust anchor. Meant for agents and scripts, so the
# person at the keyboard learns WHY before typing their password.
#
# Degrades to plain pkexec: no --reason, no running shell, or no qs on PATH
# all behave identically to calling pkexec yourself.
set -euo pipefail
reason=""
if [[ "${1:-}" == "--reason" ]]; then
reason="${2:?panama-sudo: --reason needs a value}"
shift 2
fi
[[ "${1:-}" == "--" ]] && shift
if (( $# == 0 )); then
echo 'usage: panama-sudo [--reason "why"] -- command [args...]' >&2
exit 2
fi
if [[ -n "$reason" ]] && command -v qs >/dev/null 2>&1; then
qs ipc call polkit reason "$reason" >/dev/null 2>&1 || true
fi
exec pkexec "$@"
Executable
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Panama's front door: the one command a fresh Fedora machine needs.
#
# bash <(curl -fsSL https://git.gbrown.org/gib/Panama/raw/branch/main/boot)
#
# Deliberately dumb, because a copy of this script leaves the repository the
# moment somebody curls it -- nothing here can be fixed by re-running
# ./install, so there is as little here as possible: get git, get the clone,
# hand off. Everything with judgment in it lives in `install`, which is also
# where re-runs and upgrades already work.
set -euo pipefail
REPO_URL="https://git.gbrown.org/gib/Panama.git"
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
export PANAMA_PATH
# Root would put the clone and every dotfile in root's home and run the
# desktop setup for the wrong user. sudo is used inside where it is needed.
if [[ "$(id -u)" -eq 0 ]]; then
echo "Run this as your own user, not root: the install configures YOUR desktop." >&2
exit 1
fi
# Panama assumes Fedora's repositories, package names, and GNOME base install.
if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then
echo "This looks like something other than Fedora; Panama only supports Fedora Workstation." >&2
exit 1
fi
# git is the one dependency the clone itself needs. Everything else -- gum
# included -- is bootstrapped by `install`.
if ! command -v git >/dev/null 2>&1; then
echo "Installing git, which the clone needs"
sudo dnf install -y git
fi
if [[ -d "$PANAMA_PATH/.git" ]]; then
# An existing clone makes this the recovery command too. Only a fast-forward:
# local work is never rewritten, and a diverged clone still installs from
# what it has rather than stopping someone mid-repair.
echo "Panama is already cloned at $PANAMA_PATH; updating"
git -C "$PANAMA_PATH" pull --ff-only \
|| echo "Could not fast-forward; installing from the clone as it is" >&2
else
git clone "$REPO_URL" "$PANAMA_PATH"
fi
# `curl | bash` and `bash <(curl ...)` can leave stdin as the pipe, and the
# first thing install runs is the interview, which has to be able to ask.
# Reattach the terminal when there is one; without one the interview will say
# so itself.
# The probe actually opens /dev/tty rather than testing -r: a process with no
# controlling terminal passes -r and then fails the redirect.
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
exec "$PANAMA_PATH/install" </dev/tty
fi
exec "$PANAMA_PATH/install"
+2 -2
View File
@@ -3,7 +3,6 @@
# Aliases I like
alias :q="exit"
alias :wq="exit"
alias startsunshine="systemctl --user restart sunshine.service"
alias sourcerc="source ~/.bashrc"
alias c="clear"
alias shutdown="systemctl poweroff"
@@ -32,7 +31,8 @@ alias ls='eza -lh --group-directories-first --icons'
alias lsa='ls -a'
alias lt='eza --tree --level=2 --long --icons --git'
alias lta='lt -a'
alias ff="fzf --preview 'batcat --style=numbers --color=always {}'"
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
# Directories
alias ..='cd ..'
+3 -11
View File
@@ -1,17 +1,9 @@
# espanso match file
# https://espanso.org/docs/
# Name and email triggers (:fn, :em, ...) are NOT here: they are personal, and
# this file is shared. setup-identity seeds them into match/identity.yml --
# per-machine, gitignored, yours to edit -- from the install interview.
matches:
# Name
- trigger: ":fn"
replace: "Gabriel Brown"
- trigger: ":fin"
replace: "Gabriel A Brown"
# Email
- trigger: ":em"
replace: "[email protected]"
- trigger: ":empro"
replace: "[email protected]"
# Date
- trigger: ":date"
replace: "{{mydate}}"
+1 -1
View File
@@ -29,7 +29,7 @@ Don't "fix" them.
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
| `prefs.lua` | Reads the settings file the Settings app writes. See below |
| `env.lua` | Environment. Note the uwsm caveat below |
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
| `monitors.lua` | Monitor geometry and scaling (the Kuycon by description), and the HDR decision |
| `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout |
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
| `rules.lua` | Window rules, gaming rules, layer rules for the shell |
+8
View File
@@ -39,6 +39,14 @@ hl.on("hyprland.start", function()
-- prompt if Panama's ever fails to come up.
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service")
-- Text expansion. change-settings runs `espanso service register`, which
-- writes and enables espanso's own user unit; the explicit start makes the
-- first Hyprland login after a fresh install work rather than the second.
-- Started on its own line: unlike the units above it carries no
-- ConditionEnvironment, and a missing unit (espanso not yet registered)
-- must not muddy the start of the four that lock and wallpaper depend on.
hl.exec_cmd("systemctl --user start espanso.service")
-- The shell: bar, dock, overview, quick settings, notifications, capture.
-- No systemd unit ships with quickshell, so it runs as a compositor child.
hl.exec_cmd("quickshell --daemonize")
+30 -27
View File
@@ -1,7 +1,7 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Monitors
--
-- Kuycon P20 on DP-2: 4500x3000 @ 60Hz, 1.5x fractional scale.
-- Kuycon P20 (matched by description): 4500x3000 @ 60Hz, 1.5x fractional scale.
-- 4500/1.5 = 3000 and 3000/1.5 = 2000, both integers, so this is a "clean"
-- fractional scale and Hyprland will not complain.
--
@@ -125,32 +125,10 @@ local function display_position(entry, fallback)
return fallback
end
local shipped_mode = "4500x3000@60"
local shipped_scale = 1.5
local shipped_transform = 0
local dp2 = display_entry("DP-2")
hl.monitor({
output = "DP-2",
mode = dp2 and dp2.mode or shipped_mode,
position = display_position(dp2, "0x0"),
scale = dp2 and dp2.scale or shipped_scale,
transform = dp2 and dp2.transform or shipped_transform,
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
-- falls back to a lower mode, drop this line first.
bitdepth = 10,
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
cm = "auto",
})
-- Other connected outputs use the same validated per-output store. They keep
-- automatic placement and the compositor's normal color policy; DP-2 alone
-- carries the panel-specific 10-bit policy documented above.
-- Every connected output uses the same validated per-output store. Automatic
-- placement and the compositor's normal color policy unless the entry says
-- otherwise.
for output, _ in pairs(displays) do
if output ~= "DP-2" then
local entry = display_entry(output)
if entry ~= nil then
hl.monitor({
@@ -162,7 +140,32 @@ for output, _ in pairs(displays) do
})
end
end
end
-- The Kuycon P20, matched by what it is rather than where it is plugged in.
-- This used to be a rule for connector DP-2 outright, which handed the panel's
-- 4500x3000 mode and 1.5 scale to whatever monitor a stranger's machine had on
-- its most common DisplayPort connector. Emitted after the prefs loop so a
-- saved entry for its connector still carries the mode/scale/position, while
-- this rule holds the shipped defaults and the panel-specific color policy.
local shipped_mode = "4500x3000@60"
local shipped_scale = 1.5
local shipped_transform = 0
local kuycon = display_entry("DP-2")
hl.monitor({
output = "desc:GVT Kuycon P20",
mode = kuycon and kuycon.mode or shipped_mode,
position = display_position(kuycon, "0x0"),
scale = kuycon and kuycon.scale or shipped_scale,
transform = kuycon and kuycon.transform or shipped_transform,
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
-- falls back to a lower mode, drop this line first.
bitdepth = 10,
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
cm = "auto",
})
-- Any monitor not named above: sane defaults rather than nothing.
hl.monitor({
@@ -94,6 +94,34 @@ PanelWindow {
wrapMode: Text.WordWrap
}
// The caller's stated reason, when panama-sudo passed one.
// Untrusted commentary from an unprivileged process, so it is
// labeled as a claim and drawn beside polkitd's message above --
// never in place of it. The real action text is the trust anchor.
Column {
width: parent.width
visible: Polkit.statedReason !== ""
spacing: 2
Text {
text: "Stated reason (unverified)"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: Polkit.statedReason
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.italic: true
wrapMode: Text.WordWrap
}
}
Text {
width: parent.width
visible: Polkit.users.length > 1
+27
View File
@@ -38,12 +38,31 @@ Singleton {
// How many times a wrong password has been offered for this request.
property int attempts: 0
// A caller's stated reason for the NEXT request, and the one attached to
// the request on screen. Untrusted by design -- any process can state one
// -- so the prompt shows it clearly labeled beside polkitd's real action
// message, never in place of it. See stateReason().
property var pendingReason: null
property string statedReason: ""
readonly property bool active: root.request !== null
// Held only between pressing Enter and the helper accepting it on stdin.
property string pendingSecret: ""
// panama-sudo's side channel: state WHY the authentication request about
// to arrive is being made, so the prompt can say more than the generic
// action text. Single-shot and short-lived -- it attaches only to the next
// request, and only if that request arrives within ten seconds -- so a
// stale reason can never dress up an unrelated prompt.
function stateReason(text: string): void {
const trimmed = String(text).trim().slice(0, 200);
if (trimmed === "")
return;
root.pendingReason = { text: trimmed, at: Date.now() };
}
function begin(path: string): void {
// A second request while one is open would leave the first
// unanswerable; polkit serializes these in practice, and refusing is
@@ -69,6 +88,13 @@ Singleton {
: (root.users.length > 0 ? String(root.users[0]) : "");
root.attempts = 0;
root.failureText = "";
// Consume the stated reason whether or not it is still fresh:
// either way it must not survive to a later request.
const pending = root.pendingReason;
root.pendingReason = null;
root.statedReason = (pending !== null && Date.now() - pending.at <= 10000)
? pending.text
: "";
} catch (error) {
console.warn("Polkit: could not read the request:", error);
root.dismiss("failed");
@@ -114,6 +140,7 @@ Singleton {
root.failureText = "";
root.pendingSecret = "";
root.authenticating = false;
root.statedReason = "";
}
function responsePathFor(path: string): string {
+8 -1
View File
@@ -424,8 +424,15 @@ ShellRoot {
target: "polkit"
function begin(path: string): void { Polkit.begin(path); }
function cancel(): void { Polkit.cancel(); }
// panama-sudo's side channel: the reason a privileged command is about
// to run, shown labeled on the prompt beside polkitd's own message.
function reason(text: string): void { Polkit.stateReason(text); }
function status(): string {
return JSON.stringify({ active: Polkit.active, action: Polkit.actionId });
return JSON.stringify({
active: Polkit.active,
action: Polkit.actionId,
statedReason: Polkit.statedReason
});
}
}
+7 -1
View File
@@ -38,7 +38,13 @@ cleanup() {
# design, and one of them is an email address.
[[ -n "${PANAMA_ANSWERS:-}" ]] && rm -f "$PANAMA_ANSWERS"
}
trap cleanup EXIT INT TERM
trap cleanup EXIT
# A bare `trap cleanup INT` is not an abort: bash runs the handler and then
# carries on with the script, so Ctrl-C would kill only the current stage and
# the remaining ones -- MOK enrollment, firmware -- would still run. Exit
# explicitly instead; the EXIT trap above does the actual cleanup.
trap 'exit 130' INT
trap 'exit 143' TERM
gsettings set org.gnome.desktop.screensaver lock-enabled false 2>/dev/null || true
gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
+3
View File
@@ -30,3 +30,6 @@ python3-virtualenv
ripgrep
ruby
sqlite3
# vimx: clipboard-capable vim, which the shipped `vim` alias and the vimrc
# that link-dotfiles installs both assume. Fedora carries it as vim-X11.
vim-X11
+20 -1
View File
@@ -1,11 +1,18 @@
#!/usr/bin/env bash
# Strict: the copy over / below is the one thing this stage exists to do, and
# without set -e its failure fell through to guarded no-ops and exited 0 --
# a failed stage the installer's summary could never see.
set -euo pipefail
# --- Helper functions ---
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
exists() { command -v "$1" >/dev/null 2>&1; }
# --- Defined Paths ---
PANAMA_PATH="$HOME/.local/share/Panama"
# The default, not an assignment: an exported PANAMA_PATH from a clone at
# another location must win, or the copy over / below reads the wrong tree.
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
echo -e "\n--- Copying System & User files ---"
log "Changing DNF Settings"
@@ -61,6 +68,18 @@ echo -e "\n--- Hyprland session services ---"
# PartOf=graphical-session.target.
log "Hyprland services are started per-session by hypr/autostart.lua, not enabled globally"
# espanso is the exception to the rule above: text expansion is wanted under
# any compositor and conflicts with nothing GNOME runs, and upstream's own
# mechanism is a registered user unit. The RPM ships no unit -- without this,
# the package installs, the config links, and no trigger ever fires.
# `service register` writes and enables the unit and is idempotent on re-runs;
# autostart.lua also starts it so the first Hyprland login after a fresh
# install expands text without waiting for the next one.
if exists espanso && ! systemctl --user cat espanso.service >/dev/null 2>&1; then
log "Registering the espanso text-expansion service"
espanso service register >/dev/null 2>&1 || log "Could not register espanso"
fi
# Notification daemons must NOT be installed: mako, dunst and swaync all
# register Name=org.freedesktop.Notifications for D-Bus activation, which races
# Quickshell's own notification server at login. Whoever wins keeps the name.
+11 -3
View File
@@ -23,7 +23,10 @@ packages_in() {
}
# --- Defined Paths ---
PANAMA_PATH="$HOME/.local/share/Panama"
# The default, not an assignment: ./install and link-dotfiles honor an exported
# PANAMA_PATH, and clobbering it here made a clone anywhere else source the
# extras catalog from a path that does not exist.
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
# Reading the extras catalog, shared with `panama apps` so the two front doors
# cannot disagree about what a category contains.
@@ -214,9 +217,12 @@ if rpm -q rustdesk >/dev/null 2>&1; then
log "RustDesk already installed"
else
log "Resolving the latest RustDesk release..."
# `|| true` because a failed curl -- unauthenticated GitHub API calls get
# rate-limited -- would otherwise trip set -e and kill the stage before the
# empty-result fallback below could do its job.
rustdesk_url="$(curl -fsSL https://api.github.com/repos/rustdesk/rustdesk/releases/latest 2>/dev/null \
| jq -r '.assets[].browser_download_url | select(test("x86_64\\.rpm$")) | select(test("suse") | not)' \
| head -1)"
| head -1 || true)"
if [[ -n "$rustdesk_url" ]]; then
log "Installing RustDesk from $rustdesk_url"
# The RPM ships rustdesk.service already enabled, which is what provides
@@ -271,7 +277,9 @@ install_extra_category() {
name="$(basename "$file")"
local dnf_packages flatpak_ids
dnf_packages=$(catalog_all_targets "$file" | grep -v '^flatpak:' | tr "\n" " ")
# sed rather than grep -v: most categories are flatpak-only, and grep exits 1
# when it selects nothing, which set -e above turns into a dead stage.
dnf_packages=$(catalog_all_targets "$file" | sed '/^flatpak:/d' | tr "\n" " ")
flatpak_ids=$(catalog_all_targets "$file" | sed -n 's/^flatpak://p' | tr "\n" " ")
if [[ -n "${dnf_packages// /}" ]]; then
+6 -1
View File
@@ -122,7 +122,12 @@ if [[ -n "$nvidia_card" ]]; then
fi
second="$(gum input --password --header "MOK password again")"
if [[ "$first" == "$second" ]]; then
mok_hash="$(mokutil --generate-hash="$first")"
# Fed on stdin, never as an argument: an argument sits
# in /proc/<pid>/cmdline for any local process to read
# while mokutil runs. mokutil prints its two prompts on
# stdout too, so the hash is the last line.
mok_hash="$(printf '%s\n%s\n' "$first" "$first" \
| mokutil --generate-hash | tail -n 1)"
break
fi
printf 'Those did not match.\n'
+9 -2
View File
@@ -1,5 +1,10 @@
#!/usr/bin/env bash
# Strict: a failed mv or ln here used to fall through and exit 0, so a machine
# could end up half-linked while the installer's summary reported the stage as
# fine. Anything genuinely optional below carries its own `|| true`.
set -euo pipefail
# --- Helper functions ---
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
@@ -10,8 +15,10 @@ PANAMA_DOT="$PANAMA_PATH/config/dot"
PANAMA_OLD="$PANAMA_PATH/config/old"
CONFIG="$HOME/.config"
# Make backup folder if it doesn't exist
mkdir -p "$PANAMA_OLD"
# Make backup folder if it doesn't exist -- and ~/.config itself, which a
# truly fresh HOME does not have yet. Before set -e above, its absence made
# every symlink below fail silently while the stage still reported success.
mkdir -p "$PANAMA_OLD" "$CONFIG"
# --- Bashrc ---
echo -e "\n--- Replacing .bashrc ---"
+20
View File
@@ -41,6 +41,26 @@ git config --global alias.st status
git config --global pull.rebase true
log "git aliases and pull.rebase applied"
# Text-expansion identity. The :fn/:em triggers used to hardcode the repository
# owner's name and email in the shared match file; each machine now writes its
# own from the interview's answers. Per-machine and gitignored -- espanso loads
# every file under match/, so it sits beside base.yml without being shared.
# Kept when it already exists: the file is the user's to edit, and a re-run
# must not erase what they added.
espanso_identity="${XDG_CONFIG_HOME:-$HOME/.config}/espanso/match/identity.yml"
if [[ -d "$(dirname "$espanso_identity")" && ! -e "$espanso_identity" ]] \
&& [[ -n "$git_name" || -n "$git_email" ]]; then
{
printf '# Personal expansion triggers, seeded from the install interview.\n'
printf '# Per-machine and untracked: add your own freely.\n'
printf 'matches:\n'
[[ -n "$git_name" ]] && printf ' - trigger: ":fn"\n replace: "%s"\n' "$git_name"
[[ -n "$git_email" ]] && printf ' - trigger: ":em"\n replace: "%s"\n' "$git_email"
:
} > "$espanso_identity"
log "Seeded espanso identity triggers at $espanso_identity"
fi
if [[ "${PANAMA_GH_LOGIN:-no}" == yes ]]; then
if command -v gh >/dev/null 2>&1; then
log "Signing in to GitHub"
+8 -2
View File
@@ -122,8 +122,14 @@ assert(by_output["DP-2"].mode == "4500x3000@60")
assert(by_output["DP-2"].scale == 1.5)
assert(by_output["DP-2"].transform == 0)
assert(by_output["DP-2"].position == "0x0")
assert(by_output["DP-2"].bitdepth == 10)
assert(by_output["DP-2"].cm == "auto")
-- The panel-specific color policy rides the description-matched rule, not the
-- connector: a stranger's monitor on DP-2 must not inherit the Kuycon's mode
-- or its 10-bit request.
local kuycon = by_output["desc:GVT Kuycon P20"]
assert(kuycon ~= nil)
assert(kuycon.mode == "4500x3000@60")
assert(kuycon.bitdepth == 10)
assert(kuycon.cm == "auto")
assert(by_output["HDMI-A-1"].mode == "2560x1440@60")
assert(by_output["HDMI-A-1"].scale == 1)
assert(by_output["HDMI-A-1"].transform == 1)
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# The stated reason on the authentication prompt.
#
# panama-sudo lets a caller say WHY it is about to trigger a password prompt.
# The reason is untrusted text from an unprivileged process, so the properties
# worth pinning are the ones that keep it honest:
#
# 1. The prompt renders the reason BESIDE polkitd's real action message,
# labeled as unverified -- never in place of it. Any process can claim
# "Updating your system" while requesting something else; the action text
# is the trust anchor and must survive.
# 2. Single-shot and short-lived: a reason attaches to the next request
# only, is consumed whether or not it was fresh, and expires rather than
# dressing up an unrelated prompt minutes later.
# 3. panama-sudo degrades to plain pkexec: no --reason, no qs, or a dead
# shell must all still run the command.
#
# The wrapper is exercised for real against stub qs and pkexec; the QML side
# is pinned statically, the way the polkit-agent contract pins its rules.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
wrapper="$repo_dir/bin/panama-sudo"
service="$repo_dir/config/dot/quickshell/services/Polkit.qml"
prompt="$repo_dir/config/dot/quickshell/modules/polkit/PolkitPrompt.qml"
shell_qml="$repo_dir/config/dot/quickshell/shell.qml"
findings=()
note() { findings+=("$1"); }
[[ -x "$wrapper" ]] || { printf 'polkit reason contract: %s is not executable\n' "$wrapper" >&2; exit 1; }
# ── The wrapper, for real ────────────────────────────────────────────────────
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
calls="$work/calls"
stub_dir="$work/bin"
mkdir -p "$stub_dir"
for command in qs pkexec; do
cat >"$stub_dir/$command" <<STUB
#!/usr/bin/env bash
printf '%s %s\n' "$command" "\$*" >>"$calls"
STUB
chmod +x "$stub_dir/$command"
done
run_wrapper() { PATH="$stub_dir:$PATH" "$wrapper" "$@" >/dev/null 2>&1; }
# A reason reaches the shell first, then the command runs unchanged.
: >"$calls"
run_wrapper --reason "Test reason" -- some-command --with args \
|| note 'the wrapper failed with a reason and a command'
grep -q 'qs ipc call polkit reason Test reason' "$calls" \
|| note 'the reason never reaches the shell over IPC'
grep -q 'pkexec some-command --with args' "$calls" \
|| note 'the command does not reach pkexec unchanged'
[[ "$(head -1 "$calls")" == qs* ]] \
|| note 'the reason is sent after pkexec instead of before the prompt can appear'
# No reason means no IPC chatter, and still pkexec.
: >"$calls"
run_wrapper -- some-command || note 'the wrapper failed without a reason'
grep -q 'qs' "$calls" && note 'the wrapper calls qs even when no reason was given'
grep -q 'pkexec some-command' "$calls" || note 'a reasonless call does not reach pkexec'
# A dead shell must not cost the command: qs failing is stepped over.
cat >"$stub_dir/qs" <<'STUB'
#!/usr/bin/env bash
exit 1
STUB
chmod +x "$stub_dir/qs"
: >"$calls"
run_wrapper --reason "Doomed" -- some-command \
|| note 'a failing qs stops the command instead of degrading to plain pkexec'
grep -q 'pkexec some-command' "$calls" \
|| note 'the command is lost when the shell is not answering'
# No command is a usage error, not a bare pkexec prompt for nothing.
run_wrapper --reason "Aimless" -- && note 'the wrapper accepts a reason with no command'
# ── The QML side, statically ─────────────────────────────────────────────────
# The IPC door exists and feeds the service.
rg -Fq 'target: "polkit"' "$shell_qml" \
|| note 'shell.qml has no polkit IPC target'
rg -Fq 'Polkit.stateReason(text)' "$shell_qml" \
|| note 'the polkit IPC target does not feed Polkit.stateReason'
# Single-shot, bounded, and cleared: consumed on adopt even when stale, aged
# against a ten-second window, and wiped with the rest of the request state.
rg -Fq 'root.pendingReason = null' "$service" \
|| note 'a stated reason is not consumed when a request arrives'
rg -Fq 'pending.at <= 10000' "$service" \
|| note 'a stated reason never expires, so it can dress up a later prompt'
rg -Fq 'root.statedReason = ""' "$service" \
|| note 'the stated reason survives dismissal'
# The prompt shows the real message AND the labeled reason -- both, in that
# trust order.
rg -Fq 'text: Polkit.message' "$prompt" \
|| note "polkitd's own action message is no longer rendered"
rg -Fq 'text: Polkit.statedReason' "$prompt" \
|| note 'the stated reason is never rendered'
rg -Fq 'Stated reason (unverified)' "$prompt" \
|| note 'the stated reason is not labeled as an unverified claim'
if (( ${#findings[@]} > 0 )); then
printf 'polkit reason contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'polkit reason contract: PASS\n'
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# The front door: `boot` is the script the README tells a fresh machine to
# curl, so it runs before anything else Panama ships -- including its own
# tests. What it must get right is small and worth pinning:
#
# * a machine without the clone gets one, from the documented URL, at
# PANAMA_PATH, and the install runs
# * a machine with the clone is not re-cloned -- the same command is the
# recovery command -- and a fast-forward failure does not stop the install
# * boot hands off to the clone's own install, with PANAMA_PATH exported,
# so a clone at a chosen location installs from that location
#
# Run against stub git and install in a throwaway PANAMA_PATH; nothing here
# touches the real clone or the network.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
boot="$repo_dir/boot"
findings=()
note() { findings+=("$1"); }
[[ -x "$boot" ]] || { printf 'boot contract: %s is not executable\n' "$boot" >&2; exit 1; }
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
calls="$work/calls"
stub_dir="$work/bin"
clone_dir="$work/Panama"
mkdir -p "$stub_dir"
# The stub install records that it ran and what PANAMA_PATH it saw. The stub
# git records its arguments, and materializes a clone the way the real one
# would -- boot execs the clone's install, so the clone has to contain one.
cat >"$work/fake-install" <<STUB
#!/usr/bin/env bash
printf 'install PANAMA_PATH=%s\n' "\${PANAMA_PATH:-unset}" >>"$calls"
STUB
chmod +x "$work/fake-install"
cat >"$stub_dir/git" <<STUB
#!/usr/bin/env bash
printf 'git %s\n' "\$*" >>"$calls"
if [[ "\$1" == "clone" ]]; then
mkdir -p "\$3/.git"
cp "$work/fake-install" "\$3/install"
fi
STUB
chmod +x "$stub_dir/git"
run_boot() {
: >"$calls"
PATH="$stub_dir:$PATH" PANAMA_PATH="$clone_dir" bash "$boot" </dev/null >/dev/null 2>&1
}
# ── A machine without the clone ──────────────────────────────────────────────
run_boot || note 'boot failed on a machine without the clone'
grep -q "git clone https://git.gbrown.org/gib/Panama.git $clone_dir" "$calls" \
|| note 'boot does not clone the documented repository to PANAMA_PATH'
grep -q "install PANAMA_PATH=$clone_dir" "$calls" \
|| note 'boot does not hand off to the clone'\''s install with PANAMA_PATH exported'
# ── A machine that already has it ────────────────────────────────────────────
run_boot || note 'boot failed on a machine that already has the clone'
grep -q 'git clone' "$calls" \
&& note 'boot re-clones over an existing checkout'
grep -q 'git -C .* pull --ff-only' "$calls" \
|| note 'boot does not fast-forward an existing clone'
grep -q "install PANAMA_PATH=$clone_dir" "$calls" \
|| note 'boot does not run the install from an existing clone'
# ── A diverged clone still installs ──────────────────────────────────────────
#
# pull --ff-only refusing is normal life -- local commits, a rebase upstream.
# The command doubles as the repair path, so a refusal must be stepped over.
cat >"$stub_dir/git" <<'STUB'
#!/usr/bin/env bash
[[ "$*" == *pull* ]] && exit 1
exit 0
STUB
chmod +x "$stub_dir/git"
: >"$calls"
if ! PATH="$stub_dir:$PATH" PANAMA_PATH="$clone_dir" bash "$boot" </dev/null >/dev/null 2>&1; then
note 'a clone that cannot fast-forward stops the install instead of proceeding'
fi
grep -q "install PANAMA_PATH=$clone_dir" "$calls" \
|| note 'the install does not run when the fast-forward is refused'
if (( ${#findings[@]} > 0 )); then
printf 'boot contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'boot contract: PASS\n'
+25
View File
@@ -115,6 +115,31 @@ if grep 'flatpak install' <<<"$recorded" | grep -q 'from-dnf'; then
note 'a dnf package is passed to flatpak'
fi
# A flatpak-only category -- which most of the real ones are -- must survive
# the installer's own strict options. Filtering the dnf half with `grep -v`
# once left an exit 1 for zero matches, and set -e killed the stage before its
# flatpak half ran. Run under those options, not the contract's laxer ones;
# the status is captured rather than `||`-guarded because a condition context
# would switch errexit off inside the subshell and hide the very failure this
# pins.
: >"$calls"
flatpak_only="$work/flatpak-only"
printf 'flatpak:org.example.OnlyFlatpak\n' >"$flatpak_only"
(
set -euo pipefail
PATH="$stub_dir:$PATH"
log() { :; }
source "$catalog"
eval "$filter"
eval "$loop"
install_extra_category "$flatpak_only"
)
flatpak_only_status=$?
(( flatpak_only_status == 0 )) \
|| note 'a flatpak-only category aborts the installer under set -euo pipefail'
grep -q 'flatpak install -y flathub org.example.OnlyFlatpak' <<<"$(cat "$calls" 2>/dev/null)" \
|| note 'a flatpak-only category installs nothing'
# Choosing nothing installs nothing.
: >"$calls"
(
+9 -2
View File
@@ -52,8 +52,15 @@ done <<<"$consumed"
# ── 3. Nothing is left behind ────────────────────────────────────────────────
grep -q 'trap cleanup EXIT INT TERM' "$install_script" \
|| note 'install does not arm a cleanup trap on EXIT INT TERM'
# Cleanup rides the EXIT trap; INT and TERM must exit explicitly, because a
# trap handler that merely cleans up lets bash carry on with the remaining
# stages after a Ctrl-C -- MOK enrollment and firmware included.
grep -q 'trap cleanup EXIT' "$install_script" \
|| note 'install does not arm a cleanup trap on EXIT'
grep -qE "trap 'exit [0-9]+' INT" "$install_script" \
|| note 'install does not exit on SIGINT, so Ctrl-C would keep installing'
grep -qE "trap 'exit [0-9]+' TERM" "$install_script" \
|| note 'install does not exit on SIGTERM, so a kill would keep installing'
grep -q 'rm -f "$PANAMA_ANSWERS"' "$install_script" \
|| note 'the cleanup trap does not delete the answers file'
grep -qE 'mktemp' "$install_script" \