Share One Sudo Credential Cache Across a User's Terminals - #756
Conversation
Sudo caches a credential per terminal, so a "sudo -v" answered in one terminal does nothing for a program started in another. install-tools.sh gains --sudo-timestamp, which writes a sudoers drop-in scoped to the invoking user, and bootstrap.sh runs it first in a stand-up so that one credential covers every step after it. Ubuntu ships sudo-rs as its default sudo from 25.10, and it carries no timestamp_type setting at all. The action therefore asks each installed implementation's own visudo whether it parses the drop-in, rather than reading a version number, and offers to point the sudo alternative at one that does. A bad file in /etc/sudoers.d locks every user out of sudo, so the write proves the whole set parses, proves any implementation it would switch to parses it too, lands the content under a name sudo skips, proves it where sudo will read it, and only then renames it over.
There was a problem hiding this comment.
Pull request overview
Adds an opt-in Linux host-setup action to share a single sudo credential timestamp across a user’s terminals (via a sudoers drop-in), and wires it into the host bootstrap flow so one initial sudo auth can cover the rest of a stand-up.
Changes:
- Add
install-tools.sh --sudo-timestampto write and validate/etc/sudoers.d/90-host-setup-sudo-timestamp, including safeguards to avoid sudoers lockout and optionalsudoalternative switching when needed. - Update
bootstrap.shto run the sudo-timestamp step first for--host/--dev, and add a dedicated--sudoaction + menu entry. - Extend host-setup documentation (Linux, Windows comparison table, top-level README) to describe the new behavior and its constraints.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| host-setup/windows/README.md | Documents that the new Linux-only sudo timestamp behavior has no Windows peer. |
| host-setup/README.md | Updates bootstrap example comment to include the sudo cache step in a host stand-up. |
| host-setup/linux/README.md | Adds a dedicated section describing --sudo-timestamp, safety properties, and sudo-rs compatibility behavior. |
| host-setup/linux/install-tools.sh | Implements --sudo-timestamp (sudoers drop-in staging/validation, optional alternative switching, reporting). |
| host-setup/bootstrap.sh | Runs --sudo-timestamp first for --host/--dev, and adds --sudo as a first-class action/menu choice. |
Suppressed comments (1)
host-setup/bootstrap.sh:187
- The stand_up() header comments say "GitHub comes last", but the function runs the skills install after GitHub. Please adjust the comments so they describe the actual fixed order (sudo -> packages -> tools -> GitHub -> skills).
# The order is fixed rather than chosen.
# Packages come first so a keyring or a repository is added against a current apt state, and GitHub comes last because it is the only step that waits on a person in a browser.
# The skills step runs after the tools, because install-tools.sh provides the interpreter it needs.
# The sudo step comes first, so the one credential it caches covers every step after it.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The --host help text and the stand_up() comment block both named an order the function does not run. The sudo step is first and the skills step is last, with GitHub between the tools and the skills.
|
Triage of the round on Inline, Suppressed, No other finding in the round. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
host-setup/linux/install-tools.sh:92
- The --sudo-timestamp help text says removing the drop-in file "undoes it", but the action can also switch the system's sudo implementation via update-alternatives on sudo-rs hosts. Removing the file alone will not revert that switch, so the help should mention how to restore the default alternative.
--sudo-timestamp writes a sudoers drop-in for the invoking user alone, so one "sudo -v" covers
every terminal that user has open rather than only the one it ran in. It touches no tool, and
removing the file it names undoes it.
host-setup/linux/README.md:82
- This section says "Removing the drop-in undoes all of it", but on sudo-rs hosts the action can also switch the sudo update-alternative. Consider clarifying that removing the drop-in only undoes the cache sharing, and that reverting the sudo implementation switch (if it occurred) requires update-alternatives.
The cache stays valid for 60 minutes, from `SUDO_TIMESTAMP_TIMEOUT` in the script. Removing the drop-in undoes all of it.
The help text and the README both said that removing the drop-in undoes the action. It undoes the cache sharing, and a run on a host whose sudo parses no timestamp_type also switches the sudo alternative, which "update-alternatives --auto sudo" is what reverses.
|
Triage of the round on
The round 1 thread on |
The line-ending flip rewrote every Markdown file, so the three host-setup READMEs conflicted whole-file rather than on any content. Each takes develop's normalized copy with this branch's edits re-applied as LF, which keeps the docker engine probe and the skills sudo guard that landed alongside. install-tools.sh merged with no conflict.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/linux/install-tools.sh:1036
- sudo_implementations() parses
update-alternatives --query sudoassuming a line starts withvisudo, but that output lists slaves asvisudo:(with a trailing colon) under aSlaves:section. As written, the awk never matches, so on hosts wherevisudodoes not parse the staged file (e.g. sudo-rs), the script will fail to discover any alternative sudo implementations and will always hit the "No sudo on this host parses timestamp_type" die path even when an alternative exists.
# Every installed sudo implementation, as "sudo-path visudo-path".
# The original sudo hides behind update-alternatives wherever sudo-rs holds the link, and it is the alternative rather than the link that names it.
sudo_implementations() {
command -v update-alternatives > /dev/null || return 0
local query
query=$(update-alternatives --query sudo 2> /dev/null) || return 0
awk '$1 == "Alternative:" { alt = $2 } alt != "" && $1 == "visudo" { print alt, $2 }' <<< "$query"
}
A suppressed finding held that update-alternatives --query prints a slave as "visudo:" with a trailing colon, so the sudo implementation lookup never matches and the refusal path is unconditional. The colon belongs to --display, the subcommand the function does not call, and the path the claim calls unreachable was driven end to end in a container.
|
Triage of the round on
Run against the subcommand the function actually calls: Two space-separated fields, no colon. The colon form belongs to The path the finding calls unreachable was also driven end to end in an Recorded under "Disproved Claims" in |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/linux/install-tools.sh:1224
parse_args()still accepts[tool ...]arguments even when the final action is--sudo-timestamp, butconfigure_sudo_timestampignores tool selection. That can hide a user mistake (e.g.install-tools.sh --sudo-timestamp gitsilently ignoresgit). Consider rejecting tool args when MODE issudo-timestamp.
case "$MODE" in
report) report ;;
install | upgrade) apply ;;
sudo-timestamp) configure_sudo_timestamp ;;
esac
The action changes the host rather than a tool, so a tool named with it was parsed, selected, and then ignored. Refusing beats silently discarding the intent, which is the reason the Windows tooling refuses two actions at once. The check reads the action that won, so a later action on the same command line still takes its tools.
|
Triage of the round on
The finding matches a principle this repo already states, in the Windows README's differences table: refusing beats silently discarding an intent. The check reads the action that won rather than firing inside the parse loop, so |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/linux/install-tools.sh:1115
- The recursive grep that detects timestamp settings will also scan dot-prefixed files in /etc/sudoers.d. Since sudo ignores files with a dot in the name, a leftover staged file (e.g. a previously failed ".*.pending") could trigger a misleading warning about an override that sudo will never read. Exclude dotfiles from the scan so the warning only reflects files sudo actually considers.
elsewhere=$("${SUDO[@]}" grep -rnsE '^[[:space:]]*Defaults.*timestamp_(type|timeout)' \
--exclude="${SUDOERS_FILE##*/}" /etc/sudoers /etc/sudoers.d 2> /dev/null) || elsewhere=""
The scan for a timestamp option set elsewhere read every file under /etc/sudoers.d, including the ones sudo skips: a name holding a dot, and a name ending in a tilde. This run's own staged file is dot-prefixed, so a leftover from a killed run would be named as an override that sudo never reads.
|
Triage of the round on
Measured against what sudo actually applies, with five files in place ( The two agree exactly. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/linux/install-tools.sh:1044
- sudo_parses() relies on
command -v visudousing the caller's PATH. On distros where non-root PATH omits/usr/sbin(common on Debian),sudo_parses "$staged" visudocan fail even thoughvisudoexists, which can incorrectly trigger an alternative switch (or the "No sudo on this host parses..." refusal if no alternatives are found). Consider resolving common sbin locations when the checker is a bare name.
sudo_parses() {
local file="$1" checker="$2"
command -v "$checker" > /dev/null || return 1
"$checker" -cqf "$file" > /dev/null 2>&1
}
A Debian host keeps /usr/sbin off an unprivileged PATH, on 12 and on 13 alike, so "command -v visudo" answers nothing for the user this action runs as. The active implementation then read as one that is not installed, and a host carrying the original sudo and nothing else refused with a message naming sudo-rs. A bare checker name now resolves against the sbin locations before it is run.
|
Triage of the round on
Measured rather than assumed, since the finding named Debian generally and the sbin merge landed in 13: Both, not just 12. The consequence on a Debian host carrying the original sudo and no alternatives link was a hard failure with a message naming the wrong cause: A bare checker name now resolves against Why the container tests missed it. They drove the script with |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/linux/install-tools.sh:1173
- In --sudo-timestamp mode, the --dry-run path returns immediately after printing the planned mv, so it never calls sudo_timestamp_report(). That makes "--sudo-timestamp --dry-run" less informative than intended (it won't report which timestamp_* defaults sudo currently applies unless the drop-in already matches). Call sudo_timestamp_report before returning in the dry-run path so the run still reports effective settings without writing anything.
if [[ $DRY_RUN == true ]]; then
return 0
fi
A dry run printed the commands and stopped, so it never said what sudo applies today, which is half of what a preview is for. It now reports that state, worded as the state it found rather than one the run reached. Separating an unreadable list from a list naming no timestamp option came with it, since the two had one message between them and a host with nothing set read as a host that could not be read.
|
Triage of the round on
The latent one. Adding the call naively would have printed "Could not read the settings back" on a host with nothing set, because an unreadable list and a list naming no timestamp option shared one message, and only the raw All three states, on a Debian 12 host as an unprivileged user: The dry run says "as it stands" and a real run says "in effect", so neither reads as the other. |
… Default (#768) Promote `develop` to `main`, carrying the remaining stages of the hub-hosted reusable-workflow rollout and the changes that landed beside them: - #759 Host Get-Version and Publish-Plan as Hub Reusable Tasks - #760 Host the Validate Task and Reshape the Test Pull Request Stub (settles #729 by design: the hub's validate task runs `uvx <tool>@latest`, since Dependabot tracks the action pins and not a uvx version) - #761 Host the Type-Specific Tasks and Retire the Date Badge - #762 Host the Release Chain and the Docker Core in the Hub - #748 and #752, the staged rollout tracker and the PhotoCleaner merge-bot pilot record - #758 Flip the Fleet Line-Ending Default from CRLF to LF - #753, #755, #756, #764, host-setup and test-collection changes The release that follows this promotion is the first tag carrying every hub task, so it is the pin the stage 2 to 5 adoptions and their catalog snippets use. It is also the first run of the hub's own `publish-release.yml` through `build-release-task.yml` with every target disabled, which is the live proof that `github-release` runs when its build needs are skipped. Closes #729. Refs #521 (hub half shipped, the merge-bot adoption sweep is what remains).
What
install-tools.shgains a--sudo-timestampaction. It writes/etc/sudoers.d/90-host-setup-sudo-timestampcarryingDefaults:<user> timestamp_type=globalandtimestamp_timeout=60, scoped to the invoking user, so onesudo -vcovers every terminal that user has open rather than only the one it ran in.Sudo's default is one credential cache per terminal. A program started in a second terminal therefore prompts again, with nobody there to answer.
bootstrap.shgains--sudoand a menu entry, and runs the action first in--hostand--dev, so the one credential it caches covers every step after it.Ubuntu ships sudo-rs, which carries no timestamp_type
From 25.10, Ubuntu's default
sudois sudo-rs, and its sudoers dialect hastimestamp_timeoutbut notimestamp_typeat all. Itsvisudorejects the line as an unknown setting, and nothing in it shares a cache across terminals.The action therefore asks each installed implementation's own
visudowhether it parses the drop-in, rather than reading a version number. Where the active one does not, the run states the consequence and asks before pointing thesudoalternative at one that does.update-alternatives --auto sudoreverses that. A host where no installed sudo parses the setting is refused, namingapt-get install sudoas the remedy.Not locking a host out
A parse error in any file sudo reads makes every
sudoon that host fail, and the remedy then needs a root shell. So the write:A re-run that would write the same bytes changes nothing. A timestamp option set in another sudoers file is named with its file and line rather than merged into, since which one wins is the order sudo reads them in. The run finishes by reading
sudo -lback and reporting what is actually in effect.Testing
Exercised end to end in containers, since the action needs a terminal for its sudo prompt:
--dry-runleaving no file behindSUDO_USER, an unknown user, a sudo-rs-only host, and a pre-broken sudoers, each refusedshellcheck,markdownlint,editorconfig-checker,prose_lint.py --diff, andscripts/tests/test_bootstrap.pyare clean.Deliberately not changed
docs/host-setup.mdis the tool-floor contract, and this is a host convenience rather than a tool any repo procedure needs, so the contract is untouched.