Skip to content

Share One Sudo Credential Cache Across a User's Terminals - #756

Merged
ptr727 merged 9 commits into
developfrom
feature/sudo-timestamp-global
Aug 16, 2026
Merged

Share One Sudo Credential Cache Across a User's Terminals#756
ptr727 merged 9 commits into
developfrom
feature/sudo-timestamp-global

Conversation

@ptr727

@ptr727 ptr727 commented Aug 16, 2026

Copy link
Copy Markdown
Owner

What

install-tools.sh gains a --sudo-timestamp action. It writes /etc/sudoers.d/90-host-setup-sudo-timestamp carrying Defaults:<user> timestamp_type=global and timestamp_timeout=60, scoped to the invoking user, so one sudo -v covers 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.sh gains --sudo and a menu entry, and runs the action first in --host and --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 sudo is sudo-rs, and its sudoers dialect has timestamp_timeout but no timestamp_type at all. Its visudo rejects the line as an unknown setting, and nothing in it shares a cache across terminals.

The action therefore asks each installed implementation's own visudo whether 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 the sudo alternative at one that does. update-alternatives --auto sudo reverses that. A host where no installed sudo parses the setting is refused, naming apt-get install sudo as the remedy.

Not locking a host out

A parse error in any file sudo reads makes every sudo on that host fail, and the remedy then needs a root shell. So the write:

  • proves the whole set parses before adding to it
  • proves any implementation it would switch to parses that set too, since switching to one that cannot is the lockout
  • lands the content under a dot-prefixed name sudo skips, proves it where sudo will read it, and only then renames it over, a rename being atomic where a copy into place is not
  • re-validates the set afterwards, and names the file to remove if that ever fails

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 -l back and reporting what is actually in effect.

Testing

Exercised end to end in containers, since the action needs a terminal for its sudo prompt:

  • Ubuntu 25.10, the switch path, and the drop-in landing after it
  • Debian trixie, the plain path with no alternatives link
  • idempotent re-run, and --dry-run leaving no file behind
  • a conflicting drop-in elsewhere, named in the output
  • root with no SUDO_USER, an unknown user, a sudo-rs-only host, and a pre-broken sudoers, each refused

shellcheck, markdownlint, editorconfig-checker, prose_lint.py --diff, and scripts/tests/test_bootstrap.py are clean.

Deliberately not changed

docs/host-setup.md is the tool-floor contract, and this is a host convenience rather than a tool any repo procedure needs, so the contract is untouched.

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.
Copilot AI lite review requested due to automatic review settings August 16, 2026 03:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-timestamp to write and validate /etc/sudoers.d/90-host-setup-sudo-timestamp, including safeguards to avoid sudoers lockout and optional sudo alternative switching when needed.
  • Update bootstrap.sh to run the sudo-timestamp step first for --host / --dev, and add a dedicated --sudo action + 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.

Comment thread host-setup/bootstrap.sh Outdated
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.
Copilot AI review requested due to automatic review settings August 16, 2026 03:22
@ptr727

ptr727 commented Aug 16, 2026

Copy link
Copy Markdown
Owner Author

Triage of the round on acf70a4, both findings real and both fixed in 4a9abe4.

Inline, bootstrap.sh:45 - the --host help text named an order stand_up() does not run. It now reads sudo cache, packages, tools, git and GitHub, then the skills.

Suppressed, bootstrap.sh:187 - the stand_up() comment block said "Packages come first" while the sudo line added below it claimed first as well, and "GitHub comes last" while the skills step runs after it. The block now states the real order once, one sentence per line.

No other finding in the round.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copilot AI review requested due to automatic review settings August 16, 2026 03:26
@ptr727

ptr727 commented Aug 16, 2026

Copy link
Copy Markdown
Owner Author

Triage of the round on 4a9abe4: no new inline comments, two suppressed findings, both real and both the same point. Fixed in fa00cf9.

install-tools.sh:92 and linux/README.md:82 - both said that removing the drop-in undoes the action. It undoes the cache sharing only. A run on a host whose sudo parses no timestamp_type also switches the sudo alternative, and update-alternatives --auto sudo is what reverses that half. The run's own output already said so at the prompt, the help text and the README's closing line did not.

