feat(node): self-register derives node url from source IP; App Lab provisioner

A containerised App Lab node can't see its host LAN IP, but its self-register POST is
SNAT'd to the host, so /nodes/self-register now derives http://<source-ip>:<port> when
url is omitted (explicit url still wins; token defaults to open-lan). Adds
deploy/uno-q/provision-node-app.sh to install the ZeroClaw Node app + carry in its
runtime bits. Tests updated + derived-url coverage (64 pass).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-21 13:07:32 -07:00
co-authored by Claude Opus 4.8
parent 8001e100c3
commit 40bee45842
3 changed files with 86 additions and 9 deletions
+16 -5
View File
@@ -150,15 +150,26 @@ export function createApp(opts: AppOptions): Express {
return res.status(401).json({ error: 'unauthorized' }) return res.status(401).json({ error: 'unauthorized' })
} }
const b = req.body ?? {} const b = req.body ?? {}
if (![b.kitId, b.url, b.token, b.claimCode].every((v) => typeof v === 'string' && v)) { // `url` is optional: an App-Lab-containerised node can't see its host LAN IP,
return res.status(400).json({ error: 'kitId, url, token and claimCode are required' }) // but its request is SNAT'd to the host, so we derive http://<source-ip>:<port>
// from what the server actually sees. An explicit url (e.g. from the shell
// self-register) still wins.
let url = typeof b.url === 'string' && b.url ? b.url : ''
if (!url) {
const port = Number.isFinite(Number(b.port)) ? Number(b.port) : 8080
const ip = (req.ip ?? '').replace(/^::ffff:/, '') // unwrap IPv4-mapped IPv6
if (ip) url = `http://${ip}:${port}`
} }
const r = boards.announce({ kitId: b.kitId, url: b.url, token: b.token, claimCode: b.claimCode }) const token = typeof b.token === 'string' && b.token ? b.token : 'open-lan'
if (![b.kitId, b.claimCode].every((v) => typeof v === 'string' && v) || !url) {
return res.status(400).json({ error: 'kitId and claimCode are required (url derived from source IP if omitted)' })
}
const r = boards.announce({ kitId: b.kitId, url, token, claimCode: b.claimCode })
if (r.claimed && r.teamId && nodes) { if (r.claimed && r.teamId && nodes) {
await nodes.register({ teamId: r.teamId, url: b.url, token: b.token }) await nodes.register({ teamId: r.teamId, url, token })
} }
broadcastUnclaimed() broadcastUnclaimed()
res.status(201).json({ kitId: b.kitId, claimed: r.claimed }) res.status(201).json({ kitId: b.kitId, url, claimed: r.claimed })
}) })
// A participant claims their powered-on board to their team by proving // A participant claims their powered-on board to their team by proving
+15 -4
View File
@@ -51,13 +51,24 @@ describe('board self-register + claim', () => {
await selfRegister({}, 'wrong-secret').expect(401) await selfRegister({}, 'wrong-secret').expect(401)
}) })
it('validates the body', async () => { it('validates the body (kitId + claimCode required)', async () => {
await selfRegister({ token: '' }).expect(400) await selfRegister({ kitId: '' }).expect(400)
await selfRegister({ claimCode: '' }).expect(400)
}) })
it('accepts a board presenting the fleet secret', async () => { it('accepts a board presenting the fleet secret', async () => {
const res = await selfRegister().expect(201) const res = await selfRegister().expect(201)
expect(res.body).toEqual({ kitId: 'KIT-07', claimed: false }) expect(res.body).toMatchObject({ kitId: 'KIT-07', url: board.url, claimed: false })
})
it('derives the node url from the source IP when url is omitted', async () => {
const res = await request(app)
.post('/nodes/self-register')
.set('x-fleet-secret', FLEET)
.send({ kitId: 'KIT-08', claimCode: '4821', port: 8080 })
.expect(201)
// supertest connects over loopback → derived host is 127.0.0.1
expect(res.body.url).toMatch(/^http:\/\/127\.0\.0\.1:8080$/)
}) })
it('broadcasts the unclaimed pool and lists it for the instructor', async () => { it('broadcasts the unclaimed pool and lists it for the instructor', async () => {
@@ -166,7 +177,7 @@ describe('board self-register + claim', () => {
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201) await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
// board reboots with a new IP + a fresh paired token and re-announces // board reboots with a new IP + a fresh paired token and re-announces
const res = await selfRegister({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }).expect(201) const res = await selfRegister({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }).expect(201)
expect(res.body).toEqual({ kitId: 'KIT-07', claimed: true }) expect(res.body).toMatchObject({ kitId: 'KIT-07', url: 'http://192.168.1.9:8080', claimed: true })
// the team's binding is refreshed (new url), still online, still not unclaimed // the team's binding is refreshed (new url), still online, still not unclaimed
const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200) const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200)
expect(list.body).toEqual([{ teamId: 'team-07', url: 'http://192.168.1.9:8080', online: true }]) expect(list.body).toEqual([{ teamId: 'team-07', url: 'http://192.168.1.9:8080', online: true }])
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# provision-node-app.sh — install the "ZeroClaw Node" App Lab app onto a Uno Q and
# carry in its runtime bits (binary + config + .secret_key + token + node env), then
# start it. The app itself is secret-free; this script provisions the secrets.
#
# Usage (board on USB, secrets in env):
# export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-…
# KIT_ID=crimson-otter CLAIM_CODE=4821 FLEET_SECRET=apress2026 \
# APESS_URL=http://192.168.x.x:3000 ./deploy/uno-q/provision-node-app.sh
#
# Env: SERIAL (65301572), NODE_APP_DIR (repo app dir), plus the node-env vars above.
set -u
SERIAL="${SERIAL:-65301572}"
NODE_APP_DIR="${NODE_APP_DIR:-$HOME/projects/zeroclaw/firmware/zeroclaw-node}"
DEST=/home/arduino/ArduinoApps/zeroclaw-node
S(){ adb -s "$SERIAL" shell "$@"; }
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
adb -s "$SERIAL" get-state >/dev/null 2>&1 || { bad "board $SERIAL not attached"; exit 1; }
[ -f "$NODE_APP_DIR/app.yaml" ] || { bad "app not found at $NODE_APP_DIR"; exit 1; }
[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || { bad "ANTHROPIC_OAUTH_TOKEN not set"; exit 1; }
echo "→ pushing the app to $DEST"
S "mkdir -p $DEST/bin $DEST/.zeroclaw"
adb -s "$SERIAL" push "$NODE_APP_DIR/app.yaml" "$DEST/app.yaml" >/dev/null
adb -s "$SERIAL" push "$NODE_APP_DIR/sketch" "$DEST/" >/dev/null
adb -s "$SERIAL" push "$NODE_APP_DIR/python" "$DEST/" >/dev/null
ok "app files"
echo "→ carrying in runtime bits (secret-bearing — provisioned, not committed)"
# binary: reuse the board's known-good one (already the right arch)
S "cp -f /home/arduino/zeroclaw $DEST/bin/zeroclaw && chmod +x $DEST/bin/zeroclaw"
# config + enc2 key (the config's secrets are bound to this key)
S "cp -f /home/arduino/.zeroclaw/config.toml $DEST/.zeroclaw/config.toml"
S "cp -f /home/arduino/.zeroclaw/.secret_key $DEST/.zeroclaw/.secret_key"
# cloud token (env-only → written to the board's app dir, never to the repo)
printf '%s' "$ANTHROPIC_OAUTH_TOKEN" | S "cat > $DEST/.zeroclaw/oauth_token"
# node env (self-register inputs)
S "cat > $DEST/.zeroclaw/apess-node.env" <<EOF
KIT_ID=${KIT_ID:-node-$SERIAL}
CLAIM_CODE=${CLAIM_CODE:-$(( (RANDOM % 9000) + 1000 ))}
FLEET_SECRET=${FLEET_SECRET:-}
APESS_URL=${APESS_URL:-}
GATEWAY_PORT=${GATEWAY_PORT:-8080}
EOF
ok "binary + config + .secret_key + oauth_token + apess-node.env"
echo "→ starting the app (compiles+flashes the matrix sketch, launches daemon in-container)"
S "cd $DEST && TMPDIR=/tmp timeout 300 arduino-app-cli app start $DEST 2>&1 | tail -4"
echo "→ (optional) enable Run-at-startup for boot persistence:"
echo " adb -s $SERIAL shell 'arduino-app-cli properties set default $DEST'"
ok "provisioned. In App Lab, open 'ZeroClaw Node' → Run."
</content>