Files
ai-agent/worker/install-macos.sh
Gabriel Vidal 3a0ef08dcf feat(worker): sidecar runs as a paired macOS worker — feed, pairing, launchd installer
- liveness falls back to psutil where there is no /proc (macOS)
- GET /feed + /feed/file: the hub pulls transcripts it has no mount for
- POST /pair trades a one-time code for the worker's bearer; /unpair drops it
- /health reports worker identity + permission mode
- worker/install-macos.sh: venv, launchd agent bound to the Tailscale IP,
  prints the pairing string (addr/code/claude login)
- Dockerfile copies every sidecar module (claude_cli was missing, so the
  in-container runner could not import)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 13:49:07 +02:00

167 lines
7.1 KiB
Bash

#!/usr/bin/env bash
#
# install-macos.sh — run this Mac as an ai-agent **worker**: a launchd user agent
# serving the same sidecar the homelab runs on its own host (spawn / resume /
# fork / interrupt `claude -p`), plus the transcript feed the hub pulls and the
# one-time pairing endpoint. Sessions run natively under your user, so they get
# the Keychain login, mise, gcloud, op, gh… exactly like a terminal session.
#
# install-macos.sh install (or refresh) + print a pairing string
# install-macos.sh --pair print a new pairing string (re-pair / new hub)
# install-macos.sh --update git pull the checkout, reinstall deps, restart
# install-macos.sh --status launchd state + /health
# install-macos.sh --uninstall stop + remove the agent (state kept)
#
# Nothing is published beyond the tailnet: the listener binds the Mac's
# Tailscale address only. The hub always calls in; the worker never calls out.
#
# Bootstrap on a fresh Mac (clones the repo, then runs the installer from it):
# curl -fsSL https://git.gabvdl.xyz/gabrielvidal/ai-agent/raw/branch/main/worker/install-macos.sh | bash
#
# Env overrides: WORKER_PORT (8790), WORKER_CWD (~/projects/orus-monorepo),
# WORKER_ACCOUNT (work — the account id this Mac's login is known by),
# WORKER_BIND (the Tailscale IPv4), WORKER_SRC (clone dir for the bootstrap).
set -euo pipefail
REPO_URL="https://git.gabvdl.xyz/gabrielvidal/ai-agent.git"
LABEL="xyz.gabvdl.ai-agent-worker"
SHARE="$HOME/.local/share/ai-agent-worker"
SRC="${WORKER_SRC:-$SHARE/src}"
VENV="$SHARE/venv"
STATE="$HOME/.config/ai-agent-worker"
LOGS="$HOME/Library/Logs/ai-agent-worker"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
PORT="${WORKER_PORT:-8790}"
CWD="${WORKER_CWD:-$HOME/projects/orus-monorepo}"
ACCOUNT="${WORKER_ACCOUNT:-work}"
DOMAIN="gui/$(id -u)"
die() { echo "error: $*" >&2; exit 1; }
say() { echo "==> $*"; }
[[ "$(uname -s)" == "Darwin" ]] || die "this installer is for macOS"
# ── locate the checkout (or bootstrap one) ─────────────────────────────────────
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || true)"
if [[ -n "$HERE" && -f "$HERE/../sidecar/sidecar.py" ]]; then
ROOT="$(cd "$HERE/.." && pwd)"
else
command -v git >/dev/null || die "git not found (xcode-select --install)"
if [[ -d "$SRC/.git" ]]; then
git -C "$SRC" pull --ff-only --quiet
else
say "cloning $REPO_URL → $SRC"
mkdir -p "$(dirname "$SRC")"
git clone --quiet --depth 1 "$REPO_URL" "$SRC"
fi
exec bash "$SRC/worker/install-macos.sh" "$@"
fi
SIDECAR="$ROOT/sidecar"
tailscale_bin() {
if command -v tailscale >/dev/null; then command -v tailscale
elif [[ -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]]; then
echo /Applications/Tailscale.app/Contents/MacOS/Tailscale
fi
}
py() { WORKER_STATE_DIR="$STATE" "$VENV/bin/python" "$@"; }
health() { curl -fsS --max-time 3 "http://$BIND:$PORT/health" 2>/dev/null; }
resolve_bind() {
BIND="${WORKER_BIND:-}"
if [[ -z "$BIND" ]]; then
local ts; ts="$(tailscale_bin)"
[[ -n "$ts" ]] || die "Tailscale not found — install it and log in to the tailnet"
BIND="$("$ts" ip -4 2>/dev/null | head -1 || true)"
fi
[[ "$BIND" =~ ^100\. ]] || die "no Tailscale IPv4 (got '${BIND:-nothing}') — is Tailscale up?"
}
pairing_string() {
local code email
code="$(py "$SIDECAR/pairing.py" new-code)"
# The login this Mac's `claude` already has (Keychain): the hub matches it to
# one of its accounts, so the pairing dialog needs no account pick.
email="$("$CLAUDE_BIN" auth status 2>/dev/null \
| "$VENV/bin/python" -c 'import json,sys
try: print(json.load(sys.stdin).get("email") or "")
except Exception: print("")' || true)"
echo
echo "Paste this in the hub → Settings → Workers → Add worker (one use):"
echo
echo " $BIND:$PORT/$code${email:+/$email}"
echo
}
case "${1:-}" in
--uninstall)
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
rm -f "$PLIST"
say "removed $LABEL (state kept in $STATE — delete it to forget the pairing)"
exit 0 ;;
--status)
launchctl print "$DOMAIN/$LABEL" 2>/dev/null | grep -E "state|pid|last exit" || echo "not loaded"
resolve_bind; health && echo || echo "no /health on $BIND:$PORT"
exit 0 ;;
--update)
git -C "$ROOT" pull --ff-only
;;
--pair)
[[ -x "$VENV/bin/python" ]] || die "not installed yet — run without --pair"
resolve_bind
CLAUDE_BIN="$(command -v claude || echo claude)"
pairing_string
exit 0 ;;
"") ;;
*) die "unknown option: $1" ;;
esac
# ── prerequisites ──────────────────────────────────────────────────────────────
CLAUDE_BIN="$(command -v claude || true)"
[[ -n "$CLAUDE_BIN" ]] || die "claude not found on PATH — install Claude Code and log in first"
"$CLAUDE_BIN" auth status 2>/dev/null | grep -q '"loggedIn": *true' \
|| echo "warning: \`claude auth status\` doesn't say logged in — runs will fail until you /login" >&2
[[ -d "$CWD" ]] || die "default cwd $CWD doesn't exist (set WORKER_CWD)"
resolve_bind
# ── venv ───────────────────────────────────────────────────────────────────────
say "venv + deps ($VENV)"
mkdir -p "$SHARE" "$STATE" "$LOGS/runs"
chmod 700 "$STATE"
if command -v uv >/dev/null; then
[[ -x "$VENV/bin/python" ]] || uv venv --quiet "$VENV"
uv pip install --quiet --python "$VENV/bin/python" -r "$SIDECAR/requirements.txt"
else
[[ -x "$VENV/bin/python" ]] || python3 -m venv "$VENV"
"$VENV/bin/pip" install --quiet -r "$SIDECAR/requirements.txt"
fi
# ── launchd agent ──────────────────────────────────────────────────────────────
# PATH is the one this installer runs with (your login shell's), so a session
# finds the same mise shims, gcloud, op and gh a terminal does.
say "writing $PLIST (bind $BIND:$PORT, cwd $CWD)"
mkdir -p "$(dirname "$PLIST")"
# plistlib, not sed: paths with spaces/&/| can't break the XML.
LABEL="$LABEL" PY="$VENV/bin/python" SIDECAR="$SIDECAR" BIND="$BIND" PORT="$PORT" \
CWD="$CWD" ACCOUNT="$ACCOUNT" CLAUDE_BIN="$CLAUDE_BIN" STATE="$STATE" LOGS="$LOGS" \
RUN_PATH="$(dirname "$CLAUDE_BIN"):$PATH" PLIST="$PLIST" \
"$VENV/bin/python" "$ROOT/worker/make_plist.py"
plutil -lint "$PLIST" >/dev/null
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
launchctl bootstrap "$DOMAIN" "$PLIST"
launchctl kickstart -k "$DOMAIN/$LABEL" >/dev/null 2>&1 || true
say "waiting for http://$BIND:$PORT/health"
for _ in $(seq 1 30); do health >/dev/null && break; sleep 1; done
health >/dev/null || die "worker didn't come up — see $LOGS/worker.log"
echo " $(health)"
if [[ "${1:-}" == "--update" ]] && py "$SIDECAR/pairing.py" status | grep -q '"paired": true'; then
say "updated; still paired"
exit 0
fi
pairing_string