Skip to content
5 changes: 5 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,11 @@ After the final push, sweep-resolve stale older threads for removed code paths.
- **Proved against** - `.github/workflows/merge-bot-task.yml` on `feature/reusable-workflows` at `210d88f`, and the "About contexts" property dereference rule in the GitHub Actions contexts reference read on 2026-08-15.
- **Delete when** - the task stops declaring hyphenated inputs, or the expressions reference changes the allowed set.

- **`sudo_implementations()` in `host-setup/linux/install-tools.sh` never matches, because `update-alternatives --query sudo` prints a slave as `visudo:` with a trailing colon, so a host whose active `visudo` rejects the drop-in always reaches the "No sudo on this host parses timestamp_type" refusal even where an alternative would parse it.** Raised as a suppressed finding against the sudo timestamp action.
- **Disproved by** - running the subcommand the function actually calls. `update-alternatives --query sudo` prints each slave as two space-separated fields under `Slaves:`, one space-indented line reading `visudo /usr/sbin/visudo.ws`, and the awk over that output prints `/usr/bin/sudo.ws /usr/sbin/visudo.ws` and `/usr/lib/cargo/bin/sudo /usr/lib/cargo/bin/visudo`. The colon form the finding describes belongs to `update-alternatives --display sudo`, a different subcommand, which prints `slave visudo: /usr/sbin/visudo.ws`. The path the finding says is unreachable was also driven end to end in an `ubuntu:25.10` container, where the run found `/usr/bin/sudo.ws`, switched the alternative to it, and wrote the drop-in.
- **Proved against** - `sudo_implementations` in `host-setup/linux/install-tools.sh` on `feature/sudo-timestamp-global` at `13e689f`, against `update-alternatives` 1.22.x on Ubuntu 25.10.
- **Delete when** - the function stops reading `update-alternatives --query`, or that subcommand changes its slave format.

## When in Doubt

Read [AGENTS.md](../AGENTS.md) to find the section that governs your change, and [GOVERNANCE.md](../GOVERNANCE.md) for the rule text itself. For code-style rules, [`CODESTYLE.md`](../CODESTYLE.md) (its General section plus the relevant language section) is authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions, and keep those focused on the change itself.
Expand Down
2 changes: 1 addition & 1 deletion host-setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Piping it is still detected: with no action and no terminal it reports rather th

```shell
./bootstrap.sh --report # what each tool would do, changing nothing
./bootstrap.sh --host --yes # packages, tools, git and GitHub, unattended
./bootstrap.sh --host --yes # sudo cache, packages, tools, git and GitHub, unattended
./bootstrap.sh --ref develop --report # run the tooling as it is on develop
./bootstrap.sh --help # every action and option
```
Expand Down
23 changes: 16 additions & 7 deletions host-setup/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ describe them come from one revision rather than from whatever a host happens to

Actions, the last one given wins, default --report on a terminal is the menu:
-r, --report Report what each tool would do, change nothing
--host Upgrade packages, install the tools, configure git and GitHub, install the skills
--host Share the sudo cache, upgrade packages, install the tools, configure git and
GitHub, install the skills
--dev As --host, and add the tools a development machine needs
--upgrade Upgrade the packages of the current release only
--tools Install the host tools only
--github Configure git, the SSH key, and commit signing only
--skills Install the fleet skills for the current user only
--sudo Share one sudo credential cache across this user's terminals only
--release Upgrade to the next distribution release, on its own
-h, --help Show this help

Expand Down Expand Up @@ -181,11 +183,14 @@ report() {
}

