6ca455a15f
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.
285 lines
14 KiB
Bash
Executable File
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
|