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.
91 lines
4.4 KiB
Markdown
91 lines
4.4 KiB
Markdown
# 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.
|