# 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.
# Packages come before the tools, so a keyring or a repository is added against a current apt state.
# GitHub comes after the tools, because it is the only step that waits on a person in a browser.
# The skills step comes last, and after the tools in particular, because install-tools.sh provides the interpreter it needs.
stand_up() {
local profile="$1"

run_tool install-tools.sh --sudo-timestamp
run_tool upgrade-host.sh --packages
if [[ $profile == "dev" ]]; then
run_tool install-tools.sh --install --optional
Expand Down Expand Up @@ -216,8 +221,9 @@ menu() {
log " 3 Install the host tools"
log " 4 Configure git and GitHub"
log " 5 Install the fleet skills"
log " 6 All of the above, which is a host stood up"
log " 7 All of the above plus the development tools"
log " 6 Share one sudo credential cache across this user's terminals"
log " 7 All of the above, which is a host stood up"
log " 8 All of the above plus the development tools"
log " q Quit"
log ""

Expand All @@ -229,8 +235,9 @@ menu() {
3) MODE="tools" ;;
4) MODE="github" ;;
5) MODE="skills" ;;
6) MODE="host" ;;
7) MODE="dev" ;;
6) MODE="sudo" ;;
7) MODE="host" ;;
8) MODE="dev" ;;
q | Q) exit 0 ;;
*) die "Not one of the choices" ;;
esac
Expand All @@ -248,6 +255,7 @@ parse_args() {
--tools) MODE="tools" ;;
--github) MODE="github" ;;
--skills) MODE="skills" ;;
--sudo) MODE="sudo" ;;
--release) MODE="release" ;;
-y | --yes) ASSUME_YES=true ;;
-n | --dry-run) DRY_RUN=true ;;
Expand Down Expand Up @@ -306,6 +314,7 @@ main() {
tools) run_tool install-tools.sh --install ;;
github) run_tool setup-github.sh --configure ;;
skills) SKILLS_SOURCE_COMMIT="$RESOLVED" run_tool install-skills.sh ;;
sudo) run_tool install-tools.sh --sudo-timestamp ;;
release) run_tool upgrade-host.sh --release ;;
host) stand_up host ;;
dev) stand_up dev ;;
Expand Down
19 changes: 18 additions & 1 deletion host-setup/linux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The tooling that makes a Debian or Ubuntu based host satisfy the contract in [`d

## What Is Here

- [`install-tools.sh`][install-tools] installs and upgrades the host tools. It reports what each is installed at, what upstream carries, where it comes from, and its status.
- [`install-tools.sh`][install-tools] installs and upgrades the host tools. It reports what each is installed at, what upstream carries, where it comes from, and its status. Its `--sudo-timestamp` action is the one thing it does that is about the host rather than a tool.
- [`upgrade-host.sh`][upgrade-host] upgrades the packages of the current release. Moving to the next release is a separate action behind its own flag.
- [`setup-github.sh`][setup-github] configures the SSH key, git, and commit signing. It checks both key registrations against what GitHub publishes.
- [`install-skills.sh`][install-skills] drives the hub's skills installer at [`scripts/skills_install.py`][skills-install] from this tree.
Expand Down Expand Up @@ -67,6 +67,20 @@ An install or upgrade collects a tool whose install fails and carries on, so one

**For `dotnet`, the distro feed is the default and Microsoft's feed is the fallback.** The fallback is added only where the distro carries no SDK at all, because mixing the two feeds is what breaks a host. Microsoft's feed carries amd64 only, so any other architecture without a distro SDK is a named skip. The default set is the newest SDK line the feed carries. `--optional` adds every other line, for a host that builds against more than one.

## The Sudo Credential Cache

`install-tools.sh --sudo-timestamp` shares one sudo credential cache across the invoking user's terminals. Sudo's default is one cache per terminal, so a `sudo -v` answered in one terminal does nothing for a program started in another. The action installs no tool and reports on none. It sits here because a host stand-up already runs this script.

**The drop-in is scoped to one user.** It lands at `/etc/sudoers.d/90-host-setup-sudo-timestamp` and names the invoking user, so every other account keeps the per-terminal default. Under `sudo` the invoking user is `$SUDO_USER` rather than root, since widening root's cache leaves the caller prompted exactly as before. A run as root with no invoking user to name is refused.

**Writing to `/etc/sudoers.d` is the one change here that can lock 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 whole set is proved to parse before anything is added to it. The new content then lands under a name sudo skips, since sudo ignores a file name holding a dot. It is proved again where sudo will read it, and only then is renamed over. A rename is atomic and a copy into place is not.

**A re-run that would write the same bytes changes nothing.** A drop-in carrying something else is replaced, and a timestamp option set in another file is named rather than merged into. Which of the two wins is the order sudo reads them in, not something this can decide.

**Ubuntu 25.10 and later ship `sudo-rs` as the default `sudo`, and it carries no `timestamp_type` setting at all.** Nothing there 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 offers to point the `sudo` alternative at one that does. That changes which sudo every user on the host runs, so it is stated before the prompt, and `update-alternatives --auto sudo` puts it back. A host where no installed sudo parses the setting is refused, naming `apt-get install sudo` as the remedy.

The cache stays valid for 60 minutes, from `SUDO_TIMESTAMP_TIMEOUT` in the script. Removing the drop-in undoes the sharing. Where the run also switched the sudo alternative, `update-alternatives --auto sudo` is what undoes that half.

## Release Upgrades

`upgrade-host.sh` splits the routine from the rare. `--packages` upgrades within the current release, and `--release` is its own action because the release upgrade is where hosts differ.
Expand Down Expand Up @@ -134,12 +148,15 @@ Then the dry runs, which print what each action would run:

```shell
host-setup/linux/install-tools.sh --upgrade --dry-run
host-setup/linux/install-tools.sh --sudo-timestamp --dry-run
host-setup/linux/upgrade-host.sh --release --dry-run
host-setup/linux/setup-github.sh --configure --dry-run
```

Two of those are guards rather than previews. `--release --dry-run` on a Proxmox host prints the refusal, not the commands. A docker `--upgrade --dry-run` inside a WSL distribution prints the skip. A `[dry run]` line from either means the guard sits in the wrong place.

`--sudo-timestamp --dry-run` is the one dry run that can ask for a password. Only root reads `/etc/sudoers`, and what is already set there is what the run reports on.

The scripts are checked by `shellcheck`, which runs in CI over every `.sh` file `git ls-files` returns. A local run uses the same `koalaman/shellcheck:stable` container. [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires on Linux is one `install-tools.sh` can provide, or a recorded exception. It also asserts each script here is tracked executable, so a fresh checkout can run it.

<!-- Repo -->
Expand Down
Loading