<!-- grove:last-verified v1.98 -->
> Build identity is per-release β check grove version or /api/version.
| Cell | Host | Class | Notes |
|---|---|---|---|
| mac | localhost | dev | M4 16 GB; cutting edge, direct deploy.sh (local cp) |
| cell2 | 100.121.6.45 | dev | Pi 4 GB; real Pi/systemd β catches systemd-only bugs; Tailscale SSH |
| nookman | 100.96.243.69 | prod | Pi 8 GB; jump host for cell2/palooza; signed releases |
| familynook (FN) | 100.126.143.83 | prod | x86 WAN gateway; grove.nook.li; nginx TLS; canary-first on --promote |
| funbook | 100.81.15.45 | prod Β· catch-up tier | Linux laptop on a cellular hotspot; signed releases + counted for durability, but it cannot block a release β unreachable during a --promote it is skipped loudly and caught up later |
| nook | 100.96.150.107 | prod Β· first-line | Tucker's dogfood cell β updates via self-update / dashboard UI ONLY, never deploy.sh |
| palooza | 100.86.29.8 | external | A friend's cell (cell1's old 8 GB chassis); self-update path; stays peered/friended |
cell1 (Pi 8 GB, 100.104.249.123) is shelved β SD pulled, chassis repurposed as palooza.
Port 5678 everywhere. Port 5679 = watchdog panic page. Port 5680 = relay WebSocket.
See CLAUDE.md "Fleet Nodes & config" for the canonical class/topology table.
curl -s https://grove.nook.li/invite/<token> | bash
mkdir -p ~/.grove
# Deps are pinned + hash-verified (#184) β install from the constraints file, not loose:
pip3 install --require-hashes -r constraints.txt
# flask, flask-socketio, cryptography, requests, websockets, pynacl, gevent,
# gevent-websocket, markdown (+ transitive) β every wheel sha256-pinned; a hash
# mismatch aborts (tamper signal). install.sh embeds the same constraints inline.
# Copy grove.py, web.py, acme.py, acme_jws.py, watchdog.py, relay.py, grove-mountd.py + assets/ to ~/.grove/
cd ~/.grove && nohup python3 web.py </dev/null >/tmp/grove-web.log 2>&1 &
# Dashboard at http://localhost:5678
Use </dev/null on nohup invocations (especially over SSH) to prevent SIGHUP.
The canonical fleet deploy script. Ships the build-hash set (grove.py, web.py, acme.py, acme_jws.py, watchdog.py) + the signed companions relay.py and grove-mountd.py + release.sig + the assets/ dir (deploy.sh SRC_FILES).
# Deploy to mac (dev, default)
bash deploy.sh mac
# Deploy to all nodes
bash deploy.sh
# Skip test suite
bash deploy.sh --skip-tests
# Roll back the previous build on a node
bash deploy.sh --rollback mac
# Promote the soaked build to production (required canary chain: FN β nookman)
bash deploy.sh --promote
--promote requires a clean working tree, a full gauntlet pass on mac, and refuses --skip-tests. It deploys the required canary chain β familynook first (x86 canary), waits (default 30 s, override with CANARY_SOAK_SEC=N), then nookman β and a failure there aborts the promote and offers a rollback.
funbook is a catch-up tier (2026-08-19): probed before the gauntlet, promoted after the required chain when reachable, and when it is not, the gauntlet is told not to gate on it, it is reported as NOT promoted, and a marker makes the next plain deploy keep saying so until bash deploy.sh funbook catches it up. The skip is never silent. nook + palooza are never deploy-pushed β they pick up the signed release via self-update.
Disk-headroom precheck (#167). Before writing source to any node deploy.sh checks free space in ~/.grove and refuses to deploy below DEPLOY_MIN_FREE_MB β a scp onto a full disk can leave null-byte (0-length) files that brick the cell (the cell2 incident). Free space and retry, or raise the floor via env if you know the write is safe.
Build hash = SHA-256 of grove.py + web.py + acme.py + acme_jws.py + watchdog.py + 12 frontend assets (assets/), first 12 hex chars. Both deploy.sh and web.py compute the same hash; mismatches surface in /api/version.
FN runs under a dedicated grove system account. deploy.sh scp's files to ~/ (nook's home), then calls bash deploy-grove.sh which uses sudo to copy them into /home/grove/.grove/ and restart the service. Because that path runs sudo systemctl restart grove plus sudo mkdir/cp/chown/tee into grove's store, the nook deploy user is granted broad passwordless sudo on FN (not a narrow per-binary rule).
(See fn-setup-assets.sh in the repo root for the FN provisioning helper.)
There is no GitHub Actions β origin is a bare repo on familynook. CI is a single
script, ci.sh, that runs the full hermetic suite (no live fleet needed):
bash ci.sh # py_compile Β· ruff crash-class Β· render-smoke Β· pytest Β· render-diff (info)
HARD gates (fail the build): py_compile, ruff crash-class (F821/E9), render-smoke,
pytest tests/. render-diff runs informationally β template renders change
legitimately; re-baseline with python3 render-diff.py --update.
It's wired as an automatic pre-push gate (hooks/pre-push, active once you run
git config core.hooksPath hooks): a red suite blocks the push. Bypass a WIP push with
git push --no-verify. The fast per-commit hook only does crash-class checks; the pytest
suite runs at the push boundary.
Optional independent safety net β a cron runner on the dev box:
*/30 * * * * cd ~/grove && bash ci.sh >> /tmp/grove-ci.log 2>&1 || \
osascript -e 'display notification "Grove CI is RED" with title "Grove CI"'
Truly non-bypassable upgrade (future): a server-side pre-receive hook on familynook's
grove.git that runs ci.sh and rejects a broken push β needs pytest/ruff installed
on FN, and a buggy hook can block all pushes, so it's deferred.
These unit files live in systemd/ and are installed via sudo bash systemd/install.sh.
sudo bash systemd/install.sh # grove + watchdog
sudo bash systemd/install.sh --with-relay # also installs grove-relay
Note the scope split: the small systemd/install.sh here installs only grove + watchdog (+ relay with the flag). The one-liner install.sh (repo root) is a superset β it also installs and enables grove-mountd.service when grove-mountd.py is present. On a cell that wants UI-driven USB mounting, install via the root install.sh (or drop the mountd unit in manually).
Abbreviated below β see systemd/grove.service for the full unit (it also carries a
security-hardening block: NoNewPrivileges, ProtectSystem=strict, ProtectHome=no,
PrivateTmp, ProtectKernel*/ControlGroups, RestrictSUIDSGID).
[Service]
Type=simple
User=grove
Group=grove
WorkingDirectory=/home/grove/.grove
ExecStart=/usr/bin/python3 /home/grove/.grove/web.py
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
StartLimitIntervalSec=300 # restart-storm guard: at most
StartLimitBurst=5 # 5 restarts / 5 min, else the unit gives up
MemoryMax=512M
TasksMax=64
ReadWritePaths=/home/grove/.grove /home/grove/GroveHome /tmp
[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/grove/.grove/watchdog.py --no-redirect
Restart=always
RestartSec=3
Watchdog panic page at :5679. When Grove (5678) is down this page lets you restart it from a browser.
Managed via the web UI or:
python3 ~/.grove/web.py ai-service install
python3 ~/.grove/web.py ai-service status
python3 ~/.grove/web.py ai-service uninstall
Type=forking; spawns the llama-server and exits. The watchdog defers to this unit when it is active. Do NOT enable on a Metal Mac β a full-offload server wires unevictable memory.
Only needed on a dedicated relay host. Most cells auto-connect to the fleet relay; this unit is not required for normal operation.
The one Grove component that runs as root β it performs the actual mount/umount/fstab for UI-driven USB drives (Β§9). The unprivileged web app is its only client, over /run/grove-mountd.sock (owned root:<grove-user>, mode 0660); ops are typed + allowlisted, no shell. Installed and enable --now'd by the root install.sh (not the small systemd/install.sh), only when grove-mountd.py is present. Backs the Storage / USB drive actions below.
Pis run grove-watchdog.service instead of grove.service. A */5 cron provides the reboot bootstrap and proactive memory management:
# crontab -e (as the grove user)
*/5 * * * * ~/.grove/grove-health-local.sh >> ~/.grove/health.log 2>&1
deploy.sh ships grove-health-local.sh to each Pi automatically.
# Install the watchdog service on a Pi
python3 ~/.grove/web.py watchdog-service install
sudo systemctl enable --now grove-watchdog
Any cell can act as a WAN gateway. Two options:
Set public_url in ~/.grove/config.json and ensure port 80 is reachable. acme.py handles cert issuance and renewal automatically.
nginx-grove.conf in the repo root is the reference config for grove.nook.li.
SECURITY-CRITICAL: on a public gateway, nginx must inject X-Grove-Public-Edge: 1 on every request proxied to :5678, overwriting any client-supplied value (since :5678 isn't directly reachable from the internet, this header is a trustworthy public-vs-tailnet signal). Grove's check_dashboard_auth uses this header to block the owner dashboard session and admin /api/* routes over the public edge β they remain tailnet-only. Without this tag, a valid owner session cookie obtained over Tailscale could be replayed over the public internet to unlock the full control panel.
# Required addition to every proxy_pass block that reaches :5678 from the public edge:
proxy_set_header X-Grove-Public-Edge "1";
The public edge serves: /portal, /site/, /invite/, /relay, /.well-known/, /login, /api/version, /api/health, and peer-sync APIs. The owner dashboard (/dashboard, /files, admin /api/*) is tailnet-only.
Relay WebSocket passes through to :5680 (relay.py) β no X-Grove-Public-Edge needed there.
# Relay β no grove auth, separate port
location /relay {
proxy_pass http://127.0.0.1:5680;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d grove.nook.li
# Renewal is automatic via certbot.timer
~/.grove/config.json key fields:
{
"self_name": "my-cell",
"web_port": 5678,
"web_host": "0.0.0.0",
"public_url": "https://grove.nook.li",
"desired_factor": 2,
"relay_url": "ws://100.96.243.69:5680",
"relay_host": true,
"storage_cap_gb": 50,
"peers": [],
"ai_enabled": true,
"peer_model_policy": "friends",
"disable_auto_update": false,
"sync_window": {"enabled": false, "start": "01:00", "end": "06:00"}
}
peer_model_policy controls which peers may use this cell's local AI: "friends" (default), "all", "public", "active", or "none".
Restrict heavy background work (chunk replication, integrity sweep, durability accounting) to a daily window in the cell's local time β useful on a metered link or a box you don't want churning all day. Light work (serving, sharing, chat, presence, discovery, hygiene) always runs, so the cell stays reachable and responsive regardless.
grove sync-window 01:00 06:00 # heavy sync only 01:00β06:00 (wraps midnight if start > end)
grove sync-window off # always-on (default)
Or via the API (owner-tier): POST /api/sync-window {enabled, start:"HH:MM", end:"HH:MM"} β it returns the updated sync_window and whether heavy sync is allowed right now (in_sync_window, also reported by the opp-sync status API). A critical-durability override still forces replication outside the window if any chunk this cell owns would drop to a single copy (critical_durability_overrides, default on).
Production = familynook + nookman (the required canary chain) + funbook (same class and durability, but a catch-up tier that never blocks a release), all on signed releases via --release/--promote. Dev = mac + cell2 (cutting edge, direct deploy.sh). nook is the first-line dogfood cell and palooza a friend's external cell β both update via the self-update / dashboard path, never deploy.sh. cell1 is shelved.
Normal workflow:
1. Deploy to mac: bash deploy.sh mac
2. Let it soak; run gauntlet: python3 gauntlet.py
3. Promote to prod: bash deploy.sh --promote
If a build passes health checks but misbehaves:
bash deploy.sh --rollback familynook nookman
This restores the .bak snapshot that deploy.sh captured before the deploy.
nook + palooza (and eventually family cells) pull the latest signed release themselves β ordered strictly by RELEASE_VERSION, never build hash. A freshly self-applied build must prove itself before it's committed:
_post_update_health_gate): the new build has to self-verify within a window that is counted from first contact with its own server, not from thread start β so a slow-booting weak Pi with a big library extends patience instead of being reverted just for booting slowly (the false-negative that once "wedged" palooza). "Connection refused / still booting" is not a failure; only a reachable-but-wrong response (500s, or serving the old build past the ceiling) is. A never-confirms build is left to the watchdog's crash path.~/.grove/update-quarantine.json): a build that fails the gate is rolled back to the .bak prior build and its release_version is quarantined so the auto-updater never re-pulls the same bad release into a failβrollbackβreapply loop. web.py and watchdog.py share the file/format, so either actor can quarantine and the other honours it.Grove uses llama.cpp (not ollama). Models live in ~/.grove/.
Supply-chain pinning (#184): the llama.cpp source build is pinned to a specific release tag (reproducible build vs floating HEAD), and the starter GGUF model is verified against a pinned sha256 on download β a checksum mismatch aborts. This is the AI-side counterpart to the hash-pinned pip deps in Install.
grove-ai.service supervises the llama-server on Linux/systemd hosts*/5 keepalive cron also starts the AI server if it's configured but not running (ensure_ai in grove-health-local.sh)Manage via CLI:
python3 ~/.grove/web.py ai-service install # install grove-ai.service
python3 ~/.grove/web.py ai-service status
USB drives can be added and mounted from the dashboard Storage card β no shelling in. The privileged mounting is done by grove-mountd (see systemd section): the unprivileged web app talks to it over /run/grove-mountd.sock, and mountd performs the actual mount/umount/fstab as root, with a typed + allowlisted op set (no shell). Endpoints (all owner-tier):
GET /api/drives β health of every configured drive (mode, online, chunks, free/total). Also surfaces plaintext USB mounted into GroveHome (read-only tag).GET /api/drives/available Β· GET /api/drives/block-devices β pickers for the Add Drive modal.POST /api/drives/mount-add β mount a drive transiently (by UUID) via grove-mountd β no fstab write yet (the permanent line is deferred until the owner confirms).POST /api/drives/persist-mount β writes the permanent fstab line after the owner confirms the drive in the Add Drive modal; Cancel ejects the transient mount instead, so a declined add leaves no fstab orphan.POST /api/drives/add Β· /api/drives/scan Β· /api/drives/start-ingest Β· /api/drives/stop-ingest β register a logical drive, scan it, start/stop ingesting existing files.POST /api/drives/eject β safe unmount + de-persist ({mountpoint, uuid?}). Logical-drive removal is /api/drives/remove.If grove-mountd.py / the unit isn't installed, the mount actions degrade gracefully (the UI shows status but can't add/eject).
GET /api/version β build hash, version, uptimeGET /api/health β disk, chunk count, peer countGET /api/replication-status β per-peer replication healthGET /api/route-speeds β route performance data:5679 β visible when Grove is downPeers are reached over the best available transport, best route first. Yggdrasil (a Tailscale-independent mesh, 0200::/7 addresses) is a live fallback transport: a cell self-reports its ygg address in /api/version, peers harvest it into their routes (#182), and best_route probes it only when the primary (Tailscale/LAN) path is unavailable.
Logs:
/tmp/grove-web.log β main daemon stdout/stderr/tmp/grove-watchdog.log β watchdog logjournalctl -u grove -f β on systemd boxes~/.grove/health.log β keepalive cron log (Pis)
# Via dashboard: Settings β Backup Keys (downloads ZIP)
# Via API:
curl -sf http://localhost:5678/api/backup-keys > grove-keys.zip
If you lose ~/.grove/.key, your encrypted files are unrecoverable.
# Via dashboard: Settings β Restore Keys (upload ZIP)
# Via API:
curl -sf -X POST http://localhost:5678/api/restore-keys -F "file=@grove-keys.zip"
Three paths, in order of friction:
1. Logged in elsewhere? Settings β Recovery β Generate recovery link. Single-use, valid 24 h, resets only this cell.
2. Locked out everywhere? From a terminal on the device:
grove auth set-password
3. No grove CLI? Visit /setup from localhost to set a new password.
grove apoptosis
Type APOPTOSIS to confirm. Signed tombstone propagates to peers. Irreversible.
Never run network-severing commands over SSH on a remote node. If you sever your own connection, the Pi nodes may be unreachable for days.
Dangerous over SSH (requires a recovery timer or on-box scheduled job):
nmcli con down/modify, systemctl restart NetworkManageriptables -F or any firewall flushtailscale down, tailscale logoutip link set β¦ down, reboots without a recovery at jobpkill web.py over SSH kills your own shell (the SSH session process matches the grep). Restart by PID instead:
# Find the PID
cat ~/.grove/grove-web.pid
# Kill just that PID
kill <pid>
# Restart
cd ~/.grove && nohup python3 web.py </dev/null >/tmp/grove-web.log 2>&1 &
Before restarting Grove, check for queued image-gen jobs:
python3 -c "import json; jobs=json.load(open('$HOME/.grove/ai_image_jobs.json')); print([j for j in jobs if j.get('status') in ('queued','running')])"
A blind restart kills the in-process worker; queued/running jobs become errors and must be re-submitted.
Local owner token (#189 Stage 1). Each cell keeps a per-cell ~/.grove/owner_token (mode 0600, generated on first use, never handed to peers). Local owner-auth paths (CLI, self-update, all localhost) accept it so they can stop depending on the fleet-shared peer secret β additive today (web accepts both the owner token and the peer secret on localhost owner paths), so a later stage can retire and rotate the shared secret. Treat it like a key: it's owner-equivalent on localhost.
chmod 600 (.key, peer_secret, owner_token)~/.grove directory is chmod 700grove auth set-password)X-Grove-Public-Edge: 1 on every request proxied to :5678curl -I https://grove.nook.li/dashboard should redirect to login, not render the dashboard)