The round 1 thread on bootstrap.sh is answered and resolved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

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.
Copilot AI review requested due to automatic review settings August 16, 2026 03:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sudo assuming a line starts with visudo, but that output lists slaves as visudo: (with a trailing colon) under a Slaves: section. As written, the awk never matches, so on hosts where visudo does 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.
Copilot AI review requested due to automatic review settings August 16, 2026 13:04
@ptr727

ptr727 commented Aug 16, 2026

Copy link
Copy Markdown
Owner Author

Triage of the round on 13e689f: no new inline comments, one suppressed finding, and it is declined as disproved.

install-tools.sh:1036 - the claim is that update-alternatives --query sudo prints a slave as visudo: with a trailing colon, so the awk never matches and the refusal path is unconditional.

Run against the subcommand the function actually calls:

$ update-alternatives --query sudo
Alternative: /usr/bin/sudo.ws
Priority: 40
Slaves:
 sudo.8.gz /usr/share/man/man8/sudo.ws.8.gz
 visudo /usr/sbin/visudo.ws

$ update-alternatives --query sudo | awk '$1 == "Alternative:" { alt = $2 } alt != "" && $1 == "visudo" { print alt, $2 }'
/usr/bin/sudo.ws /usr/sbin/visudo.ws
/usr/lib/cargo/bin/sudo /usr/lib/cargo/bin/visudo

Two space-separated fields, no colon. The colon form belongs to update-alternatives --display sudo, a different subcommand this function does not call, which prints slave visudo: /usr/sbin/visudo.ws.

The path the finding calls unreachable was also driven end to end in an ubuntu:25.10 container, where sudo-rs holds the link: the run found /usr/bin/sudo.ws, switched the alternative to it, and wrote the drop-in.

Recorded under "Disproved Claims" in .github/copilot-instructions.md in 3dc2c28, since a suppressed finding has no thread to carry the disproof past this pull request.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, but configure_sudo_timestamp ignores tool selection. That can hide a user mistake (e.g. install-tools.sh --sudo-timestamp git silently ignores git). Consider rejecting tool args when MODE is sudo-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.
Copilot AI review requested due to automatic review settings August 16, 2026 13:10
@ptr727

ptr727 commented Aug 16, 2026

Copy link
Copy Markdown
Owner Author

Triage of the round on 3dc2c28: no new inline comments, one suppressed finding, accepted and fixed in 6048cdd.

install-tools.sh:1224 - install-tools.sh --sudo-timestamp git parsed git, selected it, and then ignored it, because the action changes the host rather than a tool. It now refuses:

$ install-tools.sh --sudo-timestamp git
ERROR: --sudo-timestamp changes the host rather than a tool, so it takes no tool, and "git" names one
$ echo $?
1

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 --sudo-timestamp --list git still lists, which the actions-are-last-one-wins rule requires.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copilot AI review requested due to automatic review settings August 16, 2026 13:15
@ptr727

ptr727 commented Aug 16, 2026

Copy link
Copy Markdown
Owner Author

Triage of the round on 6048cdd: no new inline comments, one suppressed finding, accepted and fixed in db3a6b3.

install-tools.sh:1115 - the scan for a timestamp option set elsewhere read every file under /etc/sudoers.d, including the ones sudo skips. This action's own staged file is dot-prefixed, so a leftover from a killed run would have been named as an override sudo never reads. It now excludes a name holding a dot and a name ending in a tilde, which are sudo's own two skip rules.

Measured against what sudo actually applies, with five files in place (.90-host-setup-sudo-timestamp.pending, 50-dotted.conf, 60-backup~, 70-real, and a setting in /etc/sudoers itself):

### what the scan reports
/etc/sudoers:55:Defaults timestamp_timeout=5
/etc/sudoers.d/70-real:1:Defaults timestamp_timeout=9

### what sudo actually applies
timestamp_timeout=9
timestamp_timeout=5

