feat(scripts): provision-mac-dsh.sh — one script for a Mac, end to end

Three Macs and six accounts were done by hand, and the fourth would have
repeated every mistake the first three taught. This script carries them.

Each guard is something a hand-run got wrong first:

- an account may not own its own home. A `sudo mkdir` before sysadminctl
  leaves /Users/<account> root-owned; the account then authenticates, gets a
  shell, has a correct $HOME and cannot write to it. Surfaced on the Studio
  as a bare "Permission denied" hours after the account looked fine.
- `sudo -u` keeps the CALLER's $HOME. Without -H the install's rm -rf aims at
  the wrong account — it did, at a working install, and only permissions
  stopped it. The remote half refuses to run unless $HOME matches the target.
- the provider ships a hard-coded model catalog that the web GUI reads
  INDEPENDENTLY of agent-default-model, so a correct default still showed
  DeepSeek models in the picker. `models:` replaces it.
- reasoningEffort / maxTokens / defaultContextWindow are all measured against
  the seat; the harness defaults fail on every one.
- the key is scoped per machine and the scope is VERIFIED (200 on
  gen-reasoning, 403 on gen), not trusted from the mint.

The first run found two more: it named the vault item after the IP
(`mac-10-0-10-10/`, unreadable beside esh-mac-studio) and its config check
used grep -A3 where the block needs -A4, so it printed an empty model and
passed anyway. Both fixed, and verification now asserts the model rather
than only the answer token — a check that cannot fail is not a check.

Run twice against the same account to confirm idempotence, then against
vhpfi. docs/runbooks/mac-provisioning.md carries the operator-run stage and
the traps that are not the script's to solve.
This commit is contained in:
vh
2026-09-02 17:49:42 -07:00
parent 926fc2fb7a
commit 6ca455a15f
2 changed files with 374 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
# Provisioning a Mac for the fleet
Three Macs are provisioned this way as of 2026-09-02: `vuongs-mac-mini`
(10.100.79.2), `esh-macbook-air` (10.0.10.83), `esh-mac-studio` (10.0.10.10).
None is in `servers/` or `dns/internal.yaml` — they are the operator's personal
machines, not PFI-managed fleet hosts, and registering them there would imply
otherwise.
## Two stages
**Stage 1 — an account I can reach.** Operator-run, because it needs a password
I do not have. See "Operator steps" below.
**Stage 2 — the harness.** `scripts/provision-mac-dsh.sh <host> <account> [name]`,
idempotent, run once per account.
scripts/provision-mac-dsh.sh 10.0.10.10 vhpfi esh-mac-studio
scripts/provision-mac-dsh.sh --check 10.0.10.10 vhpfi
## Operator steps (stage 1)
```zsh
sudo sysadminctl -addUser infra-ops -fullName "PFI infra-ops" \
-shell /bin/zsh -home /Users/infra-ops -password -
sudo dseditgroup -o edit -a infra-ops -t user admin
sudo mkdir -p /Users/infra-ops/.ssh
sudo tee /Users/infra-ops/.ssh/authorized_keys >/dev/null <<'KEY'
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN+1HBwfXrkfTYWdcnWCjLJ6VLAGC87gxH5h5vKaaA3c infra-ops@pfi-fleet
KEY
sudo chown -R infra-ops:staff /Users/infra-ops/.ssh
sudo chmod 700 /Users/infra-ops/.ssh; sudo chmod 600 /Users/infra-ops/.ssh/authorized_keys
```
Then hand over a throwaway password; infra-ops rotates it, vaults it as
`<name>/infra-ops-password`, and installs a `visudo`-validated NOPASSWD drop-in.
⚠ **Install the key and prove key-auth on a FRESH connection BEFORE touching the
password.** These machines have no out-of-band access — a failed rotation means
the operator walks to the machine.
## ⚠ Traps, all of them paid for
**An account may not own its own home.** If `/Users/<account>` was created by a
`sudo mkdir` before `sysadminctl` ran, sysadminctl adopts the existing directory
and leaves it **root-owned**. The account then authenticates, gets a shell, has a
correct `$HOME`, and cannot write to it — surfacing as a bare `Permission denied`
from `mkdir` long after the account looked healthy. The script detects and fixes
this; a hand-run will not.
**`sudo -u <user>` keeps the CALLER's `$HOME`.** Without `-H` and an explicit
`HOME=`, `"$HOME/.local"` resolves to the caller's home. On 2026-09-02 that
pointed an `rm -rf` at a working install in another account; only filesystem
permissions stopped it. The remote half of the script refuses to run unless
`$HOME` matches the target account.
**A wrong username looks exactly like a wrong password.** sshd answers
`Permission denied (publickey,password,keyboard-interactive)` for a bad user, a
bad password, AND a user outside `com.apple.access_ssh`. Two of the three Macs
produced a false diagnosis this way. **Check `dscl . -list /Users` first** — the
Studio's operator account is `vhpfi`, not `lkraven`.
**`com.apple.access_ssh` gates SSH when it exists, but admins usually pass
anyway** through a nested group. Do not *create* the group if absent: doing so
flips SSH from open-to-all to members-only and can lock out the operator.
**Password rotation: use `dscl . -passwd`, not `sysadminctl`.** With FileVault on
and no Secure Token on the account, `sysadminctl -resetPasswordFor` refuses with
*"Operation is not permitted without secure token unlock"*. `dscl` works
precisely because there is no token to desync. True on all three Macs.
⚠ **FileVault kills remote access across reboots.** The machine sits at the
pre-boot unlock screen with no network until someone unlocks it physically.
Nothing unattended should depend on a Mac being reachable after a restart.
**macOS has no `adduser`, `useradd`, or `timeout`.** Use `sysadminctl`, and wrap
the ssh call locally rather than reaching for a remote `timeout`.
## Harness specifics
Node is installed **private to the account** under `~/.local/node`,
checksum-verified — deliberately not Homebrew, which owns `/opt/homebrew` and
edits PATH. One **device-scoped** gateway key per machine (`<name>-dsh`, scoped
to `gen-reasoning`), shared by that machine's accounts and vaulted at
`<name>/litellm-dsh-key`; the script verifies the scope (200 on gen-reasoning,
403 on gen) rather than trusting the mint.
See `stacks/litellm/README.md` for why `reasoningEffort: high` works at all, and
`scripts/provision-mac-dsh.sh` for the three measured limits (`reasoningEffort`,
`maxTokens`, `defaultContextWindow`) and the hard-coded model catalog the web
GUI reads independently of the default model.