Files
omarchy/test/shell.d/channel-sudo-boundary-test.sh
T
David Heinemeier HanssonandClaude Opus 5.5 e1614f2bdb 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>
2026-09-26 15:31:40 +02:00

211 lines
10 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
copy_boundary_file bin/omarchy-channel-set
copy_boundary_file bin/omarchy-refresh-pacman
copy_boundary_file bin/omarchy-update
export OMARCHY_UPDATE_LOGGED=1
# Relocate the package root into the fixture, including the explicit handoff
# from the development checkout. All privileged operations remain stand-ins.
python3 - "$SUDO_TEST_ROOT/bin/omarchy-channel-set" "$SUDO_TEST_ROOT" <<'PY'
import sys
from pathlib import Path
p = Path(sys.argv[1])
p.write_text(p.read_text().replace('/usr/share/omarchy', sys.argv[2]))
PY
for command in omarchy-dev-link omarchy-dev-unlink omarchy-state gum git; do
cat >"$SUDO_TEST_ROOT/bin/$command" <<'STUB'
#!/bin/bash
set -euo pipefail
step=${0##*/}
printf 'step:%s %s\n' "$step" "$*" >>"$SUDO_TEST_LOG"
case "$step" in
omarchy-dev-link|omarchy-dev-unlink) sudo /usr/bin/true ;;
git)
[[ $1 == "clone" ]] || exit 90
/usr/bin/cp -a "$SUDO_TEST_ROOT" "${@: -1}"
mkdir -p "${@: -1}/.git" "${@: -1}/shell"
;;
esac
STUB
chmod +x "$SUDO_TEST_ROOT/bin/$command"
done
assert_scoped_channel() {
local label=$1
assert_boundary_cold "$label"
python3 - "$SUDO_TEST_LOG" <<'PY'
import sys
events = open(sys.argv[1]).read().splitlines()
assert events[0] == 'sudo -k', events
# 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
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 < auth < hooks[1], events
assert 'sudo -k' in events[transaction:auth], events
PY
}
run_channel() {
"$OMARCHY_PATH/bin/omarchy-channel-set" "$@" >"$boundary_tmp/output" 2>&1
}
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 the switch per command, runs the refresh hook cold, hands off to one update authorization and exits cold"
done
reset_boundary
wrapper="$SUDO_TEST_HOME/omarchy/default/omarchy/sudo-no-update/sudo"
mv "$wrapper" "$boundary_tmp/saved-wrapper"
if run_channel dev; then fail "an old checkout without the wrapper was accepted"; fi
if grep -Eq '^step:omarchy-(dev-link|state)|^sudo -N ' "$SUDO_TEST_LOG"; then
fail "an incompatible dev checkout changed the system before rejection"
fi
grep -q 'Update the checkout before switching to dev' "$boundary_tmp/output" || fail "stale checkout rejection lacks recovery guidance"
assert_boundary_cold "stale checkout"
mv "$boundary_tmp/saved-wrapper" "$wrapper"
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 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
# switch must not launch it: it stops at a consistent point with instructions.
reset_boundary
mv "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/saved-package-wrapper"
mv "$SUDO_TEST_ROOT/bin/omarchy-update" "$boundary_tmp/saved-package-update"
ln -s test-step "$SUDO_TEST_ROOT/bin/omarchy-update"
if OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable; then fail "an older packaged destination was updated with ordinary sudo" "$(<"$SUDO_TEST_LOG")"; fi
grep -q "predates command-scoped sudo" "$boundary_tmp/output" || fail "an older packaged destination was not reported" "$(<"$boundary_tmp/output")"
grep -q "Run 'omarchy update' from a new terminal to finish" "$boundary_tmp/output" || fail "an older packaged destination lacks recovery guidance" "$(<"$boundary_tmp/output")"
grep -Fxq 'step:omarchy-dev-unlink --no-reboot' "$SUDO_TEST_LOG" || fail "the package switch was not completed before stopping" "$(<"$SUDO_TEST_LOG")"
grep -Fxq 'step:omarchy-state set reboot-required' "$SUDO_TEST_LOG" || fail "leaving dev for an older release did not mark the reboot" "$(<"$SUDO_TEST_LOG")"
if grep -q '^step:omarchy-update ' "$SUDO_TEST_LOG"; then fail "an older packaged updater was launched from the hardened switch" "$(<"$SUDO_TEST_LOG")"; fi
grep -q 'The channel switch did not complete' "$boundary_tmp/output" && fail "the stop was reported as an error needing a rerun" "$(<"$boundary_tmp/output")"
assert_boundary_cold "older packaged destination"
rm "$SUDO_TEST_ROOT/bin/omarchy-update"
mv "$boundary_tmp/saved-package-update" "$SUDO_TEST_ROOT/bin/omarchy-update"
mv "$boundary_tmp/saved-package-wrapper" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo"
pass "an older packaged destination stops the switch cold with instructions instead of running its updater"
# A package-backed source can be downgraded by its own transaction to a release
# 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"
exit 97
STUB
chmod +x "$SUDO_TEST_ROOT/bin/sudo"
cp "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/saved-package-wrapper"
for pattern in 'pacman -Syyuu*' 'pacman -S --needed*'; do
reset_boundary
export SUDO_TEST_REMOVE_WRAPPER_STEP=$pattern
if run_channel rc; then fail "a downgrade during '$pattern' was not detected" "$(<"$SUDO_TEST_LOG")"; fi
unset SUDO_TEST_REMOVE_WRAPPER_STEP
grep -q "predates command-scoped sudo" "$boundary_tmp/output" || fail "downgrade during '$pattern' was not reported" "$(<"$boundary_tmp/output")"
if grep -q '^unwrapped-sudo ' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' reached ordinary sudo" "$(<"$SUDO_TEST_LOG")"; fi
if grep -q '^step:omarchy-dev-unlink' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' still unlinked" "$(<"$SUDO_TEST_LOG")"; fi
if grep -q '^step:omarchy-update ' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' still updated" "$(<"$SUDO_TEST_LOG")"; fi
python3 - "$SUDO_TEST_LOG" "$pattern" <<'PY'
import sys
events = open(sys.argv[1]).read().splitlines()
transactions = [e for e in events if e.startswith('step:pacman ')]
assert len(transactions) == (1 if sys.argv[2].startswith('pacman -Syyuu') else 2), events
assert all(e in ('sudo -h', 'sudo -k') or e.startswith('sudo -N ') for e in events if e.startswith('sudo ')), events
PY
assert_boundary_cold "downgrade during $pattern"
cp "$boundary_tmp/saved-package-wrapper" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo"
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'
#!/bin/bash
printf 'user-tool:%s\n' "$*" >>"$SUDO_TEST_LOG"
STUB
chmod +x "$boundary_tmp/user tools/channel-user-tool"
for command in omarchy-hook omarchy-update-mise; do
rm "$SUDO_TEST_ROOT/bin/$command"
cat >"$SUDO_TEST_ROOT/bin/$command" <<'STUB'
#!/bin/bash
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"
done
reset_boundary
PATH="$boundary_tmp/user tools:$PATH" run_channel stable || fail "channel hooks lost the user's PATH" "$(<"$boundary_tmp/output")"
for event in 'omarchy-hook post-update' 'omarchy-update-mise' 'omarchy-hook pre-refresh-pacman'; do
grep -Fxq "user-tool:$event" "$SUDO_TEST_LOG" || fail "user PATH was not preserved for $event"
done
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 for both hooks and mise"
for step in pacman omarchy-update-system-pkgs omarchy-hook; do
reset_boundary
if SUDO_TEST_FAIL_STEP="$step" run_channel stable; then fail "$step failure was ignored"; fi
assert_boundary_cold "$step failure"
if grep -q '^step:omarchy-hook post-update$' "$SUDO_TEST_LOG"; then fail "$step failure reached the post-update hook"; fi
pass "$step failure exits cold without the post-update hook"
done
for signal in HUP INT TERM; do
reset_boundary
cat >"$SUDO_TEST_ROOT/bin/omarchy-dev-unlink" <<'STUB'
#!/bin/bash
sudo /usr/bin/true || exit 1
kill -s "$SUDO_TEST_CHANNEL_SIGNAL" "$PPID"
STUB
if SUDO_TEST_CHANNEL_SIGNAL="$signal" run_channel stable; then fail "$signal was ignored"; fi
assert_boundary_cold "$signal"
if grep -q '^step:omarchy-hook post-update$' "$SUDO_TEST_LOG"; then fail "$signal reached the post-update hook"; fi
pass "$signal stops the channel transition and revokes authorization"
done
for refusal in unsupported-sudo failed-revocation ordinary-bash; do
reset_boundary
case "$refusal" in
unsupported-sudo) export SUDO_TEST_UNSUPPORTED=1 ;;
failed-revocation) export SUDO_TEST_REVOKE_FAIL=1 ;;
esac
if [[ $refusal == "ordinary-bash" ]]; then
if /usr/bin/bash "$SUDO_TEST_ROOT/bin/omarchy-channel-set" -p >"$boundary_tmp/output" 2>&1; then fail "$refusal was accepted"; fi
elif run_channel stable; then
fail "$refusal was accepted"
fi
if grep -q '^step:' "$SUDO_TEST_LOG"; then fail "$refusal reached channel work"; fi
pass "$refusal is rejected before channel work"
done