Ask for the sudo password once per omarchy update (#13323)

* Ask for the sudo password once per omarchy update

Every sudo call in omarchy update prompted, because the no-update wrapper
covered the whole run on top of per-phase revokes, and stay-awake revoked
the timestamp on its own entry and exit. A single update could ask four
times before the snapshot finished (#13319).

Authorize once, right after confirmation, starting from a revoked
timestamp so the prompt always belongs to this update. A background
keepalive refreshes it until the update is done. Prune, snapshot,
stay-awake, keyring, system packages, migrations, orphan removal, service
restarts, the post-update hook, and mise all share that authorization.

AUR builds run third-party PKGBUILD code, so they move to the end and run
cold: the keepalive stops, the timestamp is revoked, and yay and any bare
sudo use the no-update wrapper. The timestamp is revoked again after AUR
and on every exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the single authorization for passwordless sudo and ttyless inhibition

Authorize by running a command instead of sudo -v. Under the default
verifypw=all, -v prompts even when passwordless sudo is enabled, which
would have added a prompt those users never had.

Inside an update without a terminal, stay-awake now reuses the update's
authorization with a non-interactive sudo instead of asking again through
polkit. It falls back to polkit only if that authorization is gone.

The test sudo refuses a cold non-interactive call, as the real one does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5.5 authored and GitHub committed 2026-09-26 15:31:40 +02:00
1 parent 7b336b1b0d
commit e1614f2bdb
9 files changed
+163 -51

No files matched your search

+18 -8
View File
@@ -43,7 +43,10 @@ assert_scoped_channel() {
import sys
events = open(sys.argv[1]).read().splitlines()
assert events[0] == 'sudo -k', events
sudo = [event for event in events if event.startswith('sudo ')]
# The switch itself authorizes command by command. The update it hands off to
# starts cold and authorizes once for its own phases.
auth = events.index('sudo /usr/bin/true')
sudo = [event for event in events[:auth] if event.startswith('sudo ')]
assert all(event in ('sudo -h', 'sudo -k') or event.startswith('sudo -N ') for event in sudo), events
hooks = [i for i, event in enumerate(events) if event.startswith('step:omarchy-hook ')]
assert len(hooks) == 2, events
@@ -51,8 +54,8 @@ assert events[hooks[0]] == 'step:omarchy-hook pre-refresh-pacman', events
assert events[hooks[1]] == 'step:omarchy-hook post-update', events
assert events[hooks[0] - 1] == 'sudo -k' and events[hooks[0] + 1] == 'sudo -k', events
transaction = next(i for i, event in enumerate(events) if event.startswith('step:pacman '))
assert hooks[0] < transaction, events
assert not any(event.startswith('sudo -N ') for event in events[hooks[1]:]), events
assert hooks[0] < transaction < auth < hooks[1], events
assert 'sudo -k' in events[transaction:auth], events
PY
}
@@ -63,7 +66,7 @@ for channel in stable rc edge dev; do
reset_boundary
run_channel "$channel" || fail "$channel failed" "$(<"$boundary_tmp/output")"
assert_scoped_channel "$channel"
pass "$channel starts cold, authorizes only individual commands, runs the refresh hook cold before its transaction and exits cold"
pass "$channel starts cold, authorizes the switch per command, runs the refresh hook cold, hands off to one update authorization and exits cold"
done
reset_boundary
@@ -81,7 +84,7 @@ pass "a stale dev checkout is rejected before linking or privileged work"
reset_boundary
OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable || fail "leaving dev failed" "$(<"$boundary_tmp/output")"
assert_scoped_channel "dev to stable"
pass "leaving dev preserves no-update sudo through unlink and the packaged update"
pass "leaving dev preserves no-update sudo through unlink and hands off to the packaged update"
# A packaged destination that predates the wrapper cannot be checked before its
# package is installed. Its updater authenticates without --no-update, so the
@@ -107,6 +110,7 @@ pass "an older packaged destination stops the switch cold with instructions inst
# without the wrapper. From then on a bare sudo would be the real one, so no
# privileged step may follow either transaction without checking first. A decoy
# sudo in the package bin catches any such call instead of reaching the host.
rm "$SUDO_TEST_ROOT/bin/sudo"
cat >"$SUDO_TEST_ROOT/bin/sudo" <<'STUB'
#!/bin/bash
printf 'unwrapped-sudo %s\n' "$*" >>"$SUDO_TEST_LOG"
@@ -135,6 +139,7 @@ PY
pass "a transaction that removes the wrapper stops the switch before any further sudo ($pattern)"
done
rm "$SUDO_TEST_ROOT/bin/sudo"
ln -s ../mock/sudo "$SUDO_TEST_ROOT/bin/sudo"
mkdir "$boundary_tmp/user tools"
cat >"$boundary_tmp/user tools/channel-user-tool" <<'STUB'
@@ -146,8 +151,13 @@ for command in omarchy-hook omarchy-update-mise; do
rm "$SUDO_TEST_ROOT/bin/$command"
cat >"$SUDO_TEST_ROOT/bin/$command" <<'STUB'
#!/bin/bash
[[ ! -e $SUDO_TEST_CACHE ]] || exit 91
[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92
if [[ ${1:-} == "pre-refresh-pacman" ]]; then
[[ ! -e $SUDO_TEST_CACHE ]] || exit 91
[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92
else
[[ -e $SUDO_TEST_CACHE ]] || exit 94
[[ $(command -v sudo) != "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 95
fi
channel-user-tool "${0##*/}" "$@"
STUB
chmod +x "$SUDO_TEST_ROOT/bin/$command"
@@ -161,7 +171,7 @@ assert_boundary_cold "channel user PATH"
for command in omarchy-hook omarchy-update-mise; do
ln -sfn test-step "$SUDO_TEST_ROOT/bin/$command"
done
pass "channel switching preserves user tools behind the wrapper for both hooks and mise"
pass "channel switching preserves user tools for both hooks and mise"
for step in pacman omarchy-update-system-pkgs omarchy-hook; do
reset_boundary
+13 -1
View File
@@ -52,6 +52,10 @@ if [[ ${1:-} == "-k" || ${1:-} == "-K" ]]; then
/usr/bin/rm -f "$SUDO_TEST_CACHE"
exit 0
fi
if [[ ${1:-} == "-n" && ! -e $SUDO_TEST_CACHE ]]; then
# Non-interactive sudo cannot authenticate without a cached credential.
exit 1
fi
if [[ ${1:-} == "-N" ]]; then
shift
else
@@ -77,6 +81,9 @@ else
fi
STUB
chmod +x "$SUDO_TEST_ROOT/mock/sudo"
# The updater's own phases call a bare sudo from its fixed PATH. Resolve it to
# the stand-in so no test can ever reach the host's sudo.
ln -s ../mock/sudo "$SUDO_TEST_ROOT/bin/sudo"
cat >"$SUDO_TEST_ROOT/bin/test-step" <<'STUB'
#!/bin/bash
@@ -89,7 +96,11 @@ if [[ $step == "systemd-run" ]]; then
while (( $# )) && [[ $1 == -* ]]; do shift; done
exec "$@"
fi
if [[ $step == "omarchy-hook" || $step == "omarchy-update-mise" ]]; then
# omarchy update shares one authorization with its post-update hook and mise.
# Standalone hooks, such as the pre-refresh one, and AUR builds run cold.
if [[ $step == "omarchy-hook" && ${1:-} == "post-update" ]] || [[ $step == "omarchy-update-mise" ]]; then
[[ -e $SUDO_TEST_CACHE ]] || exit 94
elif [[ $step == "omarchy-hook" || $step == "yay" ]]; then
[[ ! -e $SUDO_TEST_CACHE ]] || exit 91
fi
if [[ -n ${SUDO_TEST_REMOVE_WRAPPER_STEP:-} && "$step $*" == $SUDO_TEST_REMOVE_WRAPPER_STEP ]]; then
@@ -116,6 +127,7 @@ case "$step" in
yay)
[[ $* == *"--sudo $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo"* ]] || exit 92
[[ $* == *"--sudoloop=false"* ]] || exit 93
[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 95
;;
esac
STUB
+18 -4
View File
@@ -20,15 +20,29 @@ for args in '-y' ''; do
touch "$SUDO_TEST_CACHE"
run_update $args || fail "update failed" "$(<"$boundary_tmp/output")"
assert_boundary_cold "successful update"
grep -q '^sudo -N /usr/bin/true$' "$SUDO_TEST_LOG" || fail "update package helpers must use no-update sudo"
python3 - "$SUDO_TEST_LOG" <<'PY'
import sys
s=open(sys.argv[1]).read().splitlines()
positions=[next(i for i,line in enumerate(s) if line.startswith(prefix)) for prefix in ['step:omarchy-update-restart --services-only','step:yay','step:omarchy-hook post-update','step:omarchy-update-mise','step:omarchy-update-stay-awake stop','step:omarchy-update-restart --reboot-only']]
# A cached credential from before the update is revoked, then the update
# authorizes exactly once before any step that could need sudo.
assert s[0]=='sudo -k', s
# Authorization runs a command (sudo -v prompts even with passwordless sudo).
prune=s.index('step:omarchy-update-pkg-prune ')
auth=s.index('sudo /usr/bin/true')
assert auth < prune, s
assert [l for l in s[:prune] if l.startswith('sudo ') and l not in ('sudo -k','sudo -h')]==['sudo /usr/bin/true'], s
assert 'sudo -v' not in s, s
positions=[next(i for i,line in enumerate(s) if line.startswith(prefix)) for prefix in ['step:omarchy-update-system-pkgs','step:omarchy-migrate','step:omarchy-update-restart --services-only','step:omarchy-hook post-update','step:omarchy-update-mise','step:yay','step:omarchy-update-stay-awake stop','step:omarchy-update-restart --reboot-only']]
assert positions==sorted(positions), s
assert not any(line.startswith('sudo -N ') for line in s[positions[2]:]), s
yay=positions[5]
# Everything before AUR shares the one authorization: plain sudo, no revokes.
assert not any(line=='sudo -k' or line.startswith('sudo -N ') for line in s[auth:positions[4]]), s
assert 'sudo /usr/bin/true' in s[auth:positions[0]+1], s
# AUR builds start from a revoked credential and cannot refresh one.
assert 'sudo -k' in s[positions[4]:yay], s
assert not any(line.startswith('sudo ') and line!='sudo -k' and not line.startswith('sudo -N ') for line in s[yay:]), s
PY
pass "update $args runs privileged phases before hooks and exits cold"
pass "update $args authorizes once for everything but AUR, which runs cold last, and exits cold"
done
for step in omarchy-update-system-pkgs yay omarchy-hook omarchy-update-mise; do
+1 -1
View File
@@ -73,9 +73,9 @@ expected_steps() {
omarchy-update-analyze-logs \
omarchy-update-status \
omarchy-update-restart \
omarchy-update-aur-pkgs \
omarchy-hook \
omarchy-update-mise \
omarchy-update-aur-pkgs \
omarchy-update-stay-awake \
omarchy-update-restart
}
@@ -37,6 +37,7 @@ mkdir -p "$stub_bin" "$test_home" "$mapped_root/bin" "$mapped_root/default/omarc
cat >"$stub_bin/pkexec" <<'SH'
#!/bin/bash
[[ -z ${SUDO_EVENT_LOG:-} ]] || printf 'pkexec\n' >>"$SUDO_EVENT_LOG"
exec "$@"
SH
@@ -44,8 +45,12 @@ cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
case ${1:-} in
-h) echo 'usage: sudo [-bHkNnPS] command'; exit 0 ;;
-k|-K|-v) exit 0 ;;
-k|-K|-v)
[[ -z ${SUDO_EVENT_LOG:-} ]] || printf 'sudo %s\n' "$1" >>"$SUDO_EVENT_LOG"
exit 0
;;
esac
[[ -z ${SUDO_EVENT_LOG:-} ]] || printf 'sudo %s\n' "$*" >>"$SUDO_EVENT_LOG"
background=0
while (( $# )); do
case "$1" in
@@ -173,9 +178,31 @@ read -r version valid_pid valid_start valid_owner valid_token <"$state_dir/inhib
fail "inhibitor state is private, caller-owned, and singly linked"
run_helper stop
wait_dead "$valid_pid" || fail "valid inhibitor identity is stopped"
[[ ! -e $state_dir ]] || fail "valid state is cleaned after stop"
pass "valid XDG runtime uses private atomic inhibitor state"
# omarchy update owns its one authorization; the helper must not revoke it.
# Run on its own, the helper still starts and ends cold.
sudo_events="$test_tmp/sudo-events"
: >"$sudo_events"
OMARCHY_UPDATE_SUDO_SESSION=1 SUDO_EVENT_LOG="$sudo_events" run_helper start
OMARCHY_UPDATE_SUDO_SESSION=1 SUDO_EVENT_LOG="$sudo_events" run_helper stop
! grep -qx 'sudo -k' "$sudo_events" || fail "helper revoked the update's authorization" "$(<"$sudo_events")"
SUDO_EVENT_LOG="$sudo_events" run_helper start
SUDO_EVENT_LOG="$sudo_events" run_helper stop
grep -qx 'sudo -k' "$sudo_events" || fail "standalone helper no longer revokes sudo"
pass "helper leaves the update's authorization alone and revokes when standalone"
# Without a terminal, an update's inhibitor reuses the update's authorization
# non-interactively instead of asking again through polkit.
: >"$sudo_events"
OMARCHY_UPDATE_SUDO_SESSION=1 SUDO_EVENT_LOG="$sudo_events" run_helper start </dev/null
OMARCHY_UPDATE_SUDO_SESSION=1 SUDO_EVENT_LOG="$sudo_events" run_helper stop </dev/null
grep -q -- '^sudo -n -N -b -- ' "$sudo_events" || fail "update inhibitor without a terminal did not reuse sudo" "$(<"$sudo_events")"
! grep -qx pkexec "$sudo_events" || fail "update inhibitor without a terminal asked polkit" "$(<"$sudo_events")"
pass "update inhibitor without a terminal reuses the update's authorization instead of polkit"
permissive_runtime="$test_tmp/permissive-runtime"
mkdir -m 755 "$permissive_runtime"
if HOME="$test_home" XDG_RUNTIME_DIR="$permissive_runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \
+3 -3
View File
@@ -38,8 +38,8 @@ for step in omarchy-hook omarchy-update-mise; do
rm "$SUDO_TEST_ROOT/bin/$step"
cat >"$SUDO_TEST_ROOT/bin/$step" <<'STUB'
#!/bin/bash
[[ ! -e $SUDO_TEST_CACHE ]] || exit 91
[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92
[[ -e $SUDO_TEST_CACHE ]] || exit 91
[[ $(command -v sudo) != "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92
update-user-tool "${0##*/}"
STUB
chmod +x "$SUDO_TEST_ROOT/bin/$step"
@@ -63,5 +63,5 @@ for entry in fresh logged locked; do
grep -q '^locked-reexec$' "$SUDO_TEST_LOG" || fail "$entry update did not exercise the lock exec"
fi
assert_boundary_cold "$entry update"
pass "$entry update preserves the original user PATH through logging and locking with no-update sudo first"
pass "$entry update preserves the original user PATH through logging and locking and shares its authorization"
done