The two agree exactly. /etc/sudoers survives the *.* exclusion because its own name carries no dot, which is the case worth checking before trusting that glob.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 visudo using the caller's PATH. On distros where non-root PATH omits /usr/sbin (common on Debian), sudo_parses "$staged" visudo can fail even though visudo exists, 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.
Copilot AI review requested due to automatic review settings August 16, 2026 13:21
@ptr727

ptr727 commented Aug 16, 2026

Copy link
Copy Markdown
Owner Author

Triage of the round on db3a6b3: no new inline comments, one suppressed finding, accepted and fixed in 33f6a58. This one was a real bug on the most common host this tooling targets, and it was worse than the finding described.

install-tools.sh:1044 - sudo_parses() resolved visudo through the caller's PATH. Debian keeps /usr/sbin off an unprivileged PATH, so the active implementation read as one that is not installed.

Measured rather than assumed, since the finding named Debian generally and the sbin merge landed in 13:

debian:bookworm  non-root PATH: /usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
debian:bookworm  command -v visudo (non-root): NOT-FOUND
debian:trixie    command -v visudo (non-root): NOT-FOUND

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:

### the version before the fix, on Debian 12, as a real user:
ERROR: No sudo on this host parses timestamp_type, which is the setting that shares one cache
across terminals. sudo-rs carries no such setting, so install the original sudo with
"apt-get install sudo" and run this again.
exit=1

A bare checker name now resolves against /usr/local/sbin, /usr/sbin, /sbin, and /usr/bin before it runs. An absolute path, which is what update-alternatives hands back, passes through untouched.

Why the container tests missed it. They drove the script with su tester -c, which leaves root's PATH in place, so /usr/sbin was present in a run that was meant to represent an ordinary user. The harness now uses su - tester -c, a login shell, and both paths pass under it: Debian 12 writes the drop-in with no switch, Ubuntu 25.10 finds /usr/bin/sudo.ws, switches, and writes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copilot AI review requested due to automatic review settings August 16, 2026 13:26
@ptr727

ptr727 commented Aug 16, 2026

Copy link
Copy Markdown
Owner Author

Triage of the round on 33f6a58: no new inline comments, one suppressed finding, accepted and fixed in 8fb9488. Acting on it surfaced a second, latent defect.

install-tools.sh:1173 - the dry run printed its commands and returned before the report, so it never said what sudo applies today. The idempotent path did report, so the same command produced two different amounts of output depending on a state the caller cannot see.

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 sudo -l read separates them. They are now separate, which also fixes the post-write case: a write that lands and then is not read by sudo now says so, rather than reading as a failed password prompt.

All three states, on a Debian 12 host as an unprivileged user:

### dry run on a host with nothing set
  [dry run] sudo install -m 0440 -o root -g root ... /etc/sudoers.d/.90-host-setup-sudo-timestamp.pending
  [dry run] sudo mv ... /etc/sudoers.d/90-host-setup-sudo-timestamp
  Set for tester as it stands: neither option, so one cache per terminal

### real run
Wrote /etc/sudoers.d/90-host-setup-sudo-timestamp
  In effect for tester: timestamp_type=global timestamp_timeout=60

### dry run once it is set
/etc/sudoers.d/90-host-setup-sudo-timestamp already carries exactly this, leaving it alone
  Set for tester as it stands: timestamp_type=global timestamp_timeout=60

The dry run says "as it stands" and a real run says "in effect", so neither reads as the other.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

@ptr727
ptr727 merged commit 2671aae into develop Aug 16, 2026
6 checks passed
ptr727 added a commit that referenced this pull request Aug 16, 2026
Resolves the merge with #752's rollout-tracker edits (PhotoCleaner as the
sole pilot, split adoption checkboxes) and converts this stage's two new
inline anchor links to the reference style the file's #756 sweep already
uses elsewhere.
ptr727 added a commit that referenced this pull request Aug 16, 2026
Resolves the merge with #752's rollout-tracker edits (PhotoCleaner as the
sole pilot, split adoption checkboxes) and converts this stage's two new
inline anchor links to the reference style the file's #756 sweep already
uses elsewhere.
ptr727 added a commit that referenced this pull request Aug 16, 2026
… 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants