feat(uno-q): LAN-open boot + Telegram self-serve + guided lockdown modalities

Boards now boot LAN-open (host 0.0.0.0, allow_public_bind, require_pairing=false)
so a team reaches its node's embedded ZeroClaw dashboard + web chat directly by
IP with no token — the 'Open your node' path. Two more modalities ride the same
default (cloud-first) agent:

- Telegram: config seeds [channels.telegram.default] disabled + pre-bound to the
  default agent; a team enables it from the dashboard by pasting a @BotFather
  token. Dashboard writes only set pending_reload, and /admin/reload is
  loopback-only, so a new zeroclaw-reload-watcher.sh (loopback) applies dashboard
  config edits within seconds — no shell. provision-fleet pushes + launches it.
- Lockdown: zeroclaw-lockdown.sh flips require_pairing=true, reloads, and mints a
  pair code (open -> locked harden step); documented as a board/adb action since
  minting + reload are loopback-only.
- Voice: seeds a commented [channels.voice_duplex.default] + documents the
  aarch64 build (embedded-web + gateway-voice-duplex); build/hardware work is
  separate.

Onboarding scripts (apess-selfregister.sh, provision-uno-q.sh) now tolerate
open-boot: when the gateway mints no pair code, they announce with a placeholder
token (the open board ignores auth) instead of hard-failing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-15 16:35:28 -07:00
co-authored by Claude Opus 4.8
parent af9cae0abc
commit 7233a4b1c0
8 changed files with 273 additions and 24 deletions
+71 -8
View File
@@ -12,6 +12,8 @@ proxies each board and streams its activity to participants and instructors.
`gateway start` — only the daemon/agent/channel paths register the hardware `gateway start` — only the daemon/agent/channel paths register the hardware
tools (`uno_q_flash`, …). tools (`uno_q_flash`, …).
- **`llama-server`** on `:8083` — on-board Qwen for offline/fallback inference. - **`llama-server`** on `:8083` — on-board Qwen for offline/fallback inference.
- **`zeroclaw-reload-watcher`** (loopback) — applies a team's browser config
edits (e.g. enabling their Telegram bot) via `/admin/reload`. See *modalities*.
- **Three agents**, selected per request via `?agent=` (APESS maps the harness - **Three agents**, selected per request via `?agent=` (APESS maps the harness
provider choice → alias; see `src/lib/harness.ts` `harnessToAgent`): provider choice → alias; see `src/lib/harness.ts` `harnessToAgent`):
| alias | provider | behaviour | | alias | provider | behaviour |
@@ -20,6 +22,60 @@ proxies each board and streams its activity to participants and instructors.
| `cloud` | `custom.cloud` | cloud only | | `cloud` | `custom.cloud` | cloud only |
| `local` | `llamacpp.local`| on-board Qwen only (fully offline) | | `local` | `llamacpp.local`| on-board Qwen only (fully offline) |
## Talking to the node — the three modalities
Beyond the APESS-proxied prompt path, a team reaches its own node three ways.
All bind to the `default` (cloud-first) agent, so responses are fast; `local`
stays the offline fallback.
1. **Web chat by IP.** The board is **LAN-open during setup** (`config.template.toml`
sets `host = "0.0.0.0"`, `allow_public_bind = true`, `require_pairing = false`),
so a team opens `http://<board-lan-ip>:8080/` — the embedded ZeroClaw
dashboard + `/ws/chat` — straight from the "Open your node →" link APESS shows
after they claim. No token while open.
2. **Telegram.** Each board is its own agent, so each needs its own bot. A team
makes one via **@BotFather**, then in the dashboard (**Config → channels →
`telegram.default`**) pastes the `bot_token`, adds their username to
`allowed_users`, and sets `enabled = true`. Dashboard writes only set
`pending_reload`; the **`zeroclaw-reload-watcher`** (loopback) applies them
within a few seconds — no shell. The channel is pre-bound to `default`.
3. **Voice (browser-mic streaming).** Talk in the web chat, the agent speaks
back — requires a daemon built with the voice feature (see **Voice** below),
then uncomment `[channels.voice_duplex.default]` in the config.
### Open → locked lifecycle
Boards boot **open** so setup is frictionless on the isolated workshop LAN. When
a team is done, **harden** the node with `zeroclaw-lockdown.sh` (run on the board
or over adb — pair-code minting + reload are loopback-only, so it can't be a LAN
button):
```sh
adb -s <serial> shell '/home/arduino/zeroclaw-lockdown.sh' # prints a pair code
```
It flips `require_pairing = true`, reloads, and mints a pair code the team enters
once at `http://<board-ip>:8080/pairing`. After lockdown only their paired
devices reach the node. (Note: locking down also cuts the shared APESS proxy's
access unless you re-register a fresh token — a deliberate "graduate to a private
node" transition; validate against your run's needs.)
### Voice — building a voice-capable board binary
`gateway-voice-duplex` is a Cargo feature and the SPA is embedded at compile
time, so the board binary must be built as:
```sh
cargo xtask web build # build the SPA into web/dist first (embedded-web needs it)
cargo build --release --target aarch64-unknown-linux-gnu \
--features "hardware,peripheral-rpi,embedded-web,zeroclaw-gateway/gateway-voice-duplex"
```
`gateway-voice-duplex` has no root-crate alias, so it's namespaced. The web chat
does client-side STT (Web Speech API) and plays the streamed `tts_chunk` audio;
the server `handle_voice_event` path is finished as part of this work. **This is
the one modality that needs an on-board build + hardware test to verify.**
## Prerequisites on the board ## Prerequisites on the board
- `/home/arduino/zeroclaw` — the ZeroClaw binary (aarch64), **with the - `/home/arduino/zeroclaw` — the ZeroClaw binary (aarch64), **with the
@@ -121,17 +177,18 @@ sudo cp systemd/zeroclaw-llama.service systemd/zeroclaw-daemon.service /etc/syst
sudo systemctl enable --now zeroclaw-llama zeroclaw-daemon sudo systemctl enable --now zeroclaw-llama zeroclaw-daemon
``` ```
For a LAN fleet (participants reach the board's WiFi IP directly), set in For a LAN fleet (participants reach the board's WiFi IP directly), the template
`config.toml`: now ships this **by default** (`host = "0.0.0.0"`, `allow_public_bind = true`,
`require_pairing = false`) so the dashboard/web-chat is reachable + open during
setup — see *modalities* above. Enable the reload-watcher alongside the daemon:
```toml ```sh
[gateway] sudo cp systemd/zeroclaw-reload-watcher.service /etc/systemd/system/
host = "0.0.0.0" sudo systemctl enable --now zeroclaw-reload-watcher
allow_public_bind = true
``` ```
Then pair + register once (steps 4–5 of the script) so APESS has the board's Then register once so APESS has the board's `{ url, token }` (step 5; under
`{ url, token }`. open-boot the token is a placeholder — the open board ignores auth).
## Resilience — surviving a disconnect (no-root boards) ## Resilience — surviving a disconnect (no-root boards)
@@ -198,4 +255,10 @@ cloud-with-fallback. In APESS, the team's provider/fallback toggle picks the ali
- `apess-node.env.example` — per-board onboarding identity (KIT_ID / CLAIM_CODE / FLEET_SECRET / APESS_URL). - `apess-node.env.example` — per-board onboarding identity (KIT_ID / CLAIM_CODE / FLEET_SECRET / APESS_URL).
- `systemd/*.service` + `apess-selfregister.timer` — production units (need root). - `systemd/*.service` + `apess-selfregister.timer` — production units (need root).
- `zeroclaw-supervisor.sh` — on-board no-root watchdog (endpoint health + restart). - `zeroclaw-supervisor.sh` — on-board no-root watchdog (endpoint health + restart).
- `zeroclaw-reload-watcher.sh` — on-board: applies browser dashboard config edits
by firing the loopback `/admin/reload` when `config.toml` changes (makes
Telegram-enable / lockdown self-serve without a shell). Root unit:
`systemd/zeroclaw-reload-watcher.service`; no-root: launch via `setsid nohup`.
- `zeroclaw-lockdown.sh` — on-board: the harden step — flips `require_pairing=true`,
reloads, and mints a pair code (open → locked).
- `recover-uno-q.sh` — host-side post-disconnect recovery (re-tunnel + health-check). - `recover-uno-q.sh` — host-side post-disconnect recovery (re-tunnel + health-check).
+10 -4
View File
@@ -34,10 +34,16 @@ for _ in $(seq 1 60); do
done done
# Pair locally for a bearer token (the token stays server-side once APESS has it). # Pair locally for a bearer token (the token stays server-side once APESS has it).
CODE=$("$ZEROCLAW" gateway get-paircode --new --port "$PORT" | grep -oE '[0-9]{6}' | head -1) # Under LAN-open setup (require_pairing=false) the gateway mints no code — the
TOKEN=$(curl -s -X POST "$GW/pair" -H "X-Pairing-Code: ${CODE}" \ # open board ignores auth, so announce with a placeholder token; lockdown later
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("token",""))') # establishes real pairing (and, if wanted, re-announces a valid token).
[ -n "$TOKEN" ] || { echo "local pairing failed" >&2; exit 1; } CODE=$("$ZEROCLAW" gateway get-paircode --new --port "$PORT" 2>/dev/null | grep -oE '[0-9]{6}' | head -1 || true)
TOKEN=""
if [ -n "$CODE" ]; then
TOKEN=$(curl -s -X POST "$GW/pair" -H "X-Pairing-Code: ${CODE}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("token",""))' 2>/dev/null || true)
fi
TOKEN="${TOKEN:-open-lan}" # placeholder while the board is LAN-open
# The LAN IP participants (and APESS) reach; fall back to localhost for adb-only. # The LAN IP participants (and APESS) reach; fall back to localhost for adb-only.
IP=$(ip -4 -o addr show 2>/dev/null | grep -oE 'inet [0-9.]+' | grep -v '127.0.0.1' \ IP=$(ip -4 -o addr show 2>/dev/null | grep -oE 'inet [0-9.]+' | grep -v '127.0.0.1' \
+42 -8
View File
@@ -42,16 +42,23 @@ transport = "bridge"
enabled = true enabled = true
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Gateway — the HTTP/WS/SSE surface APESS talks to. # Gateway — the HTTP/WS/SSE surface APESS talks to, AND the embedded web
# dashboard + chat UI a team opens directly at http://<board-lan-ip>:8080.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
[gateway] [gateway]
port = 8080 port = 8080
# Bind 0.0.0.0 + allow_public_bind for a real LAN fleet (participants reach the # LAN-open by default: participants reach the board's WiFi IP directly (the
# board's WiFi IP). Leave default (localhost) when reaching it over adb-forward. # "Open your node →" link in APESS). 0.0.0.0 + allow_public_bind make the
# host = "0.0.0.0" # dashboard/web-chat reachable across the workshop subnet.
# allow_public_bind = true host = "0.0.0.0"
# paired_tokens are added by the pairing flow (see provision-uno-q.sh); never allow_public_bind = true
# commit a real token. # Open during SETUP so a team's browser can chat + edit config (paste a Telegram
# token, etc.) with no token on the isolated workshop LAN. The reload-watcher
# (loopback) applies any dashboard config edit. Run `zeroclaw-lockdown.sh` to
# flip this on and mint a pair code once a team has finished setting up.
require_pairing = false
# paired_tokens are added by the pairing flow (lockdown / provision-uno-q.sh);
# never commit a real token.
[skills] [skills]
prompt_injection_mode = "compact" prompt_injection_mode = "compact"
@@ -74,11 +81,15 @@ strict_tool_parsing = false
# Agents — one per provider strategy. The APESS harness choice routes here via # Agents — one per provider strategy. The APESS harness choice routes here via
# ?agent= (see harnessToAgent in src/lib/harness.ts). # ?agent= (see harnessToAgent in src/lib/harness.ts).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
[agents.default] # cloud + on-board Qwen fallback [agents.default] # cloud + on-board Qwen fallback — the workshop default
enabled = true enabled = true
model_provider = "custom.claude" model_provider = "custom.claude"
risk_profile = "default" risk_profile = "default"
runtime_profile = "unoq" runtime_profile = "unoq"
# Modalities this agent answers on, beyond the APESS proxy + web chat: the team's
# own Telegram bot. A team enables it from the dashboard (Config → channels →
# telegram) by pasting their @BotFather token; the reload-watcher applies it.
channels = ["telegram.default"]
[agents.cloud] # cloud only, no fallback [agents.cloud] # cloud only, no fallback
enabled = true enabled = true
@@ -91,3 +102,26 @@ enabled = true
model_provider = "llamacpp.local" model_provider = "llamacpp.local"
risk_profile = "default" risk_profile = "default"
runtime_profile = "unoq" runtime_profile = "unoq"
# ---------------------------------------------------------------------------
# Channels — extra ways a team talks to its node (all bind to `default`, above).
# ---------------------------------------------------------------------------
# Telegram: each board is its own agent, so each needs its own bot. A team
# creates one live via @BotFather, then pastes the token in the dashboard
# (Config → channels → telegram.default), sets enabled = true, and adds their
# Telegram username to allowed_users. The reload-watcher restarts the listener.
# Seeded disabled so the block shows up pre-bound in the config editor.
[channels.telegram.default]
enabled = false
bot_token = "" # from @BotFather (paste via the dashboard)
# allowed_users = ["your_tg_username"] # lock the bot to your team (no leading @)
mention_only = false
# Browser-mic streaming voice — talk to the agent inside the web chat, it speaks
# back. Requires a daemon built with the voice feature (see README §Voice):
# cargo xtask web build && cargo build --release \
# --features "hardware,peripheral-rpi,embedded-web,zeroclaw-gateway/gateway-voice-duplex"
# Enable once the board runs a voice-capable binary:
# [channels.voice_duplex.default]
# enabled = true
+13
View File
@@ -64,6 +64,19 @@ provision() { # kit serial -> 0 ok / 1 fail
fi fi
echo " ok — onboarding ($persisted)" echo " ok — onboarding ($persisted)"
# modalities — reload-watcher (applies browser dashboard config edits, e.g. a
# team enabling its Telegram bot) + the lockdown script. Best-effort.
if [ -r "$HERE/zeroclaw-reload-watcher.sh" ]; then
adb -s "$serial" push "$HERE/zeroclaw-reload-watcher.sh" /home/arduino/ >/dev/null 2>&1
adb -s "$serial" push "$HERE/zeroclaw-lockdown.sh" /home/arduino/ >/dev/null 2>&1
adb -s "$serial" shell 'chmod +x /home/arduino/zeroclaw-reload-watcher.sh /home/arduino/zeroclaw-lockdown.sh' >/dev/null 2>&1
adb -s "$serial" shell 'pgrep -f "[z]eroclaw-reload-watcher.sh" >/dev/null 2>&1 || \
setsid nohup /home/arduino/zeroclaw-reload-watcher.sh >/tmp/zc-reload.log 2>&1 </dev/null &' >/dev/null 2>&1
adb -s "$serial" shell '(crontab -l 2>/dev/null | grep -v zeroclaw-reload-watcher.sh; \
echo "@reboot /home/arduino/zeroclaw-reload-watcher.sh") | crontab -' >/dev/null 2>&1 || true
echo " ok — modalities (reload-watcher up; lockdown staged)"
fi
# default boot animation — best-effort (a flash hiccup doesn't fail onboarding) # default boot animation — best-effort (a flash hiccup doesn't fail onboarding)
if [ "$FLASH_DEFAULT" = 1 ]; then if [ "$FLASH_DEFAULT" = 1 ]; then
if "$HERE/flash-sketch.sh" "$serial" "$FLASH_SKETCH" >/dev/null 2>&1; then if "$HERE/flash-sketch.sh" "$serial" "$FLASH_SKETCH" >/dev/null 2>&1; then
+9 -4
View File
@@ -37,10 +37,15 @@ sleep 8
echo "==> [4/5] pair → bearer token" echo "==> [4/5] pair → bearer token"
a forward tcp:8080 tcp:8080 >/dev/null a forward tcp:8080 tcp:8080 >/dev/null
until curl -sf --max-time 3 http://127.0.0.1:8080/health >/dev/null; do sleep 1; done until curl -sf --max-time 3 http://127.0.0.1:8080/health >/dev/null; do sleep 1; done
CODE=$(a shell '/home/arduino/zeroclaw gateway get-paircode --new --port 8080' | grep -oE '[0-9]{6}' | head -1) # Under LAN-open setup (require_pairing=false, the template default) the gateway
TOKEN=$(curl -s -X POST http://127.0.0.1:8080/pair -H "X-Pairing-Code: ${CODE}" \ # mints no code — the open board ignores auth, so register a placeholder token.
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])') CODE=$(a shell '/home/arduino/zeroclaw gateway get-paircode --new --port 8080' 2>/dev/null | grep -oE '[0-9]{6}' | head -1 || true)
[ -n "$TOKEN" ] || { echo "pairing failed" >&2; exit 1; } TOKEN=""
if [ -n "$CODE" ]; then
TOKEN=$(curl -s -X POST http://127.0.0.1:8080/pair -H "X-Pairing-Code: ${CODE}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("token",""))' 2>/dev/null || true)
fi
TOKEN="${TOKEN:-open-lan}" # placeholder while the board is LAN-open
echo "==> [5/5] register team '$TEAM' with APESS" echo "==> [5/5] register team '$TEAM' with APESS"
# Board's LAN IP — what participants reach in a real fleet (falls back to localhost). # Board's LAN IP — what participants reach in a real fleet (falls back to localhost).
@@ -0,0 +1,18 @@
[Unit]
Description=ZeroClaw reload-watcher (applies dashboard config edits via loopback reload)
After=zeroclaw-daemon.service
Wants=zeroclaw-daemon.service
[Service]
Type=simple
User=arduino
# Watches config.toml and fires POST /admin/reload (loopback) when a team edits
# config from the browser dashboard — so Telegram-enable / allowed_users / the
# lockdown flip take effect without a shell. No-root boards: launch instead with
# setsid nohup /home/arduino/zeroclaw-reload-watcher.sh >/tmp/zc-reload.log 2>&1 </dev/null &
ExecStart=/home/arduino/zeroclaw-reload-watcher.sh
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# The "harden" step — flip a node from open (setup) to paired (locked). Boards
# boot LAN-open so a team can set up tokenless; once they're done, this locks the
# node so only their group can reach it.
#
# Runs ON the board (loopback): pair-code minting + /admin/reload are localhost-
# only, so this can't be a LAN-browser button. Trigger it over adb, from a board
# shell, or wire it to a physical button — then hand the team the pair code it
# prints (they enter it once in the dashboard's pairing screen).
#
# ./zeroclaw-lockdown.sh # on the board
# adb -s <serial> shell '/home/arduino/zeroclaw-lockdown.sh' # from the host
#
# Env:
# ZC_CONFIG config file (default /home/arduino/.zeroclaw/config.toml)
# GATEWAY_PORT loopback gateway port (default 8080)
# ZC_BIN zeroclaw binary (default /home/arduino/zeroclaw)
set -uo pipefail
CONFIG="${ZC_CONFIG:-/home/arduino/.zeroclaw/config.toml}"
PORT="${GATEWAY_PORT:-8080}"
ZC_BIN="${ZC_BIN:-/home/arduino/zeroclaw}"
BASE="http://127.0.0.1:${PORT}"
echo "==> locking down this node (require pairing)"
# 1) Persist require_pairing=true. Prefer the running gateway's config API
# (loopback → allowed even while pairing is off); fall back to editing the
# toml directly if the endpoint isn't reachable.
if ! curl -sf --max-time 5 -X PUT "${BASE}/api/config/prop?path=gateway.require_pairing" \
-H 'content-type: application/json' -d 'true' >/dev/null 2>&1; then
echo " (config API unreachable — editing $CONFIG directly)"
if grep -qE '^\s*require_pairing\s*=' "$CONFIG"; then
sed -i 's/^\(\s*require_pairing\s*=\).*/\1 true/' "$CONFIG"
else
# insert under [gateway]
sed -i '/^\[gateway\]/a require_pairing = true' "$CONFIG"
fi
fi
# 2) Reload so the live PairingGuard picks up require_pairing=true.
curl -sf --max-time 8 -X POST "${BASE}/admin/reload" >/dev/null 2>&1 || true
# give the daemon a moment to come back paired
for _ in $(seq 1 15); do
curl -sf --max-time 2 "${BASE}/health" >/dev/null 2>&1 && break
sleep 1
done
# 3) Mint a fresh pair code (localhost-only). Try the admin endpoint, then the CLI.
CODE="$(curl -sf --max-time 5 -X POST "${BASE}/admin/paircode/new" 2>/dev/null | grep -oE '[0-9]{6}' | head -1)"
[ -n "$CODE" ] || CODE="$("$ZC_BIN" gateway get-paircode --new --port "$PORT" 2>/dev/null | grep -oE '[0-9]{6}' | head -1)"
echo
if [ -n "$CODE" ]; then
echo " ┌───────────────────────────────────────────┐"
echo " │ Node locked. Pairing code: ${CODE} │"
echo " └───────────────────────────────────────────┘"
echo " Enter it once in the dashboard's pairing screen (http://<board-ip>:${PORT}/pairing)."
else
echo " ! Node lockdown persisted, but couldn't mint a pair code automatically."
echo " Run on the board: ${ZC_BIN} gateway get-paircode --new --port ${PORT}"
fi
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# On-board reload-watcher. The ZeroClaw dashboard lets a team edit config from
# their browser (enable their Telegram bot, set allowed_users, …) — but a config
# write only sets `pending_reload`; the channel listeners don't re-spawn until
# `POST /admin/reload`, which is LOOPBACK-ONLY. A LAN browser can't call it.
#
# This watcher closes that gap: it runs on the board, notices when config.toml
# changes, and fires the loopback reload — so a team's dashboard edit takes
# effect on its own within a few seconds, no shell required. It's the thing that
# makes browser self-serve (Telegram, and the lockdown flip) actually work.
#
# ./zeroclaw-reload-watcher.sh # loops; run via setsid or systemd
#
# Env:
# ZC_CONFIG config file to watch (default /home/arduino/.zeroclaw/config.toml)
# GATEWAY_PORT loopback gateway port (default 8080)
# POLL_SECS poll interval (default 3)
# SETTLE_SECS debounce after a change before reloading (default 2)
set -uo pipefail
CONFIG="${ZC_CONFIG:-/home/arduino/.zeroclaw/config.toml}"
PORT="${GATEWAY_PORT:-8080}"
POLL="${POLL_SECS:-3}"
SETTLE="${SETTLE_SECS:-2}"
BASE="http://127.0.0.1:${PORT}"
mtime() { stat -c %Y "$CONFIG" 2>/dev/null || echo 0; }
reload() { # loopback → passes the gateway's require_localhost gate
curl -sf --max-time 5 -X POST "${BASE}/admin/reload" >/dev/null 2>&1
}
echo "[reload-watcher] watching $CONFIG → ${BASE}/admin/reload (poll ${POLL}s)"
last="$(mtime)"
while :; do
sleep "$POLL"
cur="$(mtime)"
[ "$cur" = "$last" ] && continue
# config changed — let a burst of per-field writes settle, then reload once
sleep "$SETTLE"
cur="$(mtime)"
if reload; then
echo "[reload-watcher] config changed → reloaded daemon ($(date -u +%H:%M:%SZ))"
else
echo "[reload-watcher] config changed → reload failed (daemon down?); will retry"
fi
last="$cur"
done