Files
esh-pfi-infrastructure/docs/runbooks/mac-provisioning.md
T
vh 6ca455a15f 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.
2026-09-02 17:49:42 -07:00

4.4 KiB

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)

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.