chore: commit ZeroClaw per-tenant runtime spike + architecture doc
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

Saves earlier-phase artifacts that were sitting untracked:
- docs/agent-engine-architecture.md — per-tenant containerized ZeroClaw runtime
  decision doc (clawmates = §15 control plane; zeroclaw = per-tenant runtime).
- deploy/clawmates-runtime/ — slim runtime Dockerfile, dev compose, example
  agent config, README (the proven Phase-1 drive recipe).
- tools/runtime-spike/drive.mjs — Node WS drive client for the spike.

No secrets (only env-var names / commented placeholders).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-15 21:29:16 -07:00
co-authored by Claude Opus 4.8
parent 2621e97ad2
commit bd982943b8
6 changed files with 453 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Slim Clawmates runtime = the zeroclaw daemon (gateway API), API-only.
# Simple full-source build (avoids the upstream Dockerfile's stale manifest-
# prefetch step). web/dist may be empty → dashboard is omitted, /api/* works.
# Requires BuildKit (cache mounts). Build context = the zeroclaw source tree.
# syntax=docker/dockerfile:1
# bookworm-pinned so the binary's glibc matches the bookworm runtime stage
FROM rust:1.94-slim-bookworm AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config build-essential cmake libssl-dev ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
# Ensure the gateway's include_dir!("../../web/dist") target exists at compile time.
RUN mkdir -p web/dist
RUN --mount=type=cache,id=cm-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=cm-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=cm-target-bookworm,target=/app/target,sharing=locked \
cargo build --release --locked --bin zeroclaw \
&& cp target/release/zeroclaw /usr/local/bin/zeroclaw
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/local/bin/zeroclaw /usr/local/bin/zeroclaw
ENV HOME=/zeroclaw-data \
ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \
ZEROCLAW_GATEWAY_PORT=42617
RUN mkdir -p /zeroclaw-data/workspace
EXPOSE 42617
ENTRYPOINT ["zeroclaw"]
CMD ["daemon"]
+83
View File
@@ -0,0 +1,83 @@
# Clawmates per-tenant runtime (ZeroClaw) — Phase 1 spike
Goal of Phase 1: stand up **one** Clawmates tenant runtime container (a slim ZeroClaw daemon)
on **gw-04**, drive it from a small Clawmates-side client (provision an agent → run a turn →
stream events), and prove one **§15 gate** by routing an outbound action through a stub
Clawmates MCP tool. See `docs/agent-engine-architecture.md` for the full design.
## Confirmed runtime contract (from the live MindHealth/ClawHealth images in prod)
- Image entrypoint: `zeroclaw`, default cmd: `daemon`
- Gateway port: `42617` (`ZEROCLAW_GATEWAY_PORT`)
- Per-tenant data volume mounted at `/zeroclaw-data` (`HOME=/zeroclaw-data`,
`ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace`)
- Config overrides via env: `ZEROCLAW_<dotted__path>=value` (double-underscore = nesting)
## Build the slim image (off-box, push to the private registry)
ZeroClaw's own multi-stage Dockerfile builds the daemon; `ZEROCLAW_CARGO_FEATURES` selects
channels. We drop the heavy defaults (lark/whatsapp) — Clawmates exposes outbound capabilities
as gated MCP tools, not native channels.
```bash
# from a build host (this Mac, or Gitea CI) with the zeroclaw checkout:
docker build \
-t web-01:5000/clawmates-runtime:dev \
--build-arg ZEROCLAW_CARGO_FEATURES="channel-webhook" \
~/projects/zeroclaw
docker push web-01:5000/clawmates-runtime:dev # registry is Tailscale-only, insecure HTTP
```
> Build is heavy (zeroclaw is ~160k LOC Rust). Prefer building on a beefy box or CI, not gw-04;
> gw-04 only *runs* the image.
## Run the dev stack on gw-04
```bash
scp deploy/clawmates-runtime/compose.dev.yml gw-04:/home/redclaw/clawmates/
ssh gw-04 'cd ~/clawmates && docker compose -f compose.dev.yml up -d'
```
The runtime gateway is bound to `127.0.0.1:42617` on gw-04 (not public). Reach it for the spike
over the SSH/Tailscale tunnel; production fronts it with Traefik on its own router, isolated from
gr33t/cbh.
## §15 model (unchanged moat)
The tenant agent is provisioned with **no native outbound tools and no real channel/secret
config** (`tools.allow` locked). Every sensitive capability (email/slack/pay/delete/…) is a
**Clawmates MCP tool**; the agent's only egress is that gated MCP door, where `cm-safety` +
`cm-secrets` apply approvals, taint, single-use grants, broker execution, and journaling.
## Confirmed drive recipe (Phase 1 ✅ — proven on tank)
A turn ran end-to-end (`scout` answered over `/ws/chat`). The working flow:
1. **Configure** a tenant daemon. Two gotchas the spike uncovered:
- **Provider-map entries must be set via the prop/env path, NOT a TOML sub-table.**
`[providers.models.groq.default]\nmodel="…"` in `config.toml` parses to an *empty* entry
(`groq.default = {}`, model `<unset>`). Set them via env instead:
`ZEROCLAW_providers__models__groq__default__model=…`,
`ZEROCLAW_providers__models__groq__default__api_key=…` (double-underscore = path; key from Infisical).
- The agent references the provider by **`type.alias`**: `[agents.scout] model_provider = "groq.default"`.
- Onboarding gate is a flag — set `[onboard_state] quickstart_completed = true` (else `/ws/chat`
returns `NEEDS_ONBOARDING`). `onboard_state` + `agents.*` DO load fine from `config.toml`.
2. **Pair** for a bearer token: read the one-time code from the daemon's startup log
(`X-Pairing-Code: NNNNNN`), then `POST /pair` with header `X-Pairing-Code: <code>` → `{token}`.
3. **Run a turn** over WebSocket `/ws/chat?agent=<alias>&token=<token>` (Node's global WebSocket
can't set headers, so the token goes in the query — gateway accepts header > subprotocol > query).
Send `{"type":"message","content":"…"}`; receive `session_start` → `chunk*` →
`tool_call`/`tool_result` → `approval_request` (the §15 hook) → `done`.
Drive client: `tools/runtime-spike/drive.mjs` (Node ≥22). Example:
```bash
PAIR_CODE=<code> node tools/runtime-spike/drive.mjs http://<host>:42617 scout "hi"
```
## Next
- [ ] **Demonstrate a §15 gate**: give `scout` a tool under the Supervised profile and confirm the
`approval_request` round-trip over the socket (drive client already handles it).
- [ ] Replace zeroclaw's native approval with the **Clawmates MCP door** (`cm-safety`/`cm-secrets`).
- [ ] Map WS events → `run_events` journal; `/api/cost` → billing.
- [ ] `docker save` the image → `docker load` on **gw-04** and run `compose.dev.yml` there.
@@ -0,0 +1,19 @@
# Phase-1 spike config for a tenant runtime: one provider model + one agent.
# Secrets are NOT committed — the API key is injected at runtime via env
# ZEROCLAW_providers__models__groq__default__api_key=<key>
# (double-underscore = config nesting). Swap groq→anthropic for prod.
# Mark onboarding complete (headless equivalent of the browser Quickstart) so
# the agent will answer; otherwise /ws/chat returns NEEDS_ONBOARDING.
[onboard_state]
quickstart_completed = true
[providers.models.groq.default]
model = "llama-3.3-70b-versatile"
# One named agent == one Clawmates "claw". For real §15 we lock tools.allow and
# add an MCP client → the Clawmates gated door; for the spike we keep it minimal
# and use the Supervised profile so sensitive tools raise an approval_request.
[agents.scout]
model_provider = "groq.default"
risk_profile = "supervised"
+42
View File
@@ -0,0 +1,42 @@
# Clawmates per-tenant runtime — Phase 1 dev stack (gw-04)
# One tenant = one zeroclaw daemon container. Isolated on its own network so it
# cannot disturb gr33t / clawbrainhub. Gateway bound to localhost only.
#
# NOTE: provider key + gateway token are injected from Infisical at deploy time
# (do NOT commit secrets). The exact gateway-token env name is TODO (see README).
name: clawmates-dev
networks:
clawmates-net:
driver: bridge
volumes:
tenant-acme-data: {} # per-tenant /zeroclaw-data (config.toml, domain.db, brain.h5, workspace/)
services:
# The user's private multi-agent runtime (one per tenant in production).
runtime:
image: web-01:5000/clawmates-runtime:dev
container_name: clawmates-dev-runtime
command: ["daemon"]
restart: unless-stopped
networks: [clawmates-net]
ports:
- "127.0.0.1:42617:42617" # gateway, localhost-only on gw-04 (tunnel for the spike)
volumes:
- tenant-acme-data:/zeroclaw-data
environment:
ZEROCLAW_GATEWAY_PORT: "42617"
ZEROCLAW_WORKSPACE: "/zeroclaw-data/workspace"
# Provider key (from Infisical at deploy; example shape — confirm exact path):
# ZEROCLAW_providers__models__anthropic__default__api_key: "${ANTHROPIC_API_KEY}"
# Gateway auth token (env name TODO — see README):
# ZEROCLAW_GATEWAY_TOKEN: "${CLAWMATES_DEV_GATEWAY_TOKEN}"
# Hardening to add next: read-only rootfs, drop caps, no-new-privileges, mem/cpu limits.
# TODO (Phase 3): the Clawmates §15 MCP server — the single gated outbound door
# (email/slack/pay/delete/…) backed by cm-safety + cm-secrets + the audit journal.
# mcp:
# image: web-01:5000/clawmates-mcp:dev
# networks: [clawmates-net]
+212
View File
@@ -0,0 +1,212 @@
# Agent Engine Architecture — per-tenant containerized runtime
_Status: accepted direction (Phase 0). Supersedes the assumption that Clawmates' own
`cm-runtime` is the long-term execution engine. This document is the source of truth for the
backend agent-execution strategy; implementation proceeds in the phases at the end._
## Problem
Clawmates must let each customer build their own multi-agent "company/team" and run it at
scale: many agents, across machines, with full lifecycle management (provision → run →
suspend → resume → retire) — without weakening the §15 safety contract (per-tool human
approval, taint, secret broker, single audited journal) that is the product's moat and is
acceptance-blocking.
Clawmates' current `cm-runtime` (`crates/cm-runtime/src/runtime.rs`) is a real agent loop, but
it is single-host: one server process drives all agents. It does not scale horizontally or
isolate tenants at the infrastructure layer.
## Options considered
| Engine | Verdict |
|---|---|
| **clawverse** (Rust, distributed mission orchestrator) | Closest to "distributed agent fleet," but mission/repo-shaped, partly WIP, and re-implements node scheduling/allocation/health that Kubernetes already provides. **Shelved** — revisit only if one tenant must span many nodes/GPUs, or for its mission/self-improvement IP. |
| **openclaw** (Node agent OS) | Most complete *single-host* agent OS, but Node (our stack is Rust), single-tenant by design, and we lack confirmed source + license to fork/ship it. **Not the base.** |
| **zeroclaw** (`~/projects/zeroclaw`, Rust, MIT/Apache) | Multi-agent-per-daemon runtime; ownable, single-binary, embeddable, ~80 providers, 6-layer sandbox, gateway/RPC drive API. **Chosen as the per-tenant runtime.** |
## Decision
**Run one containerized ZeroClaw daemon per workspace/tenant.** The unit of scaling becomes the
*tenant container*, placed and lifecycle-managed by a standard orchestrator (Kubernetes or Fly
Machines) — which replaces a bespoke node/allocation layer. Clawmates remains the **control
plane**: tenancy, auth, billing, the audited SSE gateway, and the **§15 safety engine**. The
tenant's agents reach the outside world **only** through Clawmates-gated capabilities.
ZeroClaw **v0.8.0** makes this viable nearly out of the box:
- **Multi-agent per daemon** — isolated workspace/memory/model/policy/persona per agent → one
container = one tenant's whole team.
- **RPC transport** (local socket / remote WSS + token) with **restart-surviving, tmux-style
sessions** → clean drive channel and clean scale-to-zero/resume.
- **Schema-V3 config CRUD** over the gateway (`/api/config/*`) → declarative provisioning.
- **Per-agent tool allowlists + MCP client + private-host allowlists** → the hook to make
Clawmates the only outbound door.
- **Attribution-aware structured logs** + **per-agent/per-model cost tracking** → feed the
audited journal and billing.
- **Lean channel bundle as Cargo features** (`crates/zeroclaw-channels/Cargo.toml`:
`default-channels = acp-server, email, telegram, webhook`) → tiny, fast-cold-start image.
- **MIT/Apache license** → we may fork, containerize, and ship it.
## Existing infrastructure & what we reuse (this is already proven here)
The per-tenant ZeroClaw-in-a-container model is **already running in production** in this fleet
(ClawBooks, EasyA, gr33t). Clawmates reuses that proven stack rather than introducing K8s/Fly:
- **Substrate:** Docker + Nginx/Traefik + a per-app **orchestrator** on Hetzner VMs (Tailscale mesh).
The orchestrator (`:3500`) provisions/deprovisions a container per user; a webhook bridge (`:3501`)
reacts to Clerk `user.created`; Nginx routes `/ws/agent/{userId}` → the container.
- **Per-user container:** ZeroClaw binary + a small domain sidecar + plugins/skills.
- **Per-user volume:** `/data/users/{id}/` = `config.toml`, `domain.db` (SQLite), `brain.h5`
(ZeroClaw's HDF5/EdgeHDF5 memory) , `workspace/`, `channels/`. Backup = **2 portable files**
(`domain.db` + `brain.h5`) rsynced offsite — trivial tenant move/restore.
- **Image registry:** private registry at `web-01:5000` (Tailscale-only).
- **Auth + billing:** Clerk + Stripe (Clawmates already supports Clerk auth mode).
- **Bootstrap scripts (reuse):** `redclawsystems/zeroclaw → deploy/clawbooks/bootstrap/`
(`scale-up.sh` = Hetzner provision + bootstrap + deploy in ~5 min; `bootstrap-node.sh`;
`deploy-to-node.sh`).
- **Secrets:** Infisical at `icarus.lan:8443` (`HETZNER_API_TOKEN_RW/RO`, `ANTHROPIC/OPENAI/…`,
Clerk, Stripe, Cloudflare, Tailscale). ⚠️ Some are still plaintext in the vault and flagged for
rotation — rotate before production.
### Resources / deploy target (live check)
The 6-server Hetzner fleet (Helsinki) has ample headroom; **no new server is needed**. Dev/test
target: **gw-05** (8c / 15Gi, ~188G free, lightly loaded) — or gw-01 (~234G free). Estimated
capacity ~50–100 concurrent per-tenant containers across the fleet without new hardware.
## Target architecture
```
┌──────────────────────────────────────────────────────────────────────┐
│ Clawmates control plane (Rust SaaS) — responsibilities UNCHANGED │
│ • tenancy, auth, billing, product UI, the single audited SSE gateway │
│ • §15 engine: approvals · taint · gated categories · single-use │
│ grants · secret broker (cm-safety, cm-secrets, cm-api) │
│ • Clawmates MCP server: the ONLY outbound door (email/slack/pay/…) │
│ • tenant-runtime manager: provision/suspend/resume one container/tenant│
└───────────────┬────────────────────────────────────────────────────────┘
│ (a) provision agents via /api/config
│ (b) run turns via sessions/prompt (SSE) or RPC
│ (c) pull cost + logs
│ (d) agent's only egress = our gated MCP tools
▼
┌──────────────────────────────────────────────────────────────────────┐
│ Per-tenant ZeroClaw daemon (slim container, our fork) │
│ • N named agents (= N clawmates): per-agent workspace/memory/model │
│ • NO native outbound channels/secrets; tools.allow locked │
│ • MCP client → Clawmates MCP server for every sensitive capability │
└───────────────┬────────────────────────────────────────────────────────┘
│ spawns sandboxes for shell/browser
▼
Per-agent sandboxes — sibling pods / gVisor, brokered via socket-proxy
▲
┌──────────────────────────────────────────────────────────────────────┐
│ Substrate: Hetzner VMs (Tailscale) · Docker · Nginx/Traefik · │
│ per-app orchestrator (:3500) + Clerk webhook bridge (:3501) │
│ — the EXISTING proven ClawBooks/EasyA stack (replaces clawverse). │
│ K8s/Fly only if scale later demands it. │
└──────────────────────────────────────────────────────────────────────┘
```
## Concept mapping
| Clawmates | ZeroClaw |
|---|---|
| workspace / tenant | one daemon (one container) |
| claw (agent) | one named agent (isolated workspace/memory/model/policy) |
| chat session / turn | `POST /api/sessions/new` + `POST /api/session/{id}/prompt` (SSE), or an RPC session |
| the claw's "computer" / files | `/api/agents/{alias}/workspace/*` + a sibling sandbox for shell/browser |
| routine | a zeroclaw cron job bound to that agent |
| credits / billing | `/api/cost` (per-agent / per-model splits, cached-input tokens) |
| audit journal (`run_events`) | gateway attribution-aware logs, mirrored into `run_events` |
| §15 outbound action | a **Clawmates MCP tool** the agent calls — we gate + broker + journal |
## §15 — the safety contract stays in Clawmates
Two independent layers:
1. **No native edges.** Each tenant agent is provisioned with `tools.allow` excluding native
outbound/dangerous tools and with **no real channel or secret config**. It physically cannot
email, post, pay, or exfiltrate on its own.
2. **One gated door.** Clawmates exposes an **MCP server** of the sensitive capabilities (send
email, post to Slack, move money, delete data, change access, grant infra access). When an
agent invokes one:
- the call lands in Clawmates → classify `Effect` + `TaintSource` (reuse `cm-tools` policy),
- if gated → create an `approvals` row, render a preview, **suspend**, await a human decision,
- on approve → consume the single-use `execution_grants` row, **broker-execute with the
secret** (`cm-secrets`, never exposing the credential), and journal to `run_events`.
This reuses the existing approval state machine (`cm-safety`), secret broker (`cm-secrets`),
and the audited gateway (`cm-api`) verbatim. Defense-in-depth: run agents at ZeroClaw's
**Supervised** risk profile so its own per-dispatch allowlist + `ask_operator` back-stop us.
The §15 properties (taint-by-default, gated categories, single audited channel, single-use
grants, broker isolation) are therefore **unchanged** — they move from gating `cm-runtime`'s
in-process tools to gating the MCP boundary.
## Slim per-tenant image
- Fork/vendor zeroclaw; build with only needed features
(`--no-default-features --features channel-webhook` + MCP client), no native chat channels.
Push to the private registry at `web-01:5000`.
- Gateway bound to an in-pod socket / WSS with a per-tenant token; Nginx routes
`/ws/agent/{userId}` → the container (existing convention).
- Per-tenant **persistent volume** at `/data/users/{id}/` (`config.toml`, `domain.db`,
`brain.h5`, `workspace/`) — the proven 2-file (`domain.db` + `brain.h5`) backup/restore applies.
- Provider keys injected at start via env (`ZEROCLAW_*`) from Infisical / the Clawmates broker,
not baked into the image.
## Risks / open questions
- **Idle economics** — a container per user is costly; the existing orchestrator can **stop idle
containers** and start them on next request (per-tenant volume persists; v0.8 restart-surviving
sessions make resume clean). Tune idle TTL / wake latency; K8s+KEDA or Fly auto-stop only if we
outgrow the orchestrator.
- **Nested sandboxing** — tool sandboxes inside a tenant container; prefer **sibling** sandbox
pods via the existing `socket-proxy` pattern over privileged DinD; evaluate gVisor/sysbox.
- **§15 coverage** — must guarantee *no* un-gated path to the outside; audit zeroclaw's native
tool set per build and lock `tools.allow`.
- **Fork maintenance** — zeroclaw is v0.8 **beta**, workspace `publish=false`; maintain a thin
fork/vendor and track upstream.
- **Provisioning contract** — Schema-V3 config CRUD is the provisioning API; pin its behavior.
## Phased delivery
- **Phase 0 — this document.** Decision + architecture + §15-via-MCP + risks. ✅
- **Phase 1 — Spike (on gw-05).** Build a slim zeroclaw container (push to `web-01:5000`); from a
small Clawmates-side client, provision one agent via `/api/config`, run one turn via
`sessions/prompt`, stream events back, and prove a §15 gate by routing one outbound action
through a stub Clawmates MCP tool. Reuse the ClawBooks `deploy/clawbooks/bootstrap/` scripts as
the starting point.
- **Phase 2 — Provisioning + lifecycle.** Adapt the existing orchestrator (`:3500`) + Clerk
webhook (`:3501`) + Nginx pattern to provision/stop/resume a Clawmates tenant container +
per-claw agent on workspace/claw create.
- **Phase 3 — §15 MCP server + journal/cost bridge.** Real gated MCP capabilities (`cm-safety` /
`cm-secrets`); map zeroclaw attribution logs + `/api/cost` into `run_events` / billing.
- **Phase 4 — Idle stop/resume + per-agent sandboxes (sibling containers via socket-proxy).**
- **Phase 5 — Cutover** from `cm-runtime` to the containerized runtime behind a feature flag.
> Reuse source-of-truth: `redclawsystems/zeroclaw → deploy/clawbooks/bootstrap/` and the
> ClawBooks/EasyA orchestrator. Rotate the plaintext Hetzner/Cloudflare/Clerk tokens (vault
> `Infrastructure Overview.md`) into Infisical before production.
## Verification
- Phase 1: end-to-end on a dev box — one claw answers a turn on a containerized zeroclaw via
Clawmates, events journaled, one approval gate fires through the Clawmates MCP door.
- Keep Clawmates' E2E + §15 suites (`p0`–`p7`, approval/secret-broker tests) green against the
new backend behind the flag before cutover.
- Confirm slim-image cold-start and resume-from-sleep preserve sessions.
## References
- Clawmates runtime + safety: `crates/cm-runtime/src/runtime.rs`, `crates/cm-safety/`,
`crates/cm-secrets/`, `crates/cm-api/`, `crates/cm-tools/`, `migrations/0001_init.sql`,
spec `docs/spec.md` §15.
- ZeroClaw: `~/projects/zeroclaw` — `crates/zeroclaw-channels/Cargo.toml` (channel features),
`crates/zeroclaw-gateway/src` (drive API: config / sessions / workspace / cost / cron),
multi-agent runtime + RPC transport (v0.8.0).
- Infra & proven pattern (Obsidian vault `~/projects/Valhalla`): `20 Infrastructure/Infrastructure
Overview.md`, `20 Infrastructure/Tailscale Network Map.md`, `20 Infrastructure/10 Architecture/
SaaS Platform Template.md`, `20 Infrastructure/30 Runbooks/Credentials Inventory.md`.
- Reuse: `redclawsystems/zeroclaw → deploy/clawbooks/bootstrap/`; private registry `web-01:5000`;
Infisical `icarus.lan:8443`; dev/test box `gw-05`.
+65
View File
@@ -0,0 +1,65 @@
// Phase-1 drive client: pair with a ZeroClaw gateway, open /ws/chat as an
// agent, send one message, and print the streamed turn events. This is the
// Clawmates-side seam that (in prod) the control plane uses to run a claw's
// turn and journal its events. Node >=22 (global WebSocket + fetch).
//
// node tools/runtime-spike/drive.mjs <base_url> <agent_alias> "<message>"
// e.g. node tools/runtime-spike/drive.mjs http://100.108.129.81:42617 scout "Say hi in 5 words"
const [base, agent, message] = process.argv.slice(2);
if (!base || !agent || !message) {
console.error('usage: drive.mjs <base_url> <agent_alias> "<message>"');
process.exit(2);
}
// 1) Pair: read the one-time code from env (printed in the daemon's startup log).
const code = process.env.PAIR_CODE;
if (!code) {
console.error("set PAIR_CODE=<code from daemon startup log>");
process.exit(2);
}
const pairRes = await fetch(`${base}/pair`, {
method: "POST",
headers: { "X-Pairing-Code": code, "Content-Type": "application/json" },
body: "{}",
});
if (!pairRes.ok) {
console.error(`pair failed: ${pairRes.status} ${await pairRes.text()}`);
process.exit(1);
}
const pair = await pairRes.json();
const token = pair.token ?? pair.bearer ?? pair.access_token;
console.log(`✓ paired, token acquired (${String(token).slice(0, 6)}…)`);
// 2) Open the agent chat socket and run one turn.
// Node's global WebSocket can't set headers, so pass the token as ?token=
// (gateway accepts header > subprotocol > query).
const wsUrl =
base.replace(/^http/, "ws") +
`/ws/chat?agent=${encodeURIComponent(agent)}&name=${encodeURIComponent("clawmates-spike")}` +
`&token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
const t0 = Date.now();
ws.addEventListener("open", () => {
console.log("✓ ws open → sending message");
ws.send(JSON.stringify({ type: "message", content: message }));
});
ws.addEventListener("message", (ev) => {
let m;
try { m = JSON.parse(ev.data); } catch { return console.log("raw:", ev.data); }
switch (m.type) {
case "session_start": console.log(` session_start id=${m.session_id} resumed=${m.resumed}`); break;
case "chunk": process.stdout.write(m.content ?? ""); break;
case "tool_call": console.log(`\n [tool_call] ${m.name} ${JSON.stringify(m.args)}`); break;
case "tool_result": console.log(` [tool_result] ${m.name}: ${String(m.output).slice(0, 120)}`); break;
case "approval_request": console.log(`\n [APPROVAL REQUIRED] ${JSON.stringify(m)} ← §15 gate hook`); break;
case "done":
console.log(`\n✓ done in ${Date.now() - t0}ms`);
ws.close(); process.exit(0);
case "error": console.error("\n✗ error:", JSON.stringify(m)); ws.close(); process.exit(1);
default: console.log(" evt:", JSON.stringify(m));
}
});
ws.addEventListener("error", (e) => { console.error("ws error:", e.message ?? e); process.exit(1); });
setTimeout(() => { console.error("timeout (90s)"); process.exit(1); }, 90_000);