Files
esh-pfi-infrastructure/scripts/provision-mac-dsh.sh
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

285 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# Provision the DeepSeek Harness (`dsh`) on a Mac, for one account, pointed at
# the fleet's LiteLLM gen-reasoning seat.
#
# scripts/provision-mac-dsh.sh <host> <account> [name]
# scripts/provision-mac-dsh.sh 10.0.10.10 vhpfi esh-mac-studio
# scripts/provision-mac-dsh.sh --check 10.0.10.10 vhpfi
#
# `name` is the vault/key-alias namespace and defaults to the machine's own
# hostname. Give it explicitly to match what is already in the vault -- the
# first run derived it from the IP and produced `mac-10-0-10-10/`, which is
# unreadable next to esh-mac-studio / esh-macbook-air / vuongs-mac-mini.
#
# Written after doing this by hand on three Macs and six accounts. Every value
# and every guard below is something a hand-run got wrong first.
#
# ─────────────────────────────────────────────────────────────────────────────
# WHAT IT DOES, AND WHY EACH STEP EXISTS
#
# 1. Node under ~/.local/node, checksum-verified against nodejs.org.
# NOT Homebrew: a package manager owning /opt/homebrew and editing PATH is
# a bigger footprint than this task earns on someone's daily driver.
#
# 2. npm -g @deepseek-ai/dsh with npm_config_prefix=~/.local, so the install
# is contained in the account and nothing lands system-wide.
#
# 3. A DEVICE-SCOPED LiteLLM key (models: [gen-reasoning]), minted per host,
# never the shared all-agents key. A laptop travels; losing one should be
# one revocation, not a fleet-wide rotation. The scope is VERIFIED after
# minting, not assumed -- see `feedback_retiring_a_model_orphans_scoped_keys`.
#
# 4. ~/.dsh/.credentials.yaml (0600) + a cordis.patch.yml in BOTH profiles.
#
# 5. Verification: a real headless task must return the expected token, and
# the composed config must show gen-reasoning. A green install that cannot
# answer a prompt is the failure this script exists to stop shipping.
#
# ─────────────────────────────────────────────────────────────────────────────
# ⚠ THE FIVE THINGS THAT BIT DURING THE HAND-RUNS
#
# ⚠ `sudo -u <user>` KEEPS THE CALLER'S $HOME. Without -H (and an explicit
# HOME=), "$HOME/.local" resolves to the CALLER's home and the install's
# `rm -rf` aims at the wrong account. On 2026-09-02 this pointed a wipe at
# a working install; only filesystem permissions stopped it. The remote
# script below refuses to run unless $HOME matches the target account.
#
# ⚠ macOS HAS NO `timeout`. Wrap the ssh call locally instead.
#
# ⚠ THE PROVIDER SHIPS A HARD-CODED MODEL CATALOG. `dsh-llm-deepseek` returns
# deepseek-v4-flash/-pro/-flash-vision-exp to "discovery consumers" -- i.e.
# the web GUI's model picker -- INDEPENDENTLY of agent-default-model. Set
# `models:` or the GUI offers three models our gateway does not serve while
# headless runs work fine. This one shipped to the operator before it was
# caught.
#
# ⚠ reasoning_effort IS NOT A UNIVERSAL VOCABULARY. The seat takes only
# xhigh/medium/low; the harness emits off/low/high/max. `high` is mapped to
# `xhigh` by the gateway hook conf/reasoning_effort_map.py. Without that
# hook the seat 400s and `low` -- its WEAKEST tier -- is the only value
# that works.
#
# ⚠ THE HARNESS DEFAULT maxTokens IS 256000 against a 262144-token seat,
# leaving 6144 for input. A two-word prompt overflowed it.
#
# ⚠ ALPHA SOFTWARE. dsh is 0.1.x and its README promises compatibility-breaking
# changes. Re-run this after an upgrade rather than assuming config survives.
set -euo pipefail
GATEWAY="http://10.250.50.70:4000/v1"
MODEL="gen-reasoning"
NODE_VER="v24.9.0"
ADMIN_KEY_FILE="$HOME/.config/litellm/infra-ops-key"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECRET="$REPO_ROOT/services/secrets-broker/secret"
CHECK=0
[[ "${1:-}" == "--check" ]] && { CHECK=1; shift; }
HOST="${1:-}"; ACCOUNT="${2:-}"; NAME="${3:-}"
if [[ -z "$HOST" || -z "$ACCOUNT" ]]; then
sed -n '2,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 2
fi
say() { printf '%s\n' "$*"; }
step() { printf '\n── %s\n' "$*"; }
# How do we reach this account? Direct if its own key auth works, else via
# infra-ops + sudo. Probed, not assumed -- the accounts differ per machine
# (the Studio has vhpfi where the others have lkraven).
step "reaching $ACCOUNT@$HOST"
SSH_DIRECT=(ssh -o BatchMode=yes -o ConnectTimeout=8 -i "$HOME/.ssh/infra-ops_ed25519")
if timeout 15 "${SSH_DIRECT[@]}" "$ACCOUNT@$HOST" true 2>/dev/null; then
MODE=direct
say " direct key auth as $ACCOUNT"
elif timeout 15 "${SSH_DIRECT[@]}" "infra-ops@$HOST" "sudo -n -H -u $ACCOUNT env HOME=/Users/$ACCOUNT true" 2>/dev/null; then
MODE=viasudo
say " via infra-ops + NOPASSWD sudo -> $ACCOUNT"
else
say " ✗ cannot reach $ACCOUNT@$HOST directly or through infra-ops sudo."
say " Provision infra-ops on this host first (see docs/runbooks/mac-provisioning.md)."
exit 1
fi
# Run a script in the TARGET account, with HOME correct in both modes.
remote() {
if [[ "$MODE" == direct ]]; then
timeout "${1:-300}" "${SSH_DIRECT[@]}" "$ACCOUNT@$HOST" "bash -s -- ${2:-}"
else
timeout "${1:-300}" "${SSH_DIRECT[@]}" "infra-ops@$HOST" \
"sudo -n -H -u $ACCOUNT env HOME=/Users/$ACCOUNT bash -s -- ${2:-}"
fi
}
if (( CHECK )); then
step "check only — nothing will be changed"
remote 60 <<'EOF'
export PATH="$HOME/.local/node/bin:$HOME/.local/bin:$PATH"
printf ' HOME %s\n' "$HOME"
printf ' node %s\n' "$(node --version 2>/dev/null || echo ABSENT)"
printf ' dsh %s\n' "$(dsh --version 2>/dev/null || echo ABSENT)"
printf ' credentials %s\n' "$(test -f ~/.dsh/.credentials.yaml && echo present || echo ABSENT)"
printf ' model %s\n' "$(dsh --profile headless --dump-config 2>/dev/null | grep -A4 'id: agent-default-model' | sed -n 's/^ *model: //p' || echo '?')"
EOF
exit 0
fi
# ── device-scoped gateway key ────────────────────────────────────────────────
step "gateway key"
# Name the key and vault item after the MACHINE, not its address: addresses
# change, and `mac-10-0-10-10/` is unreadable in a vault listing.
if [[ -z "$NAME" ]]; then
NAME=$(timeout 15 "${SSH_DIRECT[@]}" "${MODE:+infra-ops}@$HOST" hostname -s 2>/dev/null \
| tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-')
NAME="${NAME:-mac-$(printf '%s' "$HOST" | tr '.' '-')}"
fi
KEY_ALIAS="${NAME}-dsh"
VAULT_ITEM="${NAME}/litellm-dsh-key"
PWTMP="$(mktemp)"; chmod 600 "$PWTMP"
trap 'shred -u "$PWTMP" 2>/dev/null || rm -f "$PWTMP"' EXIT
# One key per MACHINE, shared by its accounts: the blast radius is the device,
# so a second key per account would be extra state with no extra containment.
if "$SECRET" get "$VAULT_ITEM" >"$PWTMP" 2>/dev/null && [[ -s "$PWTMP" ]]; then
say " reusing this machine's vaulted key ($VAULT_ITEM)"
else
[[ -r "$ADMIN_KEY_FILE" ]] || { say " ✗ no LiteLLM admin key at $ADMIN_KEY_FILE"; exit 1; }
curl -s -m 20 -X POST "${GATEWAY%/v1}/key/generate" \
-H "Authorization: Bearer $(cat "$ADMIN_KEY_FILE")" -H "Content-Type: application/json" \
-d "{\"key_alias\":\"$KEY_ALIAS\",\"models\":[\"$MODEL\"],
\"metadata\":{\"host\":\"$HOST\",\"account\":\"$ACCOUNT\",\"purpose\":\"DeepSeek Harness\",\"minted_by\":\"infra-ops\"}}" \
| python3 -c "import sys,json;d=json.load(sys.stdin);k=d.get('key');
open('$PWTMP','w').write(k or '');print(' minted',d.get('key_alias'),d.get('models'))"
[[ -s "$PWTMP" ]] || { say " ✗ key mint failed"; exit 1; }
"$SECRET" put "$VAULT_ITEM" --file "$PWTMP" \
--field host="$HOST" --field alias="$KEY_ALIAS" --field scope="$MODEL" >/dev/null
say " vaulted at $VAULT_ITEM"
fi
# ⚠ VERIFY THE SCOPE. A key that silently reaches more than intended is worse
# than no scoping, because it looks contained.
KEY="$(cat "$PWTMP")"
allowed=$(curl -s -m 60 -o /dev/null -w '%{http_code}' "$GATEWAY/chat/completions" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":4}")
denied=$(curl -s -m 60 -o /dev/null -w '%{http_code}' "$GATEWAY/chat/completions" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"model":"gen","messages":[{"role":"user","content":"hi"}],"max_tokens":4}')
say " scope: $MODEL -> $allowed gen -> $denied (want 200 / 403)"
[[ "$allowed" == 200 && "$denied" == 403 ]] || { say " ✗ key scope is not what was requested"; exit 1; }
# ── install + configure ──────────────────────────────────────────────────────
# ⚠ 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. Presents as a bare
# "Permission denied" from mkdir, hours after the account looked fine.
# Found on esh-mac-studio, 2026-09-02.
step "home ownership"
timeout 60 "${SSH_DIRECT[@]}" "infra-ops@$HOST" "
owner=\$(stat -f '%Su' /Users/$ACCOUNT)
if [ \"\$owner\" != '$ACCOUNT' ]; then
echo \" /Users/$ACCOUNT was owned by \$owner — chowning to $ACCOUNT:staff\"
sudo -n chown $ACCOUNT:staff /Users/$ACCOUNT
else
echo ' ok: owned by $ACCOUNT'
fi
stat -f ' %N %Su:%Sg %Sp' /Users/$ACCOUNT"
step "installing node $NODE_VER + dsh in $ACCOUNT"
remote 600 "$ACCOUNT $NODE_VER $KEY $GATEWAY $MODEL" <<'EOF'
set -euo pipefail
ACCOUNT="$1"; NODE_VER="$2"; KEY="$3"; GATEWAY="$4"; MODEL="$5"
# ⚠ Guard: refuse if HOME is not the target account's. `sudo -u` without -H
# keeps the caller's HOME and the rm -rf below would hit the wrong account.
[ "$HOME" = "/Users/$ACCOUNT" ] || { echo "REFUSING: HOME=$HOME, expected /Users/$ACCOUNT"; exit 1; }
PREFIX="$HOME/.local"; mkdir -p "$PREFIX/bin"
TARBALL="node-${NODE_VER}-darwin-arm64"
if [ "$("$PREFIX/node/bin/node" --version 2>/dev/null)" != "$NODE_VER" ]; then
cd "$(mktemp -d)"
curl -fsSLO "https://nodejs.org/dist/${NODE_VER}/${TARBALL}.tar.gz"
curl -fsSLO "https://nodejs.org/dist/${NODE_VER}/SHASUMS256.txt"
grep " ${TARBALL}.tar.gz$" SHASUMS256.txt | shasum -a 256 -c -
rm -rf "$PREFIX/node"
tar -xzf "${TARBALL}.tar.gz"; mv "${TARBALL}" "$PREFIX/node"
fi
export PATH="$PREFIX/node/bin:$PREFIX/bin:$PATH"; export npm_config_prefix="$PREFIX"
npm install -g @deepseek-ai/dsh 2>&1 | tail -2
echo " dsh $(dsh --version)"
mkdir -p ~/.dsh
dsh --profile headless --dump-default-config >/dev/null 2>&1 || true # materialise profiles
printf 'LITELLM_API_KEY: %s\n' "$KEY" > ~/.dsh/.credentials.yaml
chmod 700 ~/.dsh; chmod 600 ~/.dsh/.credentials.yaml
for p in headless web; do
mkdir -p ~/.dsh/profiles/$p
cat > ~/.dsh/profiles/$p/cordis.patch.yml <<YML
# Managed by scripts/provision-mac-dsh.sh — edit there, not here.
#
# Device-scoped gateway key, not the shared all-agents key: one revocation if
# this machine is lost, instead of a fleet-wide rotation.
#
# Measured against the seat, not assumed:
# reasoningEffort: 'high' is translated to the seat's 'xhigh' by the gateway
# hook conf/reasoning_effort_map.py. Without it the seat 400s on 'high'.
# defaultContextWindow: the seat reports "maximum context length is 262144".
# maxTokens: the 256000 default left 6144 tokens for input and overflowed on
# a two-word prompt.
#
# models: REPLACES the provider's hard-coded advisory catalog
# (deepseek-v4-flash/-pro/-flash-vision-exp). That catalog is what the web
# GUI's model picker lists and it is INDEPENDENT of agent-default-model:
# without this block the GUI offers three models this gateway does not serve.
- id: llm-deepseek
config:
baseURL: $GATEWAY
apiKeyEnv: LITELLM_API_KEY
reasoningEffort: high
maxTokens: 32768
defaultContextWindow: 262144
models:
- id: $MODEL
name: $MODEL (PFI fleet)
description: Qwen3.8-27B-Uncensored thinking seat on ana-ml2, via the LiteLLM gateway.
contextWindow: 262144
maxTokens: 32768
- id: agent-default-model
config:
provider: deepseek-official
model: $MODEL
YML
done
# Append to .zprofile — never clobber; the account may have its own content.
grep -q 'local/node/bin' ~/.zprofile 2>/dev/null || cat >> ~/.zprofile <<'ZP'
# DeepSeek Harness + its private Node runtime (contained under ~/.local)
export PATH="$HOME/.local/node/bin:$HOME/.local/bin:$PATH"
ZP
EOF
# ── verification ─────────────────────────────────────────────────────────────
step "verify"
out=$(remote 300 <<'EOF'
export PATH="$HOME/.local/node/bin:$HOME/.local/bin:$PATH"
# ⚠ -A4, not -A3: the block is id/name/config/provider/model, so -A3 stops one
# line short and the check silently reports nothing. A check that cannot fail
# is not a check -- the assertion below is what makes this one load-bearing.
printf ' composed model: %s\n' "$(dsh --profile web --dump-config 2>/dev/null | grep -A4 'id: agent-default-model' | sed -n 's/^ *model: //p')"
printf ' login shell : %s\n' "$(zsh -lc 'command -v dsh' 2>/dev/null || echo 'NOT on PATH')"
dsh --profile headless "Reply with exactly PROVISION-OK and nothing else." 2>&1 | tail -3
EOF
)
say "$out"
if printf '%s' "$out" | grep -q "composed model: $MODEL" && printf '%s' "$out" | grep -q 'PROVISION-OK'; then
say ""
say "$ACCOUNT@$HOST provisioned and answering through $MODEL"
else
say ""
say " ✗ install completed but verification failed (wrong model, or no answer)."
say " Do not call this done."
exit 1
fi