feat(deploy): Uno Q workshop-node provisioning kit #2

Merged
osobh merged 1 commits from feat/uno-q-provisioning into main 2026-07-03 16:09:28 +00:00
5 changed files with 271 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
# Uno Q workshop node — provisioning
Turns an Arduino Uno Q into an APESS workshop node: a ZeroClaw daemon that
exposes an HTTP/SSE gateway, drives the on-board MCU (generate → compile →
flash), and answers with cloud-first / on-board-Qwen-fallback inference. APESS
proxies each board and streams its activity to participants and instructors.
## What a provisioned board runs
- **`zeroclaw daemon`** on `:8080` — the gateway APESS talks to (`/pair`,
`/webhook`, `/ws/chat`, `/api/events`, `/health`). Use `daemon`, **not**
`gateway start` — only the daemon/agent/channel paths register the hardware
tools (`uno_q_flash`, …).
- **`llama-server`** on `:8083` — on-board Qwen for offline/fallback inference.
- **Three agents**, selected per request via `?agent=` (APESS maps the harness
provider choice → alias; see `src/lib/harness.ts` `harnessToAgent`):
| alias | provider | behaviour |
|-------|----------|-----------|
| `default` | `custom.claude` | cloud, **fallback to on-board Qwen** |
| `cloud` | `custom.cloud` | cloud only |
| `local` | `llamacpp.local`| on-board Qwen only (fully offline) |
## Prerequisites on the board
- `/home/arduino/zeroclaw` — the ZeroClaw binary (aarch64), **with the
gateway-peripheral-registration fix** (merged to `osobh/zeroclaw` main).
- `/home/arduino/llama/llama-server` + `/home/arduino/models/qwen.gguf`.
- Arduino Zephyr core `arduino:zephyr:unoq` (0.51.0) + OpenOCD for flashing,
and `ArduinoGraphics` if using scroll text.
## Provision (dev — over USB/adb)
```sh
export APESS_ADMIN_CODE=adm-xxxxxxxx # instructor code
./provision-uno-q.sh <adb-serial> team-07 https://apess-api.redclaw.dev
```
Steps it runs: install `config.template.toml` (cloud endpoint substituted) →
start `llama-server` → start `zeroclaw daemon` → pair for a bearer token →
`POST /nodes` to APESS. Idempotent; re-run to re-provision.
Override the cloud endpoint (default is the local `claude_shim` on `:8090`):
```sh
CLOUD_URI=https://api.anthropic.com/v1 CLOUD_MODEL=claude-haiku-4-5 \
APESS_ADMIN_CODE=adm-xxxx ./provision-uno-q.sh <serial> team-07
```
> The shim on `:8090` is a bring-up convenience (one Mac). A real fleet points
> `CLOUD_URI` at a shared cloud endpoint (Anthropic / OpenRouter / LiteLLM) with
> a key, so boards don't each need a tunnel.
## Provision (production — systemd, boots on power-up)
With root on the board:
```sh
sudo cp systemd/zeroclaw-llama.service systemd/zeroclaw-daemon.service /etc/systemd/system/
sudo systemctl enable --now zeroclaw-llama zeroclaw-daemon
```
For a LAN fleet (participants reach the board's WiFi IP directly), set in
`config.toml`:
```toml
[gateway]
host = "0.0.0.0"
allow_public_bind = true
```
Then pair + register once (steps 45 of the script) so APESS has the board's
`{ url, token }`.
## Verify
```sh
adb -s <serial> forward tcp:8080 tcp:8080
curl -s localhost:8080/health # {"status":"ok"}
# each agent alias resolves (needs a bearer token from /pair):
curl -s -X POST 'localhost:8080/webhook?agent=local' -H "authorization: Bearer $TOK" \
-H 'content-type: application/json' -d '{"message":"one word: local"}'
```
`?agent=local` routes to Qwen (offline), `?agent=cloud` to the cloud, `?agent=default`
cloud-with-fallback. In APESS, the team's provider/fallback toggle picks the alias.
## Files
- `config.template.toml` — the node config (secrets stripped; `__CLOUD_URI__` /
`__CLOUD_MODEL__` substituted at provision time).
- `provision-uno-q.sh` — one-shot provisioner (adb-driven).
- `systemd/*.service` — production units.
+93
View File
@@ -0,0 +1,93 @@
schema_version = 3
# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------
# "cloud" primary. During bring-up this points at the local claude_shim
# (adb-reverse tunnel on :8090). For a real fleet, swap in a shared cloud
# endpoint + key, e.g.:
# uri = "https://api.anthropic.com/v1" (or an OpenRouter/LiteLLM gateway)
# api_key = "..." (prefer an env-injected key)
[providers.models.custom.cloud]
uri = "__CLOUD_URI__"
model = "__CLOUD_MODEL__"
native_tools = false
# Same cloud endpoint, but with an on-board Qwen fallback ("cloud first,
# local if it fails"). Used by the `default` agent.
[providers.models.custom.claude]
uri = "__CLOUD_URI__"
model = "__CLOUD_MODEL__"
native_tools = false
fallback = ["llamacpp.local"]
[providers.models.llamacpp]
# On-board Qwen via llama-server (see zeroclaw-llama.service).
[providers.models.llamacpp.local]
uri = "http://127.0.0.1:8083/v1"
model = "qwen"
native_tools = false
[providers.models.custom]
# ---------------------------------------------------------------------------
# Hardware — the Uno Q's onboard MCU over the GPIO bridge.
# ---------------------------------------------------------------------------
[[peripherals.boards]]
board = "arduino-uno-q"
transport = "bridge"
[peripherals]
enabled = true
# ---------------------------------------------------------------------------
# Gateway — the HTTP/WS/SSE surface APESS talks to.
# ---------------------------------------------------------------------------
[gateway]
port = 8080
# Bind 0.0.0.0 + allow_public_bind for a real LAN fleet (participants reach the
# board's WiFi IP). Leave default (localhost) when reaching it over adb-forward.
# host = "0.0.0.0"
# allow_public_bind = true
# paired_tokens are added by the pairing flow (see provision-uno-q.sh); never
# commit a real token.
[skills]
prompt_injection_mode = "compact"
# ---------------------------------------------------------------------------
# Risk profile — only the on-board hardware tools, auto-approved so the agent
# can flash without a human in the loop.
# ---------------------------------------------------------------------------
[risk_profiles.default]
level = "supervised"
allowed_tools = ["uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect"]
auto_approve = ["uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "file_read", "content_search"]
[runtime_profiles.unoq]
agentic = true
max_tool_iterations = 6
strict_tool_parsing = false
# ---------------------------------------------------------------------------
# Agents — one per provider strategy. The APESS harness choice routes here via
# ?agent= (see harnessToAgent in src/lib/harness.ts).
# ---------------------------------------------------------------------------
[agents.default] # cloud + on-board Qwen fallback
enabled = true
model_provider = "custom.claude"
risk_profile = "default"
runtime_profile = "unoq"
[agents.cloud] # cloud only, no fallback
enabled = true
model_provider = "custom.cloud"
risk_profile = "default"
runtime_profile = "unoq"
[agents.local] # on-board Qwen only (fully offline)
enabled = true
model_provider = "llamacpp.local"
risk_profile = "default"
runtime_profile = "unoq"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Provision an Arduino Uno Q as an APESS workshop node — host-driven via adb,
# no root needed on the board. Installs config, brings up the on-board Qwen and
# the ZeroClaw daemon, pairs for a bearer token, and registers with APESS.
#
# APESS_ADMIN_CODE=adm-xxxx ./provision-uno-q.sh <adb-serial> <team-id> [apess-url]
#
# Env overrides: CLOUD_URI, CLOUD_MODEL (default: local shim :8090 / sonnet).
set -euo pipefail
SERIAL="${1:?usage: provision-uno-q.sh <adb-serial> <team-id> [apess-url]}"
TEAM="${2:?team id required}"
APESS="${3:-https://apess-api.redclaw.dev}"
CLOUD_URI="${CLOUD_URI:-http://127.0.0.1:8090/v1}"
CLOUD_MODEL="${CLOUD_MODEL:-sonnet}"
ADMIN_CODE="${APESS_ADMIN_CODE:?set APESS_ADMIN_CODE to register with APESS}"
HERE="$(cd "$(dirname "$0")" && pwd)"
a() { adb -s "$SERIAL" "$@"; }
echo "==> [1/5] install config (cloud=$CLOUD_URI)"
sed -e "s#__CLOUD_URI__#${CLOUD_URI}#g" -e "s#__CLOUD_MODEL__#${CLOUD_MODEL}#g" \
"$HERE/config.template.toml" > /tmp/apess-node-config.toml
a shell 'mkdir -p /home/arduino/.zeroclaw'
a push /tmp/apess-node-config.toml /home/arduino/.zeroclaw/config.toml >/dev/null
echo "==> [2/5] start on-board Qwen (llama-server :8083)"
a shell 'pgrep -f llama-server >/dev/null 2>&1 || (cd /home/arduino/llama && \
LD_LIBRARY_PATH=/home/arduino/llama nohup ./llama-server -m /home/arduino/models/qwen.gguf \
--host 127.0.0.1 --port 8083 -np 1 -c 16384 --jinja --mlock >/tmp/llama8083.log 2>&1 &)'
echo "==> [3/5] start zeroclaw daemon (:8080)"
a shell 'pkill -f "zeroclaw daemon" 2>/dev/null; pkill -f "zeroclaw gateway" 2>/dev/null; sleep 2; \
nohup /home/arduino/zeroclaw daemon >/tmp/zc-daemon.log 2>&1 &'
sleep 8
echo "==> [4/5] pair → bearer token"
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
CODE=$(a shell '/home/arduino/zeroclaw gateway get-paircode --new --port 8080' | grep -oE '[0-9]{6}' | head -1)
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)["token"])')
[ -n "$TOKEN" ] || { echo "pairing failed" >&2; exit 1; }
echo "==> [5/5] register team '$TEAM' with APESS"
# Board's LAN IP — what participants reach in a real fleet (falls back to localhost).
IP=$(a shell "ip -4 -o addr show 2>/dev/null | grep -oE 'inet [0-9.]+' | grep -v '127.0.0.1' | awk '{print \$2}' | head -1" | tr -d '\r')
URL="http://${IP:-127.0.0.1}:8080"
curl -sf -X POST "${APESS}/nodes" -H "x-access-code: ${ADMIN_CODE}" -H 'content-type: application/json' \
-d "{\"teamId\":\"${TEAM}\",\"url\":\"${URL}\",\"token\":\"${TOKEN}\"}" >/dev/null \
&& echo " registered at ${URL}" || echo " WARN: APESS registration failed (is ${APESS} reachable?)"
echo "==> done — node '$TEAM' is live. Agents: default (cloud+fallback), cloud, local."
@@ -0,0 +1,17 @@
[Unit]
Description=ZeroClaw daemon (gateway + agent runtime)
After=network.target zeroclaw-llama.service
Wants=zeroclaw-llama.service
[Service]
Type=simple
User=arduino
# Use `daemon`, NOT `gateway start`: only the daemon/agent/channel arms register
# the peripheral tools (uno_q_flash etc.). `gateway start` on an older build
# leaves the agent unable to flash.
ExecStart=/home/arduino/zeroclaw daemon
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,16 @@
[Unit]
Description=ZeroClaw on-board Qwen (llama-server)
After=network.target
[Service]
Type=simple
User=arduino
Environment=LD_LIBRARY_PATH=/home/arduino/llama
# -np 1 is mandatory (a bigger slot count makes the tiny model 500 on ctx).
ExecStart=/home/arduino/llama/llama-server -m /home/arduino/models/qwen.gguf \
--host 127.0.0.1 --port 8083 -np 1 -c 16384 --jinja --mlock
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target