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:
1 parent
7b336b1b0d
commit
e1614f2bdb
9 files changed
+163
-51